diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md index 89e13a40e..1ef8f324e 100644 --- a/.claude/skills/changelog/SKILL.md +++ b/.claude/skills/changelog/SKILL.md @@ -26,7 +26,7 @@ Create changelog files for the important commits in this PR. The PR number is pr - `{PR_NUMBER}.performance.md` - for performance improvements - `{PR_NUMBER}.other.md` - for other changes -4. Each changelog file should at least contain a main single line starting with `- ` followed by a clear description of the change. +4. Each changelog file should at least contain a main single line starting with `- ` followed by a clear description of the change. No line wrapping. 5. If the change is complicated, changelog files can have indented lines after the main line with additional details or code samples. diff --git a/.claude/skills/pr-submit/SKILL.md b/.claude/skills/pr-submit/SKILL.md new file mode 100644 index 000000000..5724ddb6e --- /dev/null +++ b/.claude/skills/pr-submit/SKILL.md @@ -0,0 +1,28 @@ +--- +name: pr-submit +description: Create and submit a GitHub PR from the current branch +--- + +Submit the current changes as a GitHub pull request. + +## Instructions + +1. Check the current state of the repository: + - Run `git status` to see staged, unstaged, and untracked changes + - Run `git diff` to see current changes + - Run `git log --oneline -10` to see recent commits + +2. If there are uncommitted changes relevant to the PR: + - Ask the user if they want a specific prefix for the branch name (e.g., `alice/`, `fix/`, `feat/`) + - Create a new branch based on the current branch + - Commit the changes using multiple commits if the changes are unrelated + +3. Push the branch and create the PR: + - Push with `-u` flag to set upstream tracking + - Create the PR using `gh pr create` + +4. After the PR is created: + - Run `/changelog ` to generate changelog files, then commit and push them + - Run `/pr-description ` to update the PR description + +5. Return the PR URL to the user. diff --git a/.claude/skills/update-docs/SKILL.md b/.claude/skills/update-docs/SKILL.md new file mode 100644 index 000000000..f24d56f00 --- /dev/null +++ b/.claude/skills/update-docs/SKILL.md @@ -0,0 +1,250 @@ +--- +name: update-docs +description: Update documentation pages to match source code changes on the current branch +--- + +Update documentation pages to reflect source code changes on the current branch. Analyzes the diff against main, maps changed source files to their corresponding doc pages, and makes targeted edits. + +## Arguments + +``` +/update-docs [DOCS_PATH] +``` + +- `DOCS_PATH` (optional): Path to the docs repository root. If not provided, ask the user. + +Examples: +- `/update-docs /Users/me/src/docs` +- `/update-docs` + +## Instructions + +### Step 1: Resolve docs path + +If `DOCS_PATH` was provided as an argument, use it. Otherwise, ask the user for the path to their docs repository. + +Verify the path exists and contains `server/services/` subdirectory. + +### Step 2: Create docs branch + +Get the current pipecat branch name: +```bash +git rev-parse --abbrev-ref HEAD +``` + +In the docs repo, create a new branch off main with a matching name: +```bash +cd DOCS_PATH && git checkout main && git pull && git checkout -b {branch-name}-docs +``` + +For example, if the pipecat branch is `feat/new-service`, the docs branch becomes `feat/new-service-docs`. + +All doc edits in subsequent steps are made on this branch. + +### Step 3: Detect changed source files + +Run: +```bash +git diff main..HEAD --name-only +``` + +Filter to files that could affect documentation: +- `src/pipecat/services/**/*.py` (service implementations) +- `src/pipecat/transports/**/*.py` (transport implementations) +- `src/pipecat/serializers/**/*.py` (serializer implementations) +- `src/pipecat/processors/**/*.py` (processor implementations) +- `src/pipecat/audio/**/*.py` (audio utilities) +- `src/pipecat/turns/**/*.py` (turn management) +- `src/pipecat/observers/**/*.py` (observers) +- `src/pipecat/pipeline/**/*.py` (pipeline core) + +Ignore `__init__.py`, `__pycache__`, test files, and files that only contain type re-exports. + +### Step 4: Map source files to doc pages + +For each changed source file, find the corresponding doc page. Read the mapping file at `.claude/skills/update-docs/SOURCE_DOC_MAPPING.md` and apply its tiered lookup: tier 1 (known exceptions) → tier 2 (pattern matching) → tier 3 (search fallback). **First match wins.** + +### Step 5: Analyze each source-doc pair + +For each mapped pair: + +1. **Read the full source file** to understand current state +2. **Read the diff** for that file: `git diff main..HEAD -- ` +3. **Read the current doc page** in full + +Identify what changed by comparing source to docs: + +- **Constructor parameters**: Compare `__init__` signature to the Configuration section's `` entries +- **InputParams fields**: Compare `InputParams(BaseModel)` class fields to the InputParams table +- **Event handlers**: Compare `_register_event_handler` calls and event handler definitions to Event Handlers section +- **Class names / imports**: Check if Usage examples reference correct names +- **Behavioral changes**: Check if Notes section needs updating + +### Step 6: Make targeted edits + +For each doc page that needs updates, edit **only the sections that need changes**. Preserve all other content exactly as-is. + +#### Rules + +- **Never remove content** unless the corresponding source code was removed +- **Never rewrite sections** that are already accurate +- **Match existing formatting** — if the page uses `` tags, use them; if it uses tables, use tables +- **Keep descriptions concise** — match the tone and length of surrounding content +- **Preserve CardGroup, links, and examples** unless they reference removed functionality +- **Don't touch frontmatter** unless the class was renamed + +#### Section-specific guidance + +**Configuration** (constructor params): +- Use `` format if the page already uses it +- Add new params in logical order (required first, then optional) +- Remove params that no longer exist in source +- Update types/defaults that changed + +**InputParams** (runtime settings): +- Use markdown table format: `| Parameter | Type | Default | Description |` +- Match the field names and types from the `InputParams(BaseModel)` class +- Include the default values from the source + +**Usage** (code examples): +- Update import paths, class names, and parameter names +- Only modify examples if they would break or be misleading with the new API +- Don't rewrite working examples just to add new optional params + +**Notes**: +- Add notes for new behavioral gotchas or breaking changes +- Remove notes about limitations that were fixed +- Keep existing notes that are still accurate + +**Event Handlers**: +- Update the event table and example code +- Add new events, remove deleted ones +- Update handler signatures if they changed + +**Overview / Key Features / Prerequisites**: +- Only update if the PR fundamentally changes what the service does (new capability, removed capability, renamed class) +- Most PRs will NOT need changes to these sections + +### Step 7: Update guides + +Guides at `DOCS_PATH/guides/` reference specific class names, parameters, imports, and code patterns. After completing reference doc edits, check if any guides need updates too. + +For each changed source file, collect the class names, renamed parameters, and changed imports from the diff. Search the guides directory: +```bash +grep -rl "ClassName\|old_param_name" DOCS_PATH/guides/ +``` + +For each guide that references changed code: +1. Read the full guide +2. Update class names, parameter names, import paths, and code examples that are now incorrect +3. **Don't rewrite prose** — only fix the specific references that changed +4. Leave guides alone if they reference the service generally but don't use any changed APIs + +Guide directories: +- `guides/learn/` — conceptual tutorials (pipeline, LLM, STT, TTS, etc.) +- `guides/fundamentals/` — practical how-tos (metrics, recording, transcripts, etc.) +- `guides/features/` — feature-specific guides (Gemini Live, OpenAI audio, WhatsApp, etc.) +- `guides/telephony/` — telephony integration guides (Twilio, Plivo, Telnyx, etc.) + +### Step 8: Identify doc gaps + +After processing all mapped pairs, check for two kinds of gaps: + +**Missing pages**: Source files that had no doc page mapping (neither tier 1, 2, nor 3) and are not marked as "(skip)". For each, tell the user: +- The source file path +- The main class(es) it defines +- Whether a new doc page should be created + +**Missing sections**: Mapped doc pages that are missing standard sections compared to the source. For example, a transport page with no Configuration section, or a service page with no InputParams table when the source defines `InputParams(BaseModel)`. Flag these and offer to add the missing sections. + +If the user wants a new page, create it using this template structure: +``` +--- +title: "Service Name" +description: "Brief description" +--- + +## Overview + +[Description from class docstring or source analysis] + + + [Cards for API reference and examples if available] + + +## Installation + +```bash +pip install "pipecat-ai[package-name]" +``` + +## Prerequisites + +[Environment variables and account setup] + +## Configuration + +[ParamField entries for constructor params] + +## InputParams + +[Table of InputParams fields, if the service has them] + +## Usage + +### Basic Setup + +```python +[Minimal working example] +``` + +## Notes + +[Important caveats] + +## Event Handlers + +[Event table and example code] +``` + +### Step 9: Output summary + +After all edits are complete, print a summary: + +``` +## Documentation Updates + +### Updated reference pages +- `server/services/stt/deepgram.mdx` — Updated Configuration (added `new_param`), InputParams (updated `language` default) +- `server/services/tts/elevenlabs.mdx` — Updated Event Handlers (added `on_connected`) + +### Updated guides +- `guides/learn/speech-to-text.mdx` — Updated code example (renamed `old_param` → `new_param`) + +### Unmapped source files +- `src/pipecat/services/newprovider/tts.py` — NewProviderTTSService (no doc page exists) + +### Skipped files +- `src/pipecat/services/ai_service.py` — internal base class +``` + +## Guidelines + +- **Be conservative** — only change what the diff warrants. Don't "improve" docs beyond what changed in source. +- **Read before editing** — always read the full doc page before making changes so you understand the existing structure. +- **Preserve voice** — match the writing style of the existing doc page, don't impose a different tone. +- **One PR at a time** — this skill operates on the current branch's diff against main. Don't look at other branches. +- **Parallel analysis** — when multiple source files map to different doc pages, analyze and edit them in parallel for efficiency. +- **Shared source files** — files like `services/google/google.py` are shared bases. Check which services import from them and update all affected doc pages. + +## Checklist + +Before finishing, verify: + +- [ ] All changed source files were checked against the mapping table +- [ ] Each doc page edit matches the actual source code change (not guessed) +- [ ] No content was removed unless the corresponding source was removed +- [ ] New parameters have accurate types and defaults from source +- [ ] Formatting matches the existing page style +- [ ] Guides referencing changed APIs were checked and updated +- [ ] Unmapped files were reported to the user diff --git a/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md b/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md new file mode 100644 index 000000000..03e6cbbf1 --- /dev/null +++ b/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md @@ -0,0 +1,79 @@ +# Source-to-Doc Mapping + +Maps pipecat source files to their documentation pages. Source paths are relative to `src/pipecat/`. Doc paths are relative to `DOCS_PATH`. + +## Name mismatches + +These source paths don't follow the standard `services/{provider}/{type}.py` → `server/services/{type}/{provider}.mdx` pattern. + +| Source path | Doc page | +|---|---| +| `services/google/llm.py` | `server/services/llm/gemini.mdx` | +| `services/google/llm_vertex.py` | `server/services/llm/google-vertex.mdx` | +| `services/google/google.py` | (shared base — check which services use it) | +| `services/google/gemini_live/**` | `server/services/s2s/gemini-live.mdx` | +| `services/google/gemini_live/llm_vertex.py` | `server/services/s2s/gemini-live-vertex.mdx` | +| `services/aws_nova_sonic/**` | `server/services/s2s/aws.mdx` | +| `services/ultravox/**` | `server/services/s2s/ultravox.mdx` | +| `services/grok/realtime/**` | `server/services/s2s/grok.mdx` | +| `services/openai/realtime/**` | `server/services/s2s/openai.mdx` | +| `processors/frameworks/rtvi.py` | `server/frameworks/rtvi/rtvi-processor.mdx` and `server/frameworks/rtvi/rtvi-observer.mdx` | +| `processors/transcript_processor.py` | `server/utilities/transcript-processor.mdx` | +| `processors/user_idle_processor.py` | `server/utilities/user-idle-processor.mdx` | +| `processors/idle_frame_processor.py` | `server/pipeline/pipeline-idle-detection.mdx` | +| `pipeline/task.py` | `server/pipeline/pipeline-task.mdx` | +| `pipeline/runner.py` | `server/utilities/runner/guide.mdx` | +| `transports/base_transport.py` | `server/services/transport/transport-params.mdx` | + +## Skip list + +These files should never trigger doc updates. + +| Pattern | Reason | +|---|---| +| `services/ai_service.py` | Internal base class | +| `services/stt_service.py` | Internal base class | +| `services/tts_service.py` | Internal base class | +| `services/llm_service.py` | Internal base class | +| `services/websocket_service.py` | Internal base class | +| `services/openai_realtime_beta/**` | Deprecated | +| `services/openai_realtime/**` | Deprecated | +| `services/gemini_multimodal_live/**` | Deprecated | +| `services/aws/agent_core.py` | Internal | +| `services/aws/sagemaker/**` | No doc page | +| `transports/base_input.py` | Internal base class | +| `transports/base_output.py` | Internal base class | +| `transports/websocket/client.py` | No doc page | +| `serializers/base_serializer.py` | Internal base class | +| `serializers/protobuf.py` | Internal | +| `processors/audio/**` | Internal | +| `pipeline/pipeline.py` | Core architecture, not a service doc | + +## Pattern matching + +For files not in the tables above, apply these patterns. Convert underscores to hyphens in provider names for doc filenames. + +| Source pattern | Doc pattern | +|---|---| +| `services/{provider}/stt*.py` | `server/services/stt/{provider}.mdx` | +| `services/{provider}/tts*.py` | `server/services/tts/{provider}.mdx` | +| `services/{provider}/llm*.py` | `server/services/llm/{provider}.mdx` | +| `services/{provider}/image*.py` | `server/services/image-generation/{provider}.mdx` | +| `services/{provider}/video*.py` | `server/services/video/{provider}.mdx` | +| `services/{provider}/realtime/**` | `server/services/s2s/{provider}.mdx` | +| `transports/{name}/**` | `server/services/transport/{name}.mdx` | +| `serializers/{name}.py` | `server/services/serializers/{name}.mdx` | +| `observers/**` | `server/utilities/observers/` (match by class name) | +| `audio/vad/**` | `server/utilities/audio/` (match by class name) | +| `audio/filters/**` | `server/utilities/audio/` (match by class name) | +| `audio/mixers/**` | `server/utilities/audio/` (match by class name) | +| `processors/filters/**` | `server/utilities/filters/` (match by class name) | + +If the doc file doesn't exist at the resolved path, the file is **unmapped**. + +## Search fallback + +For files that don't match any table or pattern above: +1. Extract the main class name(s) from the source file +2. Search the docs directory for that class name: `grep -r "ClassName" DOCS_PATH/server/` +3. If found in a doc page, use that as the mapping diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 852611eba..df9c388bf 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -29,6 +29,7 @@ jobs: - name: Install system packages run: | + sudo apt-get update sudo apt-get install -y portaudio19-dev - name: Install dependencies @@ -41,6 +42,7 @@ jobs: --extra livekit \ --extra local-smart-turn-v3 \ --extra piper \ + --extra tracing \ --extra websocket - name: Run tests with coverage diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 005eb94f1..496b3381c 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -86,7 +86,7 @@ jobs: fi # Validate fragment types - VALID_TYPES="added changed deprecated removed fixed security other" + VALID_TYPES="added changed deprecated removed fixed performance security other" INVALID_FRAGMENTS="" for file in changelog/*.md; do diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 54495911b..5941448f3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -33,6 +33,7 @@ jobs: - name: Install system packages run: | + sudo apt-get update sudo apt-get install -y portaudio19-dev - name: Install dependencies @@ -45,6 +46,7 @@ jobs: --extra livekit \ --extra local-smart-turn-v3 \ --extra piper \ + --extra tracing \ --extra websocket - name: Test with pytest diff --git a/CHANGELOG.md b/CHANGELOG.md index ab41e8163..c917ec992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,215 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.0.103] - 2026-02-20 + +### Added + +- Added `"timestampTransportStrategy": "ASYNC"` to `InworldAITTSService`. This + allows timestamps info to trail audio chunks arrival, resulting in much + better first audio chunk latency + (PR [#3625](https://github.com/pipecat-ai/pipecat/pull/3625)) + +- Added model-specific `InputParams` to `RimeTTSService`: arcana params + (`repetition_penalty`, `temperature`, `top_p`) and mistv2 params + (`no_text_normalization`, `save_oovs`, `segment`). Model, voice, and param + changes now trigger WebSocket reconnection. + (PR [#3642](https://github.com/pipecat-ai/pipecat/pull/3642)) + +- Added `write_transport_frame()` hook to `BaseOutputTransport` allowing + transport subclasses to handle custom frame types that flow through the audio + queue. + (PR [#3719](https://github.com/pipecat-ai/pipecat/pull/3719)) + +- Added `DailySIPTransferFrame` and `DailySIPReferFrame` to the Daily + transport. These frames queue SIP transfer and SIP REFER operations with + audio, so the operation executes only after the bot finishes its current + utterance. + (PR [#3719](https://github.com/pipecat-ai/pipecat/pull/3719)) + +- Added keepalive support to `SarvamSTTService` to prevent idle connection + timeouts (e.g. when used behind a `ServiceSwitcher`). + (PR [#3730](https://github.com/pipecat-ai/pipecat/pull/3730)) + +- Added `UserIdleTimeoutUpdateFrame` to enable or disable user idle detection + at runtime by updating the timeout dynamically. + (PR [#3748](https://github.com/pipecat-ai/pipecat/pull/3748)) + +- Added `broadcast_sibling_id` field to the base `Frame` class. This field is + automatically set by `broadcast_frame()` and `broadcast_frame_instance()` to + the ID of the paired frame pushed in the opposite direction, allowing + receivers to identify broadcast pairs. + (PR [#3774](https://github.com/pipecat-ai/pipecat/pull/3774)) + +- Added `ignored_sources` parameter to `RTVIObserverParams` and + `add_ignored_source()`/`remove_ignored_source()` methods to `RTVIObserver` to + suppress RTVI messages from specific pipeline processors (e.g. a silent + evaluation LLM). + (PR [#3779](https://github.com/pipecat-ai/pipecat/pull/3779)) + +- Added `DeepgramSageMakerTTSService` for running Deepgram TTS models deployed + on AWS SageMaker endpoints via HTTP/2 bidirectional streaming. Supports the + Deepgram TTS protocol (Speak, Flush, Clear, Close), interruption handling, + and per-turn TTFB metrics. + (PR [#3785](https://github.com/pipecat-ai/pipecat/pull/3785)) + +### Changed + +- ⚠️ `RimeTTSService` now defaults to `model="arcana"` and the + `wss://users-ws.rime.ai/ws3` endpoint. `InputParams` defaults changed from + mistv2-specific values to `None` — only explicitly-set params are sent as + query params. + (PR [#3642](https://github.com/pipecat-ai/pipecat/pull/3642)) + +- `AICFilter` now shares read-only AIC models via a singleton `AICModelManager` + in `aic_filter.py`. + - Multiple filters using the same model path or `(model_id, + model_download_dir)` share one loaded model, with reference counting and + concurrent load deduplication. + - Model file I/O runs off the event loop so the filter does not block. + (PR [#3684](https://github.com/pipecat-ai/pipecat/pull/3684)) + +- Added `X-User-Agent` and `X-Request-Id` headers to `InworldTTSService` for + better traceability. + (PR [#3706](https://github.com/pipecat-ai/pipecat/pull/3706)) + +- `DailyUpdateRemoteParticipantsFrame` is no longer deprecated and is now + queued with audio like other transport frames. + (PR [#3719](https://github.com/pipecat-ai/pipecat/pull/3719)) + +- Bumped Pillow dependency upper bound from `<12` to `<13` to allow Pillow + 12.x. + (PR [#3728](https://github.com/pipecat-ai/pipecat/pull/3728)) + +- Moved STT keepalive mechanism from `WebsocketSTTService` to the `STTService` + base class, allowing any STT service (not just websocket-based ones) to use + idle-connection keepalive via the `keepalive_timeout` and + `keepalive_interval` parameters. + (PR [#3730](https://github.com/pipecat-ai/pipecat/pull/3730)) + +- Improved audio context management in `AudioContextTTSService` by moving + context ID tracking to the base class and adding + `reuse_context_id_within_turn` parameter to control concurrent TTS request + handling. + - Added helper methods: `has_active_audio_context()`, + `get_active_audio_context_id()`, `remove_active_audio_context()`, + `reset_active_audio_context()` + - Simplified Cartesia, ElevenLabs, Inworld, Rime, AsyncAI, and Gradium TTS + implementations by removing duplicate context management code + (PR [#3732](https://github.com/pipecat-ai/pipecat/pull/3732)) + +- `UserIdleController` is now always created with a default timeout of 0 + (disabled). The `user_idle_timeout` parameter changed from `Optional[float] = + None` to `float = 0` in `UserTurnProcessor`, `LLMUserAggregatorParams`, and + `UserIdleController`. + (PR [#3748](https://github.com/pipecat-ai/pipecat/pull/3748)) + +- Change the version specifier from `>=0.2.8` to `~=0.2.8` for the + `speechmatics-voice` package to ensure compatibility with future patch + versions. + (PR [#3761](https://github.com/pipecat-ai/pipecat/pull/3761)) + +- Updated `InworldTTSService` and `InworldHttpTTSService` to use `ASYNC` + timestamp transport strategy by default + (PR [#3765](https://github.com/pipecat-ai/pipecat/pull/3765)) + +- Added `start_time` and `end_time` parameters to `start_ttfb_metrics()`, + `stop_ttfb_metrics()`, `start_processing_metrics()`, and + `stop_processing_metrics()` in `FrameProcessor` and `FrameProcessorMetrics`, + allowing custom timestamps for metrics measurement. `STTService` now uses + these instead of custom TTFB tracking. + (PR [#3776](https://github.com/pipecat-ai/pipecat/pull/3776)) + +- Updated default Anthropic model from `claude-sonnet-4-5-20250929` to + `claude-sonnet-4-6`. + (PR [#3792](https://github.com/pipecat-ai/pipecat/pull/3792)) + +### Deprecated + +- Deprecated unused `Traceable`, `@traceable`, `@traced`, and + `AttachmentStrategy` in `pipecat.utils.tracing.class_decorators`. This module + will be removed in a future release. + (PR [#3733](https://github.com/pipecat-ai/pipecat/pull/3733)) + +### Fixed + +- Fixed race condition where `RTVIObserver` could send messages before + `DailyTransport` join completed. Outbound messages are now queued & delivered + after the transport is ready. + (PR [#3615](https://github.com/pipecat-ai/pipecat/pull/3615)) + +- Fixed async generator cleanup in OpenAI LLM streaming to prevent + `AttributeError` with uvloop on Python 3.12+ (MagicStack/uvloop#699). + (PR [#3698](https://github.com/pipecat-ai/pipecat/pull/3698)) + +- Fixed `SmallWebRTCTransport` input audio resampling to properly handle all + sample rates, including 8kHz audio. + (PR [#3713](https://github.com/pipecat-ai/pipecat/pull/3713)) + +- Fixed a race condition in `RTVIObserver` where bot output messages could be + sent before the bot-started-speaking event. + (PR [#3718](https://github.com/pipecat-ai/pipecat/pull/3718)) + +- Fixed Grok Realtime `session.updated` event parsing failure caused by the API + returning prefixed voice names (e.g. `"human_Ara"` instead of `"Ara"`). + (PR [#3720](https://github.com/pipecat-ai/pipecat/pull/3720)) + +- Fixed context ID reuse issue in `ElevenLabsTTSService`, `InworldTTSService`, + `RimeTTSService`, `CartesiaTTSService`, `AsyncAITTSService`, and + `PlayHTTTSService`. Services now properly reuse the same context ID across + multiple `run_tts()` invocations within a single LLM turn, preventing context + tracking issues and incorrect lifecycle signaling. + (PR [#3729](https://github.com/pipecat-ai/pipecat/pull/3729)) + +- Fixed word timestamp interleaving issue in `ElevenLabsTTSService` when + processing multiple sentences within a single LLM turn. + (PR [#3729](https://github.com/pipecat-ai/pipecat/pull/3729)) + +- Fixed tracing service decorators executing the wrapped function twice when + the function itself raised an exception (e.g., LLM rate limit, TTS timeout). + (PR [#3735](https://github.com/pipecat-ai/pipecat/pull/3735)) + +- Fixed `LLMUserAggregator` broadcasting mute events before `StartFrame` + reaches downstream processors. + (PR [#3737](https://github.com/pipecat-ai/pipecat/pull/3737)) + +- Fixed `UserIdleController` false idle triggers caused by gaps between user + and bot activity frames. The idle timer now starts only after + `BotStoppedSpeakingFrame` and is suppressed during active user turns and + function calls. + (PR [#3744](https://github.com/pipecat-ai/pipecat/pull/3744)) + +- Fixed incorrect `sample_rate` assignment in + `TavusInputTransport._on_participant_audio_data` (was using + `audio.audio_frames` instead of `audio.sample_rate`). + (PR [#3768](https://github.com/pipecat-ai/pipecat/pull/3768)) + +- Fixed `RTVIObserver` not processing upstream-only frames. Previously, all + upstream frames were filtered out to avoid duplicate messages from + broadcasted frames. Now only upstream copies of broadcasted frames are + skipped. + (PR [#3774](https://github.com/pipecat-ai/pipecat/pull/3774)) + +- Fixed mutable default arguments in `LLMContextAggregatorPair.__init__()` that + could cause shared state across instances. + (PR [#3782](https://github.com/pipecat-ai/pipecat/pull/3782)) + +- Fixed `DeepgramSageMakerSTTService` to properly track finalize lifecycle + using `request_finalize()` / `confirm_finalize()` and use `is_final` (instead + of `is_final and speech_final`) for final transcription detection, matching + `DeepgramSTTService` behavior. + (PR [#3784](https://github.com/pipecat-ai/pipecat/pull/3784)) + +- Fixed a race condition in `AudioContextTTSService` where the audio context + could time out between consecutive TTS requests within the same turn, causing + audio to be discarded. + (PR [#3787](https://github.com/pipecat-ai/pipecat/pull/3787)) + +- Fixed `push_interruption_task_frame_and_wait()` hanging indefinitely when the + `InterruptionFrame` does not reach the pipeline sink within the timeout. + Added a `timeout` keyword argument to customize the wait duration. + (PR [#3789](https://github.com/pipecat-ai/pipecat/pull/3789)) + ## [0.0.102] - 2026-02-10 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index cf4c5baec..7b79fa168 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,6 +153,3 @@ When adding a new service: Test utilities live in `src/pipecat/tests/utils.py`. Use `run_test()` to send frames through a pipeline and assert expected output frames in each direction. Use `SleepFrame(sleep=N)` to add delays between frames. -## Pull Requests - -After creating a PR, use `/changelog ` to generate the changelog file and `/pr-description ` to update the PR description. diff --git a/COMMUNITY_INTEGRATIONS.md b/COMMUNITY_INTEGRATIONS.md index f52e30b58..32fbff333 100644 --- a/COMMUNITY_INTEGRATIONS.md +++ b/COMMUNITY_INTEGRATIONS.md @@ -25,7 +25,6 @@ Your repository must contain these components: - **Source code** - Complete implementation following Pipecat patterns - **Foundational example** - Single file example showing basic usage (see [Pipecat examples](https://github.com/pipecat-ai/pipecat/tree/main/examples/foundational)) - **README.md** - Must include: - - Introduction and explanation of your integration - Installation instructions - Usage instructions with Pipecat Pipeline @@ -110,7 +109,6 @@ Once your PR is submitted, post in the `#community-integrations` Discord channel #### Key requirements: - **Frame sequence:** Output must follow this frame sequence pattern: - - `LLMFullResponseStartFrame` - Signals the start of an LLM response - `LLMTextFrame` - Contains LLM content, typically streamed as tokens - `LLMFullResponseEndFrame` - Signals the end of an LLM response @@ -265,6 +263,7 @@ class MySTTService(STTService): def __init__(self, *, model: str, region: str, **kwargs): super().__init__(**kwargs) self._settings = MySTTSettings(model=model, region=region) + self._sync_model_name_to_metrics() ``` To react to runtime setting changes, override `_update_settings`. The base implementation applies the delta to `self._settings` and returns a `dict` mapping each changed field name to its **pre-update** value. Your override should call `super()` first, then act on the changed fields: diff --git a/changelog/3759.performance.md b/changelog/3759.performance.md new file mode 100644 index 000000000..1bdc17a17 --- /dev/null +++ b/changelog/3759.performance.md @@ -0,0 +1 @@ +- Switched `GradiumTTSService` from `InterruptibleWordTTSService` to `AudioContextWordTTSService`, eliminating websocket disconnect/reconnect on every interruption by using `client_req_id`-based multiplexing. diff --git a/env.example b/env.example index 6e7db21e2..bc14ea0bf 100644 --- a/env.example +++ b/env.example @@ -47,7 +47,8 @@ DAILY_ROOM_URL=https://... # Deepgram DEEPGRAM_API_KEY=... -SAGEMAKER_ENDPOINT_NAME=... +SAGEMAKER_STT_ENDPOINT_NAME=... +SAGEMAKER_TTS_ENDPOINT_NAME=... # DeepSeek DEEPSEEK_API_KEY=... diff --git a/examples/foundational/07c-interruptible-deepgram-sagemaker.py b/examples/foundational/07c-interruptible-deepgram-sagemaker.py index 51a4b1bcb..aced7666f 100644 --- a/examples/foundational/07c-interruptible-deepgram-sagemaker.py +++ b/examples/foundational/07c-interruptible-deepgram-sagemaker.py @@ -24,7 +24,7 @@ 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 import DeepgramTTSService +from pipecat.services.deepgram.tts_sagemaker import DeepgramSageMakerTTSService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -58,11 +58,19 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # - AWS credentials configured (via environment variables or AWS CLI) # - A deployed SageMaker endpoint with Deepgram model stt = DeepgramSageMakerSTTService( - endpoint_name=os.getenv("SAGEMAKER_ENDPOINT_NAME"), + endpoint_name=os.getenv("SAGEMAKER_STT_ENDPOINT_NAME"), region=os.getenv("AWS_REGION"), ) - tts = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY"), voice="aura-2-andromeda-en") + # Initialize Deepgram SageMaker TTS Service + # This requires: + # - AWS credentials configured (via environment variables or AWS CLI) + # - A deployed SageMaker endpoint with Deepgram TTS model + tts = DeepgramSageMakerTTSService( + endpoint_name=os.getenv("SAGEMAKER_TTS_ENDPOINT_NAME"), + region=os.getenv("AWS_REGION"), + voice="aura-2-andromeda-en", + ) llm = AWSBedrockLLMService( aws_region=os.getenv("AWS_REGION"), diff --git a/examples/foundational/07q-interruptible-rime.py b/examples/foundational/07q-interruptible-rime.py index 1042d05ee..27e010273 100644 --- a/examples/foundational/07q-interruptible-rime.py +++ b/examples/foundational/07q-interruptible-rime.py @@ -56,7 +56,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = RimeTTSService( api_key=os.getenv("RIME_API_KEY", ""), - voice_id="rex", + voice_id="luna", ) llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) diff --git a/examples/foundational/07s-interruptible-google-audio-in.py b/examples/foundational/07s-interruptible-google-audio-in.py index 707db107e..1de374a3f 100644 --- a/examples/foundational/07s-interruptible-google-audio-in.py +++ b/examples/foundational/07s-interruptible-google-audio-in.py @@ -96,7 +96,7 @@ class UserAudioCollector(FrameProcessor): self._user_speaking = True elif isinstance(frame, UserStoppedSpeakingFrame): self._user_speaking = False - self._context.add_audio_frames_message(audio_frames=self._audio_frames) + await self._context.add_audio_frames_message(audio_frames=self._audio_frames) await self._user_context_aggregator.push_frame(LLMRunFrame()) elif isinstance(frame, InputAudioRawFrame): diff --git a/examples/foundational/14a-function-calling-anthropic.py b/examples/foundational/14a-function-calling-anthropic.py index 165d4b220..36030bc2b 100644 --- a/examples/foundational/14a-function-calling-anthropic.py +++ b/examples/foundational/14a-function-calling-anthropic.py @@ -72,10 +72,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady ) - llm = AnthropicLLMService( - api_key=os.getenv("ANTHROPIC_API_KEY"), - model="claude-3-7-sonnet-latest", - ) + llm = AnthropicLLMService(api_key=os.getenv("ANTHROPIC_API_KEY")) llm.register_function("get_weather", get_weather) llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) diff --git a/examples/foundational/17-detect-user-idle.py b/examples/foundational/17-detect-user-idle.py index ae2727c6a..e6af5a364 100644 --- a/examples/foundational/17-detect-user-idle.py +++ b/examples/foundational/17-detect-user-idle.py @@ -5,17 +5,21 @@ # +import asyncio import os from dotenv import load_dotenv from loguru import logger +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.frames.frames import ( EndTaskFrame, LLMMessagesAppendFrame, LLMRunFrame, TTSSpeakFrame, + UserIdleTimeoutUpdateFrame, ) from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,6 +34,7 @@ from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.llm_service import FunctionCallParams from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams @@ -74,6 +79,17 @@ class IdleHandler: await aggregator.push_frame(EndTaskFrame(), FrameDirection.UPSTREAM) +async def fetch_weather_from_api(params: FunctionCallParams): + # Simulate a slow API call, waiting longer than the user idle timeout. + await asyncio.sleep(3) + await params.result_callback({"conditions": "nice", "temperature": "75"}) + + +async def fetch_restaurant_recommendation(params: FunctionCallParams): + await asyncio.sleep(6) + await params.result_callback({"name": "The Golden Dragon"}) + + # We use lambdas to defer transport parameter creation until the transport # type is selected at runtime. transport_params = { @@ -104,6 +120,42 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm.register_function("get_current_weather", fetch_weather_from_api) + llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) + + @llm.event_handler("on_function_calls_started") + async def on_function_calls_started(service, function_calls): + await tts.queue_frame(TTSSpeakFrame("Let me check on that.")) + + weather_function = FunctionSchema( + name="get_current_weather", + description="Get the current weather", + properties={ + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "format": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The temperature unit to use. Infer this from the user's location.", + }, + }, + required=["location", "format"], + ) + restaurant_function = FunctionSchema( + name="get_restaurant_recommendation", + description="Get a restaurant recommendation", + properties={ + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + }, + required=["location"], + ) + tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) + messages = [ { "role": "system", @@ -111,7 +163,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): }, ] - context = LLMContext(messages) + context = LLMContext(messages, tools) user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams( @@ -146,6 +198,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): @user_aggregator.event_handler("on_user_turn_idle") async def on_user_turn_idle(aggregator): + logger.info(f"User turn idle") await idle_handler.handle_idle(aggregator) @user_aggregator.event_handler("on_user_turn_started") @@ -158,6 +211,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Kick off the conversation. messages.append({"role": "system", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) + await asyncio.sleep(30) + logger.info(f"Disabling idle detection") + await task.queue_frames([UserIdleTimeoutUpdateFrame(timeout=0)]) + await asyncio.sleep(30) + logger.info(f"Enabling idle detection") + await task.queue_frames([UserIdleTimeoutUpdateFrame(timeout=5)]) @transport.event_handler("on_client_disconnected") async def on_client_disconnected(transport, client): diff --git a/examples/foundational/25-google-audio-in.py b/examples/foundational/25-google-audio-in.py index 002aeaa1c..40903e1d5 100644 --- a/examples/foundational/25-google-audio-in.py +++ b/examples/foundational/25-google-audio-in.py @@ -98,7 +98,7 @@ class UserAudioCollector(FrameProcessor): self._user_speaking = True elif isinstance(frame, UserStoppedSpeakingFrame): self._user_speaking = False - self._context.add_audio_frames_message(audio_frames=self._audio_frames) + await self._context.add_audio_frames_message(audio_frames=self._audio_frames) await self._user_context_aggregator.push_frame(LLMContextFrame(context=self._context)) elif isinstance(frame, InputAudioRawFrame): if self._user_speaking: diff --git a/examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py b/examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py new file mode 100644 index 000000000..b16f8831f --- /dev/null +++ b/examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py @@ -0,0 +1,191 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""RTVIObserver ignored sources example. + +This example shows how to suppress RTVI messages from a specific pipeline +processor so that secondary branches don't leak events to the client. + +""" + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.parallel_pipeline import ParallelPipeline +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.processors.audio.vad_processor import VADProcessor +from pipecat.processors.frameworks.rtvi import RTVIObserverParams +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams +from pipecat.turns.user_turn_processor import UserTurnProcessor +from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies + +load_dotenv(override=True) + +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info("Starting bot") + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ) + + # Main LLM — drives the conversation. Its RTVI events reach the client. + main_llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + + main_messages = [ + { + "role": "system", + "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Respond to what the user said in a creative and helpful way.", + }, + ] + + # Evaluator LLM — silently grades the user's message in the background. + # Its RTVI events will be suppressed so the client is unaware of this branch. + evaluator_llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + name="EvaluatorLLM", + ) + + evaluator_messages = [ + { + "role": "system", + "content": ( + "You are a silent quality evaluator. When given a user message, " + "respond with a single JSON object: " + '{"score": <1-5>, "reason": ""}. ' + "Do not respond conversationally." + ), + }, + ] + + main_context = LLMContext(main_messages) + evaluator_context = LLMContext(evaluator_messages) + + # We use an external VADProcessor because the UserTurnProcessor is shared + # across multiple parallel aggregators. The VADProcessor emits + # VADUserStartedSpeakingFrame and VADUserStoppedSpeakingFrame which the + # UserTurnProcessor needs to manage turn lifecycle. + vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer()) + + # We use this external user turn processor. This processor will push + # UserStartedSpeakingFrame and UserStoppedSpeakingFrame as well as + # interruptions. This can be used in advanced cases when there are multiple + # aggregators in the pipeline. + user_turn_processor = UserTurnProcessor() + + # We use external user turn strategies for both aggregators since the turn + # management is done by the common UserTurnProcessor. + main_context_aggregator = LLMContextAggregatorPair( + main_context, + user_params=LLMUserAggregatorParams(user_turn_strategies=ExternalUserTurnStrategies()), + ) + evaluator_context_aggregator = LLMContextAggregatorPair( + evaluator_context, + user_params=LLMUserAggregatorParams(user_turn_strategies=ExternalUserTurnStrategies()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + vad_processor, + user_turn_processor, + ParallelPipeline( + # Main branch: speaks to the user. + [ + main_context_aggregator.user(), + main_llm, + tts, + transport.output(), + main_context_aggregator.assistant(), + ], + # Evaluator branch: silent background scoring, no audio output. + [ + evaluator_context_aggregator.user(), + evaluator_llm, + evaluator_context_aggregator.assistant(), + ], + ), + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + rtvi_observer_params=RTVIObserverParams(ignored_sources=[evaluator_llm]), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info("Client connected") + main_messages.append( + {"role": "system", "content": "Please introduce yourself to the user."} + ) + evaluator_messages.append({"role": "system", "content": "Ready to evaluate user messages."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info("Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py b/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py new file mode 100644 index 000000000..dc8576261 --- /dev/null +++ b/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py @@ -0,0 +1,134 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame, STTUpdateSettingsFrame +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.cartesia.tts import CartesiaTTSService +from pipecat.services.deepgram.stt_sagemaker import ( + DeepgramSageMakerSTTService, + DeepgramSageMakerSTTSettings, +) +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 + +load_dotenv(override=True) + +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info(f"Starting bot") + + stt = DeepgramSageMakerSTTService( + endpoint_name=os.getenv("SAGEMAKER_ENDPOINT_NAME", "my-deepgram-stt-endpoint"), + region=os.getenv("AWS_REGION", "us-east-2"), + ) + + 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(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), + stt, + user_aggregator, + llm, + tts, + transport.output(), + assistant_aggregator, + ] + ) + + 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") + messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Deepgram SageMaker STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(update=DeepgramSageMakerSTTSettings(language=Language.ES)) + ) + + @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/55q-update-settings-deepgram-sagemaker-tts.py b/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py new file mode 100644 index 000000000..2db7af7fe --- /dev/null +++ b/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py @@ -0,0 +1,137 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame, TTSUpdateSettingsFrame +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.deepgram.stt import DeepgramSTTService +from pipecat.services.deepgram.tts_sagemaker import ( + DeepgramSageMakerTTSService, + DeepgramSageMakerTTSSettings, +) +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 + +load_dotenv(override=True) + +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info(f"Starting bot") + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = DeepgramSageMakerTTSService( + endpoint_name=os.getenv("SAGEMAKER_ENDPOINT_NAME", "my-deepgram-tts-endpoint"), + region=os.getenv("AWS_REGION", "us-east-2"), + voice="aura-2-helena-en", + ) + + 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(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), + stt, + user_aggregator, + llm, + tts, + transport.output(), + assistant_aggregator, + ] + ) + + 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") + messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Deepgram SageMaker TTS settings: voice="aura-2-aries-en"') + await task.queue_frame( + TTSUpdateSettingsFrame(update=DeepgramSageMakerTTSSettings(voice="aura-2-aries-en")) + ) + + await asyncio.sleep(10) + logger.info('Updating Deepgram SageMaker TTS settings: voice="aura-2-luna-en"') + await task.queue_frame( + TTSUpdateSettingsFrame(update=DeepgramSageMakerTTSSettings(voice="aura-2-luna-en")) + ) + + @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/55u-update-settings-rime-tts.py b/examples/foundational/55u-update-settings-rime-tts.py index 25d0515fe..0704645f5 100644 --- a/examples/foundational/55u-update-settings-rime-tts.py +++ b/examples/foundational/55u-update-settings-rime-tts.py @@ -54,7 +54,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = RimeTTSService( api_key=os.getenv("RIME_API_KEY"), - voice_id="eva", + voice_id="luna", ) llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) @@ -100,8 +100,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): await task.queue_frames([LLMRunFrame()]) await asyncio.sleep(10) - logger.info("Updating Rime TTS settings: voice=rex") - await task.queue_frame(TTSUpdateSettingsFrame(update=RimeTTSSettings(voice="rex"))) + logger.info("Updating Rime TTS settings: voice=bond") + await task.queue_frame(TTSUpdateSettingsFrame(update=RimeTTSSettings(voice="bond"))) @transport.event_handler("on_client_disconnected") async def on_client_disconnected(transport, client): diff --git a/pyproject.toml b/pyproject.toml index fd3db5031..db76fa24e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "Markdown>=3.7,<4", "nltk>=3.9.1,<4", "numpy>=1.26.4,<3", - "Pillow>=11.1.0,<12", + "Pillow>=11.1.0,<13", "protobuf~=5.29.6", "pydantic>=2.10.6,<3", "pyloudnorm~=0.1.1", @@ -105,16 +105,16 @@ remote-smart-turn = [] resembleai = [ "pipecat-ai[websockets-base]" ] rime = [ "pipecat-ai[websockets-base]" ] riva = [ "pipecat-ai[nvidia]" ] -runner = [ "python-dotenv>=1.0.0,<2.0.0", "uvicorn>=0.32.0,<1.0.0", "fastapi>=0.115.6,<0.128.0", "pipecat-ai-small-webrtc-prebuilt>=2.1.0"] +runner = [ "python-dotenv>=1.0.0,<2.0.0", "uvicorn>=0.32.0,<1.0.0", "fastapi>=0.115.6,<0.128.0", "pipecat-ai-small-webrtc-prebuilt>=2.2.0"] sagemaker = ["aws_sdk_sagemaker_runtime_http2; python_version>='3.12'"] sambanova = [] -sarvam = [ "sarvamai==0.1.21", "pipecat-ai[websockets-base]" ] +sarvam = [ "sarvamai==0.1.26a2", "pipecat-ai[websockets-base]" ] sentry = [ "sentry-sdk>=2.28.0,<3" ] silero = [ "onnxruntime~=1.23.2" ] -simli = [ "simli-ai~=1.0.3"] +simli = [ "simli-ai~=2.0.1"] soniox = [ "pipecat-ai[websockets-base]" ] soundfile = [ "soundfile~=0.13.1" ] -speechmatics = [ "speechmatics-voice[smart]>=0.2.8" ] +speechmatics = [ "speechmatics-voice[smart]~=0.2.8" ] strands = [ "strands-agents>=1.9.1,<2" ] tavus=[] together = [] diff --git a/src/pipecat/audio/filters/aic_filter.py b/src/pipecat/audio/filters/aic_filter.py index 7f0626776..723b3da8f 100644 --- a/src/pipecat/audio/filters/aic_filter.py +++ b/src/pipecat/audio/filters/aic_filter.py @@ -12,10 +12,13 @@ the Koala filter and integrates with Pipecat's input transport pipeline. Classes: AICFilter: For aic-sdk (uses 'aic_sdk' module) + AICModelManager: Singleton manager for read-only AIC Model instances. """ +import asyncio from pathlib import Path -from typing import List, Optional +from threading import Lock +from typing import List, Optional, Tuple import numpy as np from aic_sdk import ( @@ -33,6 +36,177 @@ from pipecat.audio.vad.aic_vad import AICVADAnalyzer from pipecat.frames.frames import FilterControlFrame, FilterEnableFrame +class AICModelManager: + """Singleton manager for read-only AIC Model instances with reference counting. + + Caches Model instances by path or (model_id + download_dir). Multiple + AICFilter instances using the same model share one Model; the manager + acquires on first use and releases when the last reference is dropped. + """ + + _cache: dict[str, Tuple[Model, int]] = {} # key -> (model, ref_count) + _lock = Lock() + _loading: dict[ + str, asyncio.Task[Model] + ] = {} # key -> load task (deduplicates concurrent loads) + + @classmethod + def _increment_reference(cls, cache_key: str, entry: Tuple[Model, int]) -> Tuple[Model, str]: + """Increment reference count for cached entry. Caller must hold _lock.""" + cached_model, ref_count = entry + cls._cache[cache_key] = (cached_model, ref_count + 1) + logger.debug(f"AIC model cache key={cache_key!r} ref_count={ref_count + 1}") + return cached_model, cache_key + + @classmethod + def _store_new_reference(cls, cache_key: str, model: Model) -> Tuple[Model, str]: + """Store new model in cache with ref count 1. Caller must hold _lock.""" + cls._cache[cache_key] = (model, 1) + logger.debug(f"AIC model cached key={cache_key!r} ref_count=1") + return model, cache_key + + @classmethod + async def _load_model_from_file( + cls, + cache_key: str, + *, + model_path: Optional[Path] = None, + model_id: Optional[str] = None, + model_download_dir: Optional[Path] = None, + ) -> Model: + """Run the actual load (file or download). Separate to allow create_task and deduplication.""" + if model_path is not None: + logger.debug(f"Loading AIC model from file: {model_path}") + model_path_str = str(model_path) + + elif model_id is not None and model_download_dir is not None: + logger.debug(f"Downloading AIC model: {model_id}") + model_download_dir.mkdir(parents=True, exist_ok=True) + model_path_str = await Model.download_async(model_id, str(model_download_dir)) + logger.debug(f"Model downloaded to: {model_path_str}") + + else: + raise ValueError("Unexpected model_path or (model_id and model_download_dir) state.") + + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, lambda: Model.from_file(model_path_str)) + + @staticmethod + def _get_cache_key( + *, + model_path: Optional[Path] = None, + model_id: Optional[str] = None, + model_download_dir: Optional[Path] = None, + ) -> str: + """Build a stable cache key for the model. + + Args: + model_path: Path to a local .aicmodel file. + model_id: Model identifier (See https://artifacts.ai-coustics.io/ for available models). + model_download_dir: Directory used for downloading models. + + Returns: + A string key unique per (path) or (model_id + download_dir). + """ + if model_path is not None: + return f"path:{model_path.resolve()}" + + if model_id is not None and model_download_dir is not None: + return f"id:{model_id}:{model_download_dir.resolve()}" + + raise ValueError("Either model_path or (model_id and model_download_dir) must be set.") + + @classmethod + async def acquire( + cls, + *, + model_path: Optional[Path] = None, + model_id: Optional[str] = None, + model_download_dir: Optional[Path] = None, + ) -> Tuple[Model, str]: + """Get or load a Model and increment its reference count. + + Call this when starting a filter. Store the returned key and pass it + to release() when stopping the filter. + + Args: + model_path: Path to a local .aicmodel file. If set, model_id is ignored. + model_id: Model identifier to download from CDN. + model_download_dir: Directory for downloading models. Required if + model_id is used. + + Returns: + Tuple of (shared Model instance, cache key for release). + + Raises: + ValueError: If neither model_path nor (model_id + model_download_dir) + is provided, or if model_id is set without model_download_dir. + """ + cache_key = cls._get_cache_key( + model_path=model_path, + model_id=model_id, + model_download_dir=model_download_dir, + ) + + with cls._lock: + entry = cls._cache.get(cache_key) + if entry is not None: + return cls._increment_reference(cache_key, entry) + + # Deduplicate concurrent loads for the same key + load_task = cls._loading.get(cache_key) + if load_task is None: + load_task = asyncio.create_task( + cls._load_model_from_file( + cache_key, + model_path=model_path, + model_id=model_id, + model_download_dir=model_download_dir, + ) + ) + cls._loading[cache_key] = load_task + + try: + model = await load_task + finally: + with cls._lock: + cls._loading.pop(cache_key, None) + + with cls._lock: + entry = cls._cache.get(cache_key) + if entry is not None: + return cls._increment_reference(cache_key, entry) + return cls._store_new_reference(cache_key, model) + + @classmethod + def release(cls, key: str) -> None: + """Release a reference to a cached model. + + Call this when stopping a filter, with the key returned from + get_model(). When the last reference is released, the model + is removed from the cache. + + Args: + key: Cache key returned by get_model(). + """ + with cls._lock: + entry = cls._cache.get(key) + + if entry is None: + logger.warning(f"AIC model release unknown key={key!r}") + return + + model, ref_count = entry + ref_count -= 1 + + if ref_count <= 0: + del cls._cache[key] + logger.debug(f"AIC model evicted key={key!r}") + else: + cls._cache[key] = (model, ref_count) + logger.debug(f"AIC model key={key!r} ref_count={ref_count}") + + class AICFilter(BaseAudioFilter): """Audio filter using ai-coustics' AIC SDK for real-time enhancement. @@ -91,7 +265,8 @@ class AICFilter(BaseAudioFilter): 32768.0 # 2^15, for normalizing int16 (-32768 to 32767) to float32 (-1.0 to 1.0) ) - # AIC SDK objects + # AIC SDK objects; model is shared via AICModelManager + self._model_cache_key: Optional[str] = None self._model = None self._processor = None self._processor_ctx = None @@ -162,16 +337,12 @@ class AICFilter(BaseAudioFilter): """ self._sample_rate = sample_rate - # Load or download model - if self._model_path: - logger.debug(f"Loading AIC model from: {self._model_path}") - self._model = Model.from_file(str(self._model_path)) - else: - logger.debug(f"Downloading AIC model: {self._model_id}") - self._model_download_dir.mkdir(parents=True, exist_ok=True) - model_path = await Model.download_async(self._model_id, str(self._model_download_dir)) - logger.debug(f"Model downloaded to: {model_path}") - self._model = Model.from_file(model_path) + # Acquire shared read-only model from singleton manager + self._model, self._model_cache_key = await AICModelManager.acquire( + model_path=self._model_path, + model_id=self._model_id, + model_download_dir=self._model_download_dir, + ) # Get optimal frames for this sample rate self._frames_per_block = self._model.get_optimal_num_frames(self._sample_rate) @@ -242,6 +413,10 @@ class AICFilter(BaseAudioFilter): self._aic_ready = False self._audio_buffer.clear() + if self._model_cache_key is not None: + AICModelManager.release(self._model_cache_key) + self._model_cache_key = None + async def process_frame(self, frame: FilterControlFrame): """Process control frames to enable/disable filtering. diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 0e9127130..79aedb771 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -43,6 +43,7 @@ if TYPE_CHECKING: from pipecat.processors.aggregators.llm_context import LLMContext, NotGiven from pipecat.processors.frame_processor import FrameProcessor from pipecat.services.settings import ServiceSettings + from pipecat.utils.tracing.tracing_context import TracingContext class DeprecatedKeypadEntry: @@ -123,6 +124,9 @@ class Frame: id: Unique identifier for the frame instance. name: Human-readable name combining class name and instance count. pts: Presentation timestamp in nanoseconds. + broadcast_sibling_id: ID of the paired frame when this frame was + broadcast in both directions. Set automatically by + ``broadcast_frame()`` and ``broadcast_frame_instance()``. metadata: Dictionary for arbitrary frame metadata. transport_source: Name of the transport source that created this frame. transport_destination: Name of the transport destination for this frame. @@ -131,6 +135,7 @@ class Frame: id: int = field(init=False) name: str = field(init=False) pts: Optional[int] = field(init=False) + broadcast_sibling_id: Optional[int] = field(init=False) metadata: Dict[str, Any] = field(init=False) transport_source: Optional[str] = field(init=False) transport_destination: Optional[str] = field(init=False) @@ -139,6 +144,7 @@ class Frame: self.id: int = obj_id() self.name: str = f"{self.__class__.__name__}#{obj_count(self)}" self.pts: Optional[int] = None + self.broadcast_sibling_id: Optional[int] = None self.metadata: Dict[str, Any] = {} self.transport_source: Optional[str] = None self.transport_destination: Optional[str] = None @@ -1037,6 +1043,7 @@ class StartFrame(SystemFrame): Use `LLMUserAggregator`'s new `user_turn_strategies` parameter instead. report_only_initial_ttfb: Whether to report only initial time-to-first-byte. + tracing_context: Pipeline-scoped tracing context for span hierarchy. """ audio_in_sample_rate: int = 16000 @@ -1047,6 +1054,7 @@ class StartFrame(SystemFrame): enable_usage_metrics: bool = False interruption_strategies: List[BaseInterruptionStrategy] = field(default_factory=list) report_only_initial_ttfb: bool = False + tracing_context: Optional["TracingContext"] = None @dataclass @@ -2151,6 +2159,20 @@ class STTUpdateSettingsFrame(ServiceUpdateSettingsFrame): pass +@dataclass +class UserIdleTimeoutUpdateFrame(SystemFrame): + """Frame for updating the user idle timeout at runtime. + + Setting timeout to 0 disables idle detection. Setting a positive value + enables it. + + Parameters: + timeout: The new idle timeout in seconds. 0 disables idle detection. + """ + + timeout: float + + @dataclass class VADParamsUpdateFrame(ControlFrame): """Frame for updating VAD parameters. diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index 8bda19b18..2cfe26606 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -53,6 +53,7 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, F from pipecat.processors.frameworks.rtvi import RTVIObserver, RTVIObserverParams, RTVIProcessor from pipecat.utils.asyncio.task_manager import BaseTaskManager, TaskManager, TaskManagerParams from pipecat.utils.tracing.setup import is_tracing_available +from pipecat.utils.tracing.tracing_context import TracingContext from pipecat.utils.tracing.turn_trace_observer import TurnTraceObserver HEARTBEAT_SECS = 1.0 @@ -290,10 +291,13 @@ class PipelineTask(BasePipelineTask): self._turn_tracking_observer: Optional[TurnTrackingObserver] = None self._user_bot_latency_observer: Optional[UserBotLatencyObserver] = None self._turn_trace_observer: Optional[TurnTraceObserver] = None + self._tracing_context: Optional[TracingContext] = None if self._enable_turn_tracking: self._turn_tracking_observer = TurnTrackingObserver() observers.append(self._turn_tracking_observer) if self._enable_tracing and self._turn_tracking_observer: + # Create pipeline-scoped tracing context + self._tracing_context = TracingContext() # Create latency observer for tracing self._user_bot_latency_observer = UserBotLatencyObserver() observers.append(self._user_bot_latency_observer) @@ -303,6 +307,7 @@ class PipelineTask(BasePipelineTask): latency_tracker=self._user_bot_latency_observer, conversation_id=self._conversation_id, additional_span_attributes=self._additional_span_attributes, + tracing_context=self._tracing_context, ) observers.append(self._turn_trace_observer) @@ -813,6 +818,7 @@ class PipelineTask(BasePipelineTask): enable_usage_metrics=self._params.enable_usage_metrics, report_only_initial_ttfb=self._params.report_only_initial_ttfb, interruption_strategies=self._params.interruption_strategies, + tracing_context=self._tracing_context, ) start_frame.metadata = self._create_start_metadata() await self._pipeline.queue_frame(start_frame) diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 6b28b5bf2..e5884a868 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -92,9 +92,9 @@ class LLMUserAggregatorParams: user_mute_strategies: List of user mute strategies. user_turn_stop_timeout: Time in seconds to wait before considering the user's turn finished. - user_idle_timeout: Optional timeout in seconds for detecting user idle state. - If set, the aggregator will emit an `on_user_turn_idle` event when the user - has been idle (not speaking) for this duration. Set to None to disable + user_idle_timeout: Timeout in seconds for detecting user idle state. + The aggregator will emit an `on_user_turn_idle` event when the user + has been idle (not speaking) for this duration. Set to 0 to disable idle detection. vad_analyzer: Voice Activity Detection analyzer instance. filter_incomplete_user_turns: Whether to filter out incomplete user turns. @@ -109,7 +109,7 @@ class LLMUserAggregatorParams: user_turn_strategies: Optional[UserTurnStrategies] = None user_mute_strategies: List[BaseUserMuteStrategy] = field(default_factory=list) user_turn_stop_timeout: float = 5.0 - user_idle_timeout: Optional[float] = None + user_idle_timeout: float = 0 vad_analyzer: Optional[VADAnalyzer] = None filter_incomplete_user_turns: bool = False user_turn_completion_config: Optional[UserTurnCompletionConfig] = None @@ -404,15 +404,10 @@ class LLMUserAggregator(LLMContextAggregator): "on_user_turn_stop_timeout", self._on_user_turn_stop_timeout ) - # Optional user idle controller - self._user_idle_controller: Optional[UserIdleController] = None - if self._params.user_idle_timeout: - self._user_idle_controller = UserIdleController( - user_idle_timeout=self._params.user_idle_timeout - ) - self._user_idle_controller.add_event_handler( - "on_user_turn_idle", self._on_user_turn_idle - ) + self._user_idle_controller = UserIdleController( + user_idle_timeout=self._params.user_idle_timeout + ) + self._user_idle_controller.add_event_handler("on_user_turn_idle", self._on_user_turn_idle) # VAD controller self._vad_controller: Optional[VADController] = None @@ -489,8 +484,7 @@ class LLMUserAggregator(LLMContextAggregator): await self._user_turn_controller.process_frame(frame) - if self._user_idle_controller: - await self._user_idle_controller.process_frame(frame) + await self._user_idle_controller.process_frame(frame) async def push_aggregation(self) -> str: """Push the current aggregation.""" @@ -507,8 +501,7 @@ class LLMUserAggregator(LLMContextAggregator): async def _start(self, frame: StartFrame): await self._user_turn_controller.setup(self.task_manager) - if self._user_idle_controller: - await self._user_idle_controller.setup(self.task_manager) + await self._user_idle_controller.setup(self.task_manager) for s in self._params.user_mute_strategies: await s.setup(self.task_manager) @@ -541,14 +534,19 @@ class LLMUserAggregator(LLMContextAggregator): async def _cleanup(self): await self._user_turn_controller.cleanup() - - if self._user_idle_controller: - await self._user_idle_controller.cleanup() + await self._user_idle_controller.cleanup() for s in self._params.user_mute_strategies: await s.cleanup() async def _maybe_mute_frame(self, frame: Frame): + # Lifecycle frames should never be muted and should not trigger mute + # state changes. Evaluating mute strategies on StartFrame would + # broadcast UserMuteStartedFrame before StartFrame reaches downstream + # processors. + if isinstance(frame, (StartFrame, EndFrame, CancelFrame)): + return False + should_mute_frame = self._user_is_muted and isinstance( frame, ( @@ -689,6 +687,8 @@ class LLMUserAggregator(LLMContextAggregator): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStartedSpeakingFrame) + await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) + if params.enable_interruptions and self._allow_interruptions: await self.push_interruption_task_frame_and_wait() @@ -705,6 +705,8 @@ class LLMUserAggregator(LLMContextAggregator): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStoppedSpeakingFrame) + await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame()) + await self._maybe_emit_user_turn_stopped(strategy) async def _on_user_turn_stop_timeout(self, controller): @@ -1255,8 +1257,8 @@ class LLMContextAggregatorPair: self, context: LLMContext, *, - user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(), - assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(), + user_params: Optional[LLMUserAggregatorParams] = None, + assistant_params: Optional[LLMAssistantAggregatorParams] = None, ): """Initialize the LLM context aggregator pair. @@ -1265,6 +1267,8 @@ class LLMContextAggregatorPair: user_params: Parameters for the user context aggregator. assistant_params: Parameters for the assistant context aggregator. """ + user_params = user_params or LLMUserAggregatorParams() + assistant_params = assistant_params or LLMAssistantAggregatorParams() self._user = LLMUserAggregator(context, params=user_params) self._assistant = LLMAssistantAggregator(context, params=assistant_params) diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index f0c9e7183..bcdb2d57b 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -52,8 +52,6 @@ from pipecat.processors.metrics.frame_processor_metrics import FrameProcessorMet from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.base_object import BaseObject -INTERRUPTION_COMPLETION_TIMEOUT = 2.0 - class FrameDirection(Enum): """Direction of frame flow in the processing pipeline. @@ -419,27 +417,49 @@ class FrameProcessor(BaseObject): """ self._metrics.set_core_metrics_data(data) - async def start_ttfb_metrics(self): - """Start time-to-first-byte metrics collection.""" - if self.can_generate_metrics() and self.metrics_enabled: - await self._metrics.start_ttfb_metrics(self._report_only_initial_ttfb) + async def start_ttfb_metrics(self, *, start_time: Optional[float] = None): + """Start time-to-first-byte metrics collection. - async def stop_ttfb_metrics(self): - """Stop time-to-first-byte metrics collection and push results.""" + Args: + start_time: Optional timestamp to use as the start time. If None, + uses the current time. + """ if self.can_generate_metrics() and self.metrics_enabled: - frame = await self._metrics.stop_ttfb_metrics() + await self._metrics.start_ttfb_metrics( + start_time=start_time, report_only_initial_ttfb=self._report_only_initial_ttfb + ) + + async def stop_ttfb_metrics(self, *, end_time: Optional[float] = None): + """Stop time-to-first-byte metrics collection and push results. + + Args: + end_time: Optional timestamp to use as the end time. If None, uses + the current time. + """ + if self.can_generate_metrics() and self.metrics_enabled: + frame = await self._metrics.stop_ttfb_metrics(end_time=end_time) if frame: await self.push_frame(frame) - async def start_processing_metrics(self): - """Start processing metrics collection.""" - if self.can_generate_metrics() and self.metrics_enabled: - await self._metrics.start_processing_metrics() + async def start_processing_metrics(self, *, start_time: Optional[float] = None): + """Start processing metrics collection. - async def stop_processing_metrics(self): - """Stop processing metrics collection and push results.""" + Args: + start_time: Optional timestamp to use as the start time. If None, + uses the current time. + """ if self.can_generate_metrics() and self.metrics_enabled: - frame = await self._metrics.stop_processing_metrics() + await self._metrics.start_processing_metrics(start_time=start_time) + + async def stop_processing_metrics(self, *, end_time: Optional[float] = None): + """Stop processing metrics collection and push results. + + Args: + end_time: Optional timestamp to use as the end time. If None, uses + the current time. + """ + if self.can_generate_metrics() and self.metrics_enabled: + frame = await self._metrics.stop_processing_metrics(end_time=end_time) if frame: await self.push_frame(frame) @@ -741,7 +761,7 @@ class FrameProcessor(BaseObject): await self._call_event_handler("on_after_push_frame", frame) - async def push_interruption_task_frame_and_wait(self): + async def push_interruption_task_frame_and_wait(self, *, timeout: float = 5.0): """Push an interruption task frame upstream and wait for the interruption. This function sends an `InterruptionTaskFrame` upstream to the @@ -750,9 +770,11 @@ class FrameProcessor(BaseObject): attached to both frames so the caller can wait until the interruption has fully traversed the pipeline. The event is set when the `InterruptionFrame` reaches the pipeline sink. If the frame does - not complete within `INTERRUPTION_COMPLETION_TIMEOUT` seconds, a - warning is logged periodically until it completes. + not complete within the given timeout, a warning is logged and the + event is forcibly set so the caller is unblocked. + Args: + timeout: Maximum seconds to wait for the interruption to complete. """ self._wait_for_interruption = True @@ -760,19 +782,20 @@ class FrameProcessor(BaseObject): await self.push_frame(InterruptionTaskFrame(event=event), FrameDirection.UPSTREAM) - # Wait for the `InterruptionFrame` to complete and log a warning - # periodically if it takes too long. + # Wait for the `InterruptionFrame` to complete and log a warning if it + # takes too long. If it does take too long make sure we unblock it, + # otherwise we will hang here forever. while not event.is_set(): try: - await asyncio.wait_for(event.wait(), timeout=INTERRUPTION_COMPLETION_TIMEOUT) + await asyncio.wait_for(event.wait(), timeout=timeout) except asyncio.TimeoutError: logger.warning( f"{self}: InterruptionFrame has not completed after" - f" {INTERRUPTION_COMPLETION_TIMEOUT}s. Make sure" - " InterruptionFrame.complete() is being called (e.g. if the" - " frame is being blocked or consumed before reaching the" - " pipeline sink)." + f" {timeout}s. Make sure InterruptionFrame.complete()" + " is being called (e.g. if the frame is being blocked" + " or consumed before reaching the pipeline sink)." ) + event.set() self._wait_for_interruption = False @@ -787,8 +810,12 @@ class FrameProcessor(BaseObject): frame_cls: The class of the frame to be broadcasted. **kwargs: Keyword arguments to be passed to the frame's constructor. """ - await self.push_frame(frame_cls(**kwargs)) - await self.push_frame(frame_cls(**kwargs), FrameDirection.UPSTREAM) + downstream_frame = frame_cls(**kwargs) + upstream_frame = frame_cls(**kwargs) + downstream_frame.broadcast_sibling_id = upstream_frame.id + upstream_frame.broadcast_sibling_id = downstream_frame.id + await self.push_frame(downstream_frame) + await self.push_frame(upstream_frame, FrameDirection.UPSTREAM) async def broadcast_frame_instance(self, frame: Frame): """Broadcasts a frame instance upstream and downstream. @@ -812,15 +839,18 @@ class FrameProcessor(BaseObject): if not f.init and f.name not in ("id", "name") } - new_frame = frame_cls(**init_fields) + downstream_frame = frame_cls(**init_fields) for k, v in extra_fields.items(): - setattr(new_frame, k, v) - await self.push_frame(new_frame) + setattr(downstream_frame, k, v) - new_frame = frame_cls(**init_fields) + upstream_frame = frame_cls(**init_fields) for k, v in extra_fields.items(): - setattr(new_frame, k, v) - await self.push_frame(new_frame, FrameDirection.UPSTREAM) + setattr(upstream_frame, k, v) + + downstream_frame.broadcast_sibling_id = upstream_frame.id + upstream_frame.broadcast_sibling_id = downstream_frame.id + await self.push_frame(downstream_frame) + await self.push_frame(upstream_frame, FrameDirection.UPSTREAM) async def __start(self, frame: StartFrame): """Handle the start frame to initialize processor state. diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index d85cae11f..e01e95714 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -25,6 +25,7 @@ from typing import ( Literal, Mapping, Optional, + Set, Tuple, Union, ) @@ -1026,6 +1027,11 @@ class RTVIObserverParams: metrics_enabled: Indicates if metrics messages should be sent. system_logs_enabled: Indicates if system logs should be sent. errors_enabled: [Deprecated] Indicates if errors messages should be sent. + ignored_sources: List of frame processors whose frames should be silently ignored + by this observer. Useful for suppressing RTVI messages from secondary pipeline + branches (e.g. a silent evaluation LLM) that should not be visible to clients. + Sources can also be added and removed dynamically via ``add_ignored_source()`` + and ``remove_ignored_source()``. skip_aggregator_types: List of aggregation types to skip sending as tts/output messages. Note: if using this to avoid sending secure information, be sure to also disable bot_llm_enabled to avoid leaking through LLM messages. @@ -1065,6 +1071,7 @@ class RTVIObserverParams: metrics_enabled: bool = True system_logs_enabled: bool = False errors_enabled: Optional[bool] = None + ignored_sources: List[FrameProcessor] = field(default_factory=list) skip_aggregator_types: Optional[List[AggregationType | str]] = None bot_output_transforms: Optional[ List[ @@ -1110,12 +1117,17 @@ class RTVIObserver(BaseObserver): self._rtvi = rtvi self._params = params or RTVIObserverParams() + self._ignored_sources: Set[FrameProcessor] = set(self._params.ignored_sources) self._frames_seen = set() self._bot_transcription = "" self._last_user_audio_level = 0 self._last_bot_audio_level = 0 + # Track bot speaking state for queuing aggregated text frames + self._bot_is_speaking = False + self._queued_aggregated_text_frames: List[AggregatedTextFrame] = [] + if self._params.system_logs_enabled: self._system_logger_id = logger.add(self._logger_sink) @@ -1166,6 +1178,31 @@ class RTVIObserver(BaseObserver): if not (agg_type == aggregation_type and func == transform_function) ] + def add_ignored_source(self, source: FrameProcessor): + """Ignore all frames pushed by the given processor. + + Any frame whose source matches ``source`` will be silently skipped, + preventing RTVI messages from being emitted for activity in that + processor. Useful for suppressing events from secondary pipeline + branches (e.g. a silent evaluation LLM) that should not be visible + to clients. + + Args: + source: The frame processor to ignore. + """ + self._ignored_sources.add(source) + + def remove_ignored_source(self, source: FrameProcessor): + """Stop ignoring frames pushed by the given processor. + + Reverses a previous call to ``add_ignored_source()``. If ``source`` + was not previously ignored this is a no-op. + + Args: + source: The frame processor to stop ignoring. + """ + self._ignored_sources.discard(source) + def _get_function_call_report_level(self, function_name: str) -> RTVIFunctionCallReportLevel: """Get the report level for a specific function call. @@ -1216,10 +1253,13 @@ class RTVIObserver(BaseObserver): frame = data.frame direction = data.direction - # Only process downstream frames. Some frames are broadcast in both - # directions (e.g. UserStartedSpeakingFrame, FunctionCallResultFrame), - # and we only want to send one RTVI message per event. - if direction != FrameDirection.DOWNSTREAM: + # Frames from explicitly ignored sources are always skipped. + if self._ignored_sources and src in self._ignored_sources: + return + + # For broadcast frames (pushed in both directions), only process + # the downstream copy to avoid sending duplicate RTVI messages. + if frame.broadcast_sibling_id is not None and direction != FrameDirection.DOWNSTREAM: return # If we have already seen this frame, let's skip it. @@ -1384,17 +1424,30 @@ class RTVIObserver(BaseObserver): async def _handle_bot_speaking(self, frame: Frame): """Handle bot speaking event frames.""" - message = None if isinstance(frame, BotStartedSpeakingFrame): message = RTVIBotStartedSpeakingMessage() + await self.send_rtvi_message(message) + # Flush any queued aggregated text frames + for queued_frame in self._queued_aggregated_text_frames: + await self._send_aggregated_llm_text(queued_frame) + self._queued_aggregated_text_frames.clear() + self._bot_is_speaking = True elif isinstance(frame, BotStoppedSpeakingFrame): message = RTVIBotStoppedSpeakingMessage() - - if message: await self.send_rtvi_message(message) + self._bot_is_speaking = False async def _handle_aggregated_llm_text(self, frame: AggregatedTextFrame): """Handle aggregated LLM text output frames.""" + if self._bot_is_speaking: + # Bot has already started speaking, send directly + await self._send_aggregated_llm_text(frame) + else: + # Bot hasn't started speaking yet, queue the frame + self._queued_aggregated_text_frames.append(frame) + + async def _send_aggregated_llm_text(self, frame: AggregatedTextFrame): + """Send aggregated LLM text messages.""" # Skip certain aggregator types if configured to do so. if ( self._params.skip_aggregator_types diff --git a/src/pipecat/processors/metrics/frame_processor_metrics.py b/src/pipecat/processors/metrics/frame_processor_metrics.py index b8beba6e2..c82fd9698 100644 --- a/src/pipecat/processors/metrics/frame_processor_metrics.py +++ b/src/pipecat/processors/metrics/frame_processor_metrics.py @@ -107,49 +107,70 @@ class FrameProcessorMetrics(BaseObject): """ self._core_metrics_data = MetricsData(processor=name) - async def start_ttfb_metrics(self, report_only_initial_ttfb): + async def start_ttfb_metrics( + self, *, start_time: Optional[float] = None, report_only_initial_ttfb: bool + ): """Start measuring time-to-first-byte (TTFB). Args: + start_time: Optional timestamp to use as the start time. If None, + uses the current time. report_only_initial_ttfb: Whether to report only the first TTFB measurement. """ if self._should_report_ttfb: - self._start_ttfb_time = time.time() + self._start_ttfb_time = start_time or time.time() self._last_ttfb_time = 0 self._should_report_ttfb = not report_only_initial_ttfb - async def stop_ttfb_metrics(self): + async def stop_ttfb_metrics(self, *, end_time: Optional[float] = None): """Stop TTFB measurement and generate metrics frame. + Args: + end_time: Optional timestamp to use as the end time. If None, uses + the current time. + Returns: MetricsFrame containing TTFB data, or None if not measuring. """ if self._start_ttfb_time == 0: return None - self._last_ttfb_time = time.time() - self._start_ttfb_time - logger.debug(f"{self._processor_name()} TTFB: {self._last_ttfb_time}") + end_time = end_time or time.time() + + self._last_ttfb_time = end_time - self._start_ttfb_time + logger.debug(f"{self._processor_name()} TTFB: {self._last_ttfb_time:.3f}s") ttfb = TTFBMetricsData( processor=self._processor_name(), value=self._last_ttfb_time, model=self._model_name() ) self._start_ttfb_time = 0 return MetricsFrame(data=[ttfb]) - async def start_processing_metrics(self): - """Start measuring processing time.""" - self._start_processing_time = time.time() + async def start_processing_metrics(self, *, start_time: Optional[float] = None): + """Start measuring processing time. - async def stop_processing_metrics(self): + Args: + start_time: Optional timestamp to use as the start time. If None, + uses the current time. + """ + self._start_processing_time = start_time or time.time() + + async def stop_processing_metrics(self, *, end_time: Optional[float] = None): """Stop processing time measurement and generate metrics frame. + Args: + end_time: Optional timestamp to use as the end time. If None, uses + the current time. + Returns: MetricsFrame containing processing duration data, or None if not measuring. """ if self._start_processing_time == 0: return None - value = time.time() - self._start_processing_time - logger.debug(f"{self._processor_name()} processing time: {value}") + end_time = end_time or time.time() + + value = end_time - self._start_processing_time + logger.debug(f"{self._processor_name()} processing time: {value:.3f}s") processing = ProcessingMetricsData( processor=self._processor_name(), value=value, model=self._model_name() ) diff --git a/src/pipecat/services/ai_service.py b/src/pipecat/services/ai_service.py index 61ed7d456..8b32d7222 100644 --- a/src/pipecat/services/ai_service.py +++ b/src/pipecat/services/ai_service.py @@ -44,6 +44,8 @@ class AIService(FrameProcessor): super().__init__(**kwargs) self._settings: ServiceSettings = ServiceSettings(model="") self._session_properties: Dict[str, Any] = {} + self._tracing_enabled: bool = False + self._tracing_context = None def _sync_model_name_to_metrics(self): """Sync the current AI model name (in `self._settings.model`) for usage in metrics. @@ -71,7 +73,8 @@ class AIService(FrameProcessor): Args: frame: The start frame containing initialization parameters. """ - pass + self._tracing_enabled = frame.enable_tracing + self._tracing_context = frame.tracing_context async def stop(self, frame: EndFrame): """Stop the AI service. diff --git a/src/pipecat/services/anthropic/llm.py b/src/pipecat/services/anthropic/llm.py index c7f27c0d1..e79792bb0 100644 --- a/src/pipecat/services/anthropic/llm.py +++ b/src/pipecat/services/anthropic/llm.py @@ -214,7 +214,7 @@ class AnthropicLLMService(LLMService): self, *, api_key: str, - model: str = "claude-sonnet-4-5-20250929", + model: str = "claude-sonnet-4-6", params: Optional[InputParams] = None, client=None, retry_timeout_secs: Optional[float] = 5.0, @@ -225,7 +225,7 @@ class AnthropicLLMService(LLMService): Args: api_key: Anthropic API key for authentication. - model: Model name to use. Defaults to "claude-sonnet-4-5-20250929". + model: Model name to use. Defaults to "claude-sonnet-4-6". params: Optional model parameters for inference. client: Optional custom Anthropic client instance. retry_timeout_secs: Request timeout in seconds for retry logic. diff --git a/src/pipecat/services/asyncai/tts.py b/src/pipecat/services/asyncai/tts.py index 36b4ab4de..289c327b0 100644 --- a/src/pipecat/services/asyncai/tts.py +++ b/src/pipecat/services/asyncai/tts.py @@ -175,7 +175,6 @@ class AsyncAITTSService(AudioContextTTSService): self._receive_task = None self._keepalive_task = None - self._context_id = None async def _update_settings(self, update: TTSSettings) -> dict[str, Any]: """Apply a settings update. @@ -275,7 +274,7 @@ class AsyncAITTSService(AudioContextTTSService): f"{self._url}?api_key={self._api_key}&version={self._api_version}" ) init_msg = { - "model_id": self._model_name, + "model_id": self._settings.model, "voice": {"mode": "id", "id": self._settings.voice}, "output_format": { "container": self._settings.output_container, @@ -300,7 +299,7 @@ class AsyncAITTSService(AudioContextTTSService): if self._websocket: logger.debug("Disconnecting from Async") # Close all contexts and the socket - if self._context_id: + if self.has_active_audio_context(): await self._websocket.send(json.dumps({"terminate": True})) await self._websocket.close() logger.debug("Disconnected from Async") @@ -308,7 +307,7 @@ class AsyncAITTSService(AudioContextTTSService): await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: self._websocket = None - self._context_id = None + await self.remove_active_audio_context() await self._call_event_handler("on_disconnected") def _get_websocket(self): @@ -318,10 +317,11 @@ class AsyncAITTSService(AudioContextTTSService): async def flush_audio(self): """Flush any pending audio.""" - if not self._context_id or not self._websocket: + context_id = self.get_active_audio_context_id() + if not context_id or not self._websocket: return logger.trace(f"{self}: flushing audio") - msg = self._build_msg(text=" ", context_id=self._context_id, force=True) + msg = self._build_msg(text=" ", context_id=context_id, force=True) await self._websocket.send(msg) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): @@ -349,11 +349,11 @@ class AsyncAITTSService(AudioContextTTSService): # Check if this message belongs to the current context. if not self.audio_context_available(received_ctx_id): - if self._context_id == received_ctx_id: + if self.get_active_audio_context_id() == received_ctx_id: logger.debug( - f"Received a delayed message, recreating the context: {self._context_id}" + f"Received a delayed message, recreating the context: {received_ctx_id}" ) - await self.create_audio_context(self._context_id) + await self.create_audio_context(received_ctx_id) else: # This can happen if a message is received _after_ we have closed a context # due to user interruption but _before_ the `isFinal` message for the context @@ -374,10 +374,11 @@ class AsyncAITTSService(AudioContextTTSService): await asyncio.sleep(KEEPALIVE_SLEEP) try: if self._websocket and self._websocket.state is State.OPEN: - if self._context_id: + context_id = self.get_active_audio_context_id() + if context_id: keepalive_message = { "transcript": " ", - "context_id": self._context_id, + "context_id": context_id, } logger.trace("Sending keepalive message") else: @@ -393,19 +394,16 @@ class AsyncAITTSService(AudioContextTTSService): async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): """Handle interruption by closing the current context.""" + context_id = self.get_active_audio_context_id() await super()._handle_interruption(frame, direction) - # Close the current context when interrupted without closing the websocket - if self._context_id and self._websocket: + if context_id and self._websocket: try: await self._websocket.send( - json.dumps( - {"context_id": self._context_id, "close_context": True, "transcript": ""} - ) + json.dumps({"context_id": context_id, "close_context": True, "transcript": ""}) ) except Exception as e: logger.error(f"Error closing context on interruption: {e}") - self._context_id = None @traced_tts async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: @@ -425,15 +423,13 @@ class AsyncAITTSService(AudioContextTTSService): await self._connect() try: - await self.start_ttfb_metrics() - yield TTSStartedFrame(context_id=context_id) + if not self.has_active_audio_context(): + await self.start_ttfb_metrics() + yield TTSStartedFrame(context_id=context_id) + if not self.audio_context_available(context_id): + await self.create_audio_context(context_id) - if not self._context_id: - self._context_id = context_id - if not self.audio_context_available(self._context_id): - await self.create_audio_context(self._context_id) - - msg = self._build_msg(text=text, force=True, context_id=self._context_id) + msg = self._build_msg(text=text, force=True, context_id=context_id) await self._get_websocket().send(msg) await self.start_tts_usage_metrics(text) diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py index 1185a2305..cca540c72 100644 --- a/src/pipecat/services/cartesia/tts.py +++ b/src/pipecat/services/cartesia/tts.py @@ -345,7 +345,6 @@ class CartesiaTTSService(AudioContextWordTTSService): ) self._sync_model_name_to_metrics() - self._context_id = None self._receive_task = None def can_generate_metrics(self) -> bool: @@ -457,7 +456,7 @@ class CartesiaTTSService(AudioContextWordTTSService): msg = { "transcript": text, "continue": continue_transcript, - "context_id": self._context_id, + "context_id": self.get_active_audio_context_id(), "model_id": self._settings.model, "voice": voice_config, "output_format": { @@ -554,7 +553,7 @@ class CartesiaTTSService(AudioContextWordTTSService): except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: - self._context_id = None + await self.remove_active_audio_context() self._websocket = None await self._call_event_handler("on_disconnected") @@ -564,21 +563,22 @@ class CartesiaTTSService(AudioContextWordTTSService): raise Exception("Websocket not connected") async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): + context_id = self.get_active_audio_context_id() await super()._handle_interruption(frame, direction) await self.stop_all_metrics() - if self._context_id: - cancel_msg = json.dumps({"context_id": self._context_id, "cancel": True}) + if context_id: + cancel_msg = json.dumps({"context_id": context_id, "cancel": True}) await self._get_websocket().send(cancel_msg) - self._context_id = None async def flush_audio(self): """Flush any pending audio and finalize the current context.""" - if not self._context_id or not self._websocket: + context_id = self.get_active_audio_context_id() + if not context_id or not self._websocket: return logger.trace(f"{self}: flushing audio") msg = self._build_msg(text="", continue_transcript=False) await self._websocket.send(msg) - self._context_id = None + self.reset_active_audio_context() async def _process_messages(self): async for message in self._get_websocket(): @@ -610,7 +610,7 @@ class CartesiaTTSService(AudioContextWordTTSService): await self.push_frame(TTSStoppedFrame(context_id=ctx_id)) await self.stop_all_metrics() await self.push_error(error_msg=f"Error: {msg}") - self._context_id = None + self.reset_active_audio_context() else: await self.push_error(error_msg=f"Error, unknown message type: {msg}") @@ -639,11 +639,10 @@ class CartesiaTTSService(AudioContextWordTTSService): if not self._websocket or self._websocket.state is State.CLOSED: await self._connect() - if not self._context_id: + if not self.has_active_audio_context(): await self.start_ttfb_metrics() yield TTSStartedFrame(context_id=context_id) - self._context_id = context_id - await self.create_audio_context(self._context_id) + await self.create_audio_context(context_id) msg = self._build_msg(text=text) diff --git a/src/pipecat/services/deepgram/flux/stt.py b/src/pipecat/services/deepgram/flux/stt.py index 3b6ede086..410d272da 100644 --- a/src/pipecat/services/deepgram/flux/stt.py +++ b/src/pipecat/services/deepgram/flux/stt.py @@ -103,6 +103,20 @@ class DeepgramFluxSTTService(WebsocketSTTService): Provides real-time speech recognition using Deepgram's WebSocket API with Flux capabilities. Supports configurable models, VAD events, and various audio processing options including advanced turn detection and EagerEndOfTurn events for improved conversational AI performance. + + Event handlers available (in addition to WebsocketSTTService events): + + - on_speech_started(service): Deepgram detected start of speech + - on_utterance_end(service): Deepgram detected end of utterance + - on_end_of_turn(service): Deepgram detected end of turn (EOT) + - on_eager_end_of_turn(service): Deepgram predicted end of turn (EagerEOT) + - on_turn_resumed(service): User resumed speaking after EagerEOT + + Example:: + + @stt.event_handler("on_end_of_turn") + async def on_end_of_turn(service): + ... """ _settings: DeepgramFluxSTTSettings diff --git a/src/pipecat/services/deepgram/stt.py b/src/pipecat/services/deepgram/stt.py index 6e5955450..28b8a211e 100644 --- a/src/pipecat/services/deepgram/stt.py +++ b/src/pipecat/services/deepgram/stt.py @@ -63,6 +63,17 @@ class DeepgramSTTService(STTService): Provides real-time speech recognition using Deepgram's WebSocket API. Supports configurable models, languages, and various audio processing options. + + Event handlers available (in addition to STTService events): + + - on_speech_started(service): Deepgram detected start of speech + - on_utterance_end(service): Deepgram detected end of utterance + + Example:: + + @stt.event_handler("on_speech_started") + async def on_speech_started(service): + ... """ _settings: DeepgramSTTSettings diff --git a/src/pipecat/services/deepgram/stt_sagemaker.py b/src/pipecat/services/deepgram/stt_sagemaker.py index 71fdf4417..30a53a4d1 100644 --- a/src/pipecat/services/deepgram/stt_sagemaker.py +++ b/src/pipecat/services/deepgram/stt_sagemaker.py @@ -403,7 +403,6 @@ class DeepgramSageMakerSTTService(STTService): return is_final = parsed.get("is_final", False) - speech_final = parsed.get("speech_final", False) # Extract language if available language = None @@ -411,8 +410,12 @@ class DeepgramSageMakerSTTService(STTService): language = alternatives[0]["languages"][0] language = Language(language) - if is_final and speech_final: - # Final transcription + if is_final: + # Check if this response is from a finalize() call. + # Only mark as finalized when both we requested it AND Deepgram confirms it. + from_finalize = parsed.get("from_finalize", False) + if from_finalize: + self.confirm_finalize() await self.push_frame( TranscriptionFrame( transcript, @@ -470,10 +473,12 @@ class DeepgramSageMakerSTTService(STTService): if isinstance(frame, VADUserStartedSpeakingFrame): await self._start_metrics() elif isinstance(frame, VADUserStoppedSpeakingFrame): - # Send finalize message to Deepgram when user stops speaking - # This tells Deepgram to flush any remaining audio and return final results + # https://developers.deepgram.com/docs/finalize + # Mark that we're awaiting a from_finalize response + self.request_finalize() if self._client and self._client.is_active: try: await self._client.send_json({"type": "Finalize"}) except Exception as e: logger.warning(f"Error sending Finalize message: {e}") + logger.trace(f"Triggered finalize event on: {frame.name=}, {direction=}") diff --git a/src/pipecat/services/deepgram/tts.py b/src/pipecat/services/deepgram/tts.py index 23bfecb03..9216d7aa7 100644 --- a/src/pipecat/services/deepgram/tts.py +++ b/src/pipecat/services/deepgram/tts.py @@ -111,6 +111,7 @@ class DeepgramTTSService(WebsocketTTSService): voice=voice, encoding=encoding, ) + self._sync_model_name_to_metrics() self._receive_task = None self._context_id: Optional[str] = None @@ -193,6 +194,11 @@ class DeepgramTTSService(WebsocketTTSService): """ changed = await super()._update_settings(update) + # 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() + if changed: await self._disconnect() await self._connect() @@ -397,6 +403,7 @@ class DeepgramHttpTTSService(TTSService): voice=voice, encoding=encoding, ) + self._sync_model_name_to_metrics() def can_generate_metrics(self) -> bool: """Check if the service can generate metrics. diff --git a/src/pipecat/services/deepgram/tts_sagemaker.py b/src/pipecat/services/deepgram/tts_sagemaker.py new file mode 100644 index 000000000..24f9eaec9 --- /dev/null +++ b/src/pipecat/services/deepgram/tts_sagemaker.py @@ -0,0 +1,354 @@ +# +# 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, + **kwargs, + ) + + self._endpoint_name = endpoint_name + self._region = region + self._settings = DeepgramSageMakerTTSSettings( + model=voice, + voice=voice, + encoding=encoding, + ) + self._sync_model_name_to_metrics() + + 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, update: TTSSettings) -> dict[str, Any]: + """Apply a settings update 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(update) + + # 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() + + if changed: + await self._disconnect() + await self._connect() + + 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/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py index 55875cb69..72fa6c11a 100644 --- a/src/pipecat/services/elevenlabs/tts.py +++ b/src/pipecat/services/elevenlabs/tts.py @@ -435,7 +435,6 @@ class ElevenLabsTTSService(AudioContextWordTTSService): self._partial_word_start_time = 0.0 # Context management for v1 multi API - self._context_id = None self._receive_task = None self._keepalive_task = None @@ -505,19 +504,20 @@ class ElevenLabsTTSService(AudioContextWordTTSService): ) await self._disconnect() await self._connect() - elif voice_settings_changed and self._context_id: + elif voice_settings_changed and self.has_active_audio_context(): logger.debug( f"Voice settings changed ({changed.keys() & ElevenLabsTTSSettings.VOICE_SETTINGS_FIELDS}), " f"closing current context to apply changes" ) + context_id = self.get_active_audio_context_id() try: if self._websocket: await self._websocket.send( - json.dumps({"context_id": self._context_id, "close_context": True}) + json.dumps({"context_id": context_id, "close_context": True}) ) except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) - self._context_id = None + self.reset_active_audio_context() if not url_changed: # Reconnect applies all settings; only warn about fields not handled @@ -557,10 +557,11 @@ class ElevenLabsTTSService(AudioContextWordTTSService): async def flush_audio(self): """Flush any pending audio and finalize the current context.""" - if not self._context_id or not self._websocket: + context_id = self.get_active_audio_context_id() + if not context_id or not self._websocket: return logger.trace(f"{self}: flushing audio") - msg = {"context_id": self._context_id, "flush": True} + msg = {"context_id": context_id, "flush": True} await self._websocket.send(json.dumps(msg)) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): @@ -573,7 +574,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService): await super().push_frame(frame, direction) if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)): if isinstance(frame, TTSStoppedFrame): - await self.add_word_timestamps([("Reset", 0)], self._context_id) + await self.add_word_timestamps([("Reset", 0)], self.get_active_audio_context_id()) async def _connect(self): await super()._connect() @@ -648,14 +649,14 @@ class ElevenLabsTTSService(AudioContextWordTTSService): if self._websocket: logger.debug("Disconnecting from ElevenLabs") # Close all contexts and the socket - if self._context_id: + if self.has_active_audio_context(): await self._websocket.send(json.dumps({"close_socket": True})) await self._websocket.close() logger.debug("Disconnected from ElevenLabs") except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: - self._context_id = None + await self.remove_active_audio_context() self._websocket = None await self._call_event_handler("on_disconnected") @@ -666,11 +667,12 @@ class ElevenLabsTTSService(AudioContextWordTTSService): async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): """Handle interruption by closing the current context.""" + # Close the current context when interrupted without closing the websocket + context_id = self.get_active_audio_context_id() await super()._handle_interruption(frame, direction) - # Close the current context when interrupted without closing the websocket - if self._context_id and self._websocket: - logger.trace(f"Closing context {self._context_id} due to interruption") + if context_id and self._websocket: + logger.trace(f"Closing context {context_id} due to interruption") try: # ElevenLabs requires that Pipecat manages the contexts and closes them # when they're not longer in use. Since an InterruptionFrame is pushed @@ -679,11 +681,10 @@ class ElevenLabsTTSService(AudioContextWordTTSService): # Note: We do not need to call remove_audio_context here, as the context is # automatically reset when super ()._handle_interruption is called. await self._websocket.send( - json.dumps({"context_id": self._context_id, "close_context": True}) + json.dumps({"context_id": context_id, "close_context": True}) ) except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) - self._context_id = None self._partial_word = "" self._partial_word_start_time = 0.0 @@ -703,11 +704,11 @@ class ElevenLabsTTSService(AudioContextWordTTSService): # Check if this message belongs to the current context. if not self.audio_context_available(received_ctx_id): - if self._context_id == received_ctx_id: + if self.get_active_audio_context_id() == received_ctx_id: logger.debug( - f"Received a delayed message, recreating the context: {self._context_id}" + f"Received a delayed message, recreating the context: {received_ctx_id}" ) - await self.create_audio_context(self._context_id) + await self.create_audio_context(received_ctx_id) else: # This can happen if a message is received _after_ we have closed a context # due to user interruption but _before_ the `isFinal` message for the context @@ -760,13 +761,14 @@ class ElevenLabsTTSService(AudioContextWordTTSService): await asyncio.sleep(KEEPALIVE_SLEEP) try: if self._websocket and self._websocket.state is State.OPEN: - if self._context_id: + context_id = self.get_active_audio_context_id() + if context_id: # Send keepalive with context ID to keep the connection alive keepalive_message = { "text": "", - "context_id": self._context_id, + "context_id": context_id, } - logger.trace(f"Sending keepalive for context {self._context_id}") + logger.trace(f"Sending keepalive for context {context_id}") else: # It's possible to have a user interruption which clears the context # without generating a new TTS response. In this case, we'll just send @@ -780,8 +782,9 @@ class ElevenLabsTTSService(AudioContextWordTTSService): async def _send_text(self, text: str): """Send text to the WebSocket for synthesis.""" - if self._websocket and self._context_id: - msg = {"text": text, "context_id": self._context_id} + context_id = self.get_active_audio_context_id() + if self._websocket and context_id: + msg = {"text": text, "context_id": context_id} await self._websocket.send(json.dumps(msg)) @traced_tts @@ -802,31 +805,27 @@ class ElevenLabsTTSService(AudioContextWordTTSService): await self._connect() try: - await self.start_ttfb_metrics() - yield TTSStartedFrame(context_id=context_id) - self._cumulative_time = 0 - self._partial_word = "" - self._partial_word_start_time = 0.0 - # If a context ID does not exist, use the provided one. - # If an ID exists, that means the Pipeline doesn't allow - # user interruptions, so continue using the current ID. - # When interruptions are allowed, user speech results in - # an interruption, which resets the context ID. - if not self._context_id: - self._context_id = context_id - if not self.audio_context_available(self._context_id): - await self.create_audio_context(self._context_id) + if not self.has_active_audio_context(): + await self.start_ttfb_metrics() + yield TTSStartedFrame(context_id=context_id) + self._cumulative_time = 0 + self._partial_word = "" + self._partial_word_start_time = 0.0 - # Initialize context with voice settings and pronunciation dictionaries - msg = {"text": " ", "context_id": self._context_id} - if self._voice_settings: - msg["voice_settings"] = self._voice_settings - if self._pronunciation_dictionary_locators: - msg["pronunciation_dictionary_locators"] = [ - locator.model_dump() for locator in self._pronunciation_dictionary_locators - ] - await self._websocket.send(json.dumps(msg)) - logger.trace(f"Created new context {self._context_id}") + if not self.audio_context_available(context_id): + await self.create_audio_context(context_id) + + # Initialize context with voice settings and pronunciation dictionaries + msg = {"text": " ", "context_id": context_id} + if self._voice_settings: + msg["voice_settings"] = self._voice_settings + if self._pronunciation_dictionary_locators: + msg["pronunciation_dictionary_locators"] = [ + locator.model_dump() + for locator in self._pronunciation_dictionary_locators + ] + await self._websocket.send(json.dumps(msg)) + logger.trace(f"Created new context {context_id}") await self._send_text(text) await self.start_tts_usage_metrics(text) diff --git a/src/pipecat/services/gradium/tts.py b/src/pipecat/services/gradium/tts.py index 85e77f057..8b8995c41 100644 --- a/src/pipecat/services/gradium/tts.py +++ b/src/pipecat/services/gradium/tts.py @@ -17,6 +17,7 @@ from pipecat.frames.frames import ( EndFrame, ErrorFrame, Frame, + InterruptionFrame, StartFrame, TTSAudioRawFrame, TTSStartedFrame, @@ -24,7 +25,7 @@ from pipecat.frames.frames import ( ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven -from pipecat.services.tts_service import InterruptibleWordTTSService +from pipecat.services.tts_service import AudioContextWordTTSService from pipecat.utils.tracing.service_decorators import traced_tts try: @@ -50,7 +51,7 @@ class GradiumTTSSettings(TTSSettings): output_format: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) -class GradiumTTSService(InterruptibleWordTTSService): +class GradiumTTSService(AudioContextWordTTSService): """Text-to-Speech service using Gradium's websocket API.""" _settings: GradiumTTSSettings @@ -86,9 +87,9 @@ class GradiumTTSService(InterruptibleWordTTSService): params: Additional configuration parameters. **kwargs: Additional arguments passed to parent class. """ - # Initialize with parent class settings for proper frame handling super().__init__( push_stop_frames=True, + push_text_frames=False, pause_frame_processing=True, sample_rate=SAMPLE_RATE, **kwargs, @@ -108,7 +109,6 @@ class GradiumTTSService(InterruptibleWordTTSService): # State tracking self._receive_task = None - self._current_context_id: Optional[str] = None def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -137,7 +137,11 @@ class GradiumTTSService(InterruptibleWordTTSService): def _build_msg(self, text: str = "") -> dict: """Build JSON message for Gradium API.""" - return {"text": text, "type": "text"} + msg = {"text": text, "type": "text"} + context_id = self.get_active_audio_context_id() + if context_id: + msg["client_req_id"] = context_id + return msg async def start(self, frame: StartFrame): """Start the service and establish websocket connection. @@ -208,6 +212,7 @@ class GradiumTTSService(InterruptibleWordTTSService): "type": "setup", "output_format": "pcm", "voice_id": self._settings.voice, + "close_ws_on_eos": False, } if self._json_config is not None: setup_msg["json_config"] = self._json_config @@ -234,6 +239,7 @@ class GradiumTTSService(InterruptibleWordTTSService): except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: + await self.remove_active_audio_context() self._websocket = None await self._call_event_handler("on_disconnected") @@ -245,18 +251,35 @@ class GradiumTTSService(InterruptibleWordTTSService): async def flush_audio(self): """Flush any pending audio synthesis.""" - if not self._websocket: + context_id = self.get_active_audio_context_id() + if not context_id or not self._websocket: return try: - msg = {"type": "end_of_stream"} + msg = {"type": "end_of_stream", "client_req_id": context_id} await self._websocket.send(json.dumps(msg)) + self.reset_active_audio_context() except ConnectionClosedOK: logger.debug(f"{self}: connection closed normally during flush") except Exception as e: logger.error(f"{self} exception: {e}") + async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): + """Handle interruption by resetting context state. + + The parent AudioContextTTSService._handle_interruption() cancels the audio context + task and creates a new one. We reset _context_id so the next run_tts() creates a + fresh context. No websocket reconnection needed — audio from the old client_req_id + will be silently dropped since the audio context no longer exists. + + Args: + frame: The interruption frame. + direction: The direction of the frame. + """ + await super()._handle_interruption(frame, direction) + await self.stop_all_metrics() + async def _receive_messages(self): - """Process incoming websocket messages.""" + """Process incoming websocket messages, demultiplexing by client_req_id.""" # TODO(laurent): This should not be necessary as it should happen when # receiving the messages but this does not seem to always be the case # and that may lead to a busy polling loop. @@ -264,41 +287,35 @@ class GradiumTTSService(InterruptibleWordTTSService): raise ConnectionClosedOK(None, None) async for message in self._get_websocket(): msg = json.loads(message) + ctx_id = msg.get("client_req_id") if msg["type"] == "audio": - # Process audio chunk + if not ctx_id or not self.audio_context_available(ctx_id): + continue await self.stop_ttfb_metrics() await self.start_word_timestamps() frame = TTSAudioRawFrame( audio=base64.b64decode(msg["audio"]), sample_rate=self.sample_rate, num_channels=1, - context_id=self._current_context_id, + context_id=ctx_id, ) - await self.push_frame(frame) + await self.append_to_audio_context(ctx_id, frame) elif msg["type"] == "text": - if self._current_context_id: - await self.add_word_timestamps( - [(msg["text"], msg["start_s"])], self._current_context_id - ) + if ctx_id and self.audio_context_available(ctx_id): + await self.add_word_timestamps([(msg["text"], msg["start_s"])], ctx_id) + elif msg["type"] == "end_of_stream": - await self.push_frame(TTSStoppedFrame()) + if ctx_id and self.audio_context_available(ctx_id): + await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id) + await self.remove_audio_context(ctx_id) await self.stop_all_metrics() elif msg["type"] == "error": - await self.push_frame(TTSStoppedFrame()) + await self.push_frame(TTSStoppedFrame(context_id=ctx_id)) await self.stop_all_metrics() - await self.push_error(error_msg=f"Error: {msg['message']}") - - async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): - """Push frame and handle end-of-turn conditions. - - Args: - frame: The frame to push. - direction: The direction to push the frame. - """ - await super().push_frame(frame, direction) + await self.push_error(error_msg=f"Error: {msg.get('message', msg)}") @traced_tts async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: @@ -311,16 +328,17 @@ class GradiumTTSService(InterruptibleWordTTSService): Yields: Frame: Audio frames containing the synthesized speech. """ - _state = self._websocket.state if self._websocket is not None else None - logger.debug(f"{self}: Generating TTS [{text}] {_state}") + logger.debug(f"{self}: Generating TTS [{text}]") try: if not self._websocket or self._websocket.state is State.CLOSED: self._websocket = None await self._connect() try: - self._current_context_id = context_id - yield TTSStartedFrame(context_id=context_id) + if not self.has_active_audio_context(): + await self.start_ttfb_metrics() + yield TTSStartedFrame(context_id=context_id) + await self.create_audio_context(context_id) msg = self._build_msg(text=text) await self._get_websocket().send(json.dumps(msg)) diff --git a/src/pipecat/services/grok/realtime/events.py b/src/pipecat/services/grok/realtime/events.py index 4069ff927..1f89a92f7 100644 --- a/src/pipecat/services/grok/realtime/events.py +++ b/src/pipecat/services/grok/realtime/events.py @@ -216,7 +216,7 @@ class SessionProperties(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) instructions: Optional[str] = None - voice: Optional[GrokVoice] = "Ara" + voice: Optional[GrokVoice | str] = "Ara" turn_detection: Optional[TurnDetection] = Field( default_factory=lambda: TurnDetection(type="server_vad") ) diff --git a/src/pipecat/services/inworld/tts.py b/src/pipecat/services/inworld/tts.py index 1d8aa7e4b..11e6125c0 100644 --- a/src/pipecat/services/inworld/tts.py +++ b/src/pipecat/services/inworld/tts.py @@ -16,12 +16,17 @@ Inworld’s text-to-speech (TTS) models offer ultra-realistic, context-aware spe import asyncio import base64 import json +import uuid from dataclasses import dataclass, field -from typing import Any, AsyncGenerator, ClassVar, Dict, List, Mapping, Optional, Tuple +from typing import Any, AsyncGenerator, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple import aiohttp import websockets from loguru import logger + +from pipecat import version as pipecat_version + +USER_AGENT = f"pipecat/{pipecat_version()}" from pydantic import BaseModel from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven, is_given @@ -65,6 +70,7 @@ class InworldTTSSettings(TTSSettings): maintaining high quality audio output. If None (default), automatically set based on aggregate_sentences. apply_text_normalization: Whether to apply text normalization. + timestamp_transport_strategy: Strategy for timestamp transport ("ASYNC" or "SYNC"). """ audio_encoding: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) @@ -73,6 +79,7 @@ class InworldTTSSettings(TTSSettings): temperature: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) auto_mode: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) apply_text_normalization: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + timestamp_transport_strategy: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) _aliases: ClassVar[Dict[str, str]] = { "voice_id": "voice", @@ -80,6 +87,7 @@ class InworldTTSSettings(TTSSettings): "modelId": "model", "applyTextNormalization": "apply_text_normalization", "autoMode": "auto_mode", + "timestampTransportStrategy": "timestamp_transport_strategy", } @classmethod @@ -109,10 +117,12 @@ class InworldHttpTTSService(WordTTSService): Parameters: temperature: Temperature for speech synthesis. speaking_rate: Speaking rate for speech synthesis. + timestamp_transport_strategy: The strategy to use for timestamp transport. """ temperature: Optional[float] = None speaking_rate: Optional[float] = None + timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = "ASYNC" def __init__( self, @@ -170,6 +180,8 @@ class InworldHttpTTSService(WordTTSService): self._settings.temperature = params.temperature if params.speaking_rate is not None: self._settings.speaking_rate = params.speaking_rate + if params.timestamp_transport_strategy is not None: + self._settings.timestamp_transport_strategy = params.timestamp_transport_strategy self._cumulative_time = 0.0 @@ -288,10 +300,15 @@ class InworldHttpTTSService(WordTTSService): # Use WORD timestamps for simplicity and correct spacing/capitalization payload["timestampType"] = self._timestamp_type + if is_given(self._settings.timestamp_transport_strategy): + payload["timestampTransportStrategy"] = self._settings.timestamp_transport_strategy + request_id = str(uuid.uuid4()) headers = { "Authorization": f"Basic {self._api_key}", "Content-Type": "application/json", + "X-User-Agent": USER_AGENT, + "X-Request-Id": request_id, } try: @@ -305,7 +322,7 @@ class InworldHttpTTSService(WordTTSService): ) as response: if response.status != 200: error_text = await response.text() - logger.error(f"Inworld API error: {error_text}") + logger.error(f"Inworld API error (request_id={request_id}): {error_text}") yield ErrorFrame(error=f"Inworld API error: {error_text}") return @@ -474,6 +491,7 @@ class InworldTTSService(AudioContextWordTTSService): flushing of buffered text to achieve minimal latency while maintaining high quality audio output. If None (default), automatically set based on aggregate_sentences. + timestamp_transport_strategy: The strategy to use for timestamp transport. """ temperature: Optional[float] = None @@ -481,7 +499,8 @@ class InworldTTSService(AudioContextWordTTSService): apply_text_normalization: Optional[str] = None max_buffer_delay_ms: Optional[int] = None buffer_char_threshold: Optional[int] = None - auto_mode: Optional[bool] = None + auto_mode: Optional[bool] = True + timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = "ASYNC" def __init__( self, @@ -539,6 +558,8 @@ class InworldTTSService(AudioContextWordTTSService): self._settings.speaking_rate = params.speaking_rate if params.apply_text_normalization is not None: self._settings.apply_text_normalization = params.apply_text_normalization + if params.timestamp_transport_strategy is not None: + self._settings.timestamp_transport_strategy = params.timestamp_transport_strategy if params.auto_mode is not None: self._settings.auto_mode = params.auto_mode @@ -552,7 +573,6 @@ class InworldTTSService(AudioContextWordTTSService): self._receive_task = None self._keepalive_task = None - self._context_id = None # Track cumulative time across generations for monotonic timestamps within a turn. # When auto_mode is enabled, the server controls generations and timestamps reset @@ -607,9 +627,10 @@ class InworldTTSService(AudioContextWordTTSService): keeping the context open for subsequent text. The context is only closed on interruption, disconnect, or end of session. """ - if self._context_id and self._websocket: - logger.trace(f"Flushing audio for context {self._context_id}") - await self._send_flush(self._context_id) + context_id = self.get_active_audio_context_id() + if context_id and self._websocket: + logger.trace(f"Flushing audio for context {context_id}") + await self._send_flush(context_id) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): """Push a frame and handle state changes. @@ -674,7 +695,7 @@ class InworldTTSService(AudioContextWordTTSService): frame: The interruption frame. direction: The direction of the interruption. """ - old_context_id = self._context_id + old_context_id = self.get_active_audio_context_id() logger.trace(f"{self}: Handling interruption, old context: {old_context_id}") await super()._handle_interruption(frame, direction) @@ -686,7 +707,6 @@ class InworldTTSService(AudioContextWordTTSService): except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) - self._context_id = None self._cumulative_time = 0.0 self._generation_end_time = 0.0 logger.trace(f"{self}: Interruption handled, context reset to None") @@ -760,8 +780,13 @@ class InworldTTSService(AudioContextWordTTSService): if self._websocket and self._websocket.state is State.OPEN: return - logger.debug("Connecting to Inworld WebSocket TTS") - headers = [("Authorization", f"Basic {self._api_key}")] + request_id = str(uuid.uuid4()) + logger.debug(f"Connecting to Inworld WebSocket TTS (request_id={request_id})") + headers = [ + ("Authorization", f"Basic {self._api_key}"), + ("X-User-Agent", USER_AGENT), + ("X-Request-Id", request_id), + ] self._websocket = await websocket_connect(self._url, additional_headers=headers) await self._call_event_handler("on_connected") except Exception as e: @@ -780,9 +805,10 @@ class InworldTTSService(AudioContextWordTTSService): if self._websocket: logger.debug("Disconnecting from Inworld WebSocket TTS") - if self._context_id: + context_id = self.get_active_audio_context_id() + if context_id: try: - await self._send_close_context(self._context_id) + await self._send_close_context(context_id) except Exception: pass await self._websocket.close() @@ -790,7 +816,7 @@ class InworldTTSService(AudioContextWordTTSService): except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: - self._context_id = None + await self.remove_active_audio_context() self._websocket = None self._cumulative_time = 0.0 self._generation_end_time = 0.0 @@ -816,7 +842,7 @@ class InworldTTSService(AudioContextWordTTSService): ] logger.debug( f"{self}: Received message types={msg_types}, ctx_id={ctx_id}, " - f"current_ctx={self._context_id}, available={self.audio_context_available(ctx_id) if ctx_id else 'N/A'}" + f"current_ctx={self.get_active_audio_context_id()}, available={self.audio_context_available(ctx_id) if ctx_id else 'N/A'}" ) # Check for errors @@ -828,7 +854,9 @@ class InworldTTSService(AudioContextWordTTSService): # Handle "Context not found" error (code 5) # This can happen when a keepalive message is sent but no context is available. if error_code == 5 and "not found" in error_msg.lower(): - logger.debug(f"{self}: Context {ctx_id or self._context_id} not found.") + logger.debug( + f"{self}: Context {ctx_id or self.get_active_audio_context_id()} not found." + ) continue # For other errors, push error frame @@ -843,11 +871,9 @@ class InworldTTSService(AudioContextWordTTSService): # If the context isn't available but matches our current context ID, # recreate it (handles race conditions during interruption recovery). if ctx_id and not self.audio_context_available(ctx_id): - if self._context_id == ctx_id: - logger.trace( - f"{self}: Recreating audio context for current context: {self._context_id}" - ) - await self.create_audio_context(self._context_id) + if self.get_active_audio_context_id() == ctx_id: + logger.trace(f"{self}: Recreating audio context for current context: {ctx_id}") + await self.create_audio_context(ctx_id) else: # This is a message from an old/closed context - skip it logger.trace(f"{self}: Skipping message from unavailable context: {ctx_id}") @@ -869,12 +895,12 @@ class InworldTTSService(AudioContextWordTTSService): if ctx_id: await self.append_to_audio_context(ctx_id, frame) - # timestampInfo is inside audioChunk - timestamp_info = audio_chunk.get("timestampInfo") - if timestamp_info: - word_times = self._calculate_word_times(timestamp_info) - if word_times: - await self.add_word_timestamps(word_times, ctx_id) + # timestampInfo is inside audioChunk + timestamp_info = audio_chunk.get("timestampInfo") + if timestamp_info: + word_times = self._calculate_word_times(timestamp_info) + if word_times: + await self.add_word_timestamps(word_times, ctx_id) # Handle context created confirmation if "contextCreated" in result: @@ -893,8 +919,8 @@ class InworldTTSService(AudioContextWordTTSService): logger.trace(f"{self}: Context closed on server: {ctx_id}") await self.stop_ttfb_metrics() # Only reset if this is our current context - if ctx_id == self._context_id: - self._context_id = None + if ctx_id == self.get_active_audio_context_id(): + self.reset_active_audio_context() if ctx_id and self.audio_context_available(ctx_id): await self.remove_audio_context(ctx_id) await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id) @@ -906,12 +932,13 @@ class InworldTTSService(AudioContextWordTTSService): await asyncio.sleep(KEEPALIVE_SLEEP) try: if self._websocket and self._websocket.state is State.OPEN: - if self._context_id: + context_id = self.get_active_audio_context_id() + if context_id: keepalive_message = { "send_text": {"text": ""}, - "contextId": self._context_id, + "contextId": context_id, } - logger.trace(f"Sending keepalive for context {self._context_id}") + logger.trace(f"Sending keepalive for context {context_id}") else: keepalive_message = {"send_text": {"text": ""}} logger.trace("Sending keepalive without context") @@ -945,6 +972,10 @@ class InworldTTSService(AudioContextWordTTSService): create_config["applyTextNormalization"] = self._settings.apply_text_normalization if is_given(self._settings.auto_mode): create_config["autoMode"] = self._settings.auto_mode + if is_given(self._settings.timestamp_transport_strategy): + create_config["timestampTransportStrategy"] = ( + self._settings.timestamp_transport_strategy + ) # Set buffer settings for timely audio generation. # Use provided values or defaults that work well for streaming LLM output. @@ -1003,20 +1034,13 @@ class InworldTTSService(AudioContextWordTTSService): await self._connect() try: - await self.start_ttfb_metrics() - yield TTSStartedFrame(context_id=context_id) + if not self.has_active_audio_context(): + await self.start_ttfb_metrics() + yield TTSStartedFrame(context_id=context_id) + await self.create_audio_context(context_id) + await self._send_context(context_id) - if not self._context_id: - self._context_id = context_id - logger.trace(f"{self}: Creating new context {self._context_id}") - await self.create_audio_context(self._context_id) - await self._send_context(self._context_id) - elif not self.audio_context_available(self._context_id): - # Context exists on server but local tracking was removed - logger.trace(f"{self}: Recreating local audio context {self._context_id}") - await self.create_audio_context(self._context_id) - - await self._send_text(self._context_id, text) + await self._send_text(context_id, text) await self.start_tts_usage_metrics(text) except Exception as e: diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index 9db7ad27a..e4f1adb45 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -202,7 +202,6 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): self._functions: Dict[Optional[str], FunctionCallRegistryItem] = {} self._function_call_tasks: Dict[Optional[asyncio.Task], FunctionCallRunnerItem] = {} self._sequential_runner_task: Optional[asyncio.Task] = None - self._tracing_enabled: bool = False self._skip_tts: Optional[bool] = None self._summary_task: Optional[asyncio.Task] = None self._settings = LLMSettings() # Here in case subclass doesn't implement more specific settings (hopefully shouldn't happen) @@ -290,7 +289,6 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService): await super().start(frame) if not self._run_in_parallel: await self._create_sequential_runner_task() - self._tracing_enabled = frame.enable_tracing async def stop(self, frame: EndFrame): """Stop the LLM service. diff --git a/src/pipecat/services/openai/base_llm.py b/src/pipecat/services/openai/base_llm.py index aad88472a..960ebd6f7 100644 --- a/src/pipecat/services/openai/base_llm.py +++ b/src/pipecat/services/openai/base_llm.py @@ -393,20 +393,29 @@ class BaseOpenAILLMService(LLMService): else self._stream_chat_completions_universal_context(context) ) - # Ensure stream is closed on cancellation/exception to prevent socket - # leaks. OpenAI's AsyncStream uses close(), async generators use aclose(). + # Ensure stream and its async iterator are closed on cancellation/exception + # to prevent socket leaks and uvloop crashes. Closing the iterator first + # cascades cleanup through nested async generators (httpx/httpcore internals), + # preventing uvloop's broken asyncgen finalizer from firing on Python 3.12+ + # (MagicStack/uvloop#699). @asynccontextmanager async def _closing(stream): + chunk_iter = stream.__aiter__() try: - yield stream + yield chunk_iter finally: - if hasattr(stream, "aclose"): - await stream.aclose() - elif hasattr(stream, "close"): + # Close the iterator first to cascade cleanup through + # nested async generators (httpx/httpcore internals). + if hasattr(chunk_iter, "aclose"): + await chunk_iter.aclose() + # Then close the stream to release HTTP resources. + if hasattr(stream, "close"): await stream.close() + elif hasattr(stream, "aclose"): + await stream.aclose() - async with _closing(chunk_stream): - async for chunk in chunk_stream: + async with _closing(chunk_stream) as chunk_iter: + async for chunk in chunk_iter: if chunk.usage: cached_tokens = ( chunk.usage.prompt_tokens_details.cached_tokens diff --git a/src/pipecat/services/playht/tts.py b/src/pipecat/services/playht/tts.py index 26f1e2dad..ff34725f9 100644 --- a/src/pipecat/services/playht/tts.py +++ b/src/pipecat/services/playht/tts.py @@ -13,6 +13,7 @@ supporting both WebSocket streaming and HTTP-based synthesis. import io import json import struct +import uuid import warnings from dataclasses import dataclass, field from typing import Any, AsyncGenerator, Optional @@ -366,6 +367,20 @@ class PlayHTTTSService(InterruptibleTTSService): return self._websocket raise Exception("Websocket not connected") + def create_context_id(self) -> str: + """Generate a unique context ID for a TTS request in case we don't have one already in progress. + + Returns: + A unique string identifier for the TTS context. + """ + # If a context ID does not exist, create a new one. + # If an ID exists, continue using the current ID. + # When interruptions happen, user speech results in + # an interruption, which resets the context ID. + if not self._context_id: + return str(uuid.uuid4()) + return self._context_id + async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): """Handle interruption by stopping metrics and clearing request ID.""" await super()._handle_interruption(frame, direction) diff --git a/src/pipecat/services/resembleai/tts.py b/src/pipecat/services/resembleai/tts.py index 8e436923b..b8bb4a1da 100644 --- a/src/pipecat/services/resembleai/tts.py +++ b/src/pipecat/services/resembleai/tts.py @@ -27,8 +27,6 @@ from pipecat.frames.frames import ( from pipecat.processors.frame_processor import FrameDirection from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven from pipecat.services.tts_service import AudioContextWordTTSService -from pipecat.transcriptions.language import Language -from pipecat.utils.text.base_text_aggregator import BaseTextAggregator from pipecat.utils.tracing.service_decorators import traced_tts try: @@ -94,6 +92,7 @@ class ResembleAITTSService(AudioContextWordTTSService): """ super().__init__( sample_rate=sample_rate, + reuse_context_id_within_turn=False, **kwargs, ) diff --git a/src/pipecat/services/rime/tts.py b/src/pipecat/services/rime/tts.py index d4cb045df..6e03c2461 100644 --- a/src/pipecat/services/rime/tts.py +++ b/src/pipecat/services/rime/tts.py @@ -77,22 +77,32 @@ class RimeTTSSettings(TTSSettings): Parameters: audioFormat: Audio output format. samplingRate: Audio sample rate. - lang: Rime language code. - speedAlpha: Speech speed multiplier. Defaults to 1.0. - reduceLatency: Whether to reduce latency at potential quality cost. - pauseBetweenBrackets: Whether to add pauses between bracketed content. - phonemizeBetweenBrackets: Whether to phonemize bracketed content. + segment: Text segmentation mode ("immediate", "bySentence", "never"). + speedAlpha: Speech speed multiplier (mistv2 only). + reduceLatency: Whether to reduce latency at potential quality cost (mistv2 only). + pauseBetweenBrackets: Whether to add pauses between bracketed content (mistv2 only). + phonemizeBetweenBrackets: Whether to phonemize bracketed content (mistv2 only). + noTextNormalization: Whether to disable text normalization (mistv2 only). + saveOovs: Whether to save out-of-vocabulary words (mistv2 only). inlineSpeedAlpha: Inline speed control markup. + repetition_penalty: Token repetition penalty (arcana only, 1.0-2.0). + temperature: Sampling temperature (arcana only, 0.0-1.0). + top_p: Cumulative probability threshold (arcana only, 0.0-1.0). """ audioFormat: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) samplingRate: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN) - lang: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + segment: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) speedAlpha: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) reduceLatency: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) pauseBetweenBrackets: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) phonemizeBetweenBrackets: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + noTextNormalization: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + saveOovs: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) inlineSpeedAlpha: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + repetition_penalty: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + temperature: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + top_p: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) _aliases: ClassVar[Dict[str, str]] = {"speaker": "voice"} @@ -104,7 +114,6 @@ class RimeNonJsonTTSSettings(TTSSettings): Parameters: audioFormat: Audio output format. samplingRate: Audio sample rate. - lang: Rime language code. segment: Text segmentation mode ("immediate", "bySentence", "never"). repetition_penalty: Token repetition penalty (1.0-2.0). temperature: Sampling temperature (0.0-1.0). @@ -113,7 +122,6 @@ class RimeNonJsonTTSSettings(TTSSettings): audioFormat: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) samplingRate: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN) - lang: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) segment: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) repetition_penalty: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) temperature: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) @@ -137,25 +145,39 @@ class RimeTTSService(AudioContextWordTTSService): Parameters: language: Language for synthesis. Defaults to English. - speed_alpha: Speech speed multiplier. Defaults to 1.0. - reduce_latency: Whether to reduce latency at potential quality cost. - pause_between_brackets: Whether to add pauses between bracketed content. - phonemize_between_brackets: Whether to phonemize bracketed content. + segment: Text segmentation mode ("immediate", "bySentence", "never"). + repetition_penalty: Token repetition penalty (arcana only). + temperature: Sampling temperature (arcana only). + top_p: Cumulative probability threshold (arcana only). + speed_alpha: Speech speed multiplier (mistv2 only). + reduce_latency: Whether to reduce latency at potential quality cost (mistv2 only). + pause_between_brackets: Whether to add pauses between bracketed content (mistv2 only). + phonemize_between_brackets: Whether to phonemize bracketed content (mistv2 only). + no_text_normalization: Whether to disable text normalization (mistv2 only). + save_oovs: Whether to save out-of-vocabulary words (mistv2 only). """ language: Optional[Language] = Language.EN - speed_alpha: Optional[float] = 1.0 - reduce_latency: Optional[bool] = False - pause_between_brackets: Optional[bool] = False - phonemize_between_brackets: Optional[bool] = False + segment: Optional[str] = None + # Arcana params + repetition_penalty: Optional[float] = None + temperature: Optional[float] = None + top_p: Optional[float] = None + # Mistv2 params + speed_alpha: Optional[float] = None + reduce_latency: Optional[bool] = None + pause_between_brackets: Optional[bool] = None + phonemize_between_brackets: Optional[bool] = None + no_text_normalization: Optional[bool] = None + save_oovs: Optional[bool] = None def __init__( self, *, api_key: str, voice_id: str, - url: str = "wss://users.rime.ai/ws2", - model: str = "mistv2", + url: str = "wss://users-ws.rime.ai/ws3", + model: str = "arcana", sample_rate: Optional[int] = None, params: Optional[InputParams] = None, text_aggregator: Optional[BaseTextAggregator] = None, @@ -203,22 +225,38 @@ class RimeTTSService(AudioContextWordTTSService): # Store service configuration self._api_key = api_key self._url = url - self._model = model self._settings = RimeTTSSettings( - voice=voice_id, model=model, + voice=voice_id, audioFormat="pcm", - samplingRate=0, - lang=self.language_to_service_language(params.language) if params.language else "eng", - speedAlpha=params.speed_alpha, - reduceLatency=params.reduce_latency, - pauseBetweenBrackets=json.dumps(params.pause_between_brackets), - phonemizeBetweenBrackets=json.dumps(params.phonemize_between_brackets), + samplingRate=0, # updated in start() + language=self.language_to_service_language(params.language) + if params.language + else NOT_GIVEN, + segment=params.segment if params.segment is not None else NOT_GIVEN, + # Arcana params + repetition_penalty=params.repetition_penalty + if params.repetition_penalty is not None + else NOT_GIVEN, + temperature=params.temperature if params.temperature is not None else NOT_GIVEN, + top_p=params.top_p if params.top_p is not None else NOT_GIVEN, + # Mistv2 params + speedAlpha=params.speed_alpha if params.speed_alpha is not None else NOT_GIVEN, + reduceLatency=params.reduce_latency if params.reduce_latency is not None else NOT_GIVEN, + pauseBetweenBrackets=params.pause_between_brackets + if params.pause_between_brackets is not None + else NOT_GIVEN, + phonemizeBetweenBrackets=params.phonemize_between_brackets + if params.phonemize_between_brackets is not None + else NOT_GIVEN, + noTextNormalization=params.no_text_normalization + if params.no_text_normalization is not None + else NOT_GIVEN, + saveOovs=params.save_oovs if params.save_oovs is not None else NOT_GIVEN, ) self._sync_model_name_to_metrics() # State tracking - self._context_id = None # Tracks current turn self._receive_task = None self._cumulative_time = 0 # Accumulates time across messages self._extra_msg_fields = {} # Extra fields for next message @@ -242,6 +280,50 @@ class RimeTTSService(AudioContextWordTTSService): """ return language_to_rime_language(language) + def _build_ws_params(self) -> dict[str, Any]: + """Build query params for the WebSocket URL from current settings. + + Returns: + Dictionary of query parameters for the WebSocket URL. + Only explicitly-set values are included. Boolean mistv2 params + are serialized with ``json.dumps()`` for the wire format. + """ + params: dict[str, Any] = { + "speaker": self._settings.voice, + "modelId": self._settings.model, + "audioFormat": self._settings.audioFormat, + "samplingRate": self._settings.samplingRate, + } + if is_given(self._settings.language): + params["lang"] = self._settings.language + if is_given(self._settings.segment): + params["segment"] = self._settings.segment + + if self._settings.model == "arcana": + if is_given(self._settings.repetition_penalty): + params["repetition_penalty"] = self._settings.repetition_penalty + if is_given(self._settings.temperature): + params["temperature"] = self._settings.temperature + if is_given(self._settings.top_p): + params["top_p"] = self._settings.top_p + else: # mistv2/mist + if is_given(self._settings.speedAlpha): + params["speedAlpha"] = self._settings.speedAlpha + if is_given(self._settings.reduceLatency): + params["reduceLatency"] = self._settings.reduceLatency + if is_given(self._settings.pauseBetweenBrackets): + params["pauseBetweenBrackets"] = json.dumps(self._settings.pauseBetweenBrackets) + if is_given(self._settings.phonemizeBetweenBrackets): + params["phonemizeBetweenBrackets"] = json.dumps( + self._settings.phonemizeBetweenBrackets + ) + if is_given(self._settings.noTextNormalization): + params["noTextNormalization"] = json.dumps(self._settings.noTextNormalization) + if is_given(self._settings.saveOovs): + params["saveOovs"] = json.dumps(self._settings.saveOovs) + + return params + # A set of Rime-specific helpers for text transformations def SPELL(text: str) -> str: """Wrap text in Rime spell function.""" @@ -268,18 +350,22 @@ class RimeTTSService(AudioContextWordTTSService): return f"[{text}]" async def _update_settings(self, update: TTSSettings) -> dict[str, Any]: - """Apply a settings update and reconnect if voice changed.""" + """Apply a settings update and reconnect if necessary. + + Since all settings are WebSocket URL query parameters, + any setting change requires reconnecting to apply the new values. + """ changed = await super()._update_settings(update) - if "voice" in changed: + + if changed and self._websocket: await self._disconnect() await self._connect() - else: - self._warn_unhandled_updated_settings(changed) + return changed def _build_msg(self, text: str = "") -> dict: """Build JSON message for Rime API.""" - msg = {"text": text, "contextId": self._context_id} + msg = {"text": text, "contextId": self.get_active_audio_context_id()} if self._extra_msg_fields: msg |= self._extra_msg_fields self._extra_msg_fields = {} @@ -346,20 +432,8 @@ class RimeTTSService(AudioContextWordTTSService): if self._websocket and self._websocket.state is State.OPEN: return - params = "&".join( - f"{k}={v}" - for k, v in { - "speaker": self._settings.voice, - "modelId": self._settings.model, - "audioFormat": self._settings.audioFormat, - "samplingRate": self._settings.samplingRate, - "lang": self._settings.lang, - "speedAlpha": self._settings.speedAlpha, - "reduceLatency": self._settings.reduceLatency, - "pauseBetweenBrackets": self._settings.pauseBetweenBrackets, - "phonemizeBetweenBrackets": self._settings.phonemizeBetweenBrackets, - }.items() - ) + ws_params = self._build_ws_params() + params = "&".join(f"{k}={v}" for k, v in ws_params.items() if v is not None) url = f"{self._url}?{params}" headers = {"Authorization": f"Bearer {self._api_key}"} self._websocket = await websocket_connect(url, additional_headers=headers) @@ -380,7 +454,7 @@ class RimeTTSService(AudioContextWordTTSService): except Exception as e: await self.push_error(error_msg=f"Error disconnecting: {e}", exception=e) finally: - self._context_id = None + await self.remove_active_audio_context() self._websocket = None await self._call_event_handler("on_disconnected") @@ -392,11 +466,11 @@ class RimeTTSService(AudioContextWordTTSService): async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): """Handle interruption by clearing current context.""" + context_id = self.get_active_audio_context_id() await super()._handle_interruption(frame, direction) await self.stop_all_metrics() - if self._context_id: + if context_id: await self._get_websocket().send(json.dumps(self._build_clear_msg())) - self._context_id = None def _calculate_word_times(self, words: list, starts: list, ends: list) -> list: """Calculate word timing pairs with proper spacing and punctuation. @@ -429,19 +503,20 @@ class RimeTTSService(AudioContextWordTTSService): async def flush_audio(self): """Flush any pending audio synthesis.""" - if not self._context_id or not self._websocket: + context_id = self.get_active_audio_context_id() + if not context_id or not self._websocket: return logger.trace(f"{self}: flushing audio") await self._get_websocket().send(json.dumps({"operation": "flush"})) - self._context_id = None + self.reset_active_audio_context() async def _receive_messages(self): """Process incoming websocket messages.""" async for message in self._get_websocket(): msg = json.loads(message) - if not msg or not self.audio_context_available(msg["contextId"]): + if not msg or not self.audio_context_available(msg.get("contextId")): continue context_id = msg["contextId"] @@ -476,7 +551,7 @@ class RimeTTSService(AudioContextWordTTSService): await self.push_frame(TTSStoppedFrame()) await self.stop_all_metrics() await self.push_error(error_msg=f"Error: {msg['message']}") - self._context_id = None + self.reset_active_audio_context() async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): """Push frame and handle end-of-turn conditions. @@ -507,12 +582,11 @@ class RimeTTSService(AudioContextWordTTSService): await self._connect() try: - if not self._context_id: + if not self.has_active_audio_context(): await self.start_ttfb_metrics() yield TTSStartedFrame(context_id=context_id) self._cumulative_time = 0 - self._context_id = context_id - await self.create_audio_context(self._context_id) + await self.create_audio_context(context_id) msg = self._build_msg(text=text) await self._get_websocket().send(json.dumps(msg)) @@ -587,7 +661,9 @@ class RimeHttpTTSService(TTSService): self._base_url = "https://users.rime.ai/v1/rime-tts" self._settings = RimeTTSSettings( model=model, - lang=self.language_to_service_language(params.language) if params.language else "eng", + language=self.language_to_service_language(params.language) + if params.language + else "eng", speedAlpha=params.speed_alpha, reduceLatency=params.reduce_latency, pauseBetweenBrackets=params.pause_between_brackets, @@ -636,7 +712,7 @@ class RimeHttpTTSService(TTSService): } payload = { - "lang": self._settings.lang, + "lang": self._settings.language, "speedAlpha": self._settings.speedAlpha, "reduceLatency": self._settings.reduceLatency, "pauseBetweenBrackets": self._settings.pauseBetweenBrackets, @@ -691,20 +767,18 @@ class RimeHttpTTSService(TTSService): class RimeNonJsonTTSService(InterruptibleTTSService): """Pipecat TTS service for Rime's non-JSON WebSocket API. + .. deprecated:: 0.0.102 + Arcana now supports JSON WebSocket with word-level timestamps via the + ``wss://users-ws.rime.ai/ws3`` endpoint. Use :class:`RimeTTSService` + with ``model="arcana"`` instead. + This service enables Text-to-Speech synthesis over WebSocket endpoints that require plain text (not JSON) messages and return raw audio bytes. - It is designed for use with TTS models like Arcana, which currently do - not support JSON-based WebSocket protocols (though this may change in - the future). Limitations: - Does not support word-level timestamps or context IDs. - Intended specifically for integrations where the TTS provider only accepts and returns non-JSON messages. - - Note: - - Arcana and similar models may add JSON WebSocket support in the - future. This service focuses on the current plain text protocol. """ _settings: RimeNonJsonTTSSettings @@ -764,13 +838,12 @@ class RimeNonJsonTTSService(InterruptibleTTSService): params = params or RimeNonJsonTTSService.InputParams() self._api_key = api_key self._url = url - self._model = model self._settings = RimeNonJsonTTSSettings( voice=voice_id, model=model, audioFormat=audio_format, samplingRate=sample_rate, - lang=self.language_to_service_language(params.language) + language=self.language_to_service_language(params.language) if params.language else NOT_GIVEN, segment=params.segment if params.segment is not None else NOT_GIVEN, @@ -866,8 +939,8 @@ class RimeNonJsonTTSService(InterruptibleTTSService): "audioFormat": self._settings.audioFormat, "samplingRate": self._settings.samplingRate, } - if is_given(self._settings.lang): - settings_dict["lang"] = self._settings.lang + if is_given(self._settings.language): + settings_dict["lang"] = self._settings.language if is_given(self._settings.segment): settings_dict["segment"] = self._settings.segment if is_given(self._settings.repetition_penalty): diff --git a/src/pipecat/services/sarvam/stt.py b/src/pipecat/services/sarvam/stt.py index b2c9560e4..0128c1a22 100644 --- a/src/pipecat/services/sarvam/stt.py +++ b/src/pipecat/services/sarvam/stt.py @@ -120,10 +120,10 @@ MODEL_CONFIGS: Dict[str, ModelConfig] = { use_translate_method=True, ), "saaras:v3": ModelConfig( - supports_prompt=True, + supports_prompt=False, supports_mode=True, supports_language=True, - default_language="en-IN", + default_language="unknown", default_mode="transcribe", use_translate_endpoint=False, use_translate_method=False, @@ -152,6 +152,18 @@ class SarvamSTTService(STTService): """Sarvam speech-to-text service. Provides real-time speech recognition using Sarvam's WebSocket API. + + Event handlers available (in addition to STTService events): + + - on_connected(service): Connected to Sarvam WebSocket + - on_disconnected(service): Disconnected from Sarvam WebSocket + - on_connection_error(service, error): Connection error occurred + + Example:: + + @stt.event_handler("on_connected") + async def on_connected(service): + ... """ _settings: SarvamSTTSettings @@ -163,9 +175,9 @@ class SarvamSTTService(STTService): language: Target language for transcription. - saarika:v2.5: Defaults to "unknown" (auto-detect supported) - saaras:v2.5: Not used (auto-detects language) - - saaras:v3: Defaults to "en-IN" + - saaras:v3: Defaults to "unknown" (auto-detect supported) prompt: Optional prompt to guide transcription/translation style/context. - Only applicable to saaras models (v2.5 and v3). Defaults to None. + Only applicable to saaras:v2.5. Defaults to None. mode: Mode of operation for saaras:v3 models only. Options: transcribe, translate, verbatim, translit, codemix. Defaults to "transcribe" for saaras:v3. vad_signals: Enable VAD signals in response. Defaults to None. @@ -187,6 +199,8 @@ class SarvamSTTService(STTService): input_audio_codec: str = "wav", params: Optional[InputParams] = None, ttfs_p99_latency: Optional[float] = SARVAM_TTFS_P99, + keepalive_timeout: Optional[float] = None, + keepalive_interval: float = 5.0, **kwargs, ): """Initialize the Sarvam STT service. @@ -196,12 +210,15 @@ class SarvamSTTService(STTService): model: Sarvam model to use for transcription. Allowed values: - "saarika:v2.5": Standard STT model - "saaras:v2.5": STT-Translate model (auto-detects language, supports prompts) - - "saaras:v3": Advanced STT model (supports mode and prompts) + - "saaras:v3": Advanced STT model (supports mode) sample_rate: Audio sample rate. Defaults to 16000 if not specified. input_audio_codec: Audio codec/format of the input file. Defaults to "wav". params: Configuration parameters for Sarvam STT service. ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark + keepalive_timeout: Seconds of no audio before sending silence to keep the + connection alive. None disables keepalive. + keepalive_interval: Seconds between idle checks when keepalive is enabled. **kwargs: Additional arguments passed to the parent STTService. """ params = params or SarvamSTTService.InputParams() @@ -223,7 +240,13 @@ class SarvamSTTService(STTService): f"Model '{model}' does not support language parameter (auto-detects language)." ) - super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs) + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + keepalive_timeout=keepalive_timeout, + keepalive_interval=keepalive_interval, + **kwargs, + ) self._api_key = api_key @@ -490,13 +513,36 @@ class SarvamSTTService(STTService): if self._config.supports_mode and is_given(self._settings.mode): connect_kwargs["mode"] = self._settings.mode + # Prompt support differs across sarvamai versions. Prefer connect-time prompt + # when available and gracefully degrade if the SDK doesn't accept it. + if ( + is_given(self._settings.prompt) + and self._settings.prompt is not None + and self._config.supports_prompt + ): + connect_kwargs["prompt"] = self._settings.prompt + def _connect_with_sdk_headers(connect_fn, **kwargs): # Different SDK versions may use different kwarg names. - for header_kw in ("headers", "additional_headers", "extra_headers"): + # If prompt is unsupported at connect-time, retry without it. + attempts = [kwargs] + if "prompt" in kwargs: + attempts.append({k: v for k, v in kwargs.items() if k != "prompt"}) + + last_type_error = None + for attempt_kwargs in attempts: + for header_kw in ("headers", "additional_headers", "extra_headers"): + try: + return connect_fn(**attempt_kwargs, **{header_kw: self._sdk_headers}) + except TypeError as e: + last_type_error = e try: - return connect_fn(**kwargs, **{header_kw: self._sdk_headers}) - except TypeError: - pass + return connect_fn(**attempt_kwargs) + except TypeError as e: + last_type_error = e + + if last_type_error is not None: + raise last_type_error return connect_fn(**kwargs) # Choose the appropriate endpoint based on model configuration @@ -514,9 +560,15 @@ class SarvamSTTService(STTService): # Enter the async context manager self._socket_client = await self._websocket_context.__aenter__() - # Set prompt if provided (only for models that support prompts) - if is_given(self._settings.prompt) and self._config.supports_prompt: - await self._socket_client.set_prompt(self._settings.prompt) + # Fallback for SDKs that support runtime prompt updates. + if ( + is_given(self._settings.prompt) + and self._settings.prompt is not None + and self._config.supports_prompt + ): + prompt_setter = getattr(self._socket_client, "set_prompt", None) + if callable(prompt_setter): + await prompt_setter(self._settings.prompt) # Register event handler for incoming messages def _message_handler(message): @@ -529,6 +581,8 @@ class SarvamSTTService(STTService): # Start receive task using Pipecat's task management self._receive_task = self.create_task(self._receive_task_handler()) + self._create_keepalive_task() + logger.info("Connected to Sarvam successfully") except ApiError as e: @@ -542,6 +596,8 @@ class SarvamSTTService(STTService): async def _disconnect(self): """Disconnect from Sarvam WebSocket API using SDK.""" + await self._cancel_keepalive_task() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None @@ -668,6 +724,32 @@ class SarvamSTTService(STTService): } return mapping.get(language_code, Language.HI_IN) + def _is_keepalive_ready(self) -> bool: + """Check if the Sarvam SDK websocket client is connected.""" + return self._socket_client is not None + + async def _send_keepalive(self, silence: bytes): + """Send silent audio via the Sarvam SDK to keep the connection alive. + + Args: + silence: Silent 16-bit mono PCM audio bytes. + """ + audio_base64 = base64.b64encode(silence).decode("utf-8") + encoding = ( + self._input_audio_codec + if self._input_audio_codec.startswith("audio/") + else f"audio/{self._input_audio_codec}" + ) + method_kwargs = { + "audio": audio_base64, + "encoding": encoding, + "sample_rate": self.sample_rate, + } + if self._config.use_translate_method: + await self._socket_client.translate(**method_kwargs) + else: + await self._socket_client.transcribe(**method_kwargs) + async def _start_metrics(self): """Start processing metrics collection.""" await self.start_processing_metrics() diff --git a/src/pipecat/services/simli/video.py b/src/pipecat/services/simli/video.py index b1f7961af..880b40428 100644 --- a/src/pipecat/services/simli/video.py +++ b/src/pipecat/services/simli/video.py @@ -131,7 +131,6 @@ class SimliVideoService(FrameProcessor): # Build SimliConfig from new parameters # Only pass optional parameters if explicitly provided to use SimliConfig defaults config_kwargs = { - "apiKey": api_key, "faceId": face_id, } if params.max_session_length is not None: @@ -153,10 +152,10 @@ class SimliVideoService(FrameProcessor): config.maxIdleTime += 5 config.maxSessionLength += 5 self._simli_client = SimliClient( + api_key=api_key, config=config, - latencyInterval=latency_interval, simliURL=simli_url, - enable_logging=params.enable_logging or False, + enableSFU=True, ) self._pipecat_resampler: AudioResampler = None @@ -173,7 +172,7 @@ class SimliVideoService(FrameProcessor): """Start the connection to Simli service and begin processing tasks.""" try: if not self._initialized: - await self._simli_client.Initialize() + await self._simli_client.start() self._initialized = True # Create task to consume and process audio and video diff --git a/src/pipecat/services/speechmatics/stt.py b/src/pipecat/services/speechmatics/stt.py index 0ec5609b2..4a476c01f 100644 --- a/src/pipecat/services/speechmatics/stt.py +++ b/src/pipecat/services/speechmatics/stt.py @@ -166,6 +166,16 @@ class SpeechmaticsSTTService(STTService): This service provides real-time speech-to-text transcription using the Speechmatics API. It supports partial and final transcriptions, multiple languages, various audio formats, and speaker diarization. + + Event handlers available (in addition to STTService events): + + - on_speakers_result(service, speakers): Speaker diarization results received + + Example:: + + @stt.event_handler("on_speakers_result") + async def on_speakers_result(service, speakers): + ... """ _settings: SpeechmaticsSTTSettings diff --git a/src/pipecat/services/stt_service.py b/src/pipecat/services/stt_service.py index de2539932..dfdae6de6 100644 --- a/src/pipecat/services/stt_service.py +++ b/src/pipecat/services/stt_service.py @@ -22,7 +22,6 @@ from pipecat.frames.frames import ( ErrorFrame, Frame, InterruptionFrame, - MetricsFrame, ServiceSwitcherRequestMetadataFrame, StartFrame, STTMetadataFrame, @@ -32,7 +31,6 @@ from pipecat.frames.frames import ( VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) -from pipecat.metrics.metrics import TTFBMetricsData from pipecat.processors.frame_processor import FrameDirection from pipecat.services.ai_service import AIService from pipecat.services.settings import STTSettings, is_given @@ -51,6 +49,12 @@ class STTService(AIService): muting, settings management, and audio processing. Subclasses must implement the run_stt method to provide actual speech recognition. + Includes an optional keepalive mechanism that sends silent audio when no real + audio has been sent for a configurable timeout, preventing servers from closing + idle connections (e.g. when behind a ServiceSwitcher). Subclasses that enable + keepalive must override ``_send_keepalive()`` to deliver the silence in the + appropriate service-specific protocol. + Event handlers: on_connected: Called when connected to the STT service. on_disconnected: Called when disconnected from the STT service. @@ -80,6 +84,8 @@ class STTService(AIService): sample_rate: Optional[int] = None, stt_ttfb_timeout: float = 2.0, ttfs_p99_latency: Optional[float] = None, + keepalive_timeout: Optional[float] = None, + keepalive_interval: float = 5.0, **kwargs, ): """Initialize the STT service. @@ -99,14 +105,18 @@ class STTService(AIService): This is broadcast via STTMetadataFrame at pipeline start for downstream processors (e.g., turn strategies) to optimize timing. Subclasses provide measured defaults; pass a value here to override for your deployment. + keepalive_timeout: Seconds of no audio before sending silence to keep the + connection alive. None disables keepalive. Useful for services that + close idle connections (e.g. behind a ServiceSwitcher). + keepalive_interval: Seconds between idle checks when keepalive is enabled. **kwargs: Additional arguments passed to the parent AIService. """ super().__init__(**kwargs) self._audio_passthrough = audio_passthrough self._init_sample_rate = sample_rate self._sample_rate = 0 + self._settings = STTSettings() # Here in case subclass doesn't implement more specific settings (hopefully shouldn't happen) - self._tracing_enabled: bool = False self._muted: bool = False self._user_id: str = "" self._ttfs_p99_latency = ttfs_p99_latency @@ -114,12 +124,16 @@ class STTService(AIService): # STT TTFB tracking state self._stt_ttfb_timeout = stt_ttfb_timeout self._ttfb_timeout_task: Optional[asyncio.Task] = None - self._speech_end_time: Optional[float] = None self._user_speaking: bool = False - self._last_transcription_time: Optional[float] = None self._finalize_pending: bool = False self._finalize_requested: bool = False + # Keepalive state + self._keepalive_timeout = keepalive_timeout + self._keepalive_interval = keepalive_interval + self._keepalive_task: Optional[asyncio.Task] = None + self._last_audio_time: float = 0 + self._register_event_handler("on_connected") self._register_event_handler("on_disconnected") self._register_event_handler("on_connection_error") @@ -241,12 +255,12 @@ class STTService(AIService): """ await super().start(frame) self._sample_rate = self._init_sample_rate or frame.audio_in_sample_rate - self._tracing_enabled = frame.enable_tracing async def cleanup(self): """Clean up STT service resources.""" await super().cleanup() await self._cancel_ttfb_timeout() + await self._cancel_keepalive_task() async def _update_settings(self, update: STTSettings) -> dict[str, Any]: """Apply an STT settings update. @@ -287,6 +301,8 @@ class STTService(AIService): if self._muted: return + self._last_audio_time = time.monotonic() + # UserAudioRawFrame contains a user_id (e.g. Daily, Livekit) if hasattr(frame, "user_id"): self._user_id = frame.user_id @@ -369,23 +385,16 @@ class STTService(AIService): direction: The direction to push the frame. """ if isinstance(frame, TranscriptionFrame): - # Store the transcription time for TTFB calculation - self._last_transcription_time = time.time() - # Set finalized from pending state and auto-reset if self._finalize_pending: frame.finalized = True self._finalize_pending = False # If this is a finalized transcription, report TTFB immediately - if frame.finalized and self._speech_end_time is not None: - ttfb = self._last_transcription_time - self._speech_end_time - await self._emit_stt_ttfb_metric(ttfb) + if frame.finalized: + await self.stop_ttfb_metrics() # Cancel the timeout since we've already reported await self._cancel_ttfb_timeout() - # Clear state - self._speech_end_time = None - self._last_transcription_time = None await super().push_frame(frame, direction) @@ -415,8 +424,6 @@ class STTService(AIService): while user is still speaking. """ await self._cancel_ttfb_timeout() - self._speech_end_time = None - self._last_transcription_time = None async def _handle_vad_user_started_speaking(self, frame: VADUserStartedSpeakingFrame): """Handle VAD user started speaking frame to start tracking transcriptions. @@ -450,7 +457,8 @@ class STTService(AIService): # Calculate the actual speech end time (current time minus VAD stop delay). # This approximates when the last user audio was sent to the STT service, # which we use to measure against the eventual transcription response. - self._speech_end_time = frame.timestamp - frame.stop_secs + speech_end_time = frame.timestamp - frame.stop_secs + await self.start_ttfb_metrics(start_time=speech_end_time) # Start timeout task (any previous timeout was cancelled by VADUserStartedSpeakingFrame # or InterruptionFrame) @@ -459,43 +467,79 @@ class STTService(AIService): ) async def _ttfb_timeout_handler(self): - """Wait for timeout then report TTFB using the last transcription timestamp. + """Wait for timeout then report TTFB. This timeout allows the final transcription to arrive before we calculate and report TTFB. If no transcription arrived, no TTFB is reported. """ try: await asyncio.sleep(self._stt_ttfb_timeout) - - # Report TTFB if we have both speech end time and transcription time - if self._speech_end_time is not None and self._last_transcription_time is not None: - ttfb = self._last_transcription_time - self._speech_end_time - await self._emit_stt_ttfb_metric(ttfb) - - # Clear state after reporting - self._speech_end_time = None - self._last_transcription_time = None + await self.stop_ttfb_metrics() except asyncio.CancelledError: # Task was cancelled (new utterance or interruption), which is expected behavior pass finally: self._ttfb_timeout_task = None - async def _emit_stt_ttfb_metric(self, ttfb: float): - """Emit STT TTFB metric if value is non-negative. + def _create_keepalive_task(self): + """Start the keepalive task if keepalive is enabled.""" + if self._keepalive_timeout is not None: + self._last_audio_time = time.monotonic() + self._keepalive_task = self.create_task( + self._keepalive_task_handler(), name="keepalive" + ) + + async def _cancel_keepalive_task(self): + """Stop the keepalive task if running.""" + if self._keepalive_task: + await self.cancel_task(self._keepalive_task) + self._keepalive_task = None + + async def _keepalive_task_handler(self): + """Send periodic silent audio to prevent the server from closing the connection. + + When keepalive is enabled, this task checks periodically if the connection + has been idle (no audio sent) for longer than keepalive_timeout seconds. + If so, it generates silent 16-bit mono PCM audio and passes it to + _send_keepalive() for service-specific formatting and sending. + """ + while True: + await asyncio.sleep(self._keepalive_interval) + try: + if not self._is_keepalive_ready(): + continue + elapsed = time.monotonic() - self._last_audio_time + if elapsed < self._keepalive_timeout: + continue + num_samples = int(self.sample_rate * _KEEPALIVE_SILENCE_DURATION) + silence = b"\x00" * (num_samples * 2) + await self._send_keepalive(silence) + self._last_audio_time = time.monotonic() + logger.trace(f"{self} sent keepalive silence") + except Exception as e: + logger.warning(f"{self} keepalive error: {e}") + break + + def _is_keepalive_ready(self) -> bool: + """Check if the service is ready to send keepalive. + + Subclasses should override this to check their connection state. + + Returns: + True if keepalive can be sent. + """ + return True + + async def _send_keepalive(self, silence: bytes): + """Send silent audio to keep the connection alive. + + Subclasses that enable keepalive must override this to deliver silence + in their service-specific protocol. Args: - ttfb: The TTFB value in seconds. + silence: Silent 16-bit mono PCM audio bytes. """ - if ttfb >= 0: - logger.debug(f"{self} TTFB: {ttfb:.3f}s") - if self.metrics_enabled: - ttfb_data = TTFBMetricsData( - processor=self.name, - model=self._settings.model, - value=ttfb, - ) - await super().push_frame(MetricsFrame(data=[ttfb_data])) + raise NotImplementedError("Subclasses must override _send_keepalive") class SegmentedSTTService(STTService): @@ -610,46 +654,27 @@ class WebsocketSTTService(STTService, WebsocketService): Combines STT functionality with websocket connectivity, providing automatic error handling, reconnection capabilities, and optional silence-based keepalive. - The keepalive feature sends silent audio when no real audio has been sent for - a configurable timeout, preventing servers from closing idle connections (e.g. - when behind a ServiceSwitcher). Subclasses can override ``_send_keepalive()`` - to wrap the silence in a service-specific protocol. + The keepalive feature (inherited from STTService) sends silent audio when no + real audio has been sent for a configurable timeout, preventing servers from + closing idle connections (e.g. when behind a ServiceSwitcher). Subclasses can + override ``_send_keepalive()`` to wrap the silence in a service-specific protocol. """ def __init__( self, *, reconnect_on_error: bool = True, - keepalive_timeout: Optional[float] = None, - keepalive_interval: float = 5.0, **kwargs, ): """Initialize the Websocket STT service. Args: reconnect_on_error: Whether to automatically reconnect on websocket errors. - keepalive_timeout: Seconds of no audio before sending silence to keep the - connection alive. None disables keepalive. Useful for services that - close idle connections (e.g. behind a ServiceSwitcher). - keepalive_interval: Seconds between idle checks when keepalive is enabled. - **kwargs: Additional arguments passed to parent classes. + **kwargs: Additional arguments passed to parent classes (including + keepalive_timeout and keepalive_interval for STTService). """ STTService.__init__(self, **kwargs) WebsocketService.__init__(self, reconnect_on_error=reconnect_on_error, **kwargs) - self._keepalive_timeout = keepalive_timeout - self._keepalive_interval = keepalive_interval - self._keepalive_task: Optional[asyncio.Task] = None - self._last_audio_time: float = 0 - - async def process_audio_frame(self, frame: AudioRawFrame, direction: FrameDirection): - """Process an audio frame, tracking the last audio time for keepalive. - - Args: - frame: The audio frame to process. - direction: The direction of frame processing. - """ - self._last_audio_time = time.monotonic() - await super().process_audio_frame(frame, direction) async def _connect(self): """Connect and start keepalive task if enabled.""" @@ -673,44 +698,9 @@ class WebsocketSTTService(STTService, WebsocketService): self._create_keepalive_task() return result - def _create_keepalive_task(self): - """Start the keepalive task if keepalive is enabled.""" - if self._keepalive_timeout is not None: - self._last_audio_time = time.monotonic() - self._keepalive_task = self.create_task( - self._keepalive_task_handler(), name="keepalive" - ) - - async def _cancel_keepalive_task(self): - """Stop the keepalive task if running.""" - if self._keepalive_task: - await self.cancel_task(self._keepalive_task) - self._keepalive_task = None - - async def _keepalive_task_handler(self): - """Send periodic silent audio to prevent the server from closing the connection. - - When keepalive is enabled, this task checks periodically if the connection - has been idle (no audio sent) for longer than keepalive_timeout seconds. - If so, it generates silent 16-bit mono PCM audio and passes it to - _send_keepalive() for service-specific formatting and sending. - """ - while True: - await asyncio.sleep(self._keepalive_interval) - try: - if not self._websocket or self._websocket.state is not State.OPEN: - continue - elapsed = time.monotonic() - self._last_audio_time - if elapsed < self._keepalive_timeout: - continue - num_samples = int(self.sample_rate * _KEEPALIVE_SILENCE_DURATION) - silence = b"\x00" * (num_samples * 2) - await self._send_keepalive(silence) - self._last_audio_time = time.monotonic() - logger.trace(f"{self} sent keepalive silence") - except Exception as e: - logger.warning(f"{self} keepalive error: {e}") - break + def _is_keepalive_ready(self) -> bool: + """Check if the websocket is open and ready for keepalive.""" + return self._websocket is not None and self._websocket.state is State.OPEN async def _send_keepalive(self, silence: bytes): """Send silent audio over the websocket to keep the connection alive. diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index e5602d469..9d9c41b60 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -212,8 +212,6 @@ class TTSService(AIService): # TODO: Deprecate _text_filters when added to LLMTextProcessor self._text_filters: Sequence[BaseTextFilter] = text_filters or [] self._transport_destination: Optional[str] = transport_destination - self._tracing_enabled: bool = False - if text_filter: import warnings @@ -368,7 +366,6 @@ class TTSService(AIService): self._sample_rate = self._init_sample_rate or frame.audio_out_sample_rate if self._push_stop_frames and not self._stop_frame_task: self._stop_frame_task = self.create_task(self._stop_frame_handler()) - self._tracing_enabled = frame.enable_tracing async def stop(self, frame: EndFrame): """Stop the TTS service. @@ -1081,14 +1078,25 @@ class AudioContextTTSService(WebsocketTTSService): audio from context ID "A" will be played first. """ - def __init__(self, *, reconnect_on_error: bool = True, **kwargs): + _CONTEXT_KEEPALIVE = object() + + def __init__( + self, + *, + reuse_context_id_within_turn: bool = True, + reconnect_on_error: bool = True, + **kwargs, + ): """Initialize the Audio Context TTS service. Args: + reuse_context_id_within_turn: Whether the service should reuse context IDs within the same turn. reconnect_on_error: Whether to automatically reconnect on websocket errors. **kwargs: Additional arguments passed to the parent WebsocketTTSService. """ super().__init__(reconnect_on_error=reconnect_on_error, **kwargs) + self._reuse_context_id_within_turn = reuse_context_id_within_turn + self._context_id = None self._contexts: Dict[str, asyncio.Queue] = {} self._audio_context_task = None @@ -1098,6 +1106,10 @@ class AudioContextTTSService(WebsocketTTSService): Args: context_id: Unique identifier for the audio context. """ + # Set the context ID if not already set + if not self._context_id: + self._context_id = context_id + await self._contexts_queue.put(context_id) self._contexts[context_id] = asyncio.Queue() logger.trace(f"{self} created audio context {context_id}") @@ -1130,6 +1142,32 @@ class AudioContextTTSService(WebsocketTTSService): else: logger.warning(f"{self} unable to remove context {context_id}") + def has_active_audio_context(self) -> bool: + """Check if there is an active audio context. + + Returns: + True if an active audio context exists, False otherwise. + """ + return self._context_id is not None and self.audio_context_available(self._context_id) + + def get_active_audio_context_id(self) -> Optional[str]: + """Get the active audio context ID. + + Returns: + The active context ID, or None if no context is active. + """ + return self._context_id + + async def remove_active_audio_context(self): + """Remove the active audio context.""" + if self._context_id: + await self.remove_audio_context(self._context_id) + self.reset_active_audio_context() + + def reset_active_audio_context(self): + """Reset the active audio context.""" + self._context_id = None + def audio_context_available(self, context_id: str) -> bool: """Check whether the given audio context is registered. @@ -1141,6 +1179,26 @@ class AudioContextTTSService(WebsocketTTSService): """ return context_id in self._contexts + def create_context_id(self) -> str: + """Generate or reuse a context ID based on concurrent TTS support. + + If _reuse_context_id_within_turn is False and a context already exists, + the existing context ID is returned. Otherwise, a new unique context + ID is generated. + + Returns: + A context ID string for the TTS request. + """ + if self._reuse_context_id_within_turn and self._context_id: + self._refresh_active_audio_context() + return self._context_id + return super().create_context_id() + + def _refresh_active_audio_context(self): + """Signal that the audio context is still in use, resetting the timeout.""" + if self.has_active_audio_context(): + self._contexts[self._context_id].put_nowait(AudioContextTTSService._CONTEXT_KEEPALIVE) + async def start(self, frame: StartFrame): """Start the audio context TTS service. @@ -1176,6 +1234,7 @@ class AudioContextTTSService(WebsocketTTSService): async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): await super()._handle_interruption(frame, direction) await self._stop_audio_context_task() + self.reset_active_audio_context() self._create_audio_context_task() def _create_audio_context_task(self): @@ -1194,6 +1253,7 @@ class AudioContextTTSService(WebsocketTTSService): running = True while running: context_id = await self._contexts_queue.get() + self._context_id = context_id if context_id: # Process the audio context until the context doesn't have more @@ -1202,11 +1262,15 @@ class AudioContextTTSService(WebsocketTTSService): # We just finished processing the context, so we can safely remove it. del self._contexts[context_id] + self.reset_active_audio_context() # Append some silence between sentences. silence = b"\x00" * self.sample_rate frame = TTSAudioRawFrame( - audio=silence, sample_rate=self.sample_rate, num_channels=1 + audio=silence, + sample_rate=self.sample_rate, + num_channels=1, + context_id=context_id, ) await self.push_frame(frame) else: @@ -1222,6 +1286,10 @@ class AudioContextTTSService(WebsocketTTSService): while running: try: frame = await asyncio.wait_for(queue.get(), timeout=AUDIO_CONTEXT_TIMEOUT) + if frame is AudioContextTTSService._CONTEXT_KEEPALIVE: + # Context is still in use, reset the timeout. + continue + if frame: await self.push_frame(frame) running = frame is not None diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index c4f59a61d..15eb99f3a 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -237,6 +237,18 @@ class BaseOutputTransport(FrameProcessor): else: await self._write_dtmf_audio(frame) + async def write_transport_frame(self, frame: Frame): + """Handle a queued frame after preceding audio has been sent. + + Override in transport subclasses to handle custom frame types that + flow through the audio queue. Called by the media sender after the + frame has waited for any preceding audio to finish. + + Args: + frame: The frame to handle. + """ + pass + def _supports_native_dtmf(self) -> bool: """Override in transport implementations that support native DTMF. @@ -613,6 +625,11 @@ class BaseOutputTransport(FrameProcessor): downstream_frame.transport_destination = self._destination upstream_frame = BotStartedSpeakingFrame() upstream_frame.transport_destination = self._destination + + # Setting the siblings id + upstream_frame.broadcast_sibling_id = downstream_frame.id + downstream_frame.broadcast_sibling_id = upstream_frame.id + await self._transport.push_frame(downstream_frame) await self._transport.push_frame(upstream_frame, FrameDirection.UPSTREAM) @@ -635,6 +652,11 @@ class BaseOutputTransport(FrameProcessor): downstream_frame.transport_destination = self._destination upstream_frame = BotStoppedSpeakingFrame() upstream_frame.transport_destination = self._destination + + # Setting the siblings id + upstream_frame.broadcast_sibling_id = downstream_frame.id + downstream_frame.broadcast_sibling_id = upstream_frame.id + await self._transport.push_frame(downstream_frame) await self._transport.push_frame(upstream_frame, FrameDirection.UPSTREAM) @@ -681,6 +703,8 @@ class BaseOutputTransport(FrameProcessor): await self._transport.send_message(frame) elif isinstance(frame, OutputDTMFFrame): await self._transport.write_dtmf(frame) + else: + await self._transport.write_transport_frame(frame) def _next_frame(self) -> AsyncGenerator[Frame, None]: """Generate the next frame for audio processing. diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index 20a0be29f..9575fd51b 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -15,7 +15,7 @@ import asyncio import time from concurrent.futures import CancelledError as FuturesCancelledError from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Dict, Mapping, Optional, Tuple import aiohttp @@ -25,7 +25,7 @@ from pydantic import BaseModel from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams from pipecat.frames.frames import ( CancelFrame, - ControlFrame, + DataFrame, EndFrame, Frame, InputAudioRawFrame, @@ -183,34 +183,44 @@ class DailyInputTransportMessageUrgentFrame(DailyInputTransportMessageFrame): @dataclass -class DailyUpdateRemoteParticipantsFrame(ControlFrame): - """Frame to update remote participants in Daily calls. +class DailySIPTransferFrame(DataFrame): + """SIP call transfer frame for transport queuing. - .. deprecated:: 0.0.87 - `DailyUpdateRemoteParticipantsFrame` is deprecated and will be removed in a future version. - Create your own custom frame and use a custom processor to handle it or use, for example, - `on_after_push_frame` event instead in the output transport. + A SIP call transfer that will be queued. The transfer will happen after any + preceding audio finishes playing, allowing the bot to complete its current + utterance before the transfer occurs. + + Parameters: + settings: SIP call transfer settings. + """ + + settings: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass +class DailySIPReferFrame(DataFrame): + """SIP REFER frame for transport queuing. + + A SIP REFER that will be queued. The REFER will happen after any preceding + audio finishes playing, allowing the bot to complete its current utterance + before the REFER occurs. + + Parameters: + settings: SIP REFER settings. + """ + + settings: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass +class DailyUpdateRemoteParticipantsFrame(DataFrame): + """Frame to update remote participants in Daily calls. Parameters: remote_participants: See https://reference-python.daily.co/api_reference.html#daily.CallClient.update_remote_participants. """ - remote_participants: Mapping[str, Any] = None - - def __post_init__(self): - super().__post_init__() - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "DailyUpdateRemoteParticipantsFrame is deprecated and will be removed in a future version." - "Instead, create your own custom frame and handle it in the " - '`@transport.output().event_handler("on_after_push_frame")` event handler or a ' - "custom processor.", - DeprecationWarning, - stacklevel=2, - ) + remote_participants: Mapping[str, Any] = field(default_factory=dict) class WebRTCVADAnalyzer(VADAnalyzer): @@ -501,6 +511,7 @@ class DailyTransportClient(EventHandler): self._event_task = None self._audio_task = None self._video_task = None + self._join_message_queue: list = [] # Input and ouput sample rates. They will be initialize on setup(). self._in_sample_rate = 0 @@ -567,7 +578,8 @@ class DailyTransportClient(EventHandler): error: An error description or None. """ if not self._joined: - return "Unable to send messages before joining." + self._join_message_queue.append(frame) + return None participant_id = None if isinstance( @@ -768,6 +780,8 @@ class DailyTransportClient(EventHandler): await self._callbacks.on_joined(data) self._joined_event.set() + + await self._flush_join_messages() else: error_msg = f"Error joining {self._room_url}: {error}" logger.error(error_msg) @@ -1541,6 +1555,12 @@ class DailyTransportClient(EventHandler): await callback(*args) queue.task_done() + async def _flush_join_messages(self): + """Send any messages that were queued before join completed.""" + for frame in self._join_message_queue: + await self.send_message(frame) + self._join_message_queue.clear() + def _get_event_loop(self) -> asyncio.AbstractEventLoop: """Get the event loop from the task manager.""" if not self._task_manager: @@ -1946,18 +1966,6 @@ class DailyOutputTransport(BaseOutputTransport): # Leave the room. await self._client.leave() - async def process_frame(self, frame: Frame, direction: FrameDirection): - """Process outgoing frames, including transport messages. - - Args: - frame: The frame to process. - direction: The direction of frame flow in the pipeline. - """ - await super().process_frame(frame, direction) - - if isinstance(frame, DailyUpdateRemoteParticipantsFrame): - await self._client.update_remote_participants(frame.remote_participants) - async def send_message( self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame ): @@ -1968,7 +1976,7 @@ class DailyOutputTransport(BaseOutputTransport): """ error = await self._client.send_message(frame) if error: - logger.error(f"Unable to send message: {error}") + await self.push_error(f"Unable to send message: {error}") async def register_video_destination(self, destination: str): """Register a video output destination. @@ -2011,6 +2019,25 @@ class DailyOutputTransport(BaseOutputTransport): """ return await self._client.write_video_frame(frame) + async def write_transport_frame(self, frame: Frame): + """Handle queued SIP frames after preceding audio has been sent. + + Args: + frame: The frame to handle. + """ + if isinstance(frame, DailySIPTransferFrame): + error = await self._client.sip_call_transfer(frame.settings) + if error: + await self.push_error(f"Unable to transfer SIP call: {error}") + elif isinstance(frame, DailySIPReferFrame): + error = await self._client.sip_refer(frame.settings) + if error: + await self.push_error(f"Unable to perform SIP REFER: {error}") + elif isinstance(frame, DailyUpdateRemoteParticipantsFrame): + error = await self._client.update_remote_participants(frame.remote_participants) + if error: + await self.push_error(f"Unable to update remote participants: {error}") + def _supports_native_dtmf(self) -> bool: """Daily supports native DTMF via telephone events. @@ -2039,6 +2066,61 @@ class DailyTransport(BaseTransport): Provides comprehensive Daily integration including audio/video streaming, transcription, recording, dial-in/out functionality, and real-time communication features for conversational AI applications. + + Event handlers available: + + - on_joined: Called when the bot joins the room. Args: (data: dict) + - on_left: Called when the bot leaves the room. + - on_before_leave: [sync] Called just before the bot leaves the room. + - on_error: Called when a transport error occurs. Args: (error: str) + - on_call_state_updated: Called when the call state changes. Args: (state: str) + - on_first_participant_joined: Called when the first participant joins. + Args: (participant: dict) + - on_participant_joined: Called when any participant joins. + Args: (participant: dict) + - on_participant_left: Called when a participant leaves. + Args: (participant: dict, reason: str) + - on_participant_updated: Called when a participant's state changes. + Args: (participant: dict) + - on_client_connected: Called when a participant connects (alias for + on_participant_joined). Args: (participant: dict) + - on_client_disconnected: Called when a participant disconnects (alias for + on_participant_left). Args: (participant: dict) + - on_active_speaker_changed: Called when the active speaker changes. + Args: (participant: dict) + - on_app_message: Called when an app message is received. + Args: (message: Any, sender: str) + - on_transcription_message: Called when a transcription message is received. + Args: (message: dict) + - on_recording_started: Called when recording starts. Args: (status: str) + - on_recording_stopped: Called when recording stops. Args: (stream_id: str) + - on_recording_error: Called when a recording error occurs. + Args: (stream_id: str, message: str) + - on_dialin_connected: Called when a dial-in call connects. Args: (data: dict) + - on_dialin_ready: Called when the SIP endpoint is ready. + Args: (sip_endpoint: str) + - on_dialin_stopped: Called when a dial-in call stops. Args: (data: dict) + - on_dialin_error: Called when a dial-in error occurs. Args: (data: dict) + - on_dialin_warning: Called when a dial-in warning occurs. Args: (data: dict) + - on_dialout_answered: Called when a dial-out call is answered. Args: (data: dict) + - on_dialout_connected: Called when a dial-out call connects. Args: (data: dict) + - on_dialout_stopped: Called when a dial-out call stops. Args: (data: dict) + - on_dialout_error: Called when a dial-out error occurs. Args: (data: dict) + - on_dialout_warning: Called when a dial-out warning occurs. Args: (data: dict) + + Example:: + + @transport.event_handler("on_first_participant_joined") + async def on_first_participant_joined(transport, participant): + await task.queue_frame(TTSSpeakFrame("Hello!")) + + @transport.event_handler("on_participant_left") + async def on_participant_left(transport, participant, reason): + await task.queue_frame(EndFrame()) + + @transport.event_handler("on_app_message") + async def on_app_message(transport, message, sender): + logger.info(f"Message from {sender}: {message}") """ def __init__( diff --git a/src/pipecat/transports/heygen/transport.py b/src/pipecat/transports/heygen/transport.py index 2ff1c7d56..dbeded3e5 100644 --- a/src/pipecat/transports/heygen/transport.py +++ b/src/pipecat/transports/heygen/transport.py @@ -289,6 +289,17 @@ class HeyGenTransport(BaseTransport): When used, the Pipecat bot joins the same virtual room as the HeyGen Avatar and the user. This is achieved by using `HeyGenTransport`, which initiates the conversation via `HeyGenApi` and obtains a room URL that all participants connect to. + + Event handlers available: + + - on_client_connected(transport, participant): Participant connected to the session + - on_client_disconnected(transport, participant): Participant disconnected from the session + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, participant): + ... """ def __init__( diff --git a/src/pipecat/transports/livekit/transport.py b/src/pipecat/transports/livekit/transport.py index ab7325c68..1902e7cd3 100644 --- a/src/pipecat/transports/livekit/transport.py +++ b/src/pipecat/transports/livekit/transport.py @@ -950,6 +950,41 @@ class LiveKitTransport(BaseTransport): Provides comprehensive LiveKit integration including audio streaming, data messaging, participant management, and room event handling for conversational AI applications. + + Event handlers available: + + - on_connected: Called when the bot connects to the room. + - on_disconnected: Called when the bot disconnects from the room. + - on_before_disconnect: [sync] Called just before the bot disconnects. + - on_call_state_updated: Called when the call state changes. Args: (state: str) + - on_first_participant_joined: Called when the first participant joins. + Args: (participant_id: str) + - on_participant_connected: Called when a participant connects. + Args: (participant_id: str) + - on_participant_disconnected: Called when a participant disconnects. + Args: (participant_id: str) + - on_participant_left: Called when a participant leaves. + Args: (participant_id: str, reason: str) + - on_audio_track_subscribed: Called when an audio track is subscribed. + Args: (participant_id: str) + - on_audio_track_unsubscribed: Called when an audio track is unsubscribed. + Args: (participant_id: str) + - on_video_track_subscribed: Called when a video track is subscribed. + Args: (participant_id: str) + - on_video_track_unsubscribed: Called when a video track is unsubscribed. + Args: (participant_id: str) + - on_data_received: Called when data is received from a participant. + Args: (data: bytes, participant_id: str) + + Example:: + + @transport.event_handler("on_first_participant_joined") + async def on_first_participant_joined(transport, participant_id): + await task.queue_frame(TTSSpeakFrame("Hello!")) + + @transport.event_handler("on_participant_disconnected") + async def on_participant_disconnected(transport, participant_id): + await task.queue_frame(EndFrame()) """ def __init__( diff --git a/src/pipecat/transports/smallwebrtc/transport.py b/src/pipecat/transports/smallwebrtc/transport.py index 7e22fb00c..dc91588a3 100644 --- a/src/pipecat/transports/smallwebrtc/transport.py +++ b/src/pipecat/transports/smallwebrtc/transport.py @@ -233,9 +233,8 @@ class SmallWebRTCClient: self._out_sample_rate = None self._leave_counter = 0 - # We are always resampling it for 16000 if the sample_rate that we receive is bigger than that. - # otherwise we face issues with Silero VAD - self._pipecat_resampler = AudioResampler("s16", "mono", 16000) + # Audio resampler - will be configured during setup with target sample rate + self._audio_in_resampler = None @self._webrtc_connection.event_handler("connected") async def on_connected(connection: SmallWebRTCConnection): @@ -375,32 +374,22 @@ class SmallWebRTCClient: await asyncio.sleep(0.01) continue - if frame.sample_rate > self._in_sample_rate: - resampled_frames = self._pipecat_resampler.resample(frame) - for resampled_frame in resampled_frames: - # 16-bit PCM bytes - pcm_array = resampled_frame.to_ndarray().astype(np.int16) - pcm_bytes = pcm_array.tobytes() - del pcm_array # free NumPy array immediately + # Resample if needed, otherwise use the frame as-is + frames_to_process = ( + self._audio_in_resampler.resample(frame) + if frame.sample_rate != self._in_sample_rate + else [frame] + ) - audio_frame = InputAudioRawFrame( - audio=pcm_bytes, - sample_rate=resampled_frame.sample_rate, - num_channels=self._audio_in_channels, - ) - audio_frame.pts = frame.pts - del pcm_bytes # reference kept in audio_frame - - yield audio_frame - else: - # 16-bit PCM bytes - pcm_array = frame.to_ndarray().astype(np.int16) + for processed_frame in frames_to_process: + # Convert to 16-bit PCM bytes + pcm_array = processed_frame.to_ndarray().astype(np.int16) pcm_bytes = pcm_array.tobytes() del pcm_array # free NumPy array immediately audio_frame = InputAudioRawFrame( audio=pcm_bytes, - sample_rate=frame.sample_rate, + sample_rate=self._in_sample_rate, num_channels=self._audio_in_channels, ) audio_frame.pts = frame.pts @@ -450,6 +439,7 @@ class SmallWebRTCClient: self._out_sample_rate = _params.audio_out_sample_rate or frame.audio_out_sample_rate self._params = _params self._leave_counter += 1 + self._audio_in_resampler = AudioResampler("s16", "mono", self._in_sample_rate) async def connect(self): """Establish the WebRTC connection.""" @@ -874,6 +864,18 @@ class SmallWebRTCTransport(BaseTransport): Provides bidirectional audio and video streaming over WebRTC connections with support for application messaging and connection event handling. + + Event handlers available: + + - on_client_connected(transport, client): Client connected to WebRTC session + - on_client_disconnected(transport, client): Client disconnected from WebRTC session + - on_client_message(transport, message, client): Received a data channel message + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + ... """ def __init__( diff --git a/src/pipecat/transports/tavus/transport.py b/src/pipecat/transports/tavus/transport.py index 8e91e2713..dd63cb790 100644 --- a/src/pipecat/transports/tavus/transport.py +++ b/src/pipecat/transports/tavus/transport.py @@ -519,7 +519,7 @@ class TavusInputTransport(BaseInputTransport): """Handle received participant audio data.""" frame = InputAudioRawFrame( audio=audio.audio_frames, - sample_rate=audio.audio_frames, + sample_rate=audio.sample_rate, num_channels=audio.num_channels, ) frame.transport_source = audio_source @@ -661,6 +661,17 @@ class TavusTransport(BaseTransport): When used, the Pipecat bot joins the same virtual room as the Tavus Avatar and the user. This is achieved by using `TavusTransportClient`, which initiates the conversation via `TavusApi` and obtains a room URL that all participants connect to. + + Event handlers available: + + - on_client_connected(transport, participant): Participant connected to the session + - on_client_disconnected(transport, participant): Participant disconnected from the session + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, participant): + ... """ def __init__( diff --git a/src/pipecat/transports/websocket/client.py b/src/pipecat/transports/websocket/client.py index e24352575..b5b99ee97 100644 --- a/src/pipecat/transports/websocket/client.py +++ b/src/pipecat/transports/websocket/client.py @@ -471,6 +471,17 @@ class WebsocketClientTransport(BaseTransport): Provides a complete WebSocket client transport implementation with input and output capabilities, connection management, and event handling. + + Event handlers available: + + - on_connected(transport): Connected to WebSocket server + - on_disconnected(transport): Disconnected from WebSocket server + + Example:: + + @transport.event_handler("on_connected") + async def on_connected(transport): + ... """ def __init__( diff --git a/src/pipecat/transports/websocket/fastapi.py b/src/pipecat/transports/websocket/fastapi.py index d4f5809d9..f52123e52 100644 --- a/src/pipecat/transports/websocket/fastapi.py +++ b/src/pipecat/transports/websocket/fastapi.py @@ -534,6 +534,18 @@ class FastAPIWebsocketTransport(BaseTransport): Provides bidirectional WebSocket communication with frame serialization, session management, and event handling for client connections and timeouts. + + Event handlers available: + + - on_client_connected(transport, websocket): Client WebSocket connected + - on_client_disconnected(transport, websocket): Client WebSocket disconnected + - on_session_timeout(transport, websocket): Session timed out + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, websocket): + ... """ def __init__( diff --git a/src/pipecat/transports/websocket/server.py b/src/pipecat/transports/websocket/server.py index 16964cb9b..e5f628fa4 100644 --- a/src/pipecat/transports/websocket/server.py +++ b/src/pipecat/transports/websocket/server.py @@ -421,6 +421,19 @@ class WebsocketServerTransport(BaseTransport): Provides a complete WebSocket server implementation with separate input and output transports, client connection management, and event handling for real-time audio and data streaming applications. + + Event handlers available: + + - on_client_connected(transport, websocket): Client WebSocket connected + - on_client_disconnected(transport, websocket): Client WebSocket disconnected + - on_session_timeout(transport, websocket): Session timed out + - on_websocket_ready(transport): WebSocket server is ready to accept connections + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, websocket): + ... """ def __init__( diff --git a/src/pipecat/turns/user_idle_controller.py b/src/pipecat/turns/user_idle_controller.py index cce35b9cb..b3b7e8074 100644 --- a/src/pipecat/turns/user_idle_controller.py +++ b/src/pipecat/turns/user_idle_controller.py @@ -10,12 +10,15 @@ import asyncio from typing import Optional from pipecat.frames.frames import ( - BotSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, Frame, + FunctionCallCancelFrame, FunctionCallResultFrame, FunctionCallsStartedFrame, - UserSpeakingFrame, + UserIdleTimeoutUpdateFrame, UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, ) from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.base_object import BaseObject @@ -25,14 +28,14 @@ class UserIdleController(BaseObject): """Controller for managing user idle detection. This class monitors user activity and triggers an event when the user has been - idle (not speaking) for a configured timeout period. It only starts monitoring - after the first conversation activity and does not trigger while the bot is - speaking or function calls are in progress. + idle (not speaking) for a configured timeout period after the bot finishes + speaking. The timer starts when BotStoppedSpeakingFrame is received and is + cancelled when someone starts speaking again (UserStartedSpeakingFrame or + BotStartedSpeakingFrame). - The controller tracks activity using continuous frames (UserSpeakingFrame and - BotSpeakingFrame) which are emitted repeatedly while speaking is happening, and - state-based tracking for function calls (FunctionCallsStartedFrame and - FunctionCallResultFrame) which are only sent at start and end. + The timer is suppressed while a user turn is in progress to avoid false + triggers during interruptions (where BotStoppedSpeakingFrame arrives while + the user is still speaking). Event handlers available: @@ -49,12 +52,13 @@ class UserIdleController(BaseObject): def __init__( self, *, - user_idle_timeout: float, + user_idle_timeout: float = 0, ): """Initialize the user idle controller. Args: user_idle_timeout: Timeout in seconds before considering the user idle. + 0 disables idle detection. """ super().__init__() @@ -62,11 +66,9 @@ class UserIdleController(BaseObject): self._task_manager: Optional[BaseTaskManager] = None - self._conversation_started = False - self._function_call_in_progress = False - - self.user_idle_event = asyncio.Event() - self.user_idle_task: Optional[asyncio.Task] = None + self._user_turn_in_progress: bool = False + self._function_calls_in_progress: int = 0 + self._idle_timer_task: Optional[asyncio.Task] = None self._register_event_handler("on_user_turn_idle", sync=True) @@ -85,19 +87,10 @@ class UserIdleController(BaseObject): """ self._task_manager = task_manager - if not self.user_idle_task: - self.user_idle_task = self.task_manager.create_task( - self.user_idle_task_handler(), - f"{self}::user_idle_task_handler", - ) - async def cleanup(self): """Cleanup the controller.""" await super().cleanup() - - if self.user_idle_task: - await self.task_manager.cancel_task(self.user_idle_task) - self.user_idle_task = None + await self._cancel_idle_timer() async def process_frame(self, frame: Frame): """Process an incoming frame to track user activity state. @@ -105,69 +98,60 @@ class UserIdleController(BaseObject): Args: frame: The frame to be processed. """ - # Start monitoring on first conversation activity - if not self._conversation_started: - if isinstance(frame, (UserStartedSpeakingFrame, BotSpeakingFrame)): - self._conversation_started = True - self.user_idle_event.set() - else: - return + if isinstance(frame, UserIdleTimeoutUpdateFrame): + self._user_idle_timeout = frame.timeout + if self._user_idle_timeout <= 0: + await self._cancel_idle_timer() + return - # Reset idle timer on continuous activity frames - if isinstance(frame, (UserSpeakingFrame, BotSpeakingFrame)): - await self._handle_activity(frame) - # Track function call state (start/end frames, not continuous) + if isinstance(frame, BotStoppedSpeakingFrame): + # Only start the timer if the user isn't mid-turn and no function + # calls are pending. + # + # Interruption case: the frame order is UserStartedSpeaking → + # BotStoppedSpeaking → (user keeps talking) → UserStoppedSpeaking. + # Without the user-turn guard the timer would start while the user + # is still speaking. + # + # Function call case: normally FunctionCallsStarted arrives after + # BotStoppedSpeaking and cancels the timer directly. But a race + # condition can cause FunctionCallsStarted to arrive before + # BotStoppedSpeaking when pushing a TTSSpeakFrame in the + # on_function_calls_started event handler, so the counter guard + # prevents the timer from starting while a function call is in progress. + if not self._user_turn_in_progress and self._function_calls_in_progress == 0: + await self._start_idle_timer() + elif isinstance(frame, BotStartedSpeakingFrame): + await self._cancel_idle_timer() + elif isinstance(frame, UserStartedSpeakingFrame): + self._user_turn_in_progress = True + await self._cancel_idle_timer() + elif isinstance(frame, UserStoppedSpeakingFrame): + self._user_turn_in_progress = False elif isinstance(frame, FunctionCallsStartedFrame): - await self._handle_function_calls_started(frame) - elif isinstance(frame, FunctionCallResultFrame): - await self._handle_function_call_result(frame) + self._function_calls_in_progress += len(frame.function_calls) + await self._cancel_idle_timer() + elif isinstance(frame, (FunctionCallResultFrame, FunctionCallCancelFrame)): + self._function_calls_in_progress = max(0, self._function_calls_in_progress - 1) - async def _handle_activity(self, _: UserSpeakingFrame | BotSpeakingFrame): - """Handle continuous activity frames that should reset the idle timer. + async def _start_idle_timer(self): + """Start (or restart) the idle timer.""" + if self._user_idle_timeout <= 0: + return + await self._cancel_idle_timer() + self._idle_timer_task = self.task_manager.create_task( + self._idle_timer_expired(), + f"{self}::idle_timer", + ) - These frames are emitted continuously while the user or bot is speaking, - so we simply reset the timer whenever we receive them. + async def _cancel_idle_timer(self): + """Cancel the idle timer if running.""" + if self._idle_timer_task: + await self.task_manager.cancel_task(self._idle_timer_task) + self._idle_timer_task = None - Args: - frame: The activity frame to process. - """ - self.user_idle_event.set() - - async def _handle_function_calls_started(self, _: FunctionCallsStartedFrame): - """Handle function calls started event. - - Function calls can take longer than the timeout, so we track their state - to prevent idle callbacks while they're in progress. - - Args: - frame: The FunctionCallsStartedFrame to process. - """ - self._function_call_in_progress = True - self.user_idle_event.set() - - async def _handle_function_call_result(self, _: FunctionCallResultFrame): - """Handle function call result event. - - Args: - frame: The FunctionCallResultFrame to process. - """ - self._function_call_in_progress = False - self.user_idle_event.set() - - async def user_idle_task_handler(self): - """Monitors for idle timeout and triggers events. - - Runs in a loop until cancelled. The idle timer is reset whenever activity - frames are received (UserSpeakingFrame or BotSpeakingFrame). Function calls - are tracked via state since they only send start/end frames. If no activity - is detected for the configured timeout period and no function call is in - progress, the on_user_turn_idle event is triggered. - """ - while True: - try: - await asyncio.wait_for(self.user_idle_event.wait(), timeout=self._user_idle_timeout) - self.user_idle_event.clear() - except asyncio.TimeoutError: - # Only trigger if conversation has started and no function call is in progress - if self._conversation_started and not self._function_call_in_progress: - await self._call_event_handler("on_user_turn_idle") + async def _idle_timer_expired(self): + """Sleep for the timeout duration then fire the idle event.""" + await asyncio.sleep(self._user_idle_timeout) + self._idle_timer_task = None + await self._call_event_handler("on_user_turn_idle") diff --git a/src/pipecat/turns/user_turn_processor.py b/src/pipecat/turns/user_turn_processor.py index 6c771811e..7f8995202 100644 --- a/src/pipecat/turns/user_turn_processor.py +++ b/src/pipecat/turns/user_turn_processor.py @@ -66,7 +66,7 @@ class UserTurnProcessor(FrameProcessor): *, user_turn_strategies: Optional[UserTurnStrategies] = None, user_turn_stop_timeout: float = 5.0, - user_idle_timeout: Optional[float] = None, + user_idle_timeout: float = 0, **kwargs, ): """Initialize the user turn processor. @@ -75,9 +75,9 @@ class UserTurnProcessor(FrameProcessor): user_turn_strategies: Configured strategies for starting and stopping user turns. user_turn_stop_timeout: Timeout in seconds to automatically stop a user turn if no activity is detected. - user_idle_timeout: Optional timeout in seconds for detecting user idle state. - If set, the processor will emit an `on_user_turn_idle` event when the user - has been idle (not speaking) for this duration. Set to None to disable + user_idle_timeout: Timeout in seconds for detecting user idle state. + The processor will emit an `on_user_turn_idle` event when the user + has been idle (not speaking) for this duration. Set to 0 to disable idle detection. **kwargs: Additional keyword arguments. """ @@ -104,13 +104,8 @@ class UserTurnProcessor(FrameProcessor): "on_user_turn_stop_timeout", self._on_user_turn_stop_timeout ) - # Optional user idle controller - self._user_idle_controller: Optional[UserIdleController] = None - if user_idle_timeout: - self._user_idle_controller = UserIdleController(user_idle_timeout=user_idle_timeout) - self._user_idle_controller.add_event_handler( - "on_user_turn_idle", self._on_user_turn_idle - ) + self._user_idle_controller = UserIdleController(user_idle_timeout=user_idle_timeout) + self._user_idle_controller.add_event_handler("on_user_turn_idle", self._on_user_turn_idle) async def cleanup(self): """Clean up processor resources.""" @@ -149,14 +144,11 @@ class UserTurnProcessor(FrameProcessor): await self._user_turn_controller.process_frame(frame) - if self._user_idle_controller: - await self._user_idle_controller.process_frame(frame) + await self._user_idle_controller.process_frame(frame) async def _start(self, frame: StartFrame): await self._user_turn_controller.setup(self.task_manager) - - if self._user_idle_controller: - await self._user_idle_controller.setup(self.task_manager) + await self._user_idle_controller.setup(self.task_manager) async def _stop(self, frame: EndFrame): await self._cleanup() @@ -166,9 +158,7 @@ class UserTurnProcessor(FrameProcessor): async def _cleanup(self): await self._user_turn_controller.cleanup() - - if self._user_idle_controller: - await self._user_idle_controller.cleanup() + await self._user_idle_controller.cleanup() async def _on_push_frame( self, controller, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM @@ -189,6 +179,8 @@ class UserTurnProcessor(FrameProcessor): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStartedSpeakingFrame) + await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) + if params.enable_interruptions and self._allow_interruptions: await self.push_interruption_task_frame_and_wait() @@ -205,6 +197,8 @@ class UserTurnProcessor(FrameProcessor): if params.enable_user_speaking_frames: await self.broadcast_frame(UserStoppedSpeakingFrame) + await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame()) + await self._call_event_handler("on_user_turn_stopped", strategy) async def _on_user_turn_stop_timeout(self, controller): diff --git a/src/pipecat/utils/tracing/class_decorators.py b/src/pipecat/utils/tracing/class_decorators.py index 571a74804..73dacbe51 100644 --- a/src/pipecat/utils/tracing/class_decorators.py +++ b/src/pipecat/utils/tracing/class_decorators.py @@ -7,6 +7,11 @@ """Base OpenTelemetry tracing decorators and utilities for Pipecat. +.. deprecated:: 0.0.103 + This module is unused and will be removed in a future release. + Service tracing is handled by the decorators in + :mod:`pipecat.utils.tracing.service_decorators`. + This module provides class and method level tracing capabilities similar to the original NVIDIA implementation. """ @@ -16,8 +21,16 @@ import contextlib import enum import functools import inspect +import warnings from typing import Callable, Optional, TypeVar +warnings.warn( + "pipecat.utils.tracing.class_decorators is deprecated and will be removed in a future " + "release. Use pipecat.utils.tracing.service_decorators instead.", + DeprecationWarning, + stacklevel=2, +) + from pipecat.utils.tracing.setup import is_tracing_available # Import OpenTelemetry if available diff --git a/src/pipecat/utils/tracing/conversation_context_provider.py b/src/pipecat/utils/tracing/conversation_context_provider.py deleted file mode 100644 index 4bb88fe14..000000000 --- a/src/pipecat/utils/tracing/conversation_context_provider.py +++ /dev/null @@ -1,114 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -"""Conversation context provider for OpenTelemetry tracing in Pipecat. - -This module provides a singleton context provider that manages the current -conversation's tracing context, allowing services to create child spans -that are properly associated with the conversation. -""" - -import uuid -from typing import TYPE_CHECKING, Optional - -# Import types for type checking only -if TYPE_CHECKING: - from opentelemetry.context import Context - from opentelemetry.trace import SpanContext - -from pipecat.utils.tracing.setup import is_tracing_available - -if is_tracing_available(): - from opentelemetry.context import Context - from opentelemetry.trace import NonRecordingSpan, SpanContext, set_span_in_context - - -class ConversationContextProvider: - """Provides access to the current conversation's tracing context. - - This is a singleton that can be used to get the current conversation's - span context to create child spans (like turns). - """ - - _instance = None - _current_conversation_context: Optional["Context"] = None - _conversation_id: Optional[str] = None - - @classmethod - def get_instance(cls): - """Get the singleton instance. - - Returns: - The singleton ConversationContextProvider instance. - """ - if cls._instance is None: - cls._instance = ConversationContextProvider() - return cls._instance - - def set_current_conversation_context( - self, span_context: Optional["SpanContext"], conversation_id: Optional[str] = None - ): - """Set the current conversation context. - - Args: - span_context: The span context for the current conversation or None to clear it. - conversation_id: Optional ID for the conversation. - """ - if not is_tracing_available(): - return - - self._conversation_id = conversation_id - - if span_context: - # Create a non-recording span from the span context - non_recording_span = NonRecordingSpan(span_context) - self._current_conversation_context = set_span_in_context(non_recording_span) - else: - self._current_conversation_context = None - - def get_current_conversation_context(self) -> Optional["Context"]: - """Get the OpenTelemetry context for the current conversation. - - Returns: - The current conversation context or None if not available. - """ - return self._current_conversation_context - - def get_conversation_id(self) -> Optional[str]: - """Get the ID for the current conversation. - - Returns: - The current conversation ID or None if not available. - """ - return self._conversation_id - - def generate_conversation_id(self) -> str: - """Generate a new conversation ID. - - Returns: - A new randomly generated UUID string. - """ - return str(uuid.uuid4()) - - -def get_current_conversation_context() -> Optional["Context"]: - """Get the OpenTelemetry context for the current conversation. - - Returns: - The current conversation context or None if not available. - """ - provider = ConversationContextProvider.get_instance() - return provider.get_current_conversation_context() - - -def get_conversation_id() -> Optional[str]: - """Get the ID for the current conversation. - - Returns: - The current conversation ID or None if not available. - """ - provider = ConversationContextProvider.get_instance() - return provider.get_conversation_id() diff --git a/src/pipecat/utils/tracing/service_decorators.py b/src/pipecat/utils/tracing/service_decorators.py index 296ff9522..601cad53d 100644 --- a/src/pipecat/utils/tracing/service_decorators.py +++ b/src/pipecat/utils/tracing/service_decorators.py @@ -25,7 +25,6 @@ if TYPE_CHECKING: from pipecat.processors.aggregators.llm_context import NOT_GIVEN, LLMContext from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext -from pipecat.utils.tracing.conversation_context_provider import get_current_conversation_context from pipecat.utils.tracing.service_attributes import ( add_gemini_live_span_attributes, add_llm_span_attributes, @@ -34,7 +33,6 @@ from pipecat.utils.tracing.service_attributes import ( add_tts_span_attributes, ) from pipecat.utils.tracing.setup import is_tracing_available -from pipecat.utils.tracing.turn_context_provider import get_current_turn_context if is_tracing_available(): from opentelemetry import context as context_api @@ -73,6 +71,19 @@ def _noop_decorator(func): return func +def _get_turn_context(self): + """Get the current turn's tracing context if available. + + Args: + self: The service instance. + + Returns: + The turn context, or None if unavailable. + """ + tracing_ctx = getattr(self, "_tracing_context", None) + return tracing_ctx.get_turn_context() if tracing_ctx else None + + def _get_parent_service_context(self): """Get the parent service span context (internal use only). @@ -88,12 +99,14 @@ def _get_parent_service_context(self): if not is_tracing_available(): return None - # The parent span was created when Traceable was initialized and stored as self._span + # TODO: Remove this block and delete class_decorators.py once Traceable is removed. + # Legacy: support for classes inheriting from Traceable (currently unused, deprecated). if hasattr(self, "_span") and self._span: return trace.set_span_in_context(self._span) - # Fall back to conversation context if available - conversation_context = get_current_conversation_context() + # Use the conversation context set by TurnTraceObserver via TracingContext. + tracing_ctx = getattr(self, "_tracing_context", None) + conversation_context = tracing_ctx.get_conversation_context() if tracing_ctx else None if conversation_context: return conversation_context @@ -200,8 +213,7 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) - span_name = "tts" # Get parent context - turn_context = get_current_turn_context() - parent_context = turn_context or _get_parent_service_context(self) + parent_context = _get_turn_context(self) or _get_parent_service_context(self) # Create span tracer = trace.get_tracer("pipecat") @@ -236,19 +248,21 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) - @functools.wraps(f) async def gen_wrapper(self, text, *args, **kwargs): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - async for item in f(self, text, *args, **kwargs): - yield item - return + if not getattr(self, "_tracing_enabled", False): + async for item in f(self, text, *args, **kwargs): + yield item + return + fn_called = False + try: async with tracing_context(self, text): + fn_called = True async for item in f(self, text, *args, **kwargs): yield item except Exception as e: + if fn_called: + raise logging.error(f"Error in TTS tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function async for item in f(self, text, *args, **kwargs): yield item @@ -257,16 +271,18 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) - @functools.wraps(f) async def wrapper(self, text, *args, **kwargs): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - return await f(self, text, *args, **kwargs) + if not getattr(self, "_tracing_enabled", False): + return await f(self, text, *args, **kwargs) + fn_called = False + try: async with tracing_context(self, text): + fn_called = True return await f(self, text, *args, **kwargs) except Exception as e: + if fn_called: + raise logging.error(f"Error in TTS tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function return await f(self, text, *args, **kwargs) return wrapper @@ -299,17 +315,16 @@ def traced_stt(func: Optional[Callable] = None, *, name: Optional[str] = None) - def decorator(f): @functools.wraps(f) async def wrapper(self, transcript, is_final, language=None): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - return await f(self, transcript, is_final, language) + if not getattr(self, "_tracing_enabled", False): + return await f(self, transcript, is_final, language) + fn_called = False + try: service_class_name = self.__class__.__name__ span_name = "stt" # Get the turn context first, then fall back to service context - turn_context = get_current_turn_context() - parent_context = turn_context or _get_parent_service_context(self) + parent_context = _get_turn_context(self) or _get_parent_service_context(self) # Create a new span as child of the turn span or service span tracer = trace.get_tracer("pipecat") @@ -339,14 +354,16 @@ def traced_stt(func: Optional[Callable] = None, *, name: Optional[str] = None) - ) # Call the original function + fn_called = True return await f(self, transcript, is_final, language) except Exception as e: # Log any exception but don't disrupt the main flow logging.warning(f"Error in STT transcription tracing: {e}") raise except Exception as e: + if fn_called: + raise logging.error(f"Error in STT tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function return await f(self, transcript, is_final, language) return wrapper @@ -381,17 +398,16 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - def decorator(f): @functools.wraps(f) async def wrapper(self, context, *args, **kwargs): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - return await f(self, context, *args, **kwargs) + if not getattr(self, "_tracing_enabled", False): + return await f(self, context, *args, **kwargs) + fn_called = False + try: service_class_name = self.__class__.__name__ span_name = "llm" # Get the parent context - turn context if available, otherwise service context - turn_context = get_current_turn_context() - parent_context = turn_context or _get_parent_service_context(self) + parent_context = _get_turn_context(self) or _get_parent_service_context(self) # Create a new span as child of the turn span or service span tracer = trace.get_tracer("pipecat") @@ -530,6 +546,7 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - # Don't raise - let the function execute anyway # Run function with modified push_frame to capture the output + fn_called = True result = await f(self, context, *args, **kwargs) # Add aggregated output after function completes, if available @@ -555,8 +572,9 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - if ttfb is not None: current_span.set_attribute("metrics.ttfb", ttfb) except Exception as e: + if fn_called: + raise logging.error(f"Error in LLM tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function return await f(self, context, *args, **kwargs) return wrapper @@ -588,17 +606,16 @@ def traced_gemini_live(operation: str) -> Callable: def decorator(func): @functools.wraps(func) async def wrapper(self, *args, **kwargs): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - return await func(self, *args, **kwargs) + if not getattr(self, "_tracing_enabled", False): + return await func(self, *args, **kwargs) + fn_called = False + try: service_class_name = self.__class__.__name__ span_name = f"{operation}" # Get the parent context - turn context if available, otherwise service context - turn_context = get_current_turn_context() - parent_context = turn_context or _get_parent_service_context(self) + parent_context = _get_turn_context(self) or _get_parent_service_context(self) # Create a new span as child of the turn span or service span tracer = trace.get_tracer("pipecat") @@ -851,6 +868,7 @@ def traced_gemini_live(operation: str) -> Callable: current_span.set_attribute("metrics.ttfb", ttfb) # Run the original function + fn_called = True result = await func(self, *args, **kwargs) return result @@ -861,8 +879,9 @@ def traced_gemini_live(operation: str) -> Callable: raise except Exception as e: + if fn_called: + raise logging.error(f"Error in Gemini Live tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function return await func(self, *args, **kwargs) return wrapper @@ -891,17 +910,16 @@ def traced_openai_realtime(operation: str) -> Callable: def decorator(func): @functools.wraps(func) async def wrapper(self, *args, **kwargs): - try: - # Check if tracing is enabled for this service instance - if not getattr(self, "_tracing_enabled", False): - return await func(self, *args, **kwargs) + if not getattr(self, "_tracing_enabled", False): + return await func(self, *args, **kwargs) + fn_called = False + try: service_class_name = self.__class__.__name__ span_name = f"{operation}" # Get the parent context - turn context if available, otherwise service context - turn_context = get_current_turn_context() - parent_context = turn_context or _get_parent_service_context(self) + parent_context = _get_turn_context(self) or _get_parent_service_context(self) # Create a new span as child of the turn span or service span tracer = trace.get_tracer("pipecat") @@ -1071,6 +1089,7 @@ def traced_openai_realtime(operation: str) -> Callable: current_span.set_attribute("metrics.ttfb", ttfb) # Run the original function + fn_called = True result = await func(self, *args, **kwargs) return result @@ -1081,8 +1100,9 @@ def traced_openai_realtime(operation: str) -> Callable: raise except Exception as e: + if fn_called: + raise logging.error(f"Error in OpenAI Realtime tracing (continuing without tracing): {e}") - # If tracing fails, fall back to the original function return await func(self, *args, **kwargs) return wrapper diff --git a/src/pipecat/utils/tracing/tracing_context.py b/src/pipecat/utils/tracing/tracing_context.py new file mode 100644 index 000000000..c84299701 --- /dev/null +++ b/src/pipecat/utils/tracing/tracing_context.py @@ -0,0 +1,109 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Pipeline-scoped tracing context for OpenTelemetry tracing in Pipecat. + +This module provides a per-pipeline tracing context that holds the current +conversation and turn span contexts. Each PipelineTask creates its own +TracingContext, ensuring concurrent pipelines do not interfere with each other. +""" + +import uuid +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from opentelemetry.context import Context + from opentelemetry.trace import SpanContext + +from pipecat.utils.tracing.setup import is_tracing_available + +if is_tracing_available(): + from opentelemetry.context import Context + from opentelemetry.trace import NonRecordingSpan, SpanContext, set_span_in_context + + +class TracingContext: + """Pipeline-scoped tracing context. + + Holds the current conversation and turn span contexts for a single pipeline. + Created by PipelineTask, passed to TurnTraceObserver (writer) and services + (readers) via StartFrame. + """ + + def __init__(self): + """Initialize the tracing context with empty state.""" + self._conversation_context: Optional["Context"] = None + self._turn_context: Optional["Context"] = None + self._conversation_id: Optional[str] = None + + def set_conversation_context( + self, span_context: Optional["SpanContext"], conversation_id: Optional[str] = None + ): + """Set the current conversation context. + + Args: + span_context: The span context for the current conversation or None to clear it. + conversation_id: Optional ID for the conversation. + """ + if not is_tracing_available(): + return + + self._conversation_id = conversation_id + + if span_context: + non_recording_span = NonRecordingSpan(span_context) + self._conversation_context = set_span_in_context(non_recording_span) + else: + self._conversation_context = None + + def get_conversation_context(self) -> Optional["Context"]: + """Get the OpenTelemetry context for the current conversation. + + Returns: + The current conversation context or None if not available. + """ + return self._conversation_context + + def set_turn_context(self, span_context: Optional["SpanContext"]): + """Set the current turn context. + + Args: + span_context: The span context for the current turn or None to clear it. + """ + if not is_tracing_available(): + return + + if span_context: + non_recording_span = NonRecordingSpan(span_context) + self._turn_context = set_span_in_context(non_recording_span) + else: + self._turn_context = None + + def get_turn_context(self) -> Optional["Context"]: + """Get the OpenTelemetry context for the current turn. + + Returns: + The current turn context or None if not available. + """ + return self._turn_context + + @property + def conversation_id(self) -> Optional[str]: + """Get the ID for the current conversation. + + Returns: + The current conversation ID or None if not available. + """ + return self._conversation_id + + @staticmethod + def generate_conversation_id() -> str: + """Generate a new conversation ID. + + Returns: + A new randomly generated UUID string. + """ + return str(uuid.uuid4()) diff --git a/src/pipecat/utils/tracing/turn_context_provider.py b/src/pipecat/utils/tracing/turn_context_provider.py deleted file mode 100644 index edb165561..000000000 --- a/src/pipecat/utils/tracing/turn_context_provider.py +++ /dev/null @@ -1,81 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -"""Turn context provider for OpenTelemetry tracing in Pipecat. - -This module provides a singleton context provider that manages the current -turn's tracing context, allowing services to create child spans that are -properly associated with the conversation turn. -""" - -from typing import TYPE_CHECKING, Optional - -# Import types for type checking only -if TYPE_CHECKING: - from opentelemetry.context import Context - from opentelemetry.trace import SpanContext - -from pipecat.utils.tracing.setup import is_tracing_available - -if is_tracing_available(): - from opentelemetry.context import Context - from opentelemetry.trace import NonRecordingSpan, SpanContext, set_span_in_context - - -class TurnContextProvider: - """Provides access to the current turn's tracing context. - - This is a singleton that services can use to get the current turn's - span context to create child spans. - """ - - _instance = None - _current_turn_context: Optional["Context"] = None - - @classmethod - def get_instance(cls): - """Get the singleton instance. - - Returns: - The singleton TurnContextProvider instance. - """ - if cls._instance is None: - cls._instance = TurnContextProvider() - return cls._instance - - def set_current_turn_context(self, span_context: Optional["SpanContext"]): - """Set the current turn context. - - Args: - span_context: The span context for the current turn or None to clear it. - """ - if not is_tracing_available(): - return - - if span_context: - # Create a non-recording span from the span context - non_recording_span = NonRecordingSpan(span_context) - self._current_turn_context = set_span_in_context(non_recording_span) - else: - self._current_turn_context = None - - def get_current_turn_context(self) -> Optional["Context"]: - """Get the OpenTelemetry context for the current turn. - - Returns: - The current turn context or None if not available. - """ - return self._current_turn_context - - -def get_current_turn_context() -> Optional["Context"]: - """Get the OpenTelemetry context for the current turn. - - Returns: - The current turn context or None if not available. - """ - provider = TurnContextProvider.get_instance() - return provider.get_current_turn_context() diff --git a/src/pipecat/utils/tracing/turn_trace_observer.py b/src/pipecat/utils/tracing/turn_trace_observer.py index 78a9c9ea9..83c2bcdc2 100644 --- a/src/pipecat/utils/tracing/turn_trace_observer.py +++ b/src/pipecat/utils/tracing/turn_trace_observer.py @@ -19,9 +19,8 @@ from pipecat.frames.frames import StartFrame from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.observers.turn_tracking_observer import TurnTrackingObserver from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver -from pipecat.utils.tracing.conversation_context_provider import ConversationContextProvider from pipecat.utils.tracing.setup import is_tracing_available -from pipecat.utils.tracing.turn_context_provider import TurnContextProvider +from pipecat.utils.tracing.tracing_context import TracingContext # Import types for type checking only if TYPE_CHECKING: @@ -49,6 +48,7 @@ class TurnTraceObserver(BaseObserver): latency_tracker: UserBotLatencyObserver, conversation_id: Optional[str] = None, additional_span_attributes: Optional[dict] = None, + tracing_context: Optional[TracingContext] = None, **kwargs, ): """Initialize the turn trace observer. @@ -58,11 +58,13 @@ class TurnTraceObserver(BaseObserver): latency_tracker: The latency tracking observer for user-bot latency. conversation_id: Optional conversation ID for grouping turns. additional_span_attributes: Additional attributes to add to spans. + tracing_context: Pipeline-scoped tracing context for span hierarchy. **kwargs: Additional arguments passed to parent class. """ super().__init__(**kwargs) self._turn_tracker = turn_tracker self._latency_tracker = latency_tracker + self._tracing_context = tracing_context or TracingContext() self._current_span: Optional["Span"] = None self._current_turn_number: int = 0 self._trace_context_map: Dict[int, "SpanContext"] = {} @@ -123,9 +125,8 @@ class TurnTraceObserver(BaseObserver): return # Generate a conversation ID if not provided - context_provider = ConversationContextProvider.get_instance() if conversation_id is None: - conversation_id = context_provider.generate_conversation_id() + conversation_id = TracingContext.generate_conversation_id() logger.debug(f"Generated new conversation ID: {conversation_id}") self._conversation_id = conversation_id @@ -140,8 +141,8 @@ class TurnTraceObserver(BaseObserver): for k, v in (self._additional_span_attributes or {}).items(): self._conversation_span.set_attribute(k, v) - # Update the conversation context provider - context_provider.set_current_conversation_context( + # Update the tracing context + self._tracing_context.set_conversation_context( self._conversation_span.get_span_context(), conversation_id ) @@ -161,9 +162,8 @@ class TurnTraceObserver(BaseObserver): self._current_span.end() self._current_span = None - # Clear the turn context provider - context_provider = TurnContextProvider.get_instance() - context_provider.set_current_turn_context(None) + # Clear the turn context + self._tracing_context.set_turn_context(None) # Now end the conversation span if it exists if self._conversation_span: @@ -171,9 +171,8 @@ class TurnTraceObserver(BaseObserver): self._conversation_span.end() self._conversation_span = None - # Clear the context provider - context_provider = ConversationContextProvider.get_instance() - context_provider.set_current_conversation_context(None) + # Clear the conversation context + self._tracing_context.set_conversation_context(None) logger.debug(f"Ended tracing for Conversation {self._conversation_id}") self._conversation_id = None @@ -189,8 +188,7 @@ class TurnTraceObserver(BaseObserver): # Get the parent context - conversation if available, otherwise use root context parent_context = None if self._conversation_span: - context_provider = ConversationContextProvider.get_instance() - parent_context = context_provider.get_current_conversation_context() + parent_context = self._tracing_context.get_conversation_context() # Create a new span for this turn self._current_span = self._tracer.start_span("turn", context=parent_context) @@ -207,9 +205,8 @@ class TurnTraceObserver(BaseObserver): # Store the span context so services can become children of this span self._trace_context_map[turn_number] = self._current_span.get_span_context() - # Update the context provider so services can access this span - context_provider = TurnContextProvider.get_instance() - context_provider.set_current_turn_context(self._current_span.get_span_context()) + # Update the tracing context so services can access this span + self._tracing_context.set_turn_context(self._current_span.get_span_context()) logger.debug(f"Started tracing for Turn {turn_number}") @@ -228,9 +225,8 @@ class TurnTraceObserver(BaseObserver): self._current_span.end() self._current_span = None - # Clear the context provider - context_provider = TurnContextProvider.get_instance() - context_provider.set_current_turn_context(None) + # Clear the turn context + self._tracing_context.set_turn_context(None) logger.debug(f"Ended tracing for Turn {turn_number}") diff --git a/tests/test_aic_filter.py b/tests/test_aic_filter.py index 6499084af..83eca757c 100644 --- a/tests/test_aic_filter.py +++ b/tests/test_aic_filter.py @@ -5,6 +5,7 @@ # import asyncio +import time import unittest from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -23,6 +24,13 @@ except ImportError: AIC_FILTER_MODULE = "pipecat.audio.filters.aic_filter" +def _model_manager_ref_count(manager, key: str) -> int: + """Test helper: return reference count for a cache key (reads internal cache).""" + with manager._lock: + entry = manager._cache.get(key) + return entry[1] if entry else 0 + + class MockProcessor: """A lightweight mock for AIC ProcessorAsync that mimics real behavior.""" @@ -99,10 +107,11 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): @classmethod def setUpClass(cls): """Import AICFilter after confirming aic_sdk is available.""" - from pipecat.audio.filters.aic_filter import AICFilter + from pipecat.audio.filters.aic_filter import AICFilter, AICModelManager from pipecat.frames.frames import FilterEnableFrame cls.AICFilter = AICFilter + cls.AICModelManager = AICModelManager cls.FilterEnableFrame = FilterEnableFrame def setUp(self): @@ -122,13 +131,13 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): async def _start_filter_with_mocks(self, filter_instance, sample_rate=16000): """Start a filter with mocked SDK components.""" + cache_key = "test-cache-key" with ( - patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls, + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor), ): - mock_model_cls.from_file.return_value = self.mock_model - mock_model_cls.download_async = AsyncMock(return_value="/tmp/model") + mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, cache_key)) mock_config_cls.optimal.return_value = MagicMock() await filter_instance.start(sample_rate) @@ -171,37 +180,44 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): filter_instance = self._create_filter_with_mocks(model_id=None, model_path=model_path) with ( - patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls, + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor), ): - mock_model_cls.from_file.return_value = self.mock_model + mock_manager_cls.acquire = AsyncMock( + return_value=(self.mock_model, "path:/tmp/test.aicmodel") + ) mock_config_cls.optimal.return_value = MagicMock() await filter_instance.start(16000) - mock_model_cls.from_file.assert_called_once_with(str(model_path)) + mock_manager_cls.acquire.assert_called_once() + call_kw = mock_manager_cls.acquire.call_args[1] + self.assertEqual(call_kw["model_path"], model_path) + self.assertIsNone(call_kw["model_id"]) self.assertTrue(filter_instance._aic_ready) self.assertEqual(filter_instance._sample_rate, 16000) self.assertEqual(filter_instance._frames_per_block, 160) async def test_start_with_model_id_downloads(self): - """Test starting filter with model_id triggers download.""" + """Test starting filter with model_id uses manager (download happens in manager).""" filter_instance = self._create_filter_with_mocks() with ( - patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls, + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor), ): - mock_model_cls.from_file.return_value = self.mock_model - mock_model_cls.download_async = AsyncMock(return_value="/tmp/model") + mock_manager_cls.acquire = AsyncMock( + return_value=(self.mock_model, "id:test-model:/custom/cache") + ) mock_config_cls.optimal.return_value = MagicMock() await filter_instance.start(16000) - mock_model_cls.download_async.assert_called_once() - mock_model_cls.from_file.assert_called_once() + mock_manager_cls.acquire.assert_called_once() + call_kw = mock_manager_cls.acquire.call_args[1] + self.assertEqual(call_kw["model_id"], "test-model") self.assertTrue(filter_instance._aic_ready) async def test_start_creates_processor(self): @@ -209,14 +225,13 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): filter_instance = self._create_filter_with_mocks() with ( - patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls, + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, patch( f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor ) as mock_processor_cls, ): - mock_model_cls.from_file.return_value = self.mock_model - mock_model_cls.download_async = AsyncMock(return_value="/tmp/model") + mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-cache-key")) mock_config_cls.optimal.return_value = MagicMock() await filter_instance.start(16000) @@ -241,17 +256,21 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): self.assertEqual(bypass_params[-1][1], 0.0) async def test_stop_cleans_up_resources(self): - """Test that stop properly cleans up resources.""" + """Test that stop properly cleans up resources and releases model reference.""" filter_instance = self._create_filter_with_mocks() await self._start_filter_with_mocks(filter_instance) + cache_key = filter_instance._model_cache_key - await filter_instance.stop() + with patch(f"{AIC_FILTER_MODULE}.AICModelManager.release") as mock_release: + await filter_instance.stop() + mock_release.assert_called_once_with(cache_key) self.assertTrue(self.mock_processor.processor_ctx.reset_called) self.assertIsNone(filter_instance._processor) self.assertIsNone(filter_instance._processor_ctx) self.assertIsNone(filter_instance._vad_ctx) self.assertIsNone(filter_instance._model) + self.assertIsNone(filter_instance._model_cache_key) self.assertFalse(filter_instance._aic_ready) async def test_stop_without_start(self): @@ -261,6 +280,177 @@ class TestAICFilter(unittest.IsolatedAsyncioTestCase): # Should not raise await filter_instance.stop() + async def test_model_manager_reference_count(self): + """Test that AICModelManager reference count increments and decrements correctly.""" + model_path = Path("/tmp/refcount-test.aicmodel") + mock_model = MockModel() + manager = self.AICModelManager + + with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls: + mock_model_cls.from_file.return_value = mock_model + + # Acquire first reference + model1, key = await manager.acquire(model_path=model_path) + self.assertEqual(model1, mock_model) + self.assertEqual(_model_manager_ref_count(manager, key), 1) + + # Acquire second reference (same key, cached) + model2, key2 = await manager.acquire(model_path=model_path) + self.assertIs(model2, model1) + self.assertEqual(key2, key) + self.assertEqual(_model_manager_ref_count(manager, key), 2) + + # Release one reference + manager.release(key) + self.assertEqual(_model_manager_ref_count(manager, key), 1) + + # Release last reference (model evicted from cache) + manager.release(key) + self.assertEqual(_model_manager_ref_count(manager, key), 0) + + async def test_model_manager_concurrent_load_deduplication(self): + """Test that concurrent acquire calls for the same key share a single load task.""" + model_path = Path("/tmp/concurrent-load-test.aicmodel") + mock_model = MockModel() + manager = self.AICModelManager + load_count = 0 + + def from_file_once(path): + nonlocal load_count + load_count += 1 + time.sleep(0.02) # yield so other acquire callers can hit _loading and await same task + return mock_model + + with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls: + mock_model_cls.from_file.side_effect = from_file_once + + # Start several acquire calls concurrently before any completes + results = await asyncio.gather( + manager.acquire(model_path=model_path), + manager.acquire(model_path=model_path), + manager.acquire(model_path=model_path), + ) + + self.assertEqual( + load_count, 1, "Model.from_file should be called once for concurrent callers" + ) + model1, key1 = results[0] + model2, key2 = results[1] + model3, key3 = results[2] + self.assertIs(model1, mock_model) + self.assertIs(model2, mock_model) + self.assertIs(model3, mock_model) + self.assertEqual(key1, key2) + self.assertEqual(key2, key3) + self.assertEqual(_model_manager_ref_count(manager, key1), 3) + + # Release all references + manager.release(key1) + manager.release(key1) + manager.release(key1) + self.assertEqual(_model_manager_ref_count(manager, key1), 0) + + async def test_load_model_from_file_invalid_args_raises(self): + """Test _load_model_from_file defensive else: raises ValueError.""" + manager = self.AICModelManager + with self.assertRaises(ValueError) as ctx: + await manager._load_model_from_file( + "key", + model_path=None, + model_id=None, + model_download_dir=None, + ) + self.assertIn("Unexpected", str(ctx.exception)) + + async def test_model_manager_acquire_by_model_id_hits_download_path(self): + """Test acquire with model_id runs download path in _load_model_from_file.""" + model_id = "test-model-id" + model_download_dir = Path("/tmp/aic-downloads") + mock_model = MockModel() + manager = self.AICModelManager + + with patch(f"{AIC_FILTER_MODULE}.Model") as mock_model_cls: + mock_model_cls.download_async = AsyncMock( + return_value="/tmp/aic-downloads/model.aicmodel" + ) + mock_model_cls.from_file.return_value = mock_model + + model, key = await manager.acquire( + model_id=model_id, + model_download_dir=model_download_dir, + ) + + mock_model_cls.download_async.assert_called_once() + mock_model_cls.from_file.assert_called_once_with("/tmp/aic-downloads/model.aicmodel") + self.assertIs(model, mock_model) + self.assertEqual(_model_manager_ref_count(manager, key), 1) + manager.release(key) + + def test_get_cache_key_invalid_raises(self): + """Test _get_cache_key raises ValueError for invalid args.""" + with self.assertRaises(ValueError) as ctx: + self.AICModelManager._get_cache_key(model_path=None, model_id=None) + self.assertIn("model_path", str(ctx.exception)) + + with self.assertRaises(ValueError) as ctx2: + self.AICModelManager._get_cache_key( + model_path=None, + model_id="x", + model_download_dir=None, + ) + self.assertIn("model_download_dir", str(ctx2.exception)) + + async def test_start_processor_init_failure(self): + """Test start() when ProcessorAsync raises: exception logged, _aic_ready False.""" + filter_instance = self._create_filter_with_mocks() + + with ( + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, + patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, + patch( + f"{AIC_FILTER_MODULE}.ProcessorAsync", + side_effect=RuntimeError("SDK init failed"), + ), + ): + mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-key")) + mock_config_cls.optimal.return_value = MagicMock() + + await filter_instance.start(16000) + + self.assertIsNone(filter_instance._processor) + self.assertFalse(filter_instance._aic_ready) + + async def test_start_parameter_fixed_error_logged(self): + """Test start() when set_parameter raises ParameterFixedError: logged, no raise.""" + filter_instance = self._create_filter_with_mocks() + self.mock_processor.processor_ctx.set_parameter = MagicMock( + side_effect=aic_sdk.ParameterFixedError("fixed") + ) + + with ( + patch(f"{AIC_FILTER_MODULE}.AICModelManager") as mock_manager_cls, + patch(f"{AIC_FILTER_MODULE}.ProcessorConfig") as mock_config_cls, + patch(f"{AIC_FILTER_MODULE}.ProcessorAsync", return_value=self.mock_processor), + ): + mock_manager_cls.acquire = AsyncMock(return_value=(self.mock_model, "test-key")) + mock_config_cls.optimal.return_value = MagicMock() + + await filter_instance.start(16000) + + self.assertTrue(filter_instance._aic_ready) + + async def test_process_frame_set_parameter_exception_logged(self): + """Test process_frame when set_parameter raises: exception logged, no raise.""" + filter_instance = self._create_filter_with_mocks() + await self._start_filter_with_mocks(filter_instance) + filter_instance._processor_ctx.set_parameter = MagicMock( + side_effect=ValueError("param error") + ) + + await filter_instance.process_frame(self.FilterEnableFrame(enable=True)) + + self.assertFalse(filter_instance._bypass) + async def test_process_frame_enable(self): """Test processing FilterEnableFrame to enable filtering.""" filter_instance = self._create_filter_with_mocks() diff --git a/tests/test_context_aggregators_universal.py b/tests/test_context_aggregators_universal.py index 7e09a5449..1bba463b0 100644 --- a/tests/test_context_aggregators_universal.py +++ b/tests/test_context_aggregators_universal.py @@ -24,7 +24,9 @@ from pipecat.frames.frames import ( LLMThoughtEndFrame, LLMThoughtStartFrame, LLMThoughtTextFrame, + StartFrame, TranscriptionFrame, + UserMuteStartedFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, @@ -40,7 +42,11 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMUserAggregatorParams, ) from pipecat.tests.utils import SleepFrame, run_test -from pipecat.turns.user_mute import FirstSpeechUserMuteStrategy, FunctionCallUserMuteStrategy +from pipecat.turns.user_mute import ( + FirstSpeechUserMuteStrategy, + FunctionCallUserMuteStrategy, + MuteUntilFirstBotCompleteUserMuteStrategy, +) from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy from pipecat.turns.user_turn_strategies import UserTurnStrategies @@ -386,6 +392,42 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): self.assertIsNone(strategy) # strategy is None for end/cancel self.assertEqual(message.content, "Hello!") + async def test_start_frame_before_mute_event(self): + """StartFrame must reach downstream before mute events are broadcast. + + With MuteUntilFirstBotCompleteUserMuteStrategy, the mute logic should + not run on control frames (StartFrame, EndFrame, CancelFrame). This + ensures StartFrame reaches downstream processors before + UserMuteStartedFrame is broadcast. + + The default TurnAnalyzerUserTurnStopStrategy broadcasts a + SpeechControlParamsFrame when it processes StartFrame, which gets + re-queued to the aggregator. That non-control frame legitimately + triggers the mute state change, so UserMuteStartedFrame follows + StartFrame — but crucially, after it. + """ + context = LLMContext() + + user_aggregator = LLMUserAggregator( + context, + params=LLMUserAggregatorParams( + user_mute_strategies=[MuteUntilFirstBotCompleteUserMuteStrategy()], + ), + ) + + pipeline = Pipeline([user_aggregator]) + + # run_test internally sends StartFrame via PipelineRunner. With + # ignore_start=False we can verify ordering: StartFrame must arrive + # before UserMuteStartedFrame. Before the fix, UserMuteStartedFrame + # was broadcast before StartFrame reached downstream processors. + (down_frames, _) = await run_test( + pipeline, + frames_to_send=[], + expected_down_frames=[StartFrame, UserMuteStartedFrame], + ignore_start=False, + ) + class TestLLMAssistantAggregator(unittest.IsolatedAsyncioTestCase): async def test_empty(self): diff --git a/tests/test_frame_processor.py b/tests/test_frame_processor.py index 2ce4b7880..138c8e6d8 100644 --- a/tests/test_frame_processor.py +++ b/tests/test_frame_processor.py @@ -25,7 +25,6 @@ from pipecat.frames.frames import ( from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.filters.identity_filter import IdentityFilter from pipecat.processors.frame_processor import ( - INTERRUPTION_COMPLETION_TIMEOUT, FrameDirection, FrameProcessor, ) @@ -521,7 +520,7 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): # Complete after the timeout so the warning fires # but the test doesn't hang. async def delayed_complete(): - await asyncio.sleep(INTERRUPTION_COMPLETION_TIMEOUT + 1.0) + await asyncio.sleep(1.0) frame.complete() asyncio.create_task(delayed_complete()) @@ -532,7 +531,7 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): async def process_frame(self, frame: Frame, direction: FrameDirection): await super().process_frame(frame, direction) if isinstance(frame, TextFrame): - await self.push_interruption_task_frame_and_wait() + await self.push_interruption_task_frame_and_wait(timeout=0.5) await self.push_frame(OutputTransportMessageUrgentFrame(message="done")) else: await self.push_frame(frame, direction) diff --git a/tests/test_openai_llm_timeout.py b/tests/test_openai_llm_timeout.py index f7876f02f..8ee776a9b 100644 --- a/tests/test_openai_llm_timeout.py +++ b/tests/test_openai_llm_timeout.py @@ -223,3 +223,77 @@ async def test_openai_llm_emits_error_frame_on_exception(): assert "Error during completion" in pushed_errors[0]["error_msg"] assert "API Error" in pushed_errors[0]["error_msg"] assert isinstance(pushed_errors[0]["exception"], RuntimeError) + + +@pytest.mark.asyncio +async def test_openai_llm_async_iterator_closed_on_stream_end(): + """Test that the async iterator is explicitly closed after stream consumption. + + This prevents uvloop's broken asyncgen finalizer from firing on Python 3.12+ + when async generators are garbage-collected without explicit cleanup. + See MagicStack/uvloop#699. + """ + with patch.object(OpenAILLMService, "create_client"): + service = OpenAILLMService(model="gpt-4") + service._client = AsyncMock() + + # Track if the iterator's aclose was called + iterator_aclosed = False + stream_closed = False + + class MockAsyncIterator: + """Mock async iterator that tracks aclose() calls.""" + + def __init__(self): + self.iteration_count = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + self.iteration_count += 1 + if self.iteration_count > 2: + raise StopAsyncIteration() + # Return a minimal chunk + mock_chunk = AsyncMock() + mock_chunk.usage = None + mock_chunk.model = None + mock_chunk.choices = [] + return mock_chunk + + async def aclose(self): + nonlocal iterator_aclosed + iterator_aclosed = True + + class MockAsyncStream: + """Mock stream whose __aiter__ returns a separate iterator object.""" + + def __init__(self, iterator): + self._iterator = iterator + + def __aiter__(self): + return self._iterator + + async def close(self): + nonlocal stream_closed + stream_closed = True + + mock_iterator = MockAsyncIterator() + mock_stream = MockAsyncStream(mock_iterator) + + service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream) + service._stream_chat_completions_universal_context = AsyncMock(return_value=mock_stream) + service.start_ttfb_metrics = AsyncMock() + service.stop_ttfb_metrics = AsyncMock() + service.start_llm_usage_metrics = AsyncMock() + + context = LLMContext( + messages=[{"role": "user", "content": "Hello"}], + ) + + await service._process_context(context) + + # Verify the iterator was explicitly closed (prevents uvloop crash) + assert iterator_aclosed, "Async iterator should be explicitly closed" + # Verify the stream was also closed (releases HTTP resources) + assert stream_closed, "Stream should be closed to release HTTP resources" diff --git a/tests/test_tracing_context.py b/tests/test_tracing_context.py new file mode 100644 index 000000000..06aa34683 --- /dev/null +++ b/tests/test_tracing_context.py @@ -0,0 +1,127 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import unittest + +try: + from opentelemetry.sdk.trace import TracerProvider + + HAS_OPENTELEMETRY = True +except ImportError: + HAS_OPENTELEMETRY = False + +from pipecat.utils.tracing.tracing_context import TracingContext + + +@unittest.skipUnless(HAS_OPENTELEMETRY, "opentelemetry not installed") +class TestTracingContext(unittest.TestCase): + """Tests for TracingContext.""" + + @classmethod + def setUpClass(cls): + """Set up a tracer provider for generating span contexts.""" + cls._provider = TracerProvider() + cls._tracer = cls._provider.get_tracer("test") + + def test_initial_state_is_empty(self): + """Test that a new TracingContext starts with no context set.""" + ctx = TracingContext() + self.assertIsNone(ctx.get_conversation_context()) + self.assertIsNone(ctx.get_turn_context()) + self.assertIsNone(ctx.conversation_id) + + def test_set_and_get_conversation_context(self): + """Test setting and retrieving conversation context.""" + ctx = TracingContext() + span = self._tracer.start_span("conv") + span_context = span.get_span_context() + + ctx.set_conversation_context(span_context, "conv-123") + + self.assertIsNotNone(ctx.get_conversation_context()) + self.assertEqual(ctx.conversation_id, "conv-123") + span.end() + + def test_clear_conversation_context(self): + """Test clearing conversation context by passing None.""" + ctx = TracingContext() + span = self._tracer.start_span("conv") + + ctx.set_conversation_context(span.get_span_context(), "conv-123") + self.assertIsNotNone(ctx.get_conversation_context()) + + ctx.set_conversation_context(None) + self.assertIsNone(ctx.get_conversation_context()) + self.assertIsNone(ctx.conversation_id) + span.end() + + def test_set_and_get_turn_context(self): + """Test setting and retrieving turn context.""" + ctx = TracingContext() + span = self._tracer.start_span("turn") + span_context = span.get_span_context() + + ctx.set_turn_context(span_context) + + self.assertIsNotNone(ctx.get_turn_context()) + span.end() + + def test_clear_turn_context(self): + """Test clearing turn context by passing None.""" + ctx = TracingContext() + span = self._tracer.start_span("turn") + + ctx.set_turn_context(span.get_span_context()) + self.assertIsNotNone(ctx.get_turn_context()) + + ctx.set_turn_context(None) + self.assertIsNone(ctx.get_turn_context()) + span.end() + + def test_generate_conversation_id(self): + """Test that generated conversation IDs are unique UUIDs.""" + id1 = TracingContext.generate_conversation_id() + id2 = TracingContext.generate_conversation_id() + self.assertIsInstance(id1, str) + self.assertNotEqual(id1, id2) + + def test_instances_are_isolated(self): + """Test that two TracingContext instances do not share state.""" + ctx_a = TracingContext() + ctx_b = TracingContext() + + span = self._tracer.start_span("turn") + + ctx_a.set_turn_context(span.get_span_context()) + ctx_a.set_conversation_context(span.get_span_context(), "conv-a") + + # ctx_b should still be empty + self.assertIsNone(ctx_b.get_turn_context()) + self.assertIsNone(ctx_b.get_conversation_context()) + self.assertIsNone(ctx_b.conversation_id) + span.end() + + def test_conversation_and_turn_are_independent(self): + """Test that clearing turn context does not affect conversation context.""" + ctx = TracingContext() + conv_span = self._tracer.start_span("conv") + turn_span = self._tracer.start_span("turn") + + ctx.set_conversation_context(conv_span.get_span_context(), "conv-1") + ctx.set_turn_context(turn_span.get_span_context()) + + # Clear turn but conversation should remain + ctx.set_turn_context(None) + self.assertIsNone(ctx.get_turn_context()) + self.assertIsNotNone(ctx.get_conversation_context()) + self.assertEqual(ctx.conversation_id, "conv-1") + + conv_span.end() + turn_span.end() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_turn_trace_observer.py b/tests/test_turn_trace_observer.py new file mode 100644 index 000000000..41ff41b8b --- /dev/null +++ b/tests/test_turn_trace_observer.py @@ -0,0 +1,505 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import threading +import unittest + +try: + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult + + HAS_OPENTELEMETRY = True +except ImportError: + HAS_OPENTELEMETRY = False + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, +) +from pipecat.observers.turn_tracking_observer import TurnTrackingObserver +from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver +from pipecat.processors.filters.identity_filter import IdentityFilter +from pipecat.tests.utils import SleepFrame, run_test +from pipecat.utils.tracing.tracing_context import TracingContext +from pipecat.utils.tracing.turn_trace_observer import TurnTraceObserver + +if HAS_OPENTELEMETRY: + + class _InMemorySpanExporter(SpanExporter): + """Simple in-memory span exporter for testing.""" + + def __init__(self): + """Initialize the exporter.""" + self._spans = [] + self._lock = threading.Lock() + + def export(self, spans): + """Export spans to memory.""" + with self._lock: + self._spans.extend(spans) + return SpanExportResult.SUCCESS + + def get_finished_spans(self): + """Return collected spans.""" + with self._lock: + return list(self._spans) + + def clear(self): + """Clear collected spans.""" + with self._lock: + self._spans.clear() + + +@unittest.skipUnless(HAS_OPENTELEMETRY, "opentelemetry not installed") +class TestTurnTraceObserver(unittest.IsolatedAsyncioTestCase): + """Tests for TurnTraceObserver.""" + + def setUp(self): + """Set up a fresh provider and exporter for each test. + + We create a dedicated TracerProvider per test and inject its tracer + directly into the observer, avoiding the global provider singleton. + """ + self._exporter = _InMemorySpanExporter() + self._provider = TracerProvider() + self._provider.add_span_processor(SimpleSpanProcessor(self._exporter)) + self._tracer = self._provider.get_tracer("pipecat.turn") + + def tearDown(self): + """Shut down the provider to flush spans.""" + self._provider.shutdown() + + def _create_observers(self, conversation_id=None, tracing_context=None): + """Create a standard set of turn/trace observers. + + Args: + conversation_id: Optional conversation ID. + tracing_context: Optional TracingContext instance. + + Returns: + Tuple of (turn_tracker, latency_tracker, trace_observer, tracing_context). + """ + tracing_context = tracing_context or TracingContext() + turn_tracker = TurnTrackingObserver(turn_end_timeout_secs=0.2) + latency_tracker = UserBotLatencyObserver() + trace_observer = TurnTraceObserver( + turn_tracker, + latency_tracker=latency_tracker, + conversation_id=conversation_id, + tracing_context=tracing_context, + ) + # Inject the test tracer so spans go to our in-memory exporter + trace_observer._tracer = self._tracer + return turn_tracker, latency_tracker, trace_observer, tracing_context + + def _all_observers(self, trace_observer): + """Return the list of observers needed for run_test.""" + return [trace_observer._turn_tracker, trace_observer._latency_tracker, trace_observer] + + def _get_spans_by_name(self, name): + """Return finished spans with the given name.""" + return [s for s in self._exporter.get_finished_spans() if s.name == name] + + async def test_conversation_span_created_on_start_frame(self): + """Test that a conversation span is created when StartFrame is observed.""" + _, _, trace_observer, _ = self._create_observers(conversation_id="test-conv") + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # End conversation to flush the conversation span (normally done by PipelineTask._cleanup) + trace_observer.end_conversation_tracing() + + conv_spans = self._get_spans_by_name("conversation") + self.assertEqual(len(conv_spans), 1) + self.assertEqual(conv_spans[0].attributes["conversation.id"], "test-conv") + self.assertEqual(conv_spans[0].attributes["conversation.type"], "voice") + + async def test_turn_spans_created_for_each_turn(self): + """Test that a turn span is created for each conversation turn.""" + _, _, trace_observer, _ = self._create_observers() + processor = IdentityFilter() + + frames_to_send = [ + # Turn 1 + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.05), + # Turn 2 + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + turn_spans = self._get_spans_by_name("turn") + self.assertEqual(len(turn_spans), 2) + turn_numbers = {s.attributes["turn.number"] for s in turn_spans} + self.assertEqual(turn_numbers, {1, 2}) + + async def test_turn_spans_are_children_of_conversation(self): + """Test that turn spans are parented under the conversation span.""" + _, _, trace_observer, _ = self._create_observers() + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # End conversation to flush the conversation span + trace_observer.end_conversation_tracing() + + conv_spans = self._get_spans_by_name("conversation") + turn_spans = self._get_spans_by_name("turn") + self.assertEqual(len(conv_spans), 1) + self.assertEqual(len(turn_spans), 1) + + # Turn span's parent should be the conversation span + conv_span_id = conv_spans[0].context.span_id + turn_parent_id = turn_spans[0].parent.span_id + self.assertEqual(turn_parent_id, conv_span_id) + + async def test_interrupted_turn_marked(self): + """Test that an interrupted turn span has was_interrupted=True.""" + _, _, trace_observer, _ = self._create_observers() + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + # User interrupts + UserStartedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + UserStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # End conversation to flush remaining spans + trace_observer.end_conversation_tracing() + + turn_spans = self._get_spans_by_name("turn") + self.assertGreaterEqual(len(turn_spans), 1) + # First turn should be interrupted + interrupted_turns = [s for s in turn_spans if s.attributes.get("turn.was_interrupted")] + self.assertGreaterEqual(len(interrupted_turns), 1) + + async def test_tracing_context_updated_during_turn(self): + """Test that TracingContext is populated during a turn and cleared after.""" + tracing_ctx = TracingContext() + _, _, trace_observer, _ = self._create_observers(tracing_context=tracing_ctx) + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # After the turn ends, turn context should be cleared + self.assertIsNone(tracing_ctx.get_turn_context()) + + async def test_tracing_context_cleared_after_conversation_end(self): + """Test that TracingContext is cleared when conversation tracing ends.""" + tracing_ctx = TracingContext() + _, _, trace_observer, _ = self._create_observers(tracing_context=tracing_ctx) + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # Manually end conversation tracing (as PipelineTask._cleanup does) + trace_observer.end_conversation_tracing() + + self.assertIsNone(tracing_ctx.get_conversation_context()) + self.assertIsNone(tracing_ctx.get_turn_context()) + self.assertIsNone(tracing_ctx.conversation_id) + + async def test_additional_span_attributes(self): + """Test that additional span attributes are added to the conversation span.""" + extra_attrs = {"deployment.id": "abc-123", "customer.tier": "premium"} + tracing_ctx = TracingContext() + turn_tracker = TurnTrackingObserver(turn_end_timeout_secs=0.2) + latency_tracker = UserBotLatencyObserver() + trace_observer = TurnTraceObserver( + turn_tracker, + latency_tracker=latency_tracker, + additional_span_attributes=extra_attrs, + tracing_context=tracing_ctx, + ) + trace_observer._tracer = self._tracer + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[turn_tracker, latency_tracker, trace_observer], + ) + + # End conversation to flush the conversation span + trace_observer.end_conversation_tracing() + + conv_spans = self._get_spans_by_name("conversation") + self.assertEqual(len(conv_spans), 1) + self.assertEqual(conv_spans[0].attributes["deployment.id"], "abc-123") + self.assertEqual(conv_spans[0].attributes["customer.tier"], "premium") + + async def test_concurrent_pipelines_are_isolated(self): + """Test that two pipelines with separate TracingContexts don't interfere.""" + tracing_ctx_a = TracingContext() + tracing_ctx_b = TracingContext() + + _, _, trace_observer_a, _ = self._create_observers( + conversation_id="conv-a", tracing_context=tracing_ctx_a + ) + _, _, trace_observer_b, _ = self._create_observers( + conversation_id="conv-b", tracing_context=tracing_ctx_b + ) + + processor_a = IdentityFilter() + processor_b = IdentityFilter() + + frames = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + # Run both pipelines concurrently + await asyncio.gather( + run_test( + processor_a, + frames_to_send=frames, + expected_down_frames=expected, + observers=self._all_observers(trace_observer_a), + ), + run_test( + processor_b, + frames_to_send=frames, + expected_down_frames=expected, + observers=self._all_observers(trace_observer_b), + ), + ) + + # End both conversations to flush spans + trace_observer_a.end_conversation_tracing() + trace_observer_b.end_conversation_tracing() + + # Each TracingContext should have its own conversation ID + conv_spans = self._get_spans_by_name("conversation") + conv_ids = {s.attributes["conversation.id"] for s in conv_spans} + self.assertEqual(conv_ids, {"conv-a", "conv-b"}) + + # Turn spans should be children of their own conversation span, not cross-linked + turn_spans = self._get_spans_by_name("turn") + conv_span_map = {s.context.span_id: s.attributes["conversation.id"] for s in conv_spans} + for turn_span in turn_spans: + parent_id = turn_span.parent.span_id + turn_conv_id = turn_span.attributes["conversation.id"] + parent_conv_id = conv_span_map[parent_id] + self.assertEqual( + turn_conv_id, + parent_conv_id, + f"Turn span for {turn_conv_id} parented under {parent_conv_id}", + ) + + async def test_end_conversation_closes_active_turn(self): + """Test that end_conversation_tracing closes any active turn span.""" + _, _, trace_observer, _ = self._create_observers() + + # Manually start conversation and a turn + trace_observer.start_conversation_tracing("conv-end-test") + await trace_observer._handle_turn_started(1) + + self.assertIsNotNone(trace_observer._current_span) + self.assertIsNotNone(trace_observer._conversation_span) + + # End conversation — should close both turn and conversation + trace_observer.end_conversation_tracing() + + self.assertIsNone(trace_observer._current_span) + self.assertIsNone(trace_observer._conversation_span) + + # Check span attributes + turn_spans = self._get_spans_by_name("turn") + self.assertEqual(len(turn_spans), 1) + self.assertTrue(turn_spans[0].attributes["turn.was_interrupted"]) + self.assertTrue(turn_spans[0].attributes["turn.ended_by_conversation_end"]) + + async def test_conversation_id_auto_generated(self): + """Test that a conversation ID is auto-generated when none is provided.""" + _, _, trace_observer, _ = self._create_observers(conversation_id=None) + processor = IdentityFilter() + + frames_to_send = [ + UserStartedSpeakingFrame(), + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + BotStoppedSpeakingFrame(), + SleepFrame(sleep=0.4), + ] + + expected_down_frames = [ + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=self._all_observers(trace_observer), + ) + + # End conversation to flush the conversation span + trace_observer.end_conversation_tracing() + + conv_spans = self._get_spans_by_name("conversation") + self.assertEqual(len(conv_spans), 1) + # Should have an auto-generated UUID as conversation.id + conv_id = conv_spans[0].attributes["conversation.id"] + self.assertIsNotNone(conv_id) + self.assertGreater(len(conv_id), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user_idle_controller.py b/tests/test_user_idle_controller.py index 4b6cbe1d3..646223d37 100644 --- a/tests/test_user_idle_controller.py +++ b/tests/test_user_idle_controller.py @@ -6,12 +6,14 @@ import asyncio import unittest +import unittest.mock from pipecat.frames.frames import ( - BotSpeakingFrame, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, FunctionCallResultFrame, FunctionCallsStartedFrame, - UserSpeakingFrame, + UserIdleTimeoutUpdateFrame, UserStartedSpeakingFrame, ) from pipecat.turns.user_idle_controller import UserIdleController @@ -25,8 +27,8 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase): self.task_manager = TaskManager() self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop())) - async def test_basic_idle_detection(self): - """Test that idle event is triggered after timeout when no activity.""" + async def test_idle_after_bot_stops_speaking(self): + """Test that idle event fires after BotStoppedSpeakingFrame + timeout.""" controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) await controller.setup(self.task_manager) @@ -37,18 +39,16 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase): nonlocal idle_triggered idle_triggered = True - # Start conversation - await controller.process_frame(UserStartedSpeakingFrame()) + await controller.process_frame(BotStoppedSpeakingFrame()) - # Wait for idle timeout await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) self.assertTrue(idle_triggered) await controller.cleanup() - async def test_user_speaking_resets_idle_timer(self): - """Test that continuous UserSpeakingFrame frames reset the idle timer.""" + async def test_user_speaking_cancels_timer(self): + """Test that UserStartedSpeakingFrame cancels the idle timer.""" controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) await controller.setup(self.task_manager) @@ -59,20 +59,18 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase): nonlocal idle_triggered idle_triggered = True - # Start conversation + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3) await controller.process_frame(UserStartedSpeakingFrame()) - # Send UserSpeakingFrame continuously to reset timer - for _ in range(5): - await asyncio.sleep(USER_IDLE_TIMEOUT * 0.5) # 50% of timeout period - await controller.process_frame(UserSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) self.assertFalse(idle_triggered) await controller.cleanup() - async def test_bot_speaking_resets_idle_timer(self): - """Test that BotSpeakingFrame frames reset the idle timer.""" + async def test_bot_speaking_cancels_timer(self): + """Test that BotStartedSpeakingFrame cancels the idle timer.""" controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) await controller.setup(self.task_manager) @@ -83,102 +81,61 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase): nonlocal idle_triggered idle_triggered = True - # Start conversation + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3) + await controller.process_frame(BotStartedSpeakingFrame()) + + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + self.assertFalse(idle_triggered) + + await controller.cleanup() + + async def test_no_idle_before_bot_speaks(self): + """Test that idle does not fire if no BotStoppedSpeakingFrame is received.""" + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # Wait without any frames + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + self.assertFalse(idle_triggered) + + await controller.cleanup() + + async def test_interruption_no_false_trigger(self): + """Test that BotStoppedSpeakingFrame during a user turn does not start the timer.""" + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # User starts speaking (interruption) await controller.process_frame(UserStartedSpeakingFrame()) + # Bot stops speaking due to interruption + await controller.process_frame(BotStoppedSpeakingFrame()) - # Bot speaking should reset timer - for _ in range(5): - await asyncio.sleep(USER_IDLE_TIMEOUT * 0.6) # 60% of timeout - await controller.process_frame(BotSpeakingFrame()) - - self.assertFalse(idle_triggered) - - await controller.cleanup() - - async def test_function_call_prevents_idle(self): - """Test that function calls in progress prevent idle event.""" - controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) - await controller.setup(self.task_manager) - - idle_triggered = False - - @controller.event_handler("on_user_turn_idle") - async def on_user_turn_idle(controller): - nonlocal idle_triggered - idle_triggered = True - - # Start conversation - await controller.process_frame(UserStartedSpeakingFrame()) - - # Start function call - await controller.process_frame(FunctionCallsStartedFrame(function_calls=[])) - - # Wait longer than idle timeout - await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - - # Should not trigger idle because function call is in progress - self.assertFalse(idle_triggered) - - # Complete function call - await controller.process_frame( - FunctionCallResultFrame( - function_name="test", - tool_call_id="123", - arguments={}, - result=None, - run_llm=False, - ) - ) - - # Now idle should trigger - await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - self.assertTrue(idle_triggered) - - await controller.cleanup() - - async def test_no_idle_before_conversation_starts(self): - """Test that idle monitoring doesn't start before first conversation activity.""" - controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) - await controller.setup(self.task_manager) - - idle_triggered = False - - @controller.event_handler("on_user_turn_idle") - async def on_user_turn_idle(controller): - nonlocal idle_triggered - idle_triggered = True - - # Wait without starting conversation + # Wait - timer should NOT have started because user turn is in progress await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) self.assertFalse(idle_triggered) await controller.cleanup() - async def test_idle_starts_with_bot_speaking(self): - """Test that monitoring starts with BotSpeakingFrame, not just user speech.""" - controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) - await controller.setup(self.task_manager) - - idle_triggered = False - - @controller.event_handler("on_user_turn_idle") - async def on_user_turn_idle(controller): - nonlocal idle_triggered - idle_triggered = True - - # Start conversation with bot speaking - await controller.process_frame(BotSpeakingFrame()) - - # Wait for idle timeout - await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - - self.assertTrue(idle_triggered) - - await controller.cleanup() - - async def test_multiple_idle_events(self): - """Test that idle event can trigger multiple times.""" + async def test_idle_cycle(self): + """Test that idle fires, then can fire again after another bot speaking cycle.""" controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) await controller.setup(self.task_manager) @@ -189,29 +146,175 @@ class TestUserIdleController(unittest.IsolatedAsyncioTestCase): nonlocal idle_count idle_count += 1 - # Start conversation - await controller.process_frame(UserStartedSpeakingFrame()) - - # First idle + # First cycle: bot stops → idle fires + await controller.process_frame(BotStoppedSpeakingFrame()) await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - first_count = idle_count - self.assertGreaterEqual(first_count, 1) + self.assertEqual(idle_count, 1) - # Second idle + # Second cycle: bot starts → bot stops → idle fires again + await controller.process_frame(BotStartedSpeakingFrame()) + await controller.process_frame(BotStoppedSpeakingFrame()) await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - second_count = idle_count - self.assertGreater(second_count, first_count) + self.assertEqual(idle_count, 2) - # User activity resets timer - await controller.process_frame(UserSpeakingFrame()) + await controller.cleanup() - # Give a moment for the timer to reset - await asyncio.sleep(0.1) + async def test_cleanup_cancels_timer(self): + """Test that cleanup cancels a pending idle timer.""" + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3) + await controller.cleanup() - # Third idle await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) - third_count = idle_count - self.assertGreater(third_count, second_count) + + self.assertFalse(idle_triggered) + + async def test_function_call_cancels_timer(self): + """Test normal ordering: BotStopped starts timer, FunctionCallsStarted cancels it.""" + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # Bot finishes speaking, timer starts + await controller.process_frame(BotStoppedSpeakingFrame()) + # Function call starts shortly after, cancels the timer + await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3) + await controller.process_frame( + FunctionCallsStartedFrame(function_calls=[unittest.mock.Mock()]) + ) + + # Wait longer than timeout — should not fire + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + self.assertFalse(idle_triggered) + + await controller.cleanup() + + async def test_function_call_suppresses_timer(self): + """Test race condition: FunctionCallsStarted arrives before BotStopped. + + A race condition can cause FunctionCallsStarted to arrive before + BotStoppedSpeaking. The counter guard prevents the timer from starting + while a function call is in progress. + """ + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # LLM emits function call and "let me check" concurrently + await controller.process_frame( + FunctionCallsStartedFrame(function_calls=[unittest.mock.Mock()]) + ) + await controller.process_frame(BotStartedSpeakingFrame()) + await controller.process_frame(BotStoppedSpeakingFrame()) + + # Wait longer than timeout — should not fire (function call in progress) + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + self.assertFalse(idle_triggered) + + # Function call completes, bot speaks result + await controller.process_frame( + FunctionCallResultFrame( + function_name="test", tool_call_id="123", arguments={}, result="ok" + ) + ) + await controller.process_frame(BotStartedSpeakingFrame()) + await controller.process_frame(BotStoppedSpeakingFrame()) + + # Now the timer should start and fire + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + self.assertTrue(idle_triggered) + + await controller.cleanup() + + async def test_disabled_by_default(self): + """Test that timeout=0 means idle detection is disabled.""" + controller = UserIdleController() + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + self.assertFalse(idle_triggered) + + await controller.cleanup() + + async def test_enable_via_frame(self): + """Test enabling idle detection at runtime via UserIdleTimeoutUpdateFrame.""" + controller = UserIdleController() + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # Initially disabled — no idle fires + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + self.assertFalse(idle_triggered) + + # Enable idle detection + await controller.process_frame(UserIdleTimeoutUpdateFrame(timeout=USER_IDLE_TIMEOUT)) + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + self.assertTrue(idle_triggered) + + await controller.cleanup() + + async def test_disable_via_frame(self): + """Test disabling idle detection at runtime via UserIdleTimeoutUpdateFrame.""" + controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT) + await controller.setup(self.task_manager) + + idle_triggered = False + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_triggered + idle_triggered = True + + # Start the timer + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3) + + # Disable — should cancel running timer + await controller.process_frame(UserIdleTimeoutUpdateFrame(timeout=0)) + + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + self.assertFalse(idle_triggered) await controller.cleanup() diff --git a/uv.lock b/uv.lock index 703fd4692..06563ab45 100644 --- a/uv.lock +++ b/uv.lock @@ -579,15 +579,15 @@ wheels = [ [[package]] name = "azure-core" -version = "1.38.0" +version = "1.38.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/fe/5c7710bc611a4070d06ba801de9a935cc87c3d4b689c644958047bdf2cba/azure_core-1.38.2.tar.gz", hash = "sha256:67562857cb979217e48dc60980243b61ea115b77326fa93d83b729e7ff0482e7", size = 363734, upload-time = "2026-02-18T19:33:05.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/42/23/6371a551800d3812d6019cd813acd985f9fac0fedc1290129211a73da4ae/azure_core-1.38.2-py3-none-any.whl", hash = "sha256:074806c75cf239ea284a33a66827695ef7aeddac0b4e19dda266a93e4665ead9", size = 217957, upload-time = "2026-02-18T19:33:07.696Z" }, ] [[package]] @@ -1329,10 +1329,10 @@ wheels = [ [[package]] name = "cuda-pathfinder" -version = "1.3.3" +version = "1.3.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/02/4dbe7568a42e46582248942f54dc64ad094769532adbe21e525e4edf7bc4/cuda_pathfinder-1.3.3-py3-none-any.whl", hash = "sha256:9984b664e404f7c134954a771be8775dfd6180ea1e1aef4a5a37d4be05d9bbb1", size = 27154, upload-time = "2025-12-04T22:35:08.996Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/db279a3bfbd18d59d0598922a3b3c1454908d0969e8372260afec9736376/cuda_pathfinder-1.3.4-py3-none-any.whl", hash = "sha256:fb983f6e0d43af27ef486e14d5989b5f904ef45cedf40538bfdcbffa6bb01fb2", size = 30878, upload-time = "2026-02-11T18:50:31.008Z" }, ] [[package]] @@ -1573,7 +1573,7 @@ all = [ [[package]] name = "fastapi-cli" -version = "0.0.20" +version = "0.0.23" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, @@ -1581,9 +1581,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/ca/d90fb3bfbcbd6e56c77afd9d114dd6ce8955d8bb90094399d1c70e659e40/fastapi_cli-0.0.20.tar.gz", hash = "sha256:d17c2634f7b96b6b560bc16b0035ed047d523c912011395f49f00a421692bc3a", size = 19786, upload-time = "2025-12-22T17:13:33.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/9f/cbd463e57de4e977b8ea0403f95347f9150441568b1d3fe3e4949ef80ef3/fastapi_cli-0.0.23.tar.gz", hash = "sha256:210ac280ea41e73aac5a57688781256beb23c2cba3a41266896fa43e6445c8e7", size = 19763, upload-time = "2026-02-16T19:45:53.358Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/89/5c4eef60524d0fd704eb0706885b82cd5623a43396b94e4a5b17d3a3f516/fastapi_cli-0.0.20-py3-none-any.whl", hash = "sha256:e58b6a0038c0b1532b7a0af690656093dee666201b6b19d3c87175b358e9f783", size = 12390, upload-time = "2025-12-22T17:13:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/68/89/19dcfd5cd289b306abdcabac68b88a4f54b7710a2c33adc16a337ecdcdfa/fastapi_cli-0.0.23-py3-none-any.whl", hash = "sha256:7e9634fc212da0b6cfc75bd3ac366cc9dfdb43b5e9ec12e58bfd1acdd2697f25", size = 12305, upload-time = "2026-02-16T19:45:52.554Z" }, ] [package.optional-dependencies] @@ -1594,7 +1594,7 @@ standard = [ [[package]] name = "fastapi-cloud-cli" -version = "0.11.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastar" }, @@ -1606,9 +1606,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/15/6c3d85d63964340fde6f36cc80f3f365d35f371e6a918d68ff3a3d588ef2/fastapi_cloud_cli-0.11.0.tar.gz", hash = "sha256:ecc83a5db106be35af528eccb01aa9bced1d29783efd48c8c1c831cf111eea99", size = 36170, upload-time = "2026-01-15T09:51:33.681Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/0b/f07f4976784978ef159fd2e8f5c16f1f9d610578fb1fd976ff1315c11ea6/fastapi_cloud_cli-0.13.0.tar.gz", hash = "sha256:4d8f42337e8021c648f6cb0672de7d5b31b0fc7387a83d7b12f974600ac3f2fd", size = 38436, upload-time = "2026-02-17T05:18:19.033Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/07/60f79270a3320780be7e2ae8a1740cb98a692920b569ba420b97bcc6e175/fastapi_cloud_cli-0.11.0-py3-none-any.whl", hash = "sha256:76857b0f09d918acfcb50ade34682ba3b2079ca0c43fda10215de301f185a7f8", size = 26884, upload-time = "2026-01-15T09:51:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/b4/88/71a1e989d17b9edb483f32e28b7891ffdd3005271518c98ba6415987c430/fastapi_cloud_cli-0.13.0-py3-none-any.whl", hash = "sha256:874a9ed8dba34ec828f198c72de9f9a38de77ac1b15083d6bc3a4d772b0bc477", size = 27631, upload-time = "2026-02-17T05:18:18.094Z" }, ] [[package]] @@ -1751,11 +1751,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.3" +version = "3.24.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, ] [[package]] @@ -2070,9 +2070,10 @@ wheels = [ [[package]] name = "google-genai" -version = "1.62.0" +version = "1.64.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "aiohttp" }, { name = "anyio" }, { name = "distro" }, { name = "google-auth", extra = ["requests"] }, @@ -2084,9 +2085,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/4c/71b32b5c8db420cf2fd0d5ef8a672adbde97d85e5d44a0b4fca712264ef1/google_genai-1.62.0.tar.gz", hash = "sha256:709468a14c739a080bc240a4f3191df597bf64485b1ca3728e0fb67517774c18", size = 490888, upload-time = "2026-02-04T22:48:41.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/14/344b450d4387845fc5c8b7f168ffbe734b831b729ece3333fc0fe8556f04/google_genai-1.64.0.tar.gz", hash = "sha256:8db94ab031f745d08c45c69674d1892f7447c74ed21542abe599f7888e28b924", size = 496434, upload-time = "2026-02-19T02:06:13.95Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/5f/4645d8a28c6e431d0dd6011003a852563f3da7037d36af53154925b099fd/google_genai-1.62.0-py3-none-any.whl", hash = "sha256:4c3daeff3d05fafee4b9a1a31f9c07f01bc22051081aa58b4d61f58d16d1bcc0", size = 724166, upload-time = "2026-02-04T22:48:39.956Z" }, + { url = "https://files.pythonhosted.org/packages/54/56/765eca90c781fedbe2a7e7dc873ef6045048e28ba5f2d4a5bcb13e13062b/google_genai-1.64.0-py3-none-any.whl", hash = "sha256:78a4d2deeb33b15ad78eaa419f6f431755e7f0e03771254f8000d70f717e940b", size = 728836, upload-time = "2026-02-19T02:06:11.655Z" }, ] [[package]] @@ -2103,56 +2104,56 @@ wheels = [ [[package]] name = "greenlet" -version = "3.3.1" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/65/5b235b40581ad75ab97dcd8b4218022ae8e3ab77c13c919f1a1dfe9171fd/greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13", size = 273723, upload-time = "2026-01-23T15:30:37.521Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ad/eb4729b85cba2d29499e0a04ca6fbdd8f540afd7be142fd571eea43d712f/greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4", size = 574874, upload-time = "2026-01-23T16:00:54.551Z" }, - { url = "https://files.pythonhosted.org/packages/87/32/57cad7fe4c8b82fdaa098c89498ef85ad92dfbb09d5eb713adedfc2ae1f5/greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5", size = 586309, upload-time = "2026-01-23T16:05:25.18Z" }, - { url = "https://files.pythonhosted.org/packages/87/eb/8a1ec2da4d55824f160594a75a9d8354a5fe0a300fb1c48e7944265217e1/greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe", size = 586985, upload-time = "2026-01-23T15:32:47.968Z" }, - { url = "https://files.pythonhosted.org/packages/15/1c/0621dd4321dd8c351372ee8f9308136acb628600658a49be1b7504208738/greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729", size = 1547271, upload-time = "2026-01-23T16:04:18.977Z" }, - { url = "https://files.pythonhosted.org/packages/9d/53/24047f8924c83bea7a59c8678d9571209c6bfe5f4c17c94a78c06024e9f2/greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4", size = 1613427, upload-time = "2026-01-23T15:33:44.428Z" }, - { url = "https://files.pythonhosted.org/packages/ff/07/ac9bf1ec008916d1a3373cae212884c1dcff4a4ba0d41127ce81a8deb4e9/greenlet-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:7932f5f57609b6a3b82cc11877709aa7a98e3308983ed93552a1c377069b20c8", size = 226100, upload-time = "2026-01-23T15:30:56.957Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, - { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, - { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, - { url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462, upload-time = "2026-01-23T15:36:50.422Z" }, - { url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715, upload-time = "2026-01-23T15:33:17.298Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, - { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, - { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, - { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, - { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, - { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, - { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, - { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, - { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, - { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, - { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, - { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, - { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, - { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, + { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, + { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, + { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, + { url = "https://files.pythonhosted.org/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f", size = 230092, upload-time = "2026-02-20T20:17:09.379Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] [[package]] @@ -3054,7 +3055,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.7.1" +version = "0.7.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3067,9 +3068,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/48/3151de6df96e0977b8d319b03905e29db0df6929a85df1d922a030b7e68d/langsmith-0.7.1.tar.gz", hash = "sha256:e3fec2f97f7c5192f192f4873d6a076b8c6469768022323dded07087d8cb70a4", size = 984367, upload-time = "2026-02-10T01:55:24.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/55/a3641cae990c842d3f4c52e5308b391267c98ce531a7a586dfedf1a78c42/langsmith-0.7.5.tar.gz", hash = "sha256:e3bfc2d7ff0a6f9a719125e1e136b5f4fa11828a2be8979f47ee1a4c0510030e", size = 1038926, upload-time = "2026-02-19T20:47:51.144Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/87/6f2b008a456b4f5fd0fb1509bb7e1e9368c1a0c9641a535f224a9ddc10f3/langsmith-0.7.1-py3-none-any.whl", hash = "sha256:92cfa54253d35417184c297ad25bfd921d95f15d60a1ca75f14d4e7acd152a29", size = 322515, upload-time = "2026-02-10T01:55:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/32/0e/65b3fab6db843150ed38f226b39213565c644f0aaa515e0168bb1eaee5ae/langsmith-0.7.5-py3-none-any.whl", hash = "sha256:c120c43c98af5f5af8877341f8256aba1a170a292645b31572f06b0cf703c683", size = 324337, upload-time = "2026-02-19T20:47:47.537Z" }, ] [[package]] @@ -4255,104 +4256,100 @@ wheels = [ [[package]] name = "pillow" -version = "11.3.0" +version = "12.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, - { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, - { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, - { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, - { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, - { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, - { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, - { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, - { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, - { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, - { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, - { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, - { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, - { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, - { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, - { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, - { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, - { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, - { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, - { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, - { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, - { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, - { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, - { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, - { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, - { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, - { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, - { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, - { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, - { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, - { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, - { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, - { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, - { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, - { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, - { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, - { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, - { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, - { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, - { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, - { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, - { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, - { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, - { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, - { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, - { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, ] [[package]] @@ -4646,7 +4643,7 @@ docs = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx-autodoc-typehints", version = "3.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autodoc-typehints", version = "3.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-markdown-builder" }, { name = "sphinx-rtd-theme" }, { name = "toml" }, @@ -4707,7 +4704,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation", marker = "extra == 'tracing'", specifier = ">=0.54b0" }, { name = "opentelemetry-sdk", marker = "extra == 'tracing'", specifier = ">=1.33.0" }, { name = "ormsgpack", marker = "extra == 'fish'", specifier = "~=1.7.0" }, - { name = "pillow", specifier = ">=11.1.0,<12" }, + { name = "pillow", specifier = ">=11.1.0,<13" }, { name = "pipecat-ai", extras = ["local-smart-turn-v3"] }, { name = "pipecat-ai", extras = ["nvidia"], marker = "extra == 'riva'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'assemblyai'" }, @@ -4732,7 +4729,7 @@ requires-dist = [ { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'ultravox'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'websocket'" }, { name = "pipecat-ai-krisp", marker = "extra == 'krisp'", specifier = "~=0.4.0" }, - { name = "pipecat-ai-small-webrtc-prebuilt", marker = "extra == 'runner'", specifier = ">=2.1.0" }, + { name = "pipecat-ai-small-webrtc-prebuilt", marker = "extra == 'runner'", specifier = ">=2.2.0" }, { name = "piper-tts", marker = "extra == 'piper'", specifier = ">=1.3.0,<2" }, { name = "protobuf", specifier = "~=5.29.6" }, { name = "pvkoala", marker = "extra == 'koala'", specifier = "~=2.0.3" }, @@ -4747,12 +4744,12 @@ requires-dist = [ { name = "requests", marker = "extra == 'kokoro'", specifier = ">=2.32.5,<3" }, { name = "requests", marker = "extra == 'piper'", specifier = ">=2.32.5,<3" }, { name = "resampy", specifier = "~=0.4.3" }, - { name = "sarvamai", marker = "extra == 'sarvam'", specifier = "==0.1.21" }, + { name = "sarvamai", marker = "extra == 'sarvam'", specifier = "==0.1.26a2" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.28.0,<3" }, - { name = "simli-ai", marker = "extra == 'simli'", specifier = "~=1.0.3" }, + { name = "simli-ai", marker = "extra == 'simli'", specifier = "~=2.0.1" }, { name = "soundfile", marker = "extra == 'soundfile'", specifier = "~=0.13.1" }, { name = "soxr", specifier = "~=0.5.0" }, - { name = "speechmatics-voice", extras = ["smart"], marker = "extra == 'speechmatics'", specifier = ">=0.2.8" }, + { name = "speechmatics-voice", extras = ["smart"], marker = "extra == 'speechmatics'", specifier = "~=0.2.8" }, { name = "strands-agents", marker = "extra == 'strands'", specifier = ">=1.9.1,<2" }, { name = "tenacity", marker = "extra == 'livekit'", specifier = ">=8.2.3,<10.0.0" }, { name = "timm", marker = "extra == 'moondream'", specifier = "~=1.0.13" }, @@ -4803,14 +4800,14 @@ sdist = { url = "https://files.pythonhosted.org/packages/1d/37/0f1d11d1dc33234a3 [[package]] name = "pipecat-ai-small-webrtc-prebuilt" -version = "2.1.0" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi", extra = ["all"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/8b/c945a27503560d50c56f497e43209f43c81616ede5d3d2b38eeb4044dbbc/pipecat_ai_small_webrtc_prebuilt-2.1.0.tar.gz", hash = "sha256:c883304a159dee01eec74b162e51352b32c362ca19d281226a71d92b5ba1c600", size = 596971, upload-time = "2026-02-05T17:01:08.074Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/9f/b06cc0e2eaeda811959c216dade3ed38c30d20e6327a2b22f80125072c5a/pipecat_ai_small_webrtc_prebuilt-2.2.0.tar.gz", hash = "sha256:5d73fe619225b97e383863a901060d1c986f088f4de004477856b085aaba76c4", size = 466005, upload-time = "2026-02-13T19:28:54.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/f6/5754d86d513822fb4466549eb6bca05c7246676f1b5b199c9646a5475182/pipecat_ai_small_webrtc_prebuilt-2.1.0-py3-none-any.whl", hash = "sha256:0882d147f69d8cc07397ee68b75c554f4d2d75d8023207e7fb86760616bc58aa", size = 597535, upload-time = "2026-02-05T17:01:06.622Z" }, + { url = "https://files.pythonhosted.org/packages/26/71/20a015cea25dc57129ed6426fdf37a09aefe37f4dd60e3a42ba2d9e3bd1b/pipecat_ai_small_webrtc_prebuilt-2.2.0-py3-none-any.whl", hash = "sha256:e7917d23f51e5418667541a3e241b2de28a43eea35a5a9486721be3da04e719d", size = 466257, upload-time = "2026-02-13T19:28:53.188Z" }, ] [[package]] @@ -4831,11 +4828,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] [[package]] @@ -4861,7 +4858,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.8.5" +version = "7.9.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -4871,9 +4868,9 @@ dependencies = [ { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/10/8e74a5e997c8286f0b63c69da522e503b1ab11627217ab76a06c7b62d647/posthog-7.8.5.tar.gz", hash = "sha256:e4f3796ce18323d8e05139bf419a04d318ccc4ad77b210f4d9d7c7546aea4f35", size = 169117, upload-time = "2026-02-09T22:59:49.207Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/06/bcffcd262c861695fbaa74490b872e37d6fc41d3dcc1a43207d20525522f/posthog-7.9.3.tar.gz", hash = "sha256:55f7580265d290936ac4c112a4e2031a41743be4f90d4183ac9f85b721ff13ae", size = 172336, upload-time = "2026-02-18T22:20:24.085Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/b3/59b61d4b90e2efd138abaa34d98c7a89a4a352850cc3a079a60a46780655/posthog-7.8.5-py3-none-any.whl", hash = "sha256:979d306f07e61a8e837746e5dc432aafc49827fecac91bd6c624dcf3a1967448", size = 194647, upload-time = "2026-02-09T22:59:47.744Z" }, + { url = "https://files.pythonhosted.org/packages/11/7e/0e06a96823fa7c11ce73920e6ff77e82445db62ac4eae0b6f211edb4c4c2/posthog-7.9.3-py3-none-any.whl", hash = "sha256:2ddcacdef6c4afb124ebfcf27d7be58388943a7e24f8d4a51a52732c9b90bad6", size = 197819, upload-time = "2026-02-18T22:20:22.015Z" }, ] [[package]] @@ -5305,16 +5302,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] @@ -5328,14 +5325,14 @@ wheels = [ [[package]] name = "pyee" -version = "13.0.0" +version = "13.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, ] [[package]] @@ -5704,15 +5701,15 @@ wheels = [ [[package]] name = "rdflib" -version = "7.5.0" +version = "7.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "isodate", marker = "python_full_version < '3.11'" }, { name = "pyparsing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/1b/4cd9a29841951371304828d13282e27a5f25993702c7c87dcb7e0604bd25/rdflib-7.5.0.tar.gz", hash = "sha256:663083443908b1830e567350d72e74d9948b310f827966358d76eebdc92bf592", size = 4903859, upload-time = "2025-11-28T05:51:54.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/20/35d2baebacf357b562bd081936b66cd845775442973cb033a377fd639a84/rdflib-7.5.0-py3-none-any.whl", hash = "sha256:b011dfc40d0fc8a44252e906dcd8fc806a7859bc231be190c37e9568a31ac572", size = 587215, upload-time = "2025-11-28T05:51:38.178Z" }, + { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" }, ] [[package]] @@ -5731,123 +5728,123 @@ wheels = [ [[package]] name = "regex" -version = "2026.1.15" +version = "2026.2.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/c0/d8079d4f6342e4cec5c3e7d7415b5cd3e633d5f4124f7a4626908dbe84c7/regex-2026.2.19.tar.gz", hash = "sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310", size = 414973, upload-time = "2026-02-19T19:03:47.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/d2/e6ee96b7dff201a83f650241c52db8e5bd080967cb93211f57aa448dc9d6/regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e", size = 488166, upload-time = "2026-01-14T23:13:46.408Z" }, - { url = "https://files.pythonhosted.org/packages/23/8a/819e9ce14c9f87af026d0690901b3931f3101160833e5d4c8061fa3a1b67/regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f", size = 290632, upload-time = "2026-01-14T23:13:48.688Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c3/23dfe15af25d1d45b07dfd4caa6003ad710dcdcb4c4b279909bdfe7a2de8/regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b", size = 288500, upload-time = "2026-01-14T23:13:50.503Z" }, - { url = "https://files.pythonhosted.org/packages/c6/31/1adc33e2f717df30d2f4d973f8776d2ba6ecf939301efab29fca57505c95/regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c", size = 781670, upload-time = "2026-01-14T23:13:52.453Z" }, - { url = "https://files.pythonhosted.org/packages/23/ce/21a8a22d13bc4adcb927c27b840c948f15fc973e21ed2346c1bd0eae22dc/regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9", size = 850820, upload-time = "2026-01-14T23:13:54.894Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/3eeacdf587a4705a44484cd0b30e9230a0e602811fb3e2cc32268c70d509/regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c", size = 898777, upload-time = "2026-01-14T23:13:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/79/a9/1898a077e2965c35fc22796488141a22676eed2d73701e37c73ad7c0b459/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106", size = 791750, upload-time = "2026-01-14T23:13:58.527Z" }, - { url = "https://files.pythonhosted.org/packages/4c/84/e31f9d149a178889b3817212827f5e0e8c827a049ff31b4b381e76b26e2d/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618", size = 782674, upload-time = "2026-01-14T23:13:59.874Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ff/adf60063db24532add6a1676943754a5654dcac8237af024ede38244fd12/regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4", size = 767906, upload-time = "2026-01-14T23:14:01.298Z" }, - { url = "https://files.pythonhosted.org/packages/af/3e/e6a216cee1e2780fec11afe7fc47b6f3925d7264e8149c607ac389fd9b1a/regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79", size = 774798, upload-time = "2026-01-14T23:14:02.715Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/23a4a8378a9208514ed3efc7e7850c27fa01e00ed8557c958df0335edc4a/regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9", size = 845861, upload-time = "2026-01-14T23:14:04.824Z" }, - { url = "https://files.pythonhosted.org/packages/f8/57/d7605a9d53bd07421a8785d349cd29677fe660e13674fa4c6cbd624ae354/regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220", size = 755648, upload-time = "2026-01-14T23:14:06.371Z" }, - { url = "https://files.pythonhosted.org/packages/6f/76/6f2e24aa192da1e299cc1101674a60579d3912391867ce0b946ba83e2194/regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13", size = 836250, upload-time = "2026-01-14T23:14:08.343Z" }, - { url = "https://files.pythonhosted.org/packages/11/3a/1f2a1d29453299a7858eab7759045fc3d9d1b429b088dec2dc85b6fa16a2/regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3", size = 779919, upload-time = "2026-01-14T23:14:09.954Z" }, - { url = "https://files.pythonhosted.org/packages/c0/67/eab9bc955c9dcc58e9b222c801e39cff7ca0b04261792a2149166ce7e792/regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218", size = 265888, upload-time = "2026-01-14T23:14:11.35Z" }, - { url = "https://files.pythonhosted.org/packages/1d/62/31d16ae24e1f8803bddb0885508acecaec997fcdcde9c243787103119ae4/regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a", size = 277830, upload-time = "2026-01-14T23:14:12.908Z" }, - { url = "https://files.pythonhosted.org/packages/e5/36/5d9972bccd6417ecd5a8be319cebfd80b296875e7f116c37fb2a2deecebf/regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3", size = 270376, upload-time = "2026-01-14T23:14:14.782Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" }, - { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636, upload-time = "2026-01-14T23:14:17.715Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496, upload-time = "2026-01-14T23:14:19.326Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503, upload-time = "2026-01-14T23:14:20.922Z" }, - { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535, upload-time = "2026-01-14T23:14:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225, upload-time = "2026-01-14T23:14:23.897Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526, upload-time = "2026-01-14T23:14:26.039Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446, upload-time = "2026-01-14T23:14:28.109Z" }, - { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051, upload-time = "2026-01-14T23:14:29.929Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485, upload-time = "2026-01-14T23:14:31.366Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195, upload-time = "2026-01-14T23:14:32.802Z" }, - { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986, upload-time = "2026-01-14T23:14:34.898Z" }, - { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992, upload-time = "2026-01-14T23:14:37.116Z" }, - { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893, upload-time = "2026-01-14T23:14:38.426Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840, upload-time = "2026-01-14T23:14:39.785Z" }, - { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374, upload-time = "2026-01-14T23:14:41.592Z" }, - { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" }, - { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" }, - { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" }, - { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" }, - { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" }, - { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" }, - { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" }, - { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" }, - { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" }, - { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" }, - { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" }, - { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" }, - { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" }, - { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" }, - { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" }, - { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" }, - { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" }, - { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" }, - { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" }, - { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" }, - { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" }, - { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" }, - { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" }, - { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" }, - { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" }, - { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" }, - { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" }, - { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" }, - { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" }, - { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" }, - { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" }, - { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" }, - { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" }, - { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" }, - { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" }, - { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" }, - { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" }, - { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" }, - { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" }, - { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" }, - { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" }, - { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" }, - { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" }, - { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" }, - { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" }, - { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" }, - { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" }, - { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" }, - { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" }, - { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" }, - { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" }, - { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" }, - { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" }, - { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/af/de/f10b4506acfd684de4e42b0aa56ccea1a778a18864da8f6d319a40591062/regex-2026.2.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f5a37a17d110f9d5357a43aa7e3507cb077bf3143d1c549a45c4649e90e40a70", size = 488369, upload-time = "2026-02-19T18:59:45.01Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2f/b4eaef1f0b4d0bf2a73eaf07c08f6c13422918a4180c9211ce0521746d0c/regex-2026.2.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:676c4e6847a83a1d5732b4ed553881ad36f0a8133627bb695a89ecf3571499d3", size = 290743, upload-time = "2026-02-19T18:59:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/805413bd0a88d04688c0725c222cfb811bd54a2f571004c24199a1ae55d6/regex-2026.2.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82336faeecac33297cd42857c3b36f12b91810e3fdd276befdd128f73a2b43fa", size = 288652, upload-time = "2026-02-19T18:59:50.2Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/2c4cd530a878b1975398e76faef4285f11e7c9ccf1aaedfd528bfcc1f580/regex-2026.2.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52136f5b71f095cb74b736cc3a1b578030dada2e361ef2f07ca582240b703946", size = 781759, upload-time = "2026-02-19T18:59:51.836Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/9608ab1b41f6740ff4076eabadde8e8b3f3400942b348ac41e8599ccc131/regex-2026.2.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4192464fe3e6cb0ef6751f7d3b16f886d8270d359ed1590dd555539d364f0ff7", size = 850947, upload-time = "2026-02-19T18:59:53.739Z" }, + { url = "https://files.pythonhosted.org/packages/90/3a/66471b6c4f7cac17e14bf5300e46661bba2b17ffb0871bd2759e837a6f82/regex-2026.2.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e561dd47a85d2660d3d3af4e6cb2da825cf20f121e577147963f875b83d32786", size = 898794, upload-time = "2026-02-19T18:59:55.993Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d2/38c53929a5931f7398e5e49f5a5a3079cb2aba30119b4350608364cfad8c/regex-2026.2.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00ec994d7824bf01cd6c7d14c7a6a04d9aeaf7c42a2bc22d2359d715634d539b", size = 791922, upload-time = "2026-02-19T18:59:58.216Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bd/b046e065630fa25059d9c195b7b5308ea94da45eee65d40879772500f74c/regex-2026.2.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2cb00aabd96b345d56a8c2bc328c8d6c4d29935061e05078bf1f02302e12abf5", size = 783345, upload-time = "2026-02-19T18:59:59.948Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/045c643d2fa255a985e8f87d848e4be230b711a8935e4bdc58e60b8f7b84/regex-2026.2.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f374366ed35673ea81b86a8859c457d4fae6ba092b71024857e9e237410c7404", size = 768055, upload-time = "2026-02-19T19:00:01.65Z" }, + { url = "https://files.pythonhosted.org/packages/72/9f/ab7ae9f5447559562f1a788bbc85c0e526528c5e6c20542d18e4afc86aad/regex-2026.2.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9417fd853fcd00b7d55167e692966dd12d95ba1a88bf08a62002ccd85030790", size = 774955, upload-time = "2026-02-19T19:00:03.368Z" }, + { url = "https://files.pythonhosted.org/packages/37/5c/f16fc23c56f60b6f4ff194604a6e53bb8aec7b6e8e4a23a482dee8d77235/regex-2026.2.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:12e86a01594031abf892686fcb309b041bf3de3d13d99eb7e2b02a8f3c687df1", size = 846010, upload-time = "2026-02-19T19:00:05.079Z" }, + { url = "https://files.pythonhosted.org/packages/51/c8/6be4c854135d7c9f35d4deeafdaf124b039ecb4ffcaeb7ed0495ad2c97ca/regex-2026.2.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:79014115e6fdf18fd9b32e291d58181bf42d4298642beaa13fd73e69810e4cb6", size = 755938, upload-time = "2026-02-19T19:00:07.148Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8d/f683d49b9663a5324b95a328e69d397f6dade7cb84154eec116bf79fe150/regex-2026.2.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:31aefac2506967b7dd69af2c58eca3cc8b086d4110b66d6ac6e9026f0ee5b697", size = 835773, upload-time = "2026-02-19T19:00:08.939Z" }, + { url = "https://files.pythonhosted.org/packages/16/cd/619224b90da09f167fe4497c350a0d0b30edc539ee9244bf93e604c073c3/regex-2026.2.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:49cef7bb2a491f91a8869c7cdd90babf0a417047ab0bf923cd038ed2eab2ccb8", size = 780075, upload-time = "2026-02-19T19:00:10.838Z" }, + { url = "https://files.pythonhosted.org/packages/5b/88/19cfb0c262d6f9d722edef29157125418bf90eb3508186bf79335afeedae/regex-2026.2.19-cp310-cp310-win32.whl", hash = "sha256:3a039474986e7a314ace6efb9ce52f5da2bdb80ac4955358723d350ec85c32ad", size = 266004, upload-time = "2026-02-19T19:00:12.371Z" }, + { url = "https://files.pythonhosted.org/packages/82/af/5b487e0287ef72545d7ae92edecdacbe3d44e531cac24fda7de5598ba8dd/regex-2026.2.19-cp310-cp310-win_amd64.whl", hash = "sha256:5b81ff4f9cad99f90c807a00c5882fbcda86d8b3edd94e709fb531fc52cb3d25", size = 277895, upload-time = "2026-02-19T19:00:13.75Z" }, + { url = "https://files.pythonhosted.org/packages/4c/19/b6715a187ffca4d2979af92a46ce922445ba41f910bf187ccd666a2d52ef/regex-2026.2.19-cp310-cp310-win_arm64.whl", hash = "sha256:a032bc01a4bc73fc3cadba793fce28eb420da39338f47910c59ffcc11a5ba5ef", size = 270465, upload-time = "2026-02-19T19:00:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/6f/93/43f405a98f54cc59c786efb4fc0b644615ed2392fc89d57d30da11f35b5b/regex-2026.2.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:93b16a18cadb938f0f2306267161d57eb33081a861cee9ffcd71e60941eb5dfc", size = 488365, upload-time = "2026-02-19T19:00:17.857Z" }, + { url = "https://files.pythonhosted.org/packages/66/46/da0efce22cd8f5ae28eeb25ac69703f49edcad3331ac22440776f4ea0867/regex-2026.2.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:78af1e499cab704131f6f4e2f155b7f54ce396ca2acb6ef21a49507e4752e0be", size = 290737, upload-time = "2026-02-19T19:00:19.869Z" }, + { url = "https://files.pythonhosted.org/packages/fb/19/f735078448132c1c974974d30d5306337bc297fe6b6f126164bff72c1019/regex-2026.2.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eb20c11aa4c3793c9ad04c19a972078cdadb261b8429380364be28e867a843f2", size = 288654, upload-time = "2026-02-19T19:00:21.307Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/6d7c24a2f423c03ad03e3fbddefa431057186ac1c4cb4fa98b03c7f39808/regex-2026.2.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db5fd91eec71e7b08de10011a2223d0faa20448d4e1380b9daa179fa7bf58906", size = 793785, upload-time = "2026-02-19T19:00:22.926Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/fdb8107504b3122a79bde6705ac1f9d495ed1fe35b87d7cfc1864471999a/regex-2026.2.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fdbade8acba71bb45057c2b72f477f0b527c4895f9c83e6cfc30d4a006c21726", size = 860731, upload-time = "2026-02-19T19:00:25.196Z" }, + { url = "https://files.pythonhosted.org/packages/9a/fd/cc8c6f05868defd840be6e75919b1c3f462357969ac2c2a0958363b4dc23/regex-2026.2.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:31a5f561eb111d6aae14202e7043fb0b406d3c8dddbbb9e60851725c9b38ab1d", size = 907350, upload-time = "2026-02-19T19:00:27.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1b/4590db9caa8db3d5a3fe31197c4e42c15aab3643b549ef6a454525fa3a61/regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4584a3ee5f257b71e4b693cc9be3a5104249399f4116fe518c3f79b0c6fc7083", size = 800628, upload-time = "2026-02-19T19:00:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/76/05/513eaa5b96fa579fd0b813e19ec047baaaf573d7374ff010fa139b384bf7/regex-2026.2.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:196553ba2a2f47904e5dc272d948a746352e2644005627467e055be19d73b39e", size = 773711, upload-time = "2026-02-19T19:00:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/95/65/5aed06d8c54563d37fea496cf888be504879a3981a7c8e12c24b2c92c209/regex-2026.2.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0c10869d18abb759a3317c757746cc913d6324ce128b8bcec99350df10419f18", size = 783186, upload-time = "2026-02-19T19:00:34.598Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/79a633ad90f2371b4ef9cd72ba3a69a1a67d0cfaab4fe6fa8586d46044ef/regex-2026.2.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e689fed279cbe797a6b570bd18ff535b284d057202692c73420cb93cca41aa32", size = 854854, upload-time = "2026-02-19T19:00:37.306Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/0f113d477d9e91ec4545ec36c82e58be25038d06788229c91ad52da2b7f5/regex-2026.2.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0782bd983f19ac7594039c9277cd6f75c89598c1d72f417e4d30d874105eb0c7", size = 762279, upload-time = "2026-02-19T19:00:39.793Z" }, + { url = "https://files.pythonhosted.org/packages/39/cb/237e9fa4f61469fd4f037164dbe8e675a376c88cf73aaaa0aedfd305601c/regex-2026.2.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:dbb240c81cfed5d4a67cb86d7676d9f7ec9c3f186310bec37d8a1415210e111e", size = 846172, upload-time = "2026-02-19T19:00:42.134Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7c/104779c5915cc4eb557a33590f8a3f68089269c64287dd769afd76c7ce61/regex-2026.2.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80d31c3f1fe7e4c6cd1831cd4478a0609903044dfcdc4660abfe6fb307add7f0", size = 789078, upload-time = "2026-02-19T19:00:43.908Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4a/eae4e88b1317fb2ff57794915e0099198f51e760f6280b320adfa0ad396d/regex-2026.2.19-cp311-cp311-win32.whl", hash = "sha256:66e6a43225ff1064f8926adbafe0922b370d381c3330edaf9891cade52daa790", size = 266013, upload-time = "2026-02-19T19:00:47.274Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/ba89eb8fae79705e07ad1bd69e568f776159d2a8093c9dbc5303ee618298/regex-2026.2.19-cp311-cp311-win_amd64.whl", hash = "sha256:59a7a5216485a1896c5800e9feb8ff9213e11967b482633b6195d7da11450013", size = 277906, upload-time = "2026-02-19T19:00:49.011Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1a/042d8f04b28e318df92df69d8becb0f42221eb3dd4fe5e976522f4337c76/regex-2026.2.19-cp311-cp311-win_arm64.whl", hash = "sha256:ec661807ffc14c8d14bb0b8c1bb3d5906e476bc96f98b565b709d03962ee4dd4", size = 270463, upload-time = "2026-02-19T19:00:50.988Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/13b39c7c9356f333e564ab4790b6cb0df125b8e64e8d6474e73da49b1955/regex-2026.2.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c1665138776e4ac1aa75146669236f7a8a696433ec4e525abf092ca9189247cc", size = 489541, upload-time = "2026-02-19T19:00:52.728Z" }, + { url = "https://files.pythonhosted.org/packages/15/77/fcc7bd9a67000d07fbcc11ed226077287a40d5c84544e62171d29d3ef59c/regex-2026.2.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d792b84709021945597e05656aac059526df4e0c9ef60a0eaebb306f8fafcaa8", size = 291414, upload-time = "2026-02-19T19:00:54.51Z" }, + { url = "https://files.pythonhosted.org/packages/f9/87/3997fc72dc59233426ef2e18dfdd105bb123812fff740ee9cc348f1a3243/regex-2026.2.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db970bcce4d63b37b3f9eb8c893f0db980bbf1d404a1d8d2b17aa8189de92c53", size = 289140, upload-time = "2026-02-19T19:00:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d0/b7dd3883ed1cff8ee0c0c9462d828aaf12be63bf5dc55453cbf423523b13/regex-2026.2.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03d706fbe7dfec503c8c3cb76f9352b3e3b53b623672aa49f18a251a6c71b8e6", size = 798767, upload-time = "2026-02-19T19:00:59.014Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7e/8e2d09103832891b2b735a2515abf377db21144c6dd5ede1fb03c619bf09/regex-2026.2.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dbff048c042beef60aa1848961384572c5afb9e8b290b0f1203a5c42cf5af65", size = 864436, upload-time = "2026-02-19T19:01:00.772Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2e/afea8d23a6db1f67f45e3a0da3057104ce32e154f57dd0c8997274d45fcd/regex-2026.2.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccaaf9b907ea6b4223d5cbf5fa5dff5f33dc66f4907a25b967b8a81339a6e332", size = 912391, upload-time = "2026-02-19T19:01:02.865Z" }, + { url = "https://files.pythonhosted.org/packages/59/3c/ea5a4687adaba5e125b9bd6190153d0037325a0ba3757cc1537cc2c8dd90/regex-2026.2.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75472631eee7898e16a8a20998d15106cb31cfde21cdf96ab40b432a7082af06", size = 803702, upload-time = "2026-02-19T19:01:05.298Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c5/624a0705e8473a26488ec1a3a4e0b8763ecfc682a185c302dfec71daea35/regex-2026.2.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d89f85a5ccc0cec125c24be75610d433d65295827ebaf0d884cbe56df82d4774", size = 775980, upload-time = "2026-02-19T19:01:07.047Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/ed776642533232b5599b7c1f9d817fe11faf597e8a92b7a44b841daaae76/regex-2026.2.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9f81806abdca3234c3dd582b8a97492e93de3602c8772013cb4affa12d1668", size = 788122, upload-time = "2026-02-19T19:01:08.744Z" }, + { url = "https://files.pythonhosted.org/packages/8c/58/e93e093921d13b9784b4f69896b6e2a9e09580a265c59d9eb95e87d288f2/regex-2026.2.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9dadc10d1c2bbb1326e572a226d2ec56474ab8aab26fdb8cf19419b372c349a9", size = 858910, upload-time = "2026-02-19T19:01:10.488Z" }, + { url = "https://files.pythonhosted.org/packages/85/77/ff1d25a0c56cd546e0455cbc93235beb33474899690e6a361fa6b52d265b/regex-2026.2.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6bc25d7e15f80c9dc7853cbb490b91c1ec7310808b09d56bd278fe03d776f4f6", size = 764153, upload-time = "2026-02-19T19:01:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ef/8ec58df26d52d04443b1dc56f9be4b409f43ed5ae6c0248a287f52311fc4/regex-2026.2.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:965d59792f5037d9138da6fed50ba943162160443b43d4895b182551805aff9c", size = 850348, upload-time = "2026-02-19T19:01:14.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b3/c42fd5ed91639ce5a4225b9df909180fc95586db071f2bf7c68d2ccbfbe6/regex-2026.2.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:38d88c6ed4a09ed61403dbdf515d969ccba34669af3961ceb7311ecd0cef504a", size = 789977, upload-time = "2026-02-19T19:01:15.838Z" }, + { url = "https://files.pythonhosted.org/packages/b6/22/bc3b58ebddbfd6ca5633e71fd41829ee931963aad1ebeec55aad0c23044e/regex-2026.2.19-cp312-cp312-win32.whl", hash = "sha256:5df947cabab4b643d4791af5e28aecf6bf62e6160e525651a12eba3d03755e6b", size = 266381, upload-time = "2026-02-19T19:01:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4a/6ff550b63e67603ee60e69dc6bd2d5694e85046a558f663b2434bdaeb285/regex-2026.2.19-cp312-cp312-win_amd64.whl", hash = "sha256:4146dc576ea99634ae9c15587d0c43273b4023a10702998edf0fa68ccb60237a", size = 277274, upload-time = "2026-02-19T19:01:19.826Z" }, + { url = "https://files.pythonhosted.org/packages/cc/29/9ec48b679b1e87e7bc8517dff45351eab38f74fbbda1fbcf0e9e6d4e8174/regex-2026.2.19-cp312-cp312-win_arm64.whl", hash = "sha256:cdc0a80f679353bd68450d2a42996090c30b2e15ca90ded6156c31f1a3b63f3b", size = 270509, upload-time = "2026-02-19T19:01:22.075Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/a849835e76ac88fcf9e8784e642d3ea635d183c4112150ca91499d6703af/regex-2026.2.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879", size = 489329, upload-time = "2026-02-19T19:01:23.841Z" }, + { url = "https://files.pythonhosted.org/packages/da/aa/78ff4666d3855490bae87845a5983485e765e1f970da20adffa2937b241d/regex-2026.2.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64", size = 291308, upload-time = "2026-02-19T19:01:25.605Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/714384efcc07ae6beba528a541f6e99188c5cc1bc0295337f4e8a868296d/regex-2026.2.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968", size = 289033, upload-time = "2026-02-19T19:01:27.243Z" }, + { url = "https://files.pythonhosted.org/packages/75/ec/6438a9344d2869cf5265236a06af1ca6d885e5848b6561e10629bc8e5a11/regex-2026.2.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13", size = 798798, upload-time = "2026-02-19T19:01:28.877Z" }, + { url = "https://files.pythonhosted.org/packages/c2/be/b1ce2d395e3fd2ce5f2fde2522f76cade4297cfe84cd61990ff48308749c/regex-2026.2.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02", size = 864444, upload-time = "2026-02-19T19:01:30.933Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/a3406460c504f7136f140d9461960c25f058b0240e4424d6fb73c7a067ab/regex-2026.2.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161", size = 912633, upload-time = "2026-02-19T19:01:32.744Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d9/e5dbef95008d84e9af1dc0faabbc34a7fbc8daa05bc5807c5cf86c2bec49/regex-2026.2.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7", size = 803718, upload-time = "2026-02-19T19:01:34.61Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e5/61d80132690a1ef8dc48e0f44248036877aebf94235d43f63a20d1598888/regex-2026.2.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1", size = 775975, upload-time = "2026-02-19T19:01:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/05/32/ae828b3b312c972cf228b634447de27237d593d61505e6ad84723f8eabba/regex-2026.2.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4", size = 788129, upload-time = "2026-02-19T19:01:38.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/25/d74f34676f22bec401eddf0e5e457296941e10cbb2a49a571ca7a2c16e5a/regex-2026.2.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c", size = 858818, upload-time = "2026-02-19T19:01:40.409Z" }, + { url = "https://files.pythonhosted.org/packages/1e/eb/0bc2b01a6b0b264e1406e5ef11cae3f634c3bd1a6e61206fd3227ce8e89c/regex-2026.2.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f", size = 764186, upload-time = "2026-02-19T19:01:43.009Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/5fe5a630d0d99ecf0c3570f8905dafbc160443a2d80181607770086c9812/regex-2026.2.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed", size = 850363, upload-time = "2026-02-19T19:01:45.015Z" }, + { url = "https://files.pythonhosted.org/packages/c3/45/ef68d805294b01ec030cfd388724ba76a5a21a67f32af05b17924520cb0b/regex-2026.2.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a", size = 790026, upload-time = "2026-02-19T19:01:47.51Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/40d3b66923dfc5aeba182f194f0ca35d09afe8c031a193e6ae46971a0a0e/regex-2026.2.19-cp313-cp313-win32.whl", hash = "sha256:43cdde87006271be6963896ed816733b10967baaf0e271d529c82e93da66675b", size = 266372, upload-time = "2026-02-19T19:01:49.469Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f2/39082e8739bfd553497689e74f9d5e5bb531d6f8936d0b94f43e18f219c0/regex-2026.2.19-cp313-cp313-win_amd64.whl", hash = "sha256:127ea69273485348a126ebbf3d6052604d3c7da284f797bba781f364c0947d47", size = 277253, upload-time = "2026-02-19T19:01:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c2/852b9600d53fb47e47080c203e2cdc0ac7e84e37032a57e0eaa37446033a/regex-2026.2.19-cp313-cp313-win_arm64.whl", hash = "sha256:5e56c669535ac59cbf96ca1ece0ef26cb66809990cda4fa45e1e32c3b146599e", size = 270505, upload-time = "2026-02-19T19:01:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a2/e0b4575b93bc84db3b1fab24183e008691cd2db5c0ef14ed52681fbd94dd/regex-2026.2.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9", size = 492202, upload-time = "2026-02-19T19:01:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/24/b5/b84fec8cbb5f92a7eed2b6b5353a6a9eed9670fee31817c2da9eb85dc797/regex-2026.2.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7", size = 292884, upload-time = "2026-02-19T19:01:58.254Z" }, + { url = "https://files.pythonhosted.org/packages/70/0c/fe89966dfae43da46f475362401f03e4d7dc3a3c955b54f632abc52669e0/regex-2026.2.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60", size = 291236, upload-time = "2026-02-19T19:01:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f7/bda2695134f3e63eb5cccbbf608c2a12aab93d261ff4e2fe49b47fabc948/regex-2026.2.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f", size = 807660, upload-time = "2026-02-19T19:02:01.632Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/6e3a4bf5e60d17326b7003d91bbde8938e439256dec211d835597a44972d/regex-2026.2.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007", size = 873585, upload-time = "2026-02-19T19:02:03.522Z" }, + { url = "https://files.pythonhosted.org/packages/35/5e/c90c6aa4d1317cc11839359479cfdd2662608f339e84e81ba751c8a4e461/regex-2026.2.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e", size = 915243, upload-time = "2026-02-19T19:02:05.608Z" }, + { url = "https://files.pythonhosted.org/packages/90/7c/981ea0694116793001496aaf9524e5c99e122ec3952d9e7f1878af3a6bf1/regex-2026.2.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619", size = 812922, upload-time = "2026-02-19T19:02:08.115Z" }, + { url = "https://files.pythonhosted.org/packages/2d/be/9eda82afa425370ffdb3fa9f3ea42450b9ae4da3ff0a4ec20466f69e371b/regex-2026.2.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555", size = 781318, upload-time = "2026-02-19T19:02:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d5/50f0bbe56a8199f60a7b6c714e06e54b76b33d31806a69d0703b23ce2a9e/regex-2026.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1", size = 795649, upload-time = "2026-02-19T19:02:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/d039f081e44a8b0134d0bb2dd805b0ddf390b69d0b58297ae098847c572f/regex-2026.2.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5", size = 868844, upload-time = "2026-02-19T19:02:14.043Z" }, + { url = "https://files.pythonhosted.org/packages/ef/53/e2903b79a19ec8557fe7cd21cd093956ff2dbc2e0e33969e3adbe5b184dd/regex-2026.2.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04", size = 770113, upload-time = "2026-02-19T19:02:16.161Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e2/784667767b55714ebb4e59bf106362327476b882c0b2f93c25e84cc99b1a/regex-2026.2.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3", size = 854922, upload-time = "2026-02-19T19:02:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/59/78/9ef4356bd4aed752775bd18071034979b85f035fec51f3a4f9dea497a254/regex-2026.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743", size = 799636, upload-time = "2026-02-19T19:02:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/cf/54/fcfc9287f20c5c9bd8db755aafe3e8cf4d99a6a3f1c7162ee182e0ca9374/regex-2026.2.19-cp313-cp313t-win32.whl", hash = "sha256:a178df8ec03011153fbcd2c70cb961bc98cbbd9694b28f706c318bee8927c3db", size = 268968, upload-time = "2026-02-19T19:02:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a0/ff24c6cb1273e42472706d277147fc38e1f9074a280fb6034b0fc9b69415/regex-2026.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:2c1693ca6f444d554aa246b592355b5cec030ace5a2729eae1b04ab6e853e768", size = 280390, upload-time = "2026-02-19T19:02:25.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/a3f6ad89d780ffdeebb4d5e2e3e30bd2ef1f70f6a94d1760e03dd1e12c60/regex-2026.2.19-cp313-cp313t-win_arm64.whl", hash = "sha256:c0761d7ae8d65773e01515ebb0b304df1bf37a0a79546caad9cbe79a42c12af7", size = 271643, upload-time = "2026-02-19T19:02:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e2/7ad4e76a6dddefc0d64dbe12a4d3ca3947a19ddc501f864a5df2a8222ddd/regex-2026.2.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919", size = 489306, upload-time = "2026-02-19T19:02:29.058Z" }, + { url = "https://files.pythonhosted.org/packages/14/95/ee1736135733afbcf1846c58671046f99c4d5170102a150ebb3dd8d701d9/regex-2026.2.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e", size = 291218, upload-time = "2026-02-19T19:02:31.083Z" }, + { url = "https://files.pythonhosted.org/packages/ef/08/180d1826c3d7065200a5168c6b993a44947395c7bb6e04b2c2a219c34225/regex-2026.2.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5", size = 289097, upload-time = "2026-02-19T19:02:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/28/93/0651924c390c5740f5f896723f8ddd946a6c63083a7d8647231c343912ff/regex-2026.2.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e", size = 799147, upload-time = "2026-02-19T19:02:35.669Z" }, + { url = "https://files.pythonhosted.org/packages/a7/00/2078bd8bcd37d58a756989adbfd9f1d0151b7ca4085a9c2a07e917fbac61/regex-2026.2.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a", size = 865239, upload-time = "2026-02-19T19:02:38.012Z" }, + { url = "https://files.pythonhosted.org/packages/2a/13/75195161ec16936b35a365fa8c1dd2ab29fd910dd2587765062b174d8cfc/regex-2026.2.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73", size = 911904, upload-time = "2026-02-19T19:02:40.737Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/ac42f6012179343d1c4bd0ffee8c948d841cb32ea188d37e96d80527fcc9/regex-2026.2.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f", size = 803518, upload-time = "2026-02-19T19:02:42.923Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/75a08e2269b007b9783f0f86aa64488e023141219cb5f14dc1e69cda56c6/regex-2026.2.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265", size = 775866, upload-time = "2026-02-19T19:02:45.189Z" }, + { url = "https://files.pythonhosted.org/packages/92/41/70e7d05faf6994c2ca7a9fcaa536da8f8e4031d45b0ec04b57040ede201f/regex-2026.2.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a", size = 788224, upload-time = "2026-02-19T19:02:47.804Z" }, + { url = "https://files.pythonhosted.org/packages/c8/83/34a2dd601f9deb13c20545c674a55f4a05c90869ab73d985b74d639bac43/regex-2026.2.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c", size = 859682, upload-time = "2026-02-19T19:02:50.583Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/136db9a09a7f222d6e48b806f3730e7af6499a8cad9c72ac0d49d52c746e/regex-2026.2.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799", size = 764223, upload-time = "2026-02-19T19:02:52.777Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/bb947743c78a16df481fa0635c50aa1a439bb80b0e6dc24cd4e49c716679/regex-2026.2.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c", size = 850101, upload-time = "2026-02-19T19:02:55.87Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/e3bfe6e97a99f7393665926be02fef772da7f8aa59e50bc3134e4262a032/regex-2026.2.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e", size = 789904, upload-time = "2026-02-19T19:02:58.523Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/7e2be6f00cea59d08761b027ad237002e90cac74b1607200ebaa2ba3d586/regex-2026.2.19-cp314-cp314-win32.whl", hash = "sha256:5390b130cce14a7d1db226a3896273b7b35be10af35e69f1cca843b6e5d2bb2d", size = 271784, upload-time = "2026-02-19T19:03:00.418Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f6/639911530335773e7ec60bcaa519557b719586024c1d7eaad1daf87b646b/regex-2026.2.19-cp314-cp314-win_amd64.whl", hash = "sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904", size = 280506, upload-time = "2026-02-19T19:03:02.302Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ec/2582b56b4e036d46bb9b5d74a18548439ffa16c11cf59076419174d80f48/regex-2026.2.19-cp314-cp314-win_arm64.whl", hash = "sha256:7187fdee1be0896c1499a991e9bf7c78e4b56b7863e7405d7bb687888ac10c4b", size = 273557, upload-time = "2026-02-19T19:03:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/49/0b/f901cfeb4efd83e4f5c3e9f91a6de77e8e5ceb18555698aca3a27e215ed3/regex-2026.2.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175", size = 492196, upload-time = "2026-02-19T19:03:08.188Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/349b959e3da874e15eda853755567b4cde7e5309dbb1e07bfe910cfde452/regex-2026.2.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411", size = 292878, upload-time = "2026-02-19T19:03:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/98/b0/9d81b3c2c5ddff428f8c506713737278979a2c476f6e3675a9c51da0c389/regex-2026.2.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b", size = 291235, upload-time = "2026-02-19T19:03:12.5Z" }, + { url = "https://files.pythonhosted.org/packages/04/e7/be7818df8691dbe9508c381ea2cc4c1153e4fdb1c4b06388abeaa93bd712/regex-2026.2.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83", size = 807893, upload-time = "2026-02-19T19:03:15.064Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b6/b898a8b983190cfa0276031c17beb73cfd1db07c03c8c37f606d80b655e2/regex-2026.2.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3", size = 873696, upload-time = "2026-02-19T19:03:17.848Z" }, + { url = "https://files.pythonhosted.org/packages/1a/98/126ba671d54f19080ec87cad228fb4f3cc387fff8c4a01cb4e93f4ff9d94/regex-2026.2.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867", size = 915493, upload-time = "2026-02-19T19:03:20.343Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/550c84a1a1a7371867fe8be2bea7df55e797cbca4709974811410e195c5d/regex-2026.2.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a", size = 813094, upload-time = "2026-02-19T19:03:23.287Z" }, + { url = "https://files.pythonhosted.org/packages/29/fb/ba221d2fc76a27b6b7d7a60f73a7a6a7bac21c6ba95616a08be2bcb434b0/regex-2026.2.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd", size = 781583, upload-time = "2026-02-19T19:03:26.872Z" }, + { url = "https://files.pythonhosted.org/packages/26/f1/af79231301297c9e962679efc04a31361b58dc62dec1fc0cb4b8dd95956a/regex-2026.2.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe", size = 795875, upload-time = "2026-02-19T19:03:29.223Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/1e1d76cb0a2d0a4f38a039993e1c5cd971ae50435d751c5bae4f10e1c302/regex-2026.2.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969", size = 868916, upload-time = "2026-02-19T19:03:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/a1c01da76dbcfed690855a284c665cc0a370e7d02d1bd635cf9ff7dd74b8/regex-2026.2.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876", size = 770386, upload-time = "2026-02-19T19:03:33.972Z" }, + { url = "https://files.pythonhosted.org/packages/49/6f/94842bf294f432ff3836bfd91032e2ecabea6d284227f12d1f935318c9c4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854", size = 855007, upload-time = "2026-02-19T19:03:36.238Z" }, + { url = "https://files.pythonhosted.org/packages/ff/93/393cd203ca0d1d368f05ce12d2c7e91a324bc93c240db2e6d5ada05835f4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868", size = 799863, upload-time = "2026-02-19T19:03:38.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/35afda99bd92bf1a5831e55a4936d37ea4bed6e34c176a3c2238317faf4f/regex-2026.2.19-cp314-cp314t-win32.whl", hash = "sha256:2905ff4a97fad42f2d0834d8b1ea3c2f856ec209837e458d71a061a7d05f9f01", size = 274742, upload-time = "2026-02-19T19:03:40.804Z" }, + { url = "https://files.pythonhosted.org/packages/ae/42/7edc3344dcc87b698e9755f7f685d463852d481302539dae07135202d3ca/regex-2026.2.19-cp314-cp314t-win_amd64.whl", hash = "sha256:64128549b600987e0f335c2365879895f860a9161f283b14207c800a6ed623d3", size = 284443, upload-time = "2026-02-19T19:03:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/3a/45/affdf2d851b42adf3d13fc5b3b059372e9bd299371fd84cf5723c45871fa/regex-2026.2.19-cp314-cp314t-win_arm64.whl", hash = "sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0", size = 274932, upload-time = "2026-02-19T19:03:45.488Z" }, ] [[package]] @@ -5901,29 +5898,29 @@ wheels = [ [[package]] name = "rich" -version = "14.3.2" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] name = "rich-toolkit" -version = "0.19.2" +version = "0.19.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/6f/07f3e1cb44ca43c882382ecacb60232e2ed61c9275a81441d54e0b0348fb/rich_toolkit-0.19.2.tar.gz", hash = "sha256:c920006b146639fae5975485c86b41be0d83638107e428babc811dad3bd00404", size = 193414, upload-time = "2026-02-10T17:30:01.54Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/c9/4bbf4bfee195ed1b7d7a6733cc523ca61dbfb4a3e3c12ea090aaffd97597/rich_toolkit-0.19.4.tar.gz", hash = "sha256:52e23d56f9dc30d1343eb3b3f6f18764c313fbfea24e52e6a1d6069bec9c18eb", size = 193951, upload-time = "2026-02-12T10:08:15.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/36/bc918ef04846d2e064edd34f5b3fadc4234e471110cfcd849a4795ed08c8/rich_toolkit-0.19.2-py3-none-any.whl", hash = "sha256:6dc07320f69f5893146fe1a21afc250e91710b72c13a2324e3c323ca762134dd", size = 32711, upload-time = "2026-02-10T17:30:03.391Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/97d39719def09c134385bfcfbedfed255168b571e7beb3ad7765aae660ca/rich_toolkit-0.19.4-py3-none-any.whl", hash = "sha256:34ac344de8862801644be8b703e26becf44b047e687f208d7829e8f7cfc311d6", size = 32757, upload-time = "2026-02-12T10:08:15.037Z" }, ] [[package]] @@ -6192,27 +6189,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.0" +version = "0.15.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, - { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, - { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, - { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, - { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, - { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, - { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, - { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, ] [[package]] @@ -6255,7 +6252,7 @@ wheels = [ [[package]] name = "sarvamai" -version = "0.1.21" +version = "0.1.26a2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -6264,9 +6261,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/08/e5efcb30818ed220b818319255c22fd91e379489ebaa93efd6f444fb4987/sarvamai-0.1.21.tar.gz", hash = "sha256:865065635b2b99d40f5519308832954015627938e06a6333b5f62ae9c36278bb", size = 87386, upload-time = "2025-10-07T07:37:47.085Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/6c/80ab26743586532a3e9d68385549b0992e5318b5499db815889c8527cce5/sarvamai-0.1.26a2.tar.gz", hash = "sha256:0cbd1a95d13c1f8f0d1bf8fbeb37e86d3c2dc75a7ac402743bf0e571378f79e4", size = 112445, upload-time = "2026-02-16T13:16:28.392Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/4e/b9933f72681b7aed91b86913337dd3981fad97027881fbc66c3c5eb03568/sarvamai-0.1.21-py3-none-any.whl", hash = "sha256:daa4e5d16635fe434f5f270cee416849249285369141d77132a17f0bf670f120", size = 175204, upload-time = "2025-10-07T07:37:46.024Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3e/76c8ea81e790a5dab2ec9cd9fdb02cd600f90b1dfd9c895bea2fb5e6aa7f/sarvamai-0.1.26a2-py3-none-any.whl", hash = "sha256:2b0549a18e093ea382725240035a0bea18fff2a0d5207ad6c95ff7189e03264a", size = 227413, upload-time = "2026-02-16T13:16:27.045Z" }, ] [[package]] @@ -6420,15 +6417,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.52.0" +version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/eb/1b497650eb564701f9a7b8a95c51b2abe9347ed2c0b290ba78f027ebe4ea/sentry_sdk-2.52.0.tar.gz", hash = "sha256:fa0bec872cfec0302970b2996825723d67390cdd5f0229fb9efed93bd5384899", size = 410273, upload-time = "2026-02-04T15:03:54.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/63/2c6daf59d86b1c30600bff679d039f57fd1932af82c43c0bde1cbc55e8d4/sentry_sdk-2.52.0-py2.py3-none-any.whl", hash = "sha256:931c8f86169fc6f2752cb5c4e6480f0d516112e78750c312e081ababecbaf2ed", size = 435547, upload-time = "2026-02-04T15:03:51.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, ] [[package]] @@ -6465,19 +6462,18 @@ wheels = [ [[package]] name = "simli-ai" -version = "1.0.3" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiortc" }, { name = "av" }, { name = "httpx" }, - { name = "livekit" }, { name = "numpy" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/03/b0b3e12c68fd3f9c57f6afeee67841349e4866b88760f413357af3043ae4/simli_ai-1.0.3.tar.gz", hash = "sha256:e96b0621a1dbd9582b2ae3d51eefd4995983b49c1f1061eb9239707b15a1ee27", size = 13350, upload-time = "2025-11-13T12:22:32.514Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b5/6021990871daf9f5b6eb744aff68c83f2c7b257cfd2ee5b9883d0acd9cf4/simli_ai-2.0.1.tar.gz", hash = "sha256:1f63eb76900d4dac0c18406a854219e54ebab51acb0c01e245c7a0738dc72413", size = 16104, upload-time = "2026-02-17T12:46:09.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/d1/dc382ba529de0d2d51f35e9bfd20b41d8f5c96404a3aa24bae97a5a5e51f/simli_ai-1.0.3-py3-none-any.whl", hash = "sha256:ffafa7540aa28833e207be8f3b199367c7f500dac1a8ba0108395bfb7d8362bc", size = 13863, upload-time = "2025-11-13T12:22:31.218Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c1/e7aed0f59d04628c0ac738e2bf8cb6bf020870d1909f3ec9fcf265136663/simli_ai-2.0.1-py3-none-any.whl", hash = "sha256:0a48e38fe289568e56236266843484a1f0e28aca694dd8e2b96610fe40d6c687", size = 19456, upload-time = "2026-02-17T12:46:08.727Z" }, ] [[package]] @@ -6797,7 +6793,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-typehints" -version = "3.6.2" +version = "3.6.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", @@ -6807,9 +6803,9 @@ resolution-markers = [ dependencies = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/51/6603ed3786a2d52366c66f49bc8afb31ae5c0e33d4a156afcb38d2bac62c/sphinx_autodoc_typehints-3.6.2.tar.gz", hash = "sha256:3d37709a21b7b765ad6e20a04ecefcb229b9eb0007cb24f6ebaa8a4576ea7f06", size = 37574, upload-time = "2026-01-02T21:25:28.216Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/5f/ebcaed1a67e623e4a7622808a8be6b0fd8344313e185f62e85a26b0ce26a/sphinx_autodoc_typehints-3.6.3.tar.gz", hash = "sha256:6c387b47d9ad5e75b157810af5bad46901f0a22708ed5e4adf466885a9c60910", size = 38288, upload-time = "2026-02-18T04:22:08.384Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6a/877e8a6ea52fc86d88ce110ebcfe4f8474ff590d8a8d322909673af3da7b/sphinx_autodoc_typehints-3.6.2-py3-none-any.whl", hash = "sha256:9e70bee1f487b087c83ba0f4949604a4630bee396e263a324aae1dc4268d2c0f", size = 20853, upload-time = "2026-01-02T21:25:26.853Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bd/2b853836d152e40a27655828fdc02c5128f294ac452ad9a13424bb7f92fa/sphinx_autodoc_typehints-3.6.3-py3-none-any.whl", hash = "sha256:46ebc68fa85b320d55887a8d836a01e12e3b7744da973e70af8cedc74072aad5", size = 20882, upload-time = "2026-02-18T04:22:07.238Z" }, ] [[package]] @@ -6998,7 +6994,7 @@ wheels = [ [[package]] name = "strands-agents" -version = "1.25.0" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, @@ -7013,9 +7009,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/30/1b437b74d1854c9704fa3a59390bddf71c158425a76088c8b94f620756ea/strands_agents-1.25.0.tar.gz", hash = "sha256:831a91d38d82f2051efb3d2ad013b4d6d2bbdad2353421796371a7e94503bc59", size = 697397, upload-time = "2026-02-05T20:05:30.275Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/54/bf0910a1c40feacaedcf5d30840be990eabd09eff5375fa40525ba530c8d/strands_agents-1.27.0.tar.gz", hash = "sha256:84d0b670e534d7c281104a22035c10de8d43e9ad8ee589bde16f54a8387b2c56", size = 712878, upload-time = "2026-02-19T17:18:23.327Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/52/f07b14af7d71c467999ebb7f6c5ccc681cbd579a3993def21dd9b3507564/strands_agents-1.25.0-py3-none-any.whl", hash = "sha256:16b3a6331c8a1a8a79a249202c591a9cd2d777893371bcd8c12e458ada23587c", size = 345783, upload-time = "2026-02-05T20:05:28.023Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ca/d5c269f83929bdc753dce3c6091a1671e50268769b0ace009264424bf165/strands_agents-1.27.0-py3-none-any.whl", hash = "sha256:d9012515a7b4f324a600cacc539e837a51b3f7fe21da7efe1764186ade3be498", size = 351988, upload-time = "2026-02-19T17:18:19Z" }, ] [[package]] @@ -7434,7 +7430,7 @@ wheels = [ [[package]] name = "typer" -version = "0.21.2" +version = "0.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -7442,9 +7438,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/1e/a27cc02a0cd715118c71fa2aef2c687fdefc3c28d90fd0dd789c5118154c/typer-0.21.2.tar.gz", hash = "sha256:1abd95a3b675e17ff61b0838ac637fe9478d446d62ad17fa4bb81ea57cc54028", size = 120426, upload-time = "2026-02-10T19:33:46.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b6/3e681d3b6bb22647509bdbfdd18055d5adc0dce5c5585359fa46ff805fdc/typer-0.24.0.tar.gz", hash = "sha256:f9373dc4eff901350694f519f783c29b6d7a110fc0dcc11b1d7e353b85ca6504", size = 118380, upload-time = "2026-02-16T22:08:48.496Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/cc/d59f893fbdfb5f58770c05febfc4086a46875f1084453621c35605cec946/typer-0.21.2-py3-none-any.whl", hash = "sha256:c3d8de54d00347ef90b82131ca946274f017cffb46683ae3883c360fa958f55c", size = 56728, upload-time = "2026-02-10T19:33:48.01Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/4da85c2a45054bb661993c93524138ace4956cb075a7ae0c9d1deadc331b/typer-0.24.0-py3-none-any.whl", hash = "sha256:5fc435a9c8356f6160ed6e85a6301fdd6e3d8b2851da502050d1f92c5e9eddc8", size = 56441, upload-time = "2026-02-16T22:08:47.535Z" }, ] [[package]] @@ -7619,16 +7615,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] [package.optional-dependencies] @@ -7688,7 +7684,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.36.1" +version = "20.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -7696,9 +7692,9 @@ dependencies = [ { name = "platformdirs" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/03/a94d404ca09a89a7301a7008467aed525d4cdeb9186d262154dd23208709/virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7", size = 5864558, upload-time = "2026-02-19T07:48:02.385Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, + { url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" }, ] [[package]]