diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..c628e79e5 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,27 @@ +{ + "name": "pipecat-dev-skills", + "owner": { + "name": "Pipecat" + }, + "metadata": { + "description": "Development workflow skills for contributing to the Pipecat project", + "version": "1.0.0" + }, + "plugins": [ + { + "name": "pipecat-dev", + "description": "Development workflow skills for contributing to the Pipecat project", + "version": "1.0.0", + "source": "./", + "skills": [ + "./.claude/skills/changelog", + "./.claude/skills/cleanup", + "./.claude/skills/code-review", + "./.claude/skills/docstring", + "./.claude/skills/pr-description", + "./.claude/skills/pr-submit", + "./.claude/skills/update-docs" + ] + } + ] +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..ce5d2734a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "attribution": { + "commit": "" + } +} diff --git a/.claude/skills/changelog/SKILL.md b/.claude/skills/changelog/SKILL.md new file mode 100644 index 000000000..4ef6a8dc2 --- /dev/null +++ b/.claude/skills/changelog/SKILL.md @@ -0,0 +1,61 @@ +--- +name: changelog +description: Create changelog files for important commits in a PR +--- + +Create changelog files for the important commits in this PR. The PR number is provided as an argument. + +## Instructions + +1. Skip changelog for: documentation-only, internal refactoring, test-only, CI changes. + +2. First, check what commits are on the current branch compared to main: + ``` + git log main..HEAD --oneline + ``` + +3. For each significant change, create a changelog file in the `changelog/` folder using the format: + Allowed types: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`, `performance`, `other` + - `{PR_NUMBER}.added.md` - for new features + - `{PR_NUMBER}.added.2.md`, `{PR_NUMBER}.added.3.md` - for additional entries of the same type + - `{PR_NUMBER}.changed.md` - for changes to existing functionality + - `{PR_NUMBER}.fixed.md` - for bug fixes + - `{PR_NUMBER}.deprecated.md` - for deprecations + - `{PR_NUMBER}.removed.md` - for removed features + - `{PR_NUMBER}.security.md` - for security fixes + - `{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. 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. + +6. Use ⚠️ emoji prefix for breaking changes. + +7. **Write changes in user-facing terms first.** Lead with what users of the framework will notice: new APIs, changed behavior, new parameters, fixed bugs they might have hit, etc. Implementation details (internal refactoring, how something is wired up under the hood) can be included as secondary context after the user-facing description, but should never be the *only* content of a changelog entry when there is a user-visible effect. + + **Good** (user-facing first, implementation detail as context): + ``` + - Turn completion instructions now persist correctly across full context updates when using `system_instruction`. Previously they were injected as a context system message, which caused warning spam and didn't survive context updates. + ``` + + **Bad** (implementation detail only, no user-facing framing): + ``` + - Fixed turn completion instructions being injected as a context system message instead of using `system_instruction`. + ``` + + Ask yourself: "If I'm a developer building on Pipecat, what would I notice changed?" Start there. + +## Example + +For PR #3519 with a new feature and a bug fix: + +`changelog/3519.added.md`: +``` +- Added `SomeNewFeature` for doing something useful. +``` + +`changelog/3519.fixed.md`: +``` +- Fixed an issue where something was not working correctly in some user-visible scenario. The root cause was an internal implementation detail. +``` diff --git a/.claude/skills/cleanup/SKILL.md b/.claude/skills/cleanup/SKILL.md new file mode 100644 index 000000000..91a61db39 --- /dev/null +++ b/.claude/skills/cleanup/SKILL.md @@ -0,0 +1,307 @@ +# Code Cleanup Skill + +The **Code Cleanup Skill** reviews, refactors, and documents code changes in your current branch, ensuring alignment with **Pipecat's architecture, coding standards, and example patterns**. +It focuses on **readability, correctness, performance, and consistency**, while avoiding breaking changes. + +--- + +## Skill Overview + +This skill analyzes all changes introduced in your branch and performs the following actions: + +1. **Analyze Branch Changes** + - Review uncommitted changes and outgoing commits +2. **Refactor for Readability** + - Improve clarity, naming, structure, and modern Python usage +3. **Enhance Performance** + - Identify safe, conservative optimization opportunities +4. **Add Documentation** + - Apply Pipecat-style, Google-format docstrings +5. **Ensure Pattern Consistency** + - Match existing Pipecat services, pipelines, and examples +6. **Validate Examples** + - Ensure examples follow foundational patterns (e.g. `07-interruptible.py`) + +--- + +## Usage + +Invoke the skill using any of the following commands: + +- "Clean up my branch code" +- "Refactor the changes in my branch" +- "Review and improve my branch code" +- `/cleanup` + +--- + +## What This Skill Does + +### 1. Analyze Branch Changes + +The skill retrieves all uncommitted changes and outgoing commits to understand: + +- New files added +- Modified files +- Code additions and deletions +- Overall scope and intent of changes + +--- + +### 2. Code Refactoring + +#### Readability Improvements + +- Replace tuples with named classes or dataclasses +- Improve variable, method, and class naming +- Extract complex logic into well-named helper methods +- Add missing type hints +- Simplify nested or complex conditionals +- Replace deprecated methods and features +- Normalize formatting to match Pipecat style + +#### Performance Enhancements + +- Identify inefficient loops or repeated work +- Suggest appropriate data structures +- Optimize async workflows and I/O +- Remove redundant operations + +> Performance changes are conservative and non-breaking. + +--- + +### 3. Documentation + +Documentation follows **Google-style docstrings**, consistent with Pipecat conventions. + +#### Class Documentation + +```python +class ExampleService: + """Brief one-line description. + + Detailed explanation of the class purpose, responsibilities, + and important behaviors. + + Supported features: + + - Feature 1 + - Feature 2 + - Feature 3 + """ +``` + +#### Method Documentation + +```python +def process_data(self, data: str, options: Optional[dict] = None) -> bool: + """Process incoming data with optional configuration. + + Args: + data: The input data to process. + options: Optional configuration dictionary. + + Returns: + True if processing succeeded, False otherwise. + + Raises: + ValueError: If data is empty or invalid. + """ +``` + +#### Pydantic Model Parameters + +```python +class InputParams(BaseModel): + """Configuration parameters for the service. + + Parameters: + timeout: Request timeout in seconds. + retry_count: Number of retry attempts. + enable_logging: Whether to enable debug logging. + """ + + timeout: Optional[float] = None + retry_count: int = 3 + enable_logging: bool = False +``` + +--- + +### 4. Pattern Consistency Checks + +#### Service Classes + +- Correct inheritance (`TTSService`, `STTService`, `LLMService`) +- Consistent constructor signatures +- Frame emission patterns +- Metrics support: + - `can_generate_metrics()` + - TTFB metrics + - Usage metrics +- Alignment with similar existing services + +#### Examples + +Validated against `examples/foundational/07-interruptible.py`: + +- Proper `create_transport()` usage +- Correct pipeline structure +- Task setup and observers +- Event handler registration +- Runner and bot entrypoint consistency + +--- + +### 5. Specific Implementation Patterns + +#### Service Implementation + +```python +class ExampleTTSService(TTSService): + + def __init__(self, *, api_key: Optional[str] = None, **kwargs): + super().__init__(**kwargs) + self._api_key = api_key or os.getenv("SERVICE_API_KEY") + + def can_generate_metrics(self) -> bool: + return True + + async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + try: + await self.start_ttfb_metrics() + yield TTSStartedFrame() + # ... processing ... + yield TTSAudioRawFrame(...) + finally: + await self.stop_ttfb_metrics() +``` + +--- + +#### Example Structure Pattern + +```python +transport_params = { + "daily": lambda: DailyParams(...), + "twilio": lambda: FastAPIWebsocketParams(...), + "webrtc": lambda: TransportParams(...), +} + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + stt = DeepgramSTTService(...) + tts = SomeTTSService(...) + llm = OpenAILLMService(...) + + context = LLMContext(messages) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair(...) + + pipeline = Pipeline([...]) + task = PipelineTask(pipeline, params=..., observers=[...]) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + await task.queue_frames([LLMRunFrame()]) + + 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) +``` + +--- + +## Execution Flow + +1. Fetch uncommitted and outgoing changes +2. Categorize files (services, examples, tests, utilities) +3. Analyze each file: + - Readability + - Performance + - Documentation + - Pattern consistency +4. Generate actionable recommendations +5. Apply Pipecat standards + +--- + +## Examples + +### Before: Tuple Usage + +```python +def get_audio_info(self) -> Tuple[int, int]: + return (48000, 1) +``` + +### After: Named Class + +```python +class AudioInfo: + """Audio configuration information. + + Parameters: + sample_rate: Sample rate in Hz. + num_channels: Number of audio channels. + """ + + sample_rate: int + num_channels: int + +def get_audio_info(self) -> AudioInfo: + return AudioInfo(sample_rate=48000, num_channels=1) +``` + +--- + +### Before: Missing Documentation + +```python +class NewTTSService(TTSService): + def __init__(self, api_key: str, voice: str): + self._api_key = api_key + self._voice = voice +``` + +### After: Fully Documented + +```python +class NewTTSService(TTSService): + """Text-to-speech service using NewProvider API. + + Streams PCM audio and emits TTSAudioRawFrame frames compatible + with Pipecat transports. + + Supported features: + - Text-to-speech synthesis + - Streaming PCM audio + - Voice customization + - TTFB metrics + """ + + def __init__(self, *, api_key: str, voice: str, **kwargs): + """Initialize the NewTTSService. + + Args: + api_key: API key for authentication. + voice: Voice identifier to use. + **kwargs: Additional arguments passed to the parent service. + """ + super().__init__(**kwargs) + self._api_key = api_key + self.set_voice(voice) +``` + +--- + +## Notes + +- Non-breaking improvements only +- Backward compatibility preserved +- Conservative performance changes +- Google-style docstrings +- Pattern checks follow recent Pipecat code diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md new file mode 100644 index 000000000..036a7f935 --- /dev/null +++ b/.claude/skills/code-review/SKILL.md @@ -0,0 +1,107 @@ +--- +name: code-review +description: Automated code review for pull requests using multiple specialized agents +disable-model-invocation: true +allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(gh pr comment:*), Bash(gh pr diff:*), Bash(gh pr view:*), Bash(gh pr list:*) +--- + +Provide a code review for the given pull request. + +**Agent assumptions (applies to all agents and subagents):** + +- All tools are functional and will work without error. Do not test tools or make exploratory calls. Make sure this is clear to every subagent that is launched. +- Only call a tool if it is required to complete the task. Every tool call should have a clear purpose. + +To do this, follow these steps precisely: + +1. Launch a haiku agent to check if any of the following are true: + - The pull request is closed + - The pull request is a draft + - The pull request does not need code review (e.g. automated PR, trivial change that is obviously correct) + - Claude has already commented on this PR (check `gh pr view --comments` for comments left by claude) + + If any condition is true, stop and do not proceed. + +Note: Still review Claude generated PR's. + +2. Launch a haiku agent to return a list of file paths (not their contents) for all relevant CLAUDE.md files including: + - The root CLAUDE.md file, if it exists + - Any CLAUDE.md files in directories containing files modified by the pull request + +3. Launch a sonnet agent to view the pull request and return a summary of the changes + +4. Launch 4 agents in parallel to independently review the changes. Each agent should return the list of issues, where each issue includes a description and the reason it was flagged (e.g. "CLAUDE.md adherence", "bug"). The agents should do the following: + + Agents 1 + 2: CLAUDE.md compliance sonnet agents + Audit changes for CLAUDE.md compliance in parallel. Note: When evaluating CLAUDE.md compliance for a file, you should only consider CLAUDE.md files that share a file path with the file or parents. + + Agent 3: Opus bug agent (parallel subagent with agent 4) + Scan for obvious bugs. Focus only on the diff itself without reading extra context. Flag only significant bugs; ignore nitpicks and likely false positives. Do not flag issues that you cannot validate without looking at context outside of the git diff. + + Agent 4: Opus bug agent (parallel subagent with agent 3) + Look for problems that exist in the introduced code. This could be security issues, incorrect logic, etc. Only look for issues that fall within the changed code. + + **CRITICAL: We only want HIGH SIGNAL issues.** Flag issues where: + - The code will fail to compile or parse (syntax errors, type errors, missing imports, unresolved references) + - The code will definitely produce wrong results regardless of inputs (clear logic errors) + - Clear, unambiguous CLAUDE.md violations where you can quote the exact rule being broken + + Do NOT flag: + - Code style or quality concerns + - Potential issues that depend on specific inputs or state + - Subjective suggestions or improvements + + If you are not certain an issue is real, do not flag it. False positives erode trust and waste reviewer time. + + In addition to the above, each subagent should be told the PR title and description. This will help provide context regarding the author's intent. + +5. For each issue found in the previous step by agents 3 and 4, launch parallel subagents to validate the issue. These subagents should get the PR title and description along with a description of the issue. The agent's job is to review the issue to validate that the stated issue is truly an issue with high confidence. For example, if an issue such as "variable is not defined" was flagged, the subagent's job would be to validate that is actually true in the code. Another example would be CLAUDE.md issues. The agent should validate that the CLAUDE.md rule that was violated is scoped for this file and is actually violated. Use Opus subagents for bugs and logic issues, and sonnet agents for CLAUDE.md violations. + +6. Filter out any issues that were not validated in step 5. This step will give us our list of high signal issues for our review. + +7. If issues were found, skip to step 8 to post comments. + + If NO issues were found, post a summary comment using `gh pr comment` (if `--comment` argument is provided): + "No issues found. Checked for bugs and CLAUDE.md compliance." + +8. Create a list of all comments that you plan on leaving. This is only for you to make sure you are comfortable with the comments. Do not post this list anywhere. + +9. Post inline comments for each issue using `gh pr review` with inline comments. For each comment: + - Provide a brief description of the issue + - For small, self-contained fixes, include a committable suggestion block + - For larger fixes (6+ lines, structural changes, or changes spanning multiple locations), describe the issue and suggested fix without a suggestion block + - Never post a committable suggestion UNLESS committing the suggestion fixes the issue entirely. If follow up steps are required, do not leave a committable suggestion. + + **IMPORTANT: Only post ONE comment per unique issue. Do not post duplicate comments.** + +Use this list when evaluating issues in Steps 4 and 5 (these are false positives, do NOT flag): + +- Pre-existing issues +- Something that appears to be a bug but is actually correct +- Pedantic nitpicks that a senior engineer would not flag +- Issues that a linter will catch (do not run the linter to verify) +- General code quality concerns (e.g., lack of test coverage, general security issues) unless explicitly required in CLAUDE.md +- Issues mentioned in CLAUDE.md but explicitly silenced in the code (e.g., via a lint ignore comment) + +Notes: + +- Use gh CLI to interact with GitHub (e.g., fetch pull requests, create comments). Do not use web fetch. +- Create a todo list before starting. +- You must cite and link each issue in inline comments (e.g., if referring to a CLAUDE.md, include a link to it). +- If no issues are found, post a comment with the following format: + +--- + +## Code review + +No issues found. Checked for bugs and CLAUDE.md compliance. + +--- + +- When linking to code in inline comments, follow the following format precisely, otherwise the Markdown preview won't render correctly: `https://github.com/OWNER/REPO/blob/FULL_SHA/path/to/file.py#L10-L15` + - Requires full git sha + - You must provide the full sha. Commands like `https://github.com/owner/repo/blob/$(git rev-parse HEAD)/foo/bar` will not work, since your comment will be directly rendered in Markdown. + - Repo name must match the repo you're code reviewing + - # sign after the file name + - Line range format is L[start]-L[end] + - Provide at least 1 line of context before and after, centered on the line you are commenting about (eg. if you are commenting about lines 5-6, you should link to `L4-7`) diff --git a/.claude/skills/docstring/SKILL.md b/.claude/skills/docstring/SKILL.md new file mode 100644 index 000000000..129d83763 --- /dev/null +++ b/.claude/skills/docstring/SKILL.md @@ -0,0 +1,256 @@ +--- +name: docstring +description: Document a Python module and its classes using Google style +--- + +Document a Python module or class using Google-style docstrings following project conventions. The argument can be a class name or a module path. + +## Instructions + +1. Determine what to document based on the argument: + + **If a module path is provided** (e.g. `src/pipecat/audio/vad/vad_analyzer.py`): + - Use that file directly + + **If a class name is provided** (e.g. `VADAnalyzer`): + - Search for `class ClassName` in `src/pipecat/` + - If multiple files contain that class name, list all matches with their file paths, ask the user which one they want to document, and wait for confirmation + +2. Once the file is identified, read the module to understand its structure: + - Identify all classes, functions, and important type aliases + - Understand the purpose of each component + +4. Apply documentation in this order: + - Module docstring (at top, after imports) + - Class docstrings + - `__init__` methods (always document constructor parameters) + - Public methods (not starting with `_`) + - Dataclass/config classes with field descriptions + +5. Skip documentation for: + - Private methods (starting with `_`) + - Simple dunder methods (`__str__`, `__repr__`, `__post_init__`) + - Very simple pass-through properties + - **Already documented code** - If a class, method, or function already has a complete docstring that follows the project style, do not modify it. A docstring is complete if it has: + - A one-line summary + - Args section (if it has parameters) + - Returns section (if it returns something meaningful) + - Only add or improve documentation where it is missing or incomplete + +## Module Docstring Format + +```python +"""[One-line description of module purpose]. + +[Optional: Longer explanation of functionality, key classes, or use cases.] +""" +``` + +Example: +```python +"""Neuphonic text-to-speech service implementations. + +This module provides WebSocket and HTTP-based integrations with Neuphonic's +text-to-speech API for real-time audio synthesis. +""" +``` + +## Class Docstring Format + +```python +class ClassName: + """One-line summary describing what the class does. + + [Longer description explaining purpose, behavior, and key features. + Use action-oriented language.] + + [Optional: Event handlers, usage notes, or important caveats.] + """ +``` + +Example: +```python +class FrameProcessor(BaseObject): + """Base class for all frame processors in the pipeline. + + Frame processors are the building blocks of Pipecat pipelines, they can be + linked to form complex processing pipelines. They receive frames, process + them, and pass them to the next or previous processor in the chain. + + Event handlers available: + + - on_before_process_frame: Called before a frame is processed + - on_after_process_frame: Called after a frame is processed + + Example:: + + @processor.event_handler("on_before_process_frame") + async def on_before_process_frame(processor, frame): + ... + + @processor.event_handler("on_after_process_frame") + async def on_after_process_frame(processor, frame): + ... + """ +``` + +Note: When listing event handlers, do NOT use backticks. Include an `Example::` section (with double colon for Sphinx) showing the decorator pattern and function signature for each event. + +## Constructor (`__init__`) Format + +```python +def __init__(self, *, param1: Type, param2: Type = default, **kwargs): + """Initialize the [ClassName]. + + Args: + param1: Description of param1 and its purpose. + param2: Description of param2. Defaults to [default]. + **kwargs: Additional arguments passed to parent class. + """ +``` + +Example: +```python +def __init__( + self, + *, + api_key: str, + voice_id: Optional[str] = None, + sample_rate: Optional[int] = 22050, + **kwargs, +): + """Initialize the Neuphonic TTS service. + + Args: + api_key: Neuphonic API key for authentication. + voice_id: ID of the voice to use for synthesis. + sample_rate: Audio sample rate in Hz. Defaults to 22050. + **kwargs: Additional arguments passed to parent InterruptibleTTSService. + """ +``` + +## Method Docstring Format + +```python +async def method_name(self, param1: Type) -> ReturnType: + """One-line summary of what method does. + + [Longer description if behavior isn't obvious.] + + Args: + param1: Description of param1. + + Returns: + Description of return value. + + Raises: + ExceptionType: When this exception is raised. + """ +``` + +Example: +```python +async def put(self, item: Tuple[Frame, FrameDirection, FrameCallback]): + """Put an item into the priority queue. + + System frames (`SystemFrame`) have higher priority than any other + frames. If a non-frame item is provided it will have the highest priority. + + Args: + item: The item to enqueue. + """ +``` + +## Dataclass/Config Format + +```python +@dataclass +class ConfigName: + """One-line description of configuration. + + [Explanation of when/how to use this config.] + + Parameters: + field1: Description of field1. + field2: Description of field2. Defaults to [default]. + """ + + field1: Type + field2: Type = default_value +``` + +Example: +```python +@dataclass +class FrameProcessorSetup: + """Configuration parameters for frame processor initialization. + + Parameters: + clock: The clock instance for timing operations. + task_manager: The task manager for handling async operations. + observer: Optional observer for monitoring frame processing events. + """ + + clock: BaseClock + task_manager: BaseTaskManager + observer: Optional[BaseObserver] = None +``` + +## Enum Documentation Format + +```python +class EnumName(Enum): + """One-line description of the enum purpose. + + [Longer description of how the enum is used.] + + Parameters: + VALUE1: Description of VALUE1. + VALUE2: Description of VALUE2. + """ + + VALUE1 = 1 + VALUE2 = 2 +``` + +## Writing Style Guidelines + +- **Concise and professional** - No casual language or filler words +- **Action-oriented** - Start with verbs: "Processes...", "Manages...", "Converts..." +- **Purpose before implementation** - Explain WHY before HOW +- **Clear parameter descriptions** - Include type hints, defaults, and purpose +- **No redundant type info** - Type hints are in the signature, don't repeat in description +- **Use backticks for code references** - Wrap class names, method names, event names, parameter names, and code snippets in backticks + +Good: "Neuphonic API key for authentication." +Bad: "str: The API key (string) that is used for authenticating with Neuphonic." + +Good: "Triggers `on_speech_started` when the `VADAnalyzer` detects speech." +Bad: "Triggers on_speech_started when the VADAnalyzer detects speech." + +## Deprecation Notice Format + +When documenting deprecated code: + +```python +"""[Description]. + +.. deprecated:: X.X.X + `ClassName` is deprecated and will be removed in a future version. + Use `NewClassName` instead. +""" +``` + +## Checklist + +Before finishing, verify: + +- [ ] Module has a docstring at the top (after copyright header and imports) +- [ ] All public classes have docstrings +- [ ] All `__init__` methods document their parameters +- [ ] All public methods have docstrings with Args/Returns/Raises as needed +- [ ] Dataclasses use "Parameters:" section for field descriptions +- [ ] Enums document each value in "Parameters:" section +- [ ] Writing is concise and action-oriented +- [ ] No documentation added to private methods (starting with `_`) +- [ ] Existing complete docstrings were left unchanged diff --git a/.claude/skills/pr-description/SKILL.md b/.claude/skills/pr-description/SKILL.md new file mode 100644 index 000000000..666cf2bd1 --- /dev/null +++ b/.claude/skills/pr-description/SKILL.md @@ -0,0 +1,128 @@ +--- +name: pr-description +description: Update a GitHub PR description with a summary of changes +--- + +Update a GitHub pull request description based on the changes in the PR. + +## Arguments + +``` +/pr-description [--fixes ] +``` + +- `PR_NUMBER` (required): The pull request number to update +- `--fixes` (optional): Comma-separated issue numbers that this PR fixes (e.g., `--fixes 123,456`) + +Examples: +- `/pr-description 3534` +- `/pr-description 3534 --fixes 123` +- `/pr-description 3534 --fixes 123,456,789` + +## Instructions + +1. First, gather information about the PR: + - Use GitHub plugin to get PR details (title, current description, base branch) + - Use local git to get commits: `git log main..HEAD --oneline` + - Use local git to get the diff: `git diff main..HEAD` + - Parse any `--fixes` argument for issue numbers + +2. Check the existing PR description: + - If it already has a complete, accurate description that reflects the changes, do nothing + - If it's missing sections, incomplete, or outdated compared to the actual changes, proceed to update + - If it only has the template placeholder text, generate a full description + +3. Analyze the changes: + - Understand the purpose of each commit + - Identify any breaking changes (API changes, removed features, behavior changes) + - Look for new features, bug fixes, refactoring, or documentation changes + - Collect issue numbers from: + - The `--fixes` argument (if provided) + - Commit messages (patterns like "Fixes #123", "Closes #456", "Resolves #789") + +4. Generate or update the PR description with these sections: + +## PR Description Format + +### Summary (always include) + +Brief bullet points describing what changed and why. Focus on the *purpose* and *impact*, not implementation details. + +```markdown +## Summary + +- Added X to enable Y +- Fixed bug where Z would happen +- Refactored W for better maintainability +``` + +### Breaking Changes (include only if applicable) + +Document any changes that affect existing users or APIs. + +```markdown +## Breaking Changes + +- `ClassName.method()` now requires a `param` argument +- Removed deprecated `old_function()` - use `new_function()` instead +``` + +### Testing (include when non-obvious) + +How to verify the changes work. Skip for trivial changes. + +```markdown +## Testing + +- Run `uv run pytest tests/test_feature.py` to verify the fix +- Example usage: `uv run examples/new_feature.py` +``` + +### Fixes (include if issues are provided or found in commits) + +List issues this PR fixes. GitHub will automatically close these issues when the PR is merged. + +```markdown +## Fixes + +- Fixes #123 +- Fixes #456 +``` + +Note: Use "Fixes #X" format (not "Closes" or "Resolves") for consistency. Each issue should be on its own line with "Fixes" to ensure GitHub auto-closes them. + +## Guidelines + +- **Be concise** - Reviewers should understand the PR in 30 seconds +- **Focus on why** - The diff shows *what* changed, explain *why* +- **Skip empty sections** - Only include sections that have content +- **Use bullet points** - Easier to scan than paragraphs +- **Don't duplicate the diff** - Avoid listing every file or line changed + +## Example Output + +```markdown +## Summary + +- Added `/docstring` skill for documenting Python modules with Google-style docstrings +- Skill finds classes by name and handles conflicts when multiple matches exist +- Skips already-documented code to avoid unnecessary changes + +## Testing + +/docstring ClassName + +## Fixes + +- Fixes #123 +``` + +## Checklist + +Before updating the PR: + +- [ ] Verified existing description needs updating (not already complete) +- [ ] Summary accurately reflects the changes +- [ ] Breaking changes are clearly documented (if any) +- [ ] No unnecessary sections included +- [ ] Description is concise and scannable 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..6ed83abb0 --- /dev/null +++ b/.claude/skills/update-docs/SKILL.md @@ -0,0 +1,306 @@ +--- +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, do all three of the following: + +#### 8a: Create the doc page + +Create the new `.mdx` file 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] +``` + +#### 8b: Add to docs.json + +Add the new page path to `DOCS_PATH/docs.json` in the correct navigation group. The path format is `server/services/{category}/{provider}` (without the `.mdx` extension). + +Find the matching group in the navigation structure: +- **STT** → `"group": "Speech-to-Text"` under Services +- **TTS** → `"group": "Text-to-Speech"` under Services +- **LLM** → `"group": "LLM"` under Services +- **S2S** → `"group": "Speech-to-Speech"` under Services +- **Transport** → `"group": "Transport"` under Services +- **Serializer** → `"group": "Serializers"` under Services +- **Image generation** → `"group": "Image Generation"` under Services +- **Video** → `"group": "Video"` under Services +- **Memory** → `"group": "Memory"` under Services +- **Vision** → `"group": "Vision"` under Services +- **Analytics** → `"group": "Analytics & Monitoring"` under Services + +Insert the new entry **alphabetically** within the group's `pages` array. For example, adding a new STT service "foo": +```json +{ + "group": "Speech-to-Text", + "pages": [ + "server/services/stt/assemblyai", + "server/services/stt/aws", + ... + "server/services/stt/foo", + ... + ] +} +``` + +#### 8c: Add to supported-services.mdx + +Add a new row to the correct category table in `DOCS_PATH/server/services/supported-services.mdx`. + +Use this format: +``` +| [DisplayName](/server/services/{category}/{provider}) | `pip install "pipecat-ai[package]"` | +``` + +To determine the correct values: +- **DisplayName**: Use the service's human-readable name (e.g., "ElevenLabs", "AWS Polly", "Google Gemini") +- **package**: Look at the service's `pyproject.toml` extras or the import pattern in the source code. For example, if the service is in `src/pipecat/services/foo/`, the package is typically `foo`. +- If no pip dependencies are required, use `No dependencies required` instead. + +Insert the new row **alphabetically** within the table. Match the column alignment of the existing rows. + +### 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`) + +### New service pages +- `server/services/tts/newprovider.mdx` — Created page, added to docs.json (Text-to-Speech), added to supported-services.mdx + +### 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 +- [ ] New service pages were added to `docs.json` in the correct group, alphabetically +- [ ] New service pages were added to `supported-services.mdx` in the correct table, alphabetically +- [ ] 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 0dd30f9e5..26d03861b 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -29,11 +29,22 @@ jobs: - name: Install system packages run: | + sudo apt-get update sudo apt-get install -y portaudio19-dev - name: Install dependencies run: | - uv sync --group dev --extra anthropic --extra aws --extra google --extra langchain --extra websocket + uv sync --group dev \ + --extra anthropic \ + --extra aws \ + --extra deepgram \ + --extra google \ + --extra langchain \ + --extra livekit \ + --extra piper \ + --extra sagemaker \ + --extra tracing \ + --extra websocket - name: Run tests with coverage run: | 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/python-compatibility.yaml b/.github/workflows/python-compatibility.yaml index 784e6f402..26f58f9dc 100644 --- a/.github/workflows/python-compatibility.yaml +++ b/.github/workflows/python-compatibility.yaml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.10.18', '3.11.13', '3.12.11', '3.13.5'] + python-version: ['3.10.19', '3.11.14', '3.12.12', '3.13.12'] name: Python ${{ matrix.python-version }} steps: @@ -40,20 +40,10 @@ jobs: uv python install ${{ matrix.python-version }} uv python pin ${{ matrix.python-version }} - - name: Test uv sync with all extras (Python < 3.13) - if: "!startsWith(matrix.python-version, '3.13.')" + - name: Test uv sync with all extras run: | uv sync --group dev --all-extras --no-extra krisp - - name: Test uv sync without PyTorch extras (Python 3.13+) - if: startsWith(matrix.python-version, '3.13.') - run: | - uv sync --group dev --all-extras \ - --no-extra krisp \ - --no-extra local-smart-turn \ - --no-extra moondream \ - --no-extra mlx-whisper - - name: Verify installation run: | uv run python --version diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 8e58845e4..b22d502c4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -33,11 +33,22 @@ jobs: - name: Install system packages run: | + sudo apt-get update sudo apt-get install -y portaudio19-dev - name: Install dependencies run: | - uv sync --group dev --extra anthropic --extra aws --extra google --extra langchain --extra websocket + uv sync --group dev \ + --extra anthropic \ + --extra aws \ + --extra deepgram \ + --extra google \ + --extra langchain \ + --extra livekit \ + --extra piper \ + --extra sagemaker \ + --extra tracing \ + --extra websocket - name: Test with pytest run: | diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml new file mode 100644 index 000000000..d26862766 --- /dev/null +++ b/.github/workflows/update-docs.yml @@ -0,0 +1,147 @@ +name: Update Documentation on PR Merge + +on: + pull_request_target: + types: [closed] + branches: [main] + paths: + - "src/pipecat/services/**" + - "src/pipecat/transports/**" + - "src/pipecat/serializers/**" + - "src/pipecat/processors/**" + - "src/pipecat/audio/**" + - "src/pipecat/turns/**" + - "src/pipecat/observers/**" + - "src/pipecat/pipeline/**" + workflow_dispatch: + inputs: + pr_number: + description: "PR number to generate docs for" + required: true + type: string + +jobs: + update-docs: + if: >- + github.event_name == 'workflow_dispatch' || + github.event.pull_request.merged == true + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: read + id-token: write + steps: + - name: Checkout pipecat + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Checkout docs + uses: actions/checkout@v4 + with: + repository: pipecat-ai/docs + token: ${{ secrets.DOCS_SYNC_TOKEN }} + path: _docs + + - name: Resolve PR number + id: pr + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT" + else + echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + fi + + - name: Update documentation + uses: anthropics/claude-code-action@v1 + env: + DOCS_SYNC_TOKEN: ${{ secrets.DOCS_SYNC_TOKEN }} + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + prompt: | + You are updating documentation for the pipecat-ai/docs repository based on + changes merged in PR #${{ steps.pr.outputs.number }} of pipecat-ai/pipecat. + + ## Setup + + 1. Read the skill instructions at `.claude/skills/update-docs/SKILL.md` + 2. Read the source-to-doc mapping at `.claude/skills/update-docs/SOURCE_DOC_MAPPING.md` + 3. The docs repository is checked out at `./_docs/` + + ## Get the diff + + Run `gh pr diff ${{ steps.pr.outputs.number }}` to see what changed in the PR. + Also run `gh pr diff ${{ steps.pr.outputs.number }} --name-only` to get the list of changed files. + Filter to source files matching the directories listed in SKILL.md Step 3. + + If no relevant source files were changed, exit with "No documentation changes needed." + + ## Follow the skill instructions + + Apply the SKILL.md workflow (Steps 3-9) with these adaptations for automation: + + ### Docs path + Use `./_docs/` — it's already checked out. Do not ask for a path. + + ### Branch management + - Branch name: `docs/pr-${{ steps.pr.outputs.number }}` + - Work inside `./_docs/` for all doc edits and git operations + - Check if the branch already exists on the remote: + ```bash + cd _docs && git fetch origin docs/pr-${{ steps.pr.outputs.number }} 2>/dev/null + ``` + - If it exists: check it out (supports workflow re-runs) + - If not: create it from main + + ### Git config + Before committing in `_docs`, set: + ```bash + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + ``` + + ### No interactive questions + Do not ask questions. If you encounter gaps (unmapped files, missing sections, + ambiguous changes), note them in the PR body under "## Gaps identified". + + ### Creating the docs PR + After committing all changes in `_docs`, push and create a PR: + ```bash + cd _docs + git push -u origin docs/pr-${{ steps.pr.outputs.number }} + GH_TOKEN=$DOCS_SYNC_TOKEN gh pr create \ + --repo pipecat-ai/docs \ + --label auto-docs \ + --title "docs: update for pipecat PR #${{ steps.pr.outputs.number }}" \ + --body "$(cat <<'BODY' + Automated documentation update for [pipecat PR #${{ steps.pr.outputs.number }}](https://github.com/pipecat-ai/pipecat/pull/${{ steps.pr.outputs.number }}). + + ## Changes + + + ## Gaps identified + + BODY + )" + ``` + + ### Re-run handling + If `gh pr create` fails because a PR from that branch already exists, + push the updated commits and use `gh pr edit` to update the body instead. + + ### No-op + If after analyzing the diff you determine no documentation changes are needed + (e.g., only skip-listed files changed, or changes don't affect public API docs), + exit cleanly without creating a branch or PR. Output "No documentation changes needed." + + ## Important rules + - Only modify files inside `./_docs/` — never modify pipecat source code + - Follow the conservative editing rules from SKILL.md Step 6 + - Read each doc page fully before editing (SKILL.md Guidelines) + - Use `GH_TOKEN=$DOCS_SYNC_TOKEN` for all `gh` commands targeting pipecat-ai/docs + claude_args: | + --model claude-sonnet-4-5-20250929 + --max-turns 30 + --allowedTools "Read,Write,Edit,Glob,Grep,Bash" diff --git a/.gitignore b/.gitignore index 2e5da188e..512ec7316 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,14 @@ __pycache__/ *~ venv .venv -/.idea +.idea +.gradle +.next +next-env.d.ts +local.properties +*.log +*.lock +smart_turn_audio_log #*# # Distribution / Packaging @@ -27,7 +34,7 @@ share/python-wheels/ *.egg MANIFEST .DS_Store -.env +.env* fly.toml # Examples @@ -51,4 +58,7 @@ docs/api/_build/ docs/api/api # uv -.python-version \ No newline at end of file +.python-version + +# Pipecat +whisker_setup.py \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a22a905ad..dfd42c6c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,2332 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.0.106] - 2026-03-18 + +### Added + +- Added optional `service` field to `ServiceUpdateSettingsFrame` (and its + subclasses `LLMUpdateSettingsFrame`, `TTSUpdateSettingsFrame`, + `STTUpdateSettingsFrame`) to target a specific service instance. When + `service` is set, only the matching service applies the settings; others + forward the frame unchanged. This enables updating a single service when + multiple services of the same type exist in the pipeline. + (PR [#4004](https://github.com/pipecat-ai/pipecat/pull/4004)) + +- Added `sip_provider` and `room_geo` parameters to `configure()` in the Daily + runner. These convenience parameters let callers specify a SIP provider name + and geographic region directly without manually constructing + `DailyRoomProperties` and `DailyRoomSipParams`. + (PR [#4005](https://github.com/pipecat-ai/pipecat/pull/4005)) + +- Added `PerplexityLLMAdapter` that automatically transforms conversation + messages to satisfy Perplexity's stricter API constraints (strict role + alternation, no non-initial system messages, last message must be user/tool). + Previously, certain conversation histories could cause Perplexity API errors + that didn't occur with OpenAI (`PerplexityLLMService` subclasses + `OpenAILLMService` since Perplexity uses an OpenAI-compatible API). + (PR [#4009](https://github.com/pipecat-ai/pipecat/pull/4009)) + +- Added DTMF input event support to the Daily transport. Incoming DTMF tones + are now received via Daily's `on_dtmf_event` callback and pushed into the + pipeline as `InputDTMFFrame`, enabling bots to react to keypad presses from + phone callers. + (PR [#4047](https://github.com/pipecat-ai/pipecat/pull/4047)) + +- Added `WakePhraseUserTurnStartStrategy` for triggering user turns based on + wake phrases, with support for `single_activation` mode. Deprecates + `WakeCheckFilter`. + (PR [#4064](https://github.com/pipecat-ai/pipecat/pull/4064)) + +- Added `default_user_turn_start_strategies()` and + `default_user_turn_stop_strategies()` helper functions for composing custom + strategy lists. + (PR [#4064](https://github.com/pipecat-ai/pipecat/pull/4064)) + +### Changed + +- Changed tool result JSON serialization to use `ensure_ascii=False`, + preserving UTF-8 characters instead of escaping them. This reduces context + size and token usage for non-English languages. + (PR [#3457](https://github.com/pipecat-ai/pipecat/pull/3457)) + +- `OpenAIRealtimeSTTService`'s `noise_reduction` parameter is now part of + `OpenAIRealtimeSTTSettings`, making it runtime-updatable via + `STTUpdateSettingsFrame`. The direct `noise_reduction` init argument is + deprecated as of 0.0.106. + (PR [#3991](https://github.com/pipecat-ai/pipecat/pull/3991)) + +- Updated `sarvamai` dependency from `0.1.26a2` (alpha) to `0.1.26` (stable + release). + (PR [#3997](https://github.com/pipecat-ai/pipecat/pull/3997)) + +- `SimliVideoService` now extends `AIService` instead of `FrameProcessor`, + aligning it with the HeyGen and Tavus video services. It supports + `SimliVideoService.Settings(...)` for configuration and uses + `start()`/`stop()`/`cancel()` lifecycle methods. Existing constructor usage + (`api_key`, `face_id`, etc.) remains unchanged. + (PR [#4001](https://github.com/pipecat-ai/pipecat/pull/4001)) + +- Update `pipecat-ai-small-webrtc-prebuilt` to `2.4.0`. + (PR [#4023](https://github.com/pipecat-ai/pipecat/pull/4023)) + +- Nova Sonic assistant text transcripts are now delivered in real-time using + speculative text events instead of delayed final text events. Previously, + assistant text only arrived after all audio had finished playing, causing + laggy transcripts in client UIs. Speculative text arrives before each audio + chunk, providing text synchronized with what the bot is saying. This also + simplifies the internal text handling by removing the interruption re-push + hack and assistant text buffer. + (PR [#4042](https://github.com/pipecat-ai/pipecat/pull/4042)) + +- Updated `daily-python` dependency to 0.25.0. + (PR [#4047](https://github.com/pipecat-ai/pipecat/pull/4047)) + +- Added `enable_dialout` parameter to `configure()` in `pipecat.runner.daily` + to support dial-out rooms. Also narrowed misleading `Optional` type hints and + deduplicated token expiry calculation. + (PR [#4048](https://github.com/pipecat-ai/pipecat/pull/4048)) + +- Extended `ProcessFrameResult` to stop strategies, allowing a stop strategy to + short-circuit evaluation of subsequent strategies by returning `STOP`. + (PR [#4064](https://github.com/pipecat-ai/pipecat/pull/4064)) + +- `GradiumSTTService` now takes both an `encoding` and `sample_rate` + constructor argument which is assmebled in the class to form the + `input_format`. PCM accepts `8000`, `16000`, and `24000` Hz sample rates. + (PR [#4066](https://github.com/pipecat-ai/pipecat/pull/4066)) + +- Improved `GradiumSTTService` transcription accuracy by reworking how text + fragments are accumulated and finalized. Previously, trailing words could be + dropped when the server's `flushed` response arrived before all text tokens + were delivered. The service now uses a short aggregation delay after flush to + capture trailing tokens, producing complete utterances. + (PR [#4066](https://github.com/pipecat-ai/pipecat/pull/4066)) + +### Deprecated + +- `SimliVideoService.InputParams` is deprecated. Use the direct constructor + parameters `max_session_length`, `max_idle_time`, and `enable_logging` + instead. + (PR [#4001](https://github.com/pipecat-ai/pipecat/pull/4001)) + +- Deprecated `LocalSmartTurnAnalyzerV2` and `LocalCoreMLSmartTurnAnalyzer`. Use + `LocalSmartTurnAnalyzerV3` instead. Instantiating these analyzers will now + emit a `DeprecationWarning`. + (PR [#4012](https://github.com/pipecat-ai/pipecat/pull/4012)) + +- Deprecated `WakeCheckFilter` in favor of `WakePhraseUserTurnStartStrategy`. + (PR [#4064](https://github.com/pipecat-ai/pipecat/pull/4064)) + +### Fixed + +- Fixed an issue where the default model for `OpenAILLMService` and + `AzureLLMService` was mistakenly reverted to `gpt-4o`. The defaults are now + restored to `gpt-4.1`. + (PR [#4000](https://github.com/pipecat-ai/pipecat/pull/4000)) + +- Fixed a race condition where `EndTaskFrame` could cause the pipeline to shut + down before in-flight frames (e.g. LLM function call responses) finished + processing. `EndTaskFrame` and `StopTaskFrame` now flow through the pipeline + as `ControlFrame`s, ensuring all pending work is flushed before shutdown + begins. `CancelTaskFrame` and `InterruptionTaskFrame` remain immediate + (`SystemFrame`). + (PR [#4006](https://github.com/pipecat-ai/pipecat/pull/4006)) + +- Fixed `ParallelPipeline` dropping or misordering frames during lifecycle + synchronization. Buffered frames are now flushed in the correct order + relative to synchronization frames (`StartFrame` goes first, + `EndFrame`/`CancelFrame` go after), and frames added to the buffer during + flush are also drained. + (PR [#4007](https://github.com/pipecat-ai/pipecat/pull/4007)) + +- Fixed `TTSService` potentially canceling in-flight audio during shutdown. The + stop sequence now waits for all queued audio contexts to finish processing + before canceling the stop frame task. + (PR [#4007](https://github.com/pipecat-ai/pipecat/pull/4007)) + +- Fixed `Language` enum values (e.g. `Language.ES`) not being converted to + service-specific codes when passed via + `settings=Service.Settings(language=Language.ES)` at init time. This caused + API errors (e.g. 400 from Rime) because the raw enum was sent instead of the + expected language code (e.g. `"spa"`). Runtime updates via + `UpdateSettingsFrame` were unaffected. The fix centralizes conversion in the + base `TTSService` and `STTService` classes so all services handle this + consistently. + (PR [#4024](https://github.com/pipecat-ai/pipecat/pull/4024)) + +- Fixed `DeepgramSTTService` ignoring the `base_url` scheme when using `ws://` + or `http://`. Previously these were silently overwritten with `wss://` / + `https://`, breaking air-gapped or private deployments that don't use TLS. + All scheme choices (`wss://`, `https://`, `ws://`, `http://`, or bare + hostname) are now respected. + (PR [#4026](https://github.com/pipecat-ai/pipecat/pull/4026)) + +- Fixed `LLMSwitcher.register_function()` and `register_direct_function()` not + accepting or forwarding the `timeout_secs` parameter. + (PR [#4037](https://github.com/pipecat-ai/pipecat/pull/4037)) + +- Fixed empty user transcriptions in Nova Sonic causing spurious interruptions. + Previously, an empty transcription could trigger an interruption of the + assistant's response even though the user hadn't actually spoken. + (PR [#4042](https://github.com/pipecat-ai/pipecat/pull/4042)) + +- Fixed `SonioxSTTService` and `OpenAIRealtimeSTTService` crash when language + parameters contain plain strings instead of `Language` enum values. + (PR [#4046](https://github.com/pipecat-ai/pipecat/pull/4046)) + +- Fixed premature user turn stops caused by late transcriptions arriving + between turns. A stale transcript from the previous turn could persist into + the next turn and trigger a stop before the current turn's real transcript + arrived. Stop strategies are now reset at both turn start and turn stop to + prevent state from leaking across turn boundaries. + (PR [#4057](https://github.com/pipecat-ai/pipecat/pull/4057)) + +- Fixed raw language strings like `"de-DE"` silently failing when passed to + TTS/STT services (e.g. ElevenLabs producing no audio). Raw strings now go + through the same `Language` enum resolution as enum values, so regional codes + like `"de-DE"` are properly converted to service-expected formats like + `"de"`. Unrecognized strings log a warning instead of failing silently. + (PR [#4058](https://github.com/pipecat-ai/pipecat/pull/4058)) + +- Fixed Deepgram STT list-type settings (`keyterm`, `keywords`, `search`, + `redact`, `replace`) being stringified instead of passed as lists to the SDK, + which caused them to be sent as literal strings (e.g. `"['pipecat']"`) in the + WebSocket query params. + (PR [#4063](https://github.com/pipecat-ai/pipecat/pull/4063)) + +- Fixed `MinWordsUserTurnStartStrategy` including text below the word threshold + in the output by resetting aggregation when the minimum word count is not + met. + (PR [#4064](https://github.com/pipecat-ai/pipecat/pull/4064)) + +- Fixed audio overlap and potential dropped TTS content when multiple assistant + turns occur in quick succession. `TTSService` now flushes remaining text + before pausing frame processing on `LLMFullResponseEndFrame`/`EndFrame`, + instead of pausing first. + (PR [#4071](https://github.com/pipecat-ai/pipecat/pull/4071)) + +### Security + +- Bumped PyJWT minimum version from 2.10.1 to 2.12.0 in the `livekit` extra to + address CVE-2026-32597 (GHSA-752w-5fwx-jx9f), where PyJWT <= 2.11.0 accepted + unknown `crit` header extensions. + (PR [#4035](https://github.com/pipecat-ai/pipecat/pull/4035)) + +## [0.0.105] - 2026-03-10 + +### Added + +- Added concurrent audio context support: `CartesiaTTSService` can now + synthesize the next sentence while the previous one is still playing, by + setting `pause_frame_processing=False` and routing each sentence through its + own audio context queue. + (PR [#3804](https://github.com/pipecat-ai/pipecat/pull/3804)) + +- Added custom video track support to Daily transport. Use + `video_out_destinations` in `DailyParams` to publish multiple video tracks + simultaneously, mirroring the existing `audio_out_destinations` feature. + (PR [#3831](https://github.com/pipecat-ai/pipecat/pull/3831)) + +- Added `ServiceSwitcherStrategyFailover` that automatically switches to the + next service when the active service reports a non-fatal error. Recovery + policies can be implemented via the `on_service_switched` event handler. + (PR [#3861](https://github.com/pipecat-ai/pipecat/pull/3861)) + +- Added optional `timeout_secs` parameter to `register_function()` and + `register_direct_function()` for per-tool function call timeout control, + overriding the global `function_call_timeout_secs` default. + (PR [#3915](https://github.com/pipecat-ai/pipecat/pull/3915)) + +- Added `cloud-audio-only` recording option to Daily transport's + `enable_recording` property. + (PR [#3916](https://github.com/pipecat-ai/pipecat/pull/3916)) + +- Wired up `system_instruction` in `BaseOpenAILLMService`, + `AnthropicLLMService`, and `AWSBedrockLLMService` so it works as a default + system prompt, matching the behavior of the Google services. This enables + sharing a single `LLMContext` across multiple LLM services, where each + service provides its own system instruction independently. + + ```python + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + system_instruction="You are a helpful assistant.", + ) + + context = LLMContext() + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + context.add_message({"role": "user", "content": "Please introduce yourself."}) + await task.queue_frames([LLMRunFrame()]) + ``` + (PR [#3918](https://github.com/pipecat-ai/pipecat/pull/3918)) + +- Added `vad_threshold` parameter to `AssemblyAIConnectionParams` for + configuring voice activity detection sensitivity in U3 Pro. Aligning this + with external VAD thresholds (e.g., Silero VAD) prevents the "dead zone" + where AssemblyAI transcribes speech that VAD hasn't detected yet. + (PR [#3927](https://github.com/pipecat-ai/pipecat/pull/3927)) + +- Added `push_empty_transcripts` parameter to `BaseWhisperSTTService` and + `OpenAISTTService` to allow empty transcripts to be pushed downstream as + `TranscriptionFrame` instead of discarding them (the default behavior). This + is intended for situations where VAD fires even though the user did not + speak. In these cases, it is useful to know that nothing was transcribed so + that the agent can resume speaking, instead of waiting longer for a + transcription. + (PR [#3930](https://github.com/pipecat-ai/pipecat/pull/3930)) + +- LLM services (`BaseOpenAILLMService`, `AnthropicLLMService`, + `AWSBedrockLLMService`) now log a warning when both `system_instruction` and + a system message in the context are set. The constructor's + `system_instruction` takes precedence. + (PR [#3932](https://github.com/pipecat-ai/pipecat/pull/3932)) + +- Runtime settings updates (via `STTUpdateSettingsFrame`) now work for AWS + Transcribe, Azure, Cartesia, Deepgram, ElevenLabs Realtime, Gradium, and + Soniox STT services. Previously, changing settings at runtime only stored the + new values without reconnecting. + (PR [#3946](https://github.com/pipecat-ai/pipecat/pull/3946)) + +- Exposed `on_summary_applied` event on `LLMAssistantAggregator`, allowing + users to listen for context summarization events without accessing private + members. + (PR [#3947](https://github.com/pipecat-ai/pipecat/pull/3947)) + +- Deepgram Flux STT settings (`keyterm`, `eot_threshold`, + `eager_eot_threshold`, `eot_timeout_ms`) can now be updated mid-stream via + `STTUpdateSettingsFrame` without triggering a reconnect. The new values are + sent to Deepgram as a Configure WebSocket message on the existing connection. + (PR [#3953](https://github.com/pipecat-ai/pipecat/pull/3953)) + +- Added `system_instruction` parameter to `run_inference` across all LLM + services, allowing callers to override the system prompt for one-shot + inference calls. Used by `_generate_summary` to pass the summarization prompt + cleanly. + (PR [#3968](https://github.com/pipecat-ai/pipecat/pull/3968)) + +### Changed + +- Audio context management (previously in `AudioContextTTSService`) is now + built into `TTSService`. All WebSocket providers (`cartesia`, `elevenlabs`, + `asyncai`, `inworld`, `rime`, `gradium`, `resembleai`) now inherit from + `WebsocketTTSService` directly. Word-timestamp baseline is set automatically + on the first audio chunk of each context instead of requiring each provider + to call `start_word_timestamps()` in their receive loop. + (PR [#3804](https://github.com/pipecat-ai/pipecat/pull/3804)) + +- Daily transport now uses `CustomVideoSource`/`CustomVideoTrack` instead of + `VirtualCameraDevice` for the default camera output, mirroring how audio + already works with `CustomAudioSource`/`CustomAudioTrack`. + (PR [#3831](https://github.com/pipecat-ai/pipecat/pull/3831)) + +- ⚠️ Updated `DeepgramSTTService` to use `deepgram-sdk` v6. The `LiveOptions` + class was removed from the SDK and is now provided by pipecat directly; + import it from `pipecat.services.deepgram.stt` instead of `deepgram`. + (PR [#3848](https://github.com/pipecat-ai/pipecat/pull/3848)) + +- `ServiceSwitcherStrategy` base class now provides a `handle_error()` hook for + subclasses to implement error-based switching. `ServiceSwitcher` defaults to + `ServiceSwitcherStrategyManual` and `strategy_type` is now optional. + (PR [#3861](https://github.com/pipecat-ai/pipecat/pull/3861)) + +- Support for Voice Focus 2.0 models. + - Updated `aic-sdk` to `~=2.1.0` to support Voice Focus 2.0 models. + - Cleaned unused `ParameterFixedError` exception handling in `AICFilter` + parameter setup. + (PR [#3889](https://github.com/pipecat-ai/pipecat/pull/3889)) + +- `max_context_tokens` and `max_unsummarized_messages` in + `LLMAutoContextSummarizationConfig` (and deprecated + `LLMContextSummarizationConfig`) can now be set to `None` independently to + disable that summarization threshold. At least one must remain set. + (PR [#3914](https://github.com/pipecat-ai/pipecat/pull/3914)) + +- ⚠️ Removed `formatted_finals` and `word_finalization_max_wait_time` from + `AssemblyAIConnectionParams` as these were v2 API parameters not supported in + v3. Clarified that `format_turns` only applies to Universal-Streaming models; + U3 Pro has automatic formatting built-in. + (PR [#3927](https://github.com/pipecat-ai/pipecat/pull/3927)) + +- Changed `DeepgramTTSService` to send a Clear message on interruption instead + of disconnecting and reconnecting the WebSocket, allowing the connection to + persist throughout the session. + (PR [#3958](https://github.com/pipecat-ai/pipecat/pull/3958)) + +- Re-added `enhancement_level` support to `AICFilter` with runtime + `FilterEnableFrame` control, applying `ProcessorParameter.Bypass` and + `ProcessorParameter.EnhancementLevel` together. + (PR [#3961](https://github.com/pipecat-ai/pipecat/pull/3961)) + +- Updated `daily-python` dependency from `~=0.23.0` to `~=0.24.0`. + (PR [#3970](https://github.com/pipecat-ai/pipecat/pull/3970)) + +- Updated `FishAudioTTSService` default model from `s1` to `s2-pro`, matching + Fish Audio's latest recommended model for improved quality and speed. + (PR [#3973](https://github.com/pipecat-ai/pipecat/pull/3973)) + +- `AzureSTTService` `region` parameter is now optional when `private_endpoint` + is provided. A `ValueError` is raised if neither is given, and a warning is + logged if both are provided (`private_endpoint` takes priority). + (PR [#3974](https://github.com/pipecat-ai/pipecat/pull/3974)) + +### Deprecated + +- Deprecated `AudioContextTTSService` and `AudioContextWordTTSService`. + Subclass `WebsocketTTSService` directly instead; audio context management is + now part of the base `TTSService`. + - Deprecated `WordTTSService`, `WebsocketWordTTSService`, and + `InterruptibleWordTTSService`. Word timestamp logic is now always active in + `TTSService` and no longer needs to be opted into via a subclass. + (PR [#3804](https://github.com/pipecat-ai/pipecat/pull/3804)) + +- Deprecated `pipecat.services.google.llm_vertex`, + `pipecat.services.google.llm_openai`, and + `pipecat.services.google.gemini_live.llm_vertex` modules. Use + `pipecat.services.google.vertex.llm`, `pipecat.services.google.openai.llm`, + and `pipecat.services.google.gemini_live.vertex.llm` instead. The old import + paths still work but will emit a `DeprecationWarning`. + (PR [#3980](https://github.com/pipecat-ai/pipecat/pull/3980)) + +### Removed + +- ⚠️ Removed `supports_word_timestamps` parameter from `TTSService.__init__()`. + Word timestamp logic is now always active. Remove this argument from any + custom subclass `super().__init__()` calls. + (PR [#3804](https://github.com/pipecat-ai/pipecat/pull/3804)) + +### Fixed + +- Fixed `DeepgramSTTService` keepalive ping timeout disconnections. The + deepgram-sdk v6 removed automatic keepalive; pipecat now sends explicit + `KeepAlive` messages every 5 seconds, within the recommended 3–5 second + interval before Deepgram's 10-second inactivity timeout. + (PR [#3848](https://github.com/pipecat-ai/pipecat/pull/3848)) + +- Fixed `BufferError: Existing exports of data: object cannot be re-sized` in + `AICFilter` caused by holding a `memoryview` on the mutable audio buffer + across async yield points. + (PR [#3889](https://github.com/pipecat-ai/pipecat/pull/3889)) + +- Fixed TTS context not being appended to the assistant message history when + using `TTSSpeakFrame` with `append_to_context=True` with some TTS providers. + (PR [#3936](https://github.com/pipecat-ai/pipecat/pull/3936)) + +- Fixed context summarization leaving orphaned tool responses in the kept + context when tool calls were moved to the summarized portion. + (PR [#3937](https://github.com/pipecat-ai/pipecat/pull/3937)) + +- Fixed turn completion state not resetting at end of LLM responses. + `LLMFullResponseEndFrame` is pushed (not received) by the LLM service, so the + mixin now handles it in `push_frame` instead of `process_frame`. + (PR [#3956](https://github.com/pipecat-ai/pipecat/pull/3956)) + +- Fixed turn completion instructions being injected as a context system message + instead of using `system_instruction`. This caused warning spam when + `system_instruction` was also set and didn't persist across full context + updates. + (PR [#3957](https://github.com/pipecat-ai/pipecat/pull/3957)) + +- Fixed `TTSService` audio context queue getting blocked when + `append_to_audio_context()` was called with a `None` context ID, which + prevented subsequent audio from being delivered. + (PR [#3958](https://github.com/pipecat-ai/pipecat/pull/3958)) + +- Fixed `on_call_state_updated` event handler in LiveKit transport receiving + incorrect number of arguments due to redundant `self` passed to + `_call_event_handler`. + (PR [#3959](https://github.com/pipecat-ai/pipecat/pull/3959)) + +- Fixed OpenAI Realtime, OpenAI Realtime Beta, and Grok realtime services + treating `conversation_already_has_active_response` as a fatal error. These + services now log it as a non-fatal debug event when a response is already in + progress. + (PR [#3960](https://github.com/pipecat-ai/pipecat/pull/3960)) + +- Fixed `SmallWebRTCConnection` silently discarding messages sent before the + data channel is open by queuing them and flushing once the channel is ready. + A bounded queue (`MAX_MESSAGE_QUEUE_SIZE = 50`) prevents unbounded memory + growth, and a 10-second timeout after connection clears the queue and falls + back to discard mode if the data channel never opens. + (PR [#3962](https://github.com/pipecat-ai/pipecat/pull/3962)) + +- Fixed `AzureSTTService` failing to initialize when `private_endpoint` is + provided. The Azure Speech SDK's `SpeechConfig` does not accept both `region` + and `endpoint` simultaneously, so they are now passed conditionally. + (PR [#3967](https://github.com/pipecat-ai/pipecat/pull/3967)) + +- Fixed `GoogleLLMService` ignoring the `system_instruction` set via + constructor or `GoogleLLMSettings` when a system message was also present in + the context. The settings value now correctly takes priority, and a warning + is logged when both are set. + (PR [#3976](https://github.com/pipecat-ai/pipecat/pull/3976)) + +### Other + +- Updated foundational examples to use `system_instruction` on LLM services + instead of adding system messages to `LLMContext`. + (PR [#3918](https://github.com/pipecat-ai/pipecat/pull/3918)) + +- Updated AssemblyAI turn detection example to use `keyterms_prompt` list + format instead of `prompt` string for improved clarity. + (PR [#3929](https://github.com/pipecat-ai/pipecat/pull/3929)) + +- Updated foundational examples and eval scripts to use `"user"` role instead + of `"system"` when adding messages to `LLMContext`, since system prompts + should be set via `system_instruction` on the LLM service. + (PR [#3931](https://github.com/pipecat-ai/pipecat/pull/3931)) + +## [0.0.104] - 2026-03-02 + +### Added + +- Added `TextAggregationMetricsData` metric measuring the time from the first + LLM token to the first complete sentence, representing the latency cost of + sentence aggregation in the TTS pipeline. + (PR [#3696](https://github.com/pipecat-ai/pipecat/pull/3696)) + +- Added support for using strongly-typed objects instead of dicts for updating + service settings at runtime. + + Instead of, say: + + ```python + await task.queue_frame( + STTUpdateSettingsFrame(settings={"language": Language.ES}) + ) + ``` + + you'd do: + + ```python + await task.queue_frame( + STTUpdateSettingsFrame(delta=DeepgramSTTSettings(language=Language.ES)) + ) + ``` + + Each service now vends strongly-typed classes like `DeepgramSTTSettings` + representing the service's runtime-updatable settings. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Added support for specifying private endpoints for Azure Speech-to-Text, + enabling use in private networks behind firewalls. + (PR [#3764](https://github.com/pipecat-ai/pipecat/pull/3764)) + +- Added `LemonSliceTransport` and `LemonSliceApi` to support adding real-time + LemonSlice Avatars to any Daily room. + (PR [#3791](https://github.com/pipecat-ai/pipecat/pull/3791)) + +- Added `output_medium` parameter to `AgentInputParams` and + `OneShotInputParams` in Ultravox service to control initial output medium + (text or voice) at call creation time. + (PR [#3806](https://github.com/pipecat-ai/pipecat/pull/3806)) + +- Added `TurnMetricsData` as a generic metrics class for turn detection, with + e2e processing time measurement. `KrispVivaTurn` now emits `TurnMetricsData` + with `e2e_processing_time_ms` tracking the interval from VAD + speech-to-silence transition to turn completion. + (PR [#3809](https://github.com/pipecat-ai/pipecat/pull/3809)) + +- Added `on_audio_context_interrupted()` and `on_audio_context_completed()` + callbacks to `AudioContextTTSService`. Subclasses can override these to + perform provider-specific cleanup instead of overriding + `_handle_interruption()`. + (PR [#3814](https://github.com/pipecat-ai/pipecat/pull/3814)) + +- Added `on_summary_applied` event to `LLMContextSummarizer` for observability, + providing message counts before and after context summarization. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Added `summary_message_template` to `LLMContextSummarizationConfig` for + customizing how summaries are formatted when injected into context (e.g., + wrapping in XML tags). + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Added `summarization_timeout` to `LLMContextSummarizationConfig` (default + 120s) to prevent hung LLM calls from permanently blocking future + summarizations. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Added optional `llm` field to `LLMContextSummarizationConfig` for routing + summarization to a dedicated LLM service (e.g., a cheaper/faster model) + instead of the pipeline's primary model. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Add AssemblyAI u3-rt-pro model support with built-in turn detection mode + (PR [#3856](https://github.com/pipecat-ai/pipecat/pull/3856)) + +- Added `LLMSummarizeContextFrame` to trigger on-demand context summarization + from anywhere in the pipeline (e.g. a function call tool). Accepts an + optional `config: LLMContextSummaryConfig` to override summary generation + settings per request. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- Added `LLMContextSummaryConfig` (summary generation params: + `target_context_tokens`, `min_messages_after_summary`, + `summarization_prompt`) and `LLMAutoContextSummarizationConfig` (auto-trigger + thresholds: `max_context_tokens`, `max_unsummarized_messages`, plus a nested + `summary_config`). These replace the monolithic + `LLMContextSummarizationConfig`. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- Added support for the `speed_alpha` parameter to the `arcana` model in + `RimeTTSService`. + (PR [#3873](https://github.com/pipecat-ai/pipecat/pull/3873)) + +- Added `ClientConnectedFrame`, a new `SystemFrame` pushed by all transports + (Daily, LiveKit, FastAPI WebSocket, WebSocket Server, SmallWebRTC, HeyGen, + Tavus) when a client connects. Enables observers to track transport readiness + timing. + (PR [#3881](https://github.com/pipecat-ai/pipecat/pull/3881)) + +- Added `StartupTimingObserver` for measuring how long each processor's + `start()` method takes during pipeline startup. Also measures transport + readiness — the time from `StartFrame` to first client connection — via the + `on_transport_timing_report` event. + (PR [#3881](https://github.com/pipecat-ai/pipecat/pull/3881)) + +- Added `BotConnectedFrame` for SFU transports and `on_transport_timing_report` + event to `StartupTimingObserver` with bot and client connection timing. + (PR [#3881](https://github.com/pipecat-ai/pipecat/pull/3881)) + +- Added optional `direction` parameter to `PipelineTask.queue_frame()` and + `PipelineTask.queue_frames()`, allowing frames to be pushed upstream from the + end of the pipeline. + (PR [#3883](https://github.com/pipecat-ai/pipecat/pull/3883)) + +- Added `on_latency_breakdown` event to `UserBotLatencyObserver` providing + per-service TTFB, text aggregation, user turn duration, and function call + latency metrics for each user-to-bot response cycle. + (PR [#3885](https://github.com/pipecat-ai/pipecat/pull/3885)) + +- Added `on_first_bot_speech_latency` event to `UserBotLatencyObserver` + measuring the time from client connection to first bot speech. An + `on_latency_breakdown` is also emitted for this first speech event. + (PR [#3885](https://github.com/pipecat-ai/pipecat/pull/3885)) + +- Added `broadcast_interruption()` to `FrameProcessor`. This method pushes an + `InterruptionFrame` both upstream and downstream directly from the calling + processor, avoiding the round-trip through the pipeline task that + `push_interruption_task_frame_and_wait()` required. + (PR [#3896](https://github.com/pipecat-ai/pipecat/pull/3896)) + +### Changed + +- Added `text_aggregation_mode` parameter to `TTSService` and all TTS + subclasses with a new `TextAggregationMode` enum (`SENTENCE`, `TOKEN`). All + text now flows through text aggregators regardless of mode, enabling pattern + detection and tag handling in TOKEN mode. + (PR [#3696](https://github.com/pipecat-ai/pipecat/pull/3696)) + +- ⚠️ Refactored runtime-updatable service settings to use strongly-typed + classes (`TTSSettings`, `STTSettings`, `LLMSettings`, and service-specific + subclasses) instead of plain dicts. Each service's `_settings` now holds + these strongly-typed objects. For service maintainers, see changes in + COMMUNITY_INTEGRATIONS.md. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Word timestamp support has been moved from `WordTTSService` into `TTSService` + via a new `supports_word_timestamps` parameter. Services that previously + extended `WordTTSService`, `AudioContextWordTTSService`, or + `WebsocketWordTTSService` now pass `supports_word_timestamps=True` to their + parent `__init__` instead. + (PR [#3786](https://github.com/pipecat-ai/pipecat/pull/3786)) + +- Improved Ultravox TTFB measurement accuracy by using VAD speech end time + instead of `UserStoppedSpeakingFrame` timing. + (PR [#3806](https://github.com/pipecat-ai/pipecat/pull/3806)) + +- Aligned `UltravoxRealtimeLLMService` frame handling with OpenAI/Gemini + realtime services: added `InterruptionFrame` handling with metrics cleanup, + processing metrics at response boundaries, and improved agent transcript + handling for both voice and text output modalities. + (PR [#3806](https://github.com/pipecat-ai/pipecat/pull/3806)) + +- Updated `OpenAIRealtimeLLMService` default model to `gpt-realtime-1.5`. + (PR [#3807](https://github.com/pipecat-ai/pipecat/pull/3807)) + +- Added `api_key` parameter to `KrispVivaSDKManager`, `KrispVivaTurn`, and + `KrispVivaFilter` for Krisp SDK v1.6.1+ licensing. Falls back to + `KRISP_VIVA_API_KEY` environment variable. + (PR [#3809](https://github.com/pipecat-ai/pipecat/pull/3809)) + +- Bumped `nltk` minimum version from 3.9.1 to 3.9.3 to resolve a security + vulnerability. + (PR [#3811](https://github.com/pipecat-ai/pipecat/pull/3811)) + +- `ServiceSettingsUpdateFrame`s are now `UninterruptibleFrame`s. Generally + speaking, you don't want a user interruption to prevent a service setting + change from going into effect. Note that you usually don't use + `ServiceSettingsUpdateFrame` directly, you use one of its subclasses: + - `LLMUpdateSettingsFrame` + - `TTSUpdateSettingsFrame` + - `STTUpdateSettingsFrame` + (PR [#3819](https://github.com/pipecat-ai/pipecat/pull/3819)) + +- Updated context summarization to use `user` role instead of `assistant` for + summary messages. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Rename `AssemblyAISTTService` parameter + `min_end_of_turn_silence_when_confident` parameter to `min_turn_silence` (old + name still supported with deprecation warning) + (PR [#3856](https://github.com/pipecat-ai/pipecat/pull/3856)) + +- ⚠️ Renamed `LLMAssistantAggregatorParams` fields: + `enable_context_summarization` → `enable_auto_context_summarization` and + `context_summarization_config` → `auto_context_summarization_config` (now + accepts `LLMAutoContextSummarizationConfig`). The old names still work with a + `DeprecationWarning` for one release cycle. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- `ElevenLabsRealtimeSTTService` now sets `TranscriptionFrame.finalized` to + `True` when using `CommitStrategy.MANUAL`. + (PR [#3865](https://github.com/pipecat-ai/pipecat/pull/3865)) + +- Updated numba version pin from == to >=0.61.2 + (PR [#3868](https://github.com/pipecat-ai/pipecat/pull/3868)) + +- Updated tracing code to use `ServiceSettings` dataclass API + (`given_fields()`, attribute access) instead of dict-style access + (`.items()`, `in`, subscript). + (PR [#3879](https://github.com/pipecat-ai/pipecat/pull/3879)) + +- ⚠️ Removed `event` field and `complete()` method from `InterruptionFrame`. + Removed `event` field from `InterruptionTaskFrame`. These are no longer + needed since `broadcast_interruption()` does not require a round-trip + completion signal. + (PR [#3896](https://github.com/pipecat-ai/pipecat/pull/3896)) + +- Moved `pipecat.services.deepgram.stt_sagemaker` and + `pipecat.services.deepgram.tts_sagemaker` to + `pipecat.services.deepgram.sagemaker.stt` and + `pipecat.services.deepgram.sagemaker.tts`. The old import paths still work + but emit a `DeprecationWarning`. + (PR [#3902](https://github.com/pipecat-ai/pipecat/pull/3902)) + +### Deprecated + +- ⚠️ Deprecated `aggregate_sentences` parameter on `TTSService` and all TTS + subclasses. Use `text_aggregation_mode=TextAggregationMode.SENTENCE` or + `text_aggregation_mode=TextAggregationMode.TOKEN` instead. + (PR [#3696](https://github.com/pipecat-ai/pipecat/pull/3696)) + +- Deprecated `set_model()`, `set_voice()`, and `set_language()` on AI services + in favor of runtime updates via `TTSUpdateSettingsFrame`, + `STTUpdateSettingsFrame`, and `LLMUpdateSettingsFrame`. + + ⚠️ Note, too, a subtle behavior change in these deprecated methods. Whereas + previously only `set_language()` caused the service to actually react to the + update (e.g. by reconnecting to a remote service so it an pick up the + change), now all these methods do. This change was made as part of a refactor + making them all work the same way under the hood. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Dict-based `*UpdateSettingsFrame(settings={...})` is deprecated in favor of + passing typed settings delta objects with + `*UpdateSettingsFrame(delta={...})`. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Deprecated `WordTTSService`, `WebsocketWordTTSService`, + `AudioContextWordTTSService`, and `InterruptibleWordTTSService`. Use their + non-word counterparts with `supports_word_timestamps=True` instead: + - `WordTTSService` → `TTSService(supports_word_timestamps=True)` + - `WebsocketWordTTSService` → + `WebsocketTTSService(supports_word_timestamps=True)` + - `AudioContextWordTTSService` → + `AudioContextTTSService(supports_word_timestamps=True)` + - `InterruptibleWordTTSService` → + `InterruptibleTTSService(supports_word_timestamps=True)` + (PR [#3786](https://github.com/pipecat-ai/pipecat/pull/3786)) + +- Deprecated `SmartTurnMetricsData` in favor of `TurnMetricsData`. + `BaseSmartTurn` now emits `TurnMetricsData` directly. + (PR [#3809](https://github.com/pipecat-ai/pipecat/pull/3809)) + +- Deprecated `LLMContextSummarizationConfig`. Use + `LLMAutoContextSummarizationConfig` with a nested `LLMContextSummaryConfig` + instead. The old class emits a `DeprecationWarning`. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- Deprecated `push_interruption_task_frame_and_wait()` in `FrameProcessor`. Use + `broadcast_interruption()` instead. The old method now delegates to + `broadcast_interruption()` and logs a deprecation warning. + (PR [#3896](https://github.com/pipecat-ai/pipecat/pull/3896)) + +### Removed + +- Removed `local-smart-turn-v3` optional extra from `pyproject.toml`. The + `transformers` and `onnxruntime` packages are now always installed as core + dependencies since they are required by the default turn stop strategy, + `TurnAnalyzerUserTurnStopStrategy` which uses `LocalSmartTurnAnalyzerV3`. + (PR [#3803](https://github.com/pipecat-ai/pipecat/pull/3803)) + +- ⚠️ Removed `PlayHTTTSService` and `PlayHTHttpTTSService`. PlayHT has been + shut down and is no longer available. + (PR [#3838](https://github.com/pipecat-ai/pipecat/pull/3838)) + +### Fixed + +- Added `LLMSpecificMessage` handling in `LLMContextSummarizationUtil` to skip + provider-specific messages during context summarization. + (PR [#3794](https://github.com/pipecat-ai/pipecat/pull/3794)) + +- Treated `response_cancel_not_active` as a non-fatal error in realtime + services (`OpenAIRealtimeLLMService`, `GrokRealtimeLLMService`, + `OpenAIRealtimeBetaLLMService`) to prevent WebSocket disconnection when + cancelling an inactive response. + (PR [#3795](https://github.com/pipecat-ai/pipecat/pull/3795)) + +- Fixed Poetry compatibility by inlining `local-smart-turn-v3` dependencies + (`transformers`, `onnxruntime`) into core dependencies instead of using a + self-referential extra. + (PR [#3803](https://github.com/pipecat-ai/pipecat/pull/3803)) + +- Fixed `SentryMetrics` method signatures to match updated + `FrameProcessorMetrics` base class, resolving `TypeError` when using + `start_time`/`end_time` keyword arguments. + (PR [#3808](https://github.com/pipecat-ai/pipecat/pull/3808)) + +- Fixed STT TTFB metrics not being reported for `SonioxSTTService` and + `AWSTranscribeSTTService` due to missing `can_generate_metrics()` override. + (PR [#3813](https://github.com/pipecat-ai/pipecat/pull/3813)) + +- Fixed an issue where `AudioContextTTSService`-based providers (AsyncAI, + ElevenLabs, Inworld, Rime) did not close or clean up their server-side audio + contexts after normal speech completion, only on interruption. + (PR [#3814](https://github.com/pipecat-ai/pipecat/pull/3814)) + +- Fixed STT TTFB metrics measuring timeout expiry time instead of actual + transcript arrival time. + (PR [#3822](https://github.com/pipecat-ai/pipecat/pull/3822)) + +- Fixed `InterimTranscriptionFrame` and `TranslationFrame` being + unintentionally pushed downstream in `LLMUserAggregator`. They are now + consumed like `TranscriptionFrame`. + (PR [#3825](https://github.com/pipecat-ai/pipecat/pull/3825)) + +- Fixed misleading "Empty audio frame received for STT service" warnings when + using audio filters (e.g. `RNNoiseFilter`, `KrispVivaFilter`, `AICFilter`) + that buffer audio internally. + (PR [#3828](https://github.com/pipecat-ai/pipecat/pull/3828)) + +- Fixed issues with `RimeNonJsonTTSService` where trailing punctuation is + sometimes vocalized + (PR [#3837](https://github.com/pipecat-ai/pipecat/pull/3837)) + +- Fixed `TTSSpeakFrame` not committing spoken text to the conversation context + when used outside of an LLM response (e.g., bot greetings or injected + speech). + (PR [#3845](https://github.com/pipecat-ai/pipecat/pull/3845)) + +- Removed verbose per-chunk audio logging from `GenesysAudioHookSerializer` + that flooded production logs. + (PR [#3850](https://github.com/pipecat-ai/pipecat/pull/3850)) + +- Add beta feature warning when using custom prompts with AssemblyAI + (PR [#3856](https://github.com/pipecat-ai/pipecat/pull/3856)) + +- Fixed `LocalSmartTurnAnalyzerV3` producing incorrect end-of-turn predictions + at non-16kHz sample rates (e.g. 8kHz Twilio telephony) by adding automatic + resampling to 16kHz before Whisper feature extraction. + (PR [#3857](https://github.com/pipecat-ai/pipecat/pull/3857)) + +- Fixed `PipelineTask` double-inserting `RTVIProcessor` into the frame chain + when the user provides both an `RTVIProcessor` in the pipeline and a custom + `RTVIObserver` subclass in observers. + (PR [#3867](https://github.com/pipecat-ai/pipecat/pull/3867)) + +- Fixed turn completion instructions being lost when `LLMMessagesUpdateFrame` + replaces the LLM context. When `filter_incomplete_user_turns` is enabled, the + turn completion system message is now re-injected after context replacement. + (PR [#3888](https://github.com/pipecat-ai/pipecat/pull/3888)) + +- Fixed Azure TTS and STT services silently swallowing cancellation errors + (invalid API key, network failures, rate limiting) instead of propagating + them as `ErrorFrame`s to the pipeline. + (PR [#3893](https://github.com/pipecat-ai/pipecat/pull/3893)) + +### Performance + +- Switched `GradiumTTSService` from `InterruptibleWordTTSService` to + `AudioContextWordTTSService`, eliminating websocket disconnect/reconnect on + every interruption by using `client_req_id`-based multiplexing. + (PR [#3759](https://github.com/pipecat-ai/pipecat/pull/3759)) + +### Other + +- Standardized Sarvam STT/TTS User-Agent header handling to consistently send + Pipecat SDK identity in websocket requests. + (PR [#3886](https://github.com/pipecat-ai/pipecat/pull/3886)) + +## [0.0.103] - 2026-02-20 + +### Added + +- 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 + +- Added `ResembleAITTSService` for text-to-speech using Resemble AI's streaming + WebSocket API with word-level timestamps and jitter buffering for smooth + audio playback. + (PR [#3134](https://github.com/pipecat-ai/pipecat/pull/3134)) + +- Added `UserBotLatencyObserver` for tracking user-to-bot response latency. + When tracing is enabled, latency measurements are automatically recorded as + `turn.user_bot_latency_seconds` attributes on OpenTelemetry turn spans. + (PR [#3355](https://github.com/pipecat-ai/pipecat/pull/3355)) + +- Added `append_to_context` parameter to `TTSSpeakFrame` for conditional LLM + context addition. + - Allows fine-grained control over whether text should be added to + conversation context + - Defaults to `True` to maintain backward compatibility + (PR [#3584](https://github.com/pipecat-ai/pipecat/pull/3584)) + +- Added TTS context tracking system with `context_id` field to trace audio + generation through the pipeline. + - `TTSAudioRawFrame`, `TTSStartedFrame`, `TTSStoppedFrame` now include + `context_id` + - `AggregatedTextFrame` and `TTSTextFrame` now include `context_id` + - Enables tracking which TTS request generated specific audio chunks + (PR [#3584](https://github.com/pipecat-ai/pipecat/pull/3584)) + +- Added support for Inworld TTS Websocket Auto Mode for improved latency + (PR [#3593](https://github.com/pipecat-ai/pipecat/pull/3593)) + +- Added new frames for context summarization: `LLMContextSummaryRequestFrame` + and `LLMContextSummaryResultFrame`. + (PR [#3621](https://github.com/pipecat-ai/pipecat/pull/3621)) + +- Added context summarization feature to automatically compress conversation + history when conversation length limits (by token or message count) are + reached, enabling efficient long-running conversations. + - Configure via `enable_context_summarization=True` in + `LLMAssistantAggregatorParams` + - Customize behavior with `LLMContextSummarizationConfig` (max tokens, + thresholds, etc.) + - Automatically preserves incomplete function call sequences during + summarization + - See new examples: + `examples/foundational/54-context-summarization-openai.py` and + `examples/foundational/54a-context-summarization-google.py` + (PR [#3621](https://github.com/pipecat-ai/pipecat/pull/3621)) + +- Added RTVI function call lifecycle events (`llm-function-call-started`, + `llm-function-call-in-progress`, `llm-function-call-stopped`) with + configurable security levels via + `RTVIObserverParams.function_call_report_level`. Supports per-function + control over what information is exposed (`DISABLED`, `NONE`, `NAME`, or + `FULL`). + (PR [#3630](https://github.com/pipecat-ai/pipecat/pull/3630)) + +- Added `RequestMetadataFrame` and metadata handling for `ServiceSwitcher` to + ensure STT services correctly emit `STTMetadataFrame` when switching between + services. Only the active service's metadata is propagated downstream, + switching services triggers the newly active service to re-emit its metadata, + and proper frame ordering is maintained at startup. + (PR [#3637](https://github.com/pipecat-ai/pipecat/pull/3637)) + +- Added `STTMetadataFrame` to broadcast STT service latency information at + pipeline start. + - STT services broadcast P99 time-to-final-segment (`ttfs_p99_latency`) to + downstream processors + - Turn stop strategies automatically configure their STT timeout from this + metadata + - Developers can override `ttfs_p99_latency` via constructor argument for + custom deployments + - Added measured P99 values for STT providers. + - See [stt-benchmark](https://github.com/pipecat-ai/stt-benchmark) to + measure latency for your configuration + (PR [#3637](https://github.com/pipecat-ai/pipecat/pull/3637)) + +- Added support for `is_sandbox` parameter in `LiveAvatarNewSessionRequest` to + enable sandbox mode for HeyGen LiveAvatar sessions. + (PR [#3653](https://github.com/pipecat-ai/pipecat/pull/3653)) + +- Added support for `video_settings` parameter in `LiveAvatarNewSessionRequest` + to configure video encoding (H264/VP8) and quality levels. + (PR [#3653](https://github.com/pipecat-ai/pipecat/pull/3653)) + +- Added `OpenAIRealtimeSTTService` for real-time streaming speech-to-text using + OpenAI's Realtime API WebSocket transcription sessions. Supports local VAD + and server-side VAD modes, noise reduction, and automatic reconnection. + (PR [#3656](https://github.com/pipecat-ai/pipecat/pull/3656)) + +- Added `bulbul:v3-beta` TTS model support for Sarvam AI with temperature + control and 25 new speaker voices. + (PR [#3671](https://github.com/pipecat-ai/pipecat/pull/3671)) + +- Added `saaras:v3` STT model support for Sarvam AI with new `mode` parameter + (transcribe, translate, verbatim, translit, codemix) and prompt support. + (PR [#3671](https://github.com/pipecat-ai/pipecat/pull/3671)) + +- Added new OpenAI TTS voice options `marin` and `cedar`. + (PR [#3682](https://github.com/pipecat-ai/pipecat/pull/3682)) + +- Added `UserMuteStartedFrame` and `UserMuteStoppedFrame` system frames, and + corresponding `user-mute-started` / `user-mute-stopped` RTVI messages, so + clients can observe when mute strategies activate or deactivate. + (PR [#3687](https://github.com/pipecat-ai/pipecat/pull/3687)) + +### Changed + +- Updated all 30+ TTS service implementations to support context tracking with + `context_id`. + - Services now generate and propagate context IDs through TTS frames + - Enables end-to-end tracing of TTS requests through the pipeline + (PR [#3584](https://github.com/pipecat-ai/pipecat/pull/3584)) + +- ⚠️ `TTSService.run_tts()` now requires a `context_id` parameter for context + tracking. + - Custom TTS service implementations must update their `run_tts()` + signature + - Before: `async def run_tts(self, text: str) -> AsyncGenerator[Frame, + None]:` + - After: `async def run_tts(self, text: str, context_id: str) -> + AsyncGenerator[Frame, None]:` + (PR [#3584](https://github.com/pipecat-ai/pipecat/pull/3584)) + +- Simplified context aggregators to use `frame.append_to_context` flag instead + of tracking internal state. + - Cleaner logic in `LLMResponseAggregator` and + `LLMResponseUniversalAggregator` + - More consistent behavior across aggregator implementations + (PR [#3584](https://github.com/pipecat-ai/pipecat/pull/3584)) + +- Updated timestamps to be cumulative within an agent turn, using + flushCompleted message as an indication of when timestamps from the server + are reset to 0 + (PR [#3593](https://github.com/pipecat-ai/pipecat/pull/3593)) + +- Changed `KokoroTTSService` to use `kokoro-onnx` instead of `kokoro` as the + underlying TTS engine. + (PR [#3612](https://github.com/pipecat-ai/pipecat/pull/3612)) + +- Improved user turn stop timing in `TranscriptionUserTurnStopStrategy` and + `TurnAnalyzerUserTurnStopStrategy`. + - Timeout now starts on `VADUserStoppedSpeakingFrame` for tighter, more + predictable timing + - Added support for finalized transcripts + (`TranscriptionFrame.finalized=True`) to trigger earlier + - Added fallback timeout for edge cases where transcripts arrive without + VAD events + - Removed `InterimTranscriptionFrame` handling (no longer affects timing) + (PR [#3637](https://github.com/pipecat-ai/pipecat/pull/3637)) + +- Improved the accuracy of the `UserBotLatencyObserver` and + `UserBotLatencyLogObserver` by measuring from the time when the user actually + starts speaking. + (PR [#3637](https://github.com/pipecat-ai/pipecat/pull/3637)) + +- ⚠️ Renamed `timeout` parameter to `user_speech_timeout` in + `TranscriptionUserTurnStopStrategy`. + (PR [#3637](https://github.com/pipecat-ai/pipecat/pull/3637)) + +- Updated the `VADUserStartedSpeakingFrame` to include `start_secs` and + `timestamp` and `VADUserStoppedSpeakingFrame` to include `stop_secs` and + `timestamp`, removing the need to separately handle the + `SpeechControlParamsFrame` for VADParams values. + (PR [#3637](https://github.com/pipecat-ai/pipecat/pull/3637)) + +- ⚠️ Renamed `TranscriptionUserTurnStopStrategy` to + `SpeechTimeoutUserTurnStopStrategy`. The old name is deprecated and will be + removed in a future release. + (PR [#3637](https://github.com/pipecat-ai/pipecat/pull/3637)) + +- `AssemblyAISTTService` now automatically configures optimal settings for + manual turn detection when `vad_force_turn_endpoint=True`. This sets + `end_of_turn_confidence_threshold=1.0` and `max_turn_silence=2000` by + default, which disables model-based turn detection and reduces latency by + relying on external VAD for turn endpoints. Warnings are logged if + conflicting settings are detected. + (PR [#3644](https://github.com/pipecat-ai/pipecat/pull/3644)) + +- Upgraded the `pipecat-ai-small-webrtc-prebuilt` package to v2.1.0. + (PR [#3652](https://github.com/pipecat-ai/pipecat/pull/3652)) + +- Changed default session mode from "CUSTOM" to "LITE" in HeyGen LiveAvatar + integration, with VP8 as the default video encoding. + (PR [#3653](https://github.com/pipecat-ai/pipecat/pull/3653)) + +- ⚠️ The default `VADParams` `stop_secs` default is changing from `0.8` seconds + to `0.2` seconds. This change both simplifies the developer experience and + improves the performance of STT services. With a shorter `stop_secs` value, + STT services using a local VAD can finalize sooner, resulting in faster + transcription. + - `SpeechTimeoutUserTurnStopStrategy`: control how long to wait for + additional user speech using `user_speech_timeout` (default: 0.6 sec). + - `TurnAnalyzerUserTurnStopStrategy`: the turn analyzer automatically + adjusts the user wait time based on the audio input. + (PR [#3659](https://github.com/pipecat-ai/pipecat/pull/3659)) + +- Moved interruption wait event from per-processor instance state to + `InterruptionFrame` itself. Added `InterruptionFrame.complete()` to signal + when the interruption has fully traversed the pipeline. Custom processors + that block or consume an `InterruptionFrame` before it reaches the pipeline + sink must call `frame.complete()` to avoid stalling + `push_interruption_task_frame_and_wait()`. A warning is logged if completion + does not happen within 2 seconds. + (PR [#3660](https://github.com/pipecat-ai/pipecat/pull/3660)) + +- Update the default model to `scribe_v2` for `ElevenLabsSTTService`. + (PR [#3664](https://github.com/pipecat-ai/pipecat/pull/3664)) + +- Changed the `DeepgramSTTService` default setting for `smart_format` to + `False`, as agents don't need smart formatting. Disabling this setting + provides a small performance improvement, as well. + (PR [#3666](https://github.com/pipecat-ai/pipecat/pull/3666)) + +- Changed `FunctionCallCancelFrame` to broadcast in both directions for + consistency with other function call frames. + (PR [#3672](https://github.com/pipecat-ai/pipecat/pull/3672)) + +- Changed default user turn stop strategy from + `TranscriptionUserTurnStopStrategy` to `TurnAnalyzerUserTurnStopStrategy` + with `LocalSmartTurnAnalyzerV3`. + (PR [#3689](https://github.com/pipecat-ai/pipecat/pull/3689)) + +- Renamed `RequestMetadataFrame` to `ServiceSwitcherRequestMetadataFrame` and + added a `service` field to target a specific service. The frame is now pushed + downstream by services after handling instead of being silently consumed. + (PR [#3692](https://github.com/pipecat-ai/pipecat/pull/3692)) + +- Update `SonioxSTTService` to set `vad_force_turn_endpoint` to `True`. This + setting disabled the turn detection logic available natively in Soniox. + Instead, Soniox relies on a local VAD to finalize the transcript. This + configuration meaningfully reduces the time to final segment for Soniox. With + this setting enabled, Soniox outputs a transcript in ~250ms (median). Pipecat + enables smart-turn detection by default using the `LocalSmartTurnAnalyzerV3`. + To use the native turn detection logic in Soniox, just set + `vad_force_turn_endpoint` to `False`. + (PR [#3697](https://github.com/pipecat-ai/pipecat/pull/3697)) + +- Update `SonioxSTTService` default model to `stt-rt-v4`. + (PR [#3697](https://github.com/pipecat-ai/pipecat/pull/3697)) + +- Updated the default model to `async_flash_v1.0` and base URL to + `https://api.async.com` for `AsyncAITTSService`. + (PR [#3701](https://github.com/pipecat-ai/pipecat/pull/3701)) + +### Deprecated + +- Deprecated `UserBotLatencyLogObserver`. Use `UserBotLatencyObserver` directly + with its `on_latency_measured` event handler instead. + (PR [#3355](https://github.com/pipecat-ai/pipecat/pull/3355)) + +- Deprecated `RTVILLMFunctionCallMessage`, `RTVILLMFunctionCallMessageData`, + and `RTVIProcessor.handle_function_call()`. Use the new + `llm-function-call-in-progress` event sent automatically by `RTVIObserver` + instead. + (PR [#3630](https://github.com/pipecat-ai/pipecat/pull/3630)) + +### Removed + +- ⚠️ Removed `timeout` parameter from `TurnAnalyzerUserTurnStopStrategy`. The + timeout is now managed internally based on STT latency. + (PR [#3637](https://github.com/pipecat-ai/pipecat/pull/3637)) + +### Fixed + +- Fixed pipeline freeze when `InterruptionFrame` discards `EndFrame` or + `StopFrame` by making terminal frames uninterruptible. + (PR [#3542](https://github.com/pipecat-ai/pipecat/pull/3542)) + +- Fixed OpenAI LLM stream not being closed on cancellation/exception, which + could leak sockets. + (PR [#3589](https://github.com/pipecat-ai/pipecat/pull/3589)) + +- Fixed `PipelineTask` adding duplicate `RTVIProcessor` and `RTVIObserver` when + they were already provided in the pipeline or observers list. They are now + detected and skipped, with appropriate warnings and errors logged for + mismatched configurations. + (PR [#3610](https://github.com/pipecat-ai/pipecat/pull/3610)) + +- Fixed function call timeout task not being cancelled when the handler + completes without calling `result_callback` or is cancelled externally, which + caused `RuntimeWarning: coroutine was never awaited`. + (PR [#3616](https://github.com/pipecat-ai/pipecat/pull/3616)) + +- Fixed sentence splitting for Japanese, Chinese, Korean, and other non-Latin + languages in TTS pipeline. NLTK's sentence tokenizer does not support CJK + languages, causing text to accumulate until flush instead of being split at + sentence boundaries. Added fallback detection for unambiguous non-Latin + sentence-ending punctuation (e.g., `。`, `?`, `!`). + (PR [#3617](https://github.com/pipecat-ai/pipecat/pull/3617)) + +- Fixed `PipelineTask` to also call `set_bot_ready()` when an external + `RTVIProcessor` is provided. + (PR [#3623](https://github.com/pipecat-ai/pipecat/pull/3623)) + +- Fixed `VADController` not broadcasting `SpeechControlParamsFrame` on startup, + which prevented STT services from receiving VAD params needed for TTFB + measurement. + (PR [#3628](https://github.com/pipecat-ai/pipecat/pull/3628)) + +- Fixed `StopAsyncIteration` exceptions in `parse_telephony_websocket()` when + WebSocket connections close before sending expected messages. + (PR [#3629](https://github.com/pipecat-ai/pipecat/pull/3629)) + +- Fixed WebSocket transport error when broadcasting + `InputTransportMessageFrame` by correctly instantiating the frame with its + message parameter. + (PR [#3635](https://github.com/pipecat-ai/pipecat/pull/3635)) + +- Fixed orphan OpenTelemetry spans during flow initialization and transitions + in tracing. + (PR [#3649](https://github.com/pipecat-ai/pipecat/pull/3649)) + +- Fixed `SambaNovaLLMService` and `GoogleLLMOpenAIBetaService` streams not + being closed on cancellation/exception, which could leak sockets. + (PR [#3663](https://github.com/pipecat-ai/pipecat/pull/3663)) + +- Fixed an issue in `InworldTTSService` where punctuation was pronounced. Now, + the `InworldTTSService` ensures proper spacing between sentences, resolving + pronunciation issues. + (PR [#3667](https://github.com/pipecat-ai/pipecat/pull/3667)) + +- Fixed `ParallelPipeline` allowing frames pushed by internal processors to + escape during lifecycle frame (`StartFrame`/`EndFrame`/`CancelFrame`) + synchronization. These frames are now buffered and flushed after all branches + complete. + (PR [#3668](https://github.com/pipecat-ai/pipecat/pull/3668)) + +- Fixed issues in Sarvam STT and TTS services: missing event handler + registration for VAD signals, `Optional[bool]` type annotations, WebSocket + state cleanup on API errors, and TTS disconnect/reconnection state + management. + (PR [#3671](https://github.com/pipecat-ai/pipecat/pull/3671)) + +- Fixed `RTVIObserver` sending duplicate client messages for frames that are + broadcast in both directions (e.g. `UserStartedSpeakingFrame`, + `FunctionCallResultFrame`). + (PR [#3672](https://github.com/pipecat-ai/pipecat/pull/3672)) + +- Fixed WebSocket STT services (ElevenLabs, Cartesia, Gladia, Soniox) + disconnecting due to idle timeout when no audio is being sent (e.g. when + inactive behind a `ServiceSwitcher`). `WebsocketSTTService` now provides + opt-in silence-based keepalive via `keepalive_timeout` and + `keepalive_interval` parameters. + (PR [#3675](https://github.com/pipecat-ai/pipecat/pull/3675)) + +## [0.0.101] - 2026-01-30 + +### Added + +- Additions for `AICFilter` and `AICVADAnalyzer`: + - Added model downloading support to `AICFilter` with `model_id` and + `model_download_dir` parameters. + - Added `model_path` parameter to `AICFilter` for loading local `.aicmodel` + files. + - Added unit tests for `AICFilter` and `AICVADAnalyzer`. + (PR [#3408](https://github.com/pipecat-ai/pipecat/pull/3408)) + +- Added handling for `server_content.interrupted` signal in the Gemini Live + service for faster interruption response in the case where there isn't + already turn tracking in the pipeline, e.g. local VAD + context aggregators. + When there is already turn tracking in the pipeline, the additional + interruption does no harm. + (PR [#3429](https://github.com/pipecat-ai/pipecat/pull/3429)) + +- Added new `GenesysFrameSerializer` for the Genesys AudioHook WebSocket + protocol, enabling bidirectional audio streaming between Pipecat pipelines + and Genesys Cloud contact center. + (PR [#3500](https://github.com/pipecat-ai/pipecat/pull/3500)) + +- Added `reached_upstream_types` and `reached_downstream_types` read-only + properties to `PipelineTask` for inspecting current frame filters. + (PR [#3510](https://github.com/pipecat-ai/pipecat/pull/3510)) + +- Added `add_reached_upstream_filter()` and `add_reached_downstream_filter()` + methods to `PipelineTask` for appending frame types. + (PR [#3510](https://github.com/pipecat-ai/pipecat/pull/3510)) + +- Added `UserTurnCompletionLLMServiceMixin` for LLM services to detect and + filter incomplete user turns. When enabled via `filter_incomplete_user_turns` + in `LLMUserAggregatorParams`, the LLM outputs a turn completion marker at the + start of each response: ✓ (complete), ○ (incomplete short), or ◐ (incomplete + long). Incomplete turns are suppressed, and configurable timeouts + automatically re-prompt the user. + (PR [#3518](https://github.com/pipecat-ai/pipecat/pull/3518)) + +- Added `FrameProcessor.broadcast_frame_instance(frame)` method to broadcast a + frame instance by extracting its fields and creating new instances for each + direction. + (PR [#3519](https://github.com/pipecat-ai/pipecat/pull/3519)) + +- `PipelineTask` now automatically adds `RTVIProcessor` and registers + `RTVIObserver` when `enable_rtvi=True` (default), simplifying pipeline setup. + (PR [#3519](https://github.com/pipecat-ai/pipecat/pull/3519)) + +- Added `RTVIProcessor.create_rtvi_observer()` factory method for creating RTVI + observers. + (PR [#3519](https://github.com/pipecat-ai/pipecat/pull/3519)) + +- Added `video_out_codec` parameter to `TransportParams` allowing configuration + of the preferred video codec (e.g., `"VP8"`, `"H264"`, `"H265"`) for video + output in `DailyTransport`. + (PR [#3520](https://github.com/pipecat-ai/pipecat/pull/3520)) + +- Added `location` parameter to Google TTS services (`GoogleHttpTTSService`, + `GoogleTTSService`, `GeminiTTSService`) for regional endpoint support. + (PR [#3523](https://github.com/pipecat-ai/pipecat/pull/3523)) + +- Added new `PIPECAT_SMART_TURN_LOG_DATA` environment variable, which causes + Smart Turn input data to be saved to disk + (PR [#3525](https://github.com/pipecat-ai/pipecat/pull/3525)) + +- Added `result_callback` parameter to `UserImageRequestFrame` to support + deferred function call results. + (PR [#3571](https://github.com/pipecat-ai/pipecat/pull/3571)) + +- Added `function_call_timeout_secs` parameter to `LLMService` to configure + timeout for deferred function calls (defaults to 10.0 seconds). + (PR [#3571](https://github.com/pipecat-ai/pipecat/pull/3571)) + +- Added `vad_analyzer` parameter to `LLMUserAggregatorParams`. VAD analysis is + now handled inside the `LLMUserAggregator` rather than in the transport, + keeping voice activity detection closer to where it is consumed. The + `vad_analyzer` on `BaseInputTransport` is now deprecated. + + ```python + context_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + vad_analyzer=SileroVADAnalyzer(), + ), + ) + ``` + (PR [#3583](https://github.com/pipecat-ai/pipecat/pull/3583)) + +- Added `VADProcessor` for detecting speech in audio streams within a pipeline. + Pushes `VADUserStartedSpeakingFrame`, `VADUserStoppedSpeakingFrame`, and + `UserSpeakingFrame` downstream based on VAD state changes. + (PR [#3583](https://github.com/pipecat-ai/pipecat/pull/3583)) + +- Added `VADController` for managing voice activity detection state and + emitting speech events independently of transport or pipeline processors. + (PR [#3583](https://github.com/pipecat-ai/pipecat/pull/3583)) + +- Added local `PiperTTSService` for offline text-to-speech using Piper voice + models. The existing HTTP-based service has been renamed to + `PiperHttpTTSService`. + (PR [#3585](https://github.com/pipecat-ai/pipecat/pull/3585)) + +- `main()` in `pipecat.runner.run` now accepts an optional + `argparse.ArgumentParser`, allowing bots to define custom CLI arguments + accessible via `runner_args.cli_args`. + (PR [#3590](https://github.com/pipecat-ai/pipecat/pull/3590)) + +- Added `KokoroTTSService` for local text-to-speech synthesis using the + Kokoro-82M model. + (PR [#3595](https://github.com/pipecat-ai/pipecat/pull/3595)) + +### Changed + +- Updated `AICFilter` and `AICVADAnalyzer` to use aic-sdk ~= 2.0.1. + (PR [#3408](https://github.com/pipecat-ai/pipecat/pull/3408)) + +- Improved the STT TTFB (Time To First Byte) measurement, reporting the delay + between when the user stops speaking and when the final transcription is + received. Note: Unlike traditional TTFB which measures from a discrete + request, STT services receive continuous audio input—so we measure from + speech end to final transcript, which captures the latency that matters for + voice AI applications. In support of this change, added `finalized` field to + `TranscriptionFrame` to indicate when a transcript is the final result for an + utterance. + (PR [#3495](https://github.com/pipecat-ai/pipecat/pull/3495)) + +- `SarvamSTTService` now defaults `vad_signals` and `high_vad_sensitivity` to + `None` (omitted from connection parameters), improving latency by ~300ms + compared to the previous defaults. + (PR [#3495](https://github.com/pipecat-ai/pipecat/pull/3495)) + +- Changed frame filter storage from tuples to sets in `PipelineTask`. + (PR [#3510](https://github.com/pipecat-ai/pipecat/pull/3510)) + +- Changed default Inworld TTS model from `inworld-tts-1` to + `inworld-tts-1.5-max`. + (PR [#3531](https://github.com/pipecat-ai/pipecat/pull/3531)) + +- `FrameSerializer` now subclasses from `BaseObject` to enable event support. + (PR [#3560](https://github.com/pipecat-ai/pipecat/pull/3560)) + +- Added support for TTFS in `SpeechmaticsSTTService` and set the default mode + to `EXTERNAL` to support Pipecat-controlled VAD. + - Changed dependency to `speechmatics-voice[smart]>=0.2.8` + (PR [#3562](https://github.com/pipecat-ai/pipecat/pull/3562)) + +- ⚠️ Changed function call handling to use timeout-based completion instead of + immediate callback execution. + - Function calls that defer their results (e.g., `UserImageRequestFrame`) + now use a timeout mechanism + - The `result_callback` is invoked automatically when the deferred + operation completes or after timeout + - This change affects examples using `UserImageRequestFrame` - the + `result_callback` should now be passed to the frame instead of being called + immediately + (PR [#3571](https://github.com/pipecat-ai/pipecat/pull/3571)) + +- Pipecat runner now uses `DAILY_ROOM_URL` instead of `DAILY_SAMPLE_ROOM_URL`. + (PR [#3582](https://github.com/pipecat-ai/pipecat/pull/3582)) + +- Updates to `GradiumSTTService`: + - Now flushes pending transcriptions when VAD detects the user stopped + speaking, improving response latency. + - `GradiumSTTService` now supports `InputParams` for configuring `language` + and `delay_in_frames` settings. + (PR [#3587](https://github.com/pipecat-ai/pipecat/pull/3587)) + +### Deprecated + +- ⚠️ Deprecated `vad_analyzer` parameter on `BaseInputTransport`. Pass + `vad_analyzer` to `LLMUserAggregatorParams` instead or use `VADProcessor` in + the pipeline. + (PR [#3583](https://github.com/pipecat-ai/pipecat/pull/3583)) + +### Removed + +- Removed deprecated `AICFilter` parameters: `enhancement_level`, `voice_gain`, + `noise_gate_enable`. + (PR [#3408](https://github.com/pipecat-ai/pipecat/pull/3408)) + +### Fixed + +- Fixed an issue where if you were using `OpenRouterLLMService` with a Gemini + model, it wouldn't handle multiple `"system"` messages as expected (and as we + do in `GoogleLLMService`), which is to convert subsequent ones into `"user"` + messages. Instead, the latest `"system"` message would overwrite the previous + ones. + (PR [#3406](https://github.com/pipecat-ai/pipecat/pull/3406)) + +- Transports now properly broadcast `InputTransportMessageFrame` frames both + upstream and downstream instead of only pushing downstream. + (PR [#3519](https://github.com/pipecat-ai/pipecat/pull/3519)) + +- Fixed `FrameProcessor.broadcast_frame()` to deep copy kwargs, preventing + shared mutable references between the downstream and upstream frame + instances. + (PR [#3519](https://github.com/pipecat-ai/pipecat/pull/3519)) + +- Fixed OpenAI LLM services to emit `ErrorFrame` on completion timeout, + enabling proper error handling and LLMSwitcher failover. + (PR [#3529](https://github.com/pipecat-ai/pipecat/pull/3529)) + +- Fixed a logging issue where non-ASCII characters (e.g., Japanese, Chinese, + etc.) were being unnecessarily escaped to Unicode sequences when function + call occurred. + (PR [#3536](https://github.com/pipecat-ai/pipecat/pull/3536)) + +- Fixed how audio tracks are synchronized inside the `AudioBufferProcessor` to + fix timing issues where silence and audio were misaligned between user and + bot buffers. + (PR [#3541](https://github.com/pipecat-ai/pipecat/pull/3541)) + +- Fixed race condition in `OpenAIRealtimeBetaLLMService` that could cause an + error when truncating the conversation. + (PR [#3567](https://github.com/pipecat-ai/pipecat/pull/3567)) + +- Fixed an infinite loop in `WebsocketService` that blocked the event loop when + a remote server closed the connection gracefully. + (PR [#3574](https://github.com/pipecat-ai/pipecat/pull/3574)) + +- Fixed `LLMUserAggregator` and `LLMAssistantAggregator` not emitting pending + transcripts via `on_user_turn_stopped` and `on_assistant_turn_stopped` events + when the conversation ends (`EndFrame`) or is cancelled (`CancelFrame`). + (PR [#3575](https://github.com/pipecat-ai/pipecat/pull/3575)) + +- Added missing `LiveKitRunnerArguments` and `LiveKitTransport` support in + runner utilities to enable LiveKit transport configuration. + (PR [#3580](https://github.com/pipecat-ai/pipecat/pull/3580)) + +- Fixed race condition in `OpenAIRealtimeLLMService` that could cause an error + when truncating the conversation. + (PR [#3581](https://github.com/pipecat-ai/pipecat/pull/3581)) + +- Fixed `PiperHttpTTSService` (olf `PiperTTSService`) to resample audio output + based on the model's sample rate parsed from the WAV header. + (PR [#3585](https://github.com/pipecat-ai/pipecat/pull/3585)) + +- Fixed `UserTurnController` to reset user turn timeout when interim + transcriptions are received. + (PR [#3594](https://github.com/pipecat-ai/pipecat/pull/3594)) + +- Fixed an issue in the `IVRNavigator` where the `TextFrame`s pushed had + incorrect spacing. Now, the internal `IVRProcessor` pushes + `AggregatedTextFrame`s when in conversation mode. This allows for controlling + spacing of the outputted, aggregated text. + (PR [#3604](https://github.com/pipecat-ai/pipecat/pull/3604)) + +- Fixed `GeminiLiveLLMService` transcription timeout handler not being + scheduled by yielding to the event loop after task creation. + (PR [#3605](https://github.com/pipecat-ai/pipecat/pull/3605)) + +## [0.0.100] - 2026-01-20 + +### Added + +- Added Hathora service to support Hathora-hosted TTS and STT models (only + non-streaming) + (PR [#3169](https://github.com/pipecat-ai/pipecat/pull/3169)) + +- Added `CambTTSService`, using Camb.ai's TTS integration with MARS models + (mars-flash, mars-pro, mars-instruct) for high-quality text-to-speech + synthesis. + (PR [#3349](https://github.com/pipecat-ai/pipecat/pull/3349)) + +- Added the `additional_headers` param to `WebsocketClientParams`, allowing + `WebsocketClientTransport` to send custom headers on connect, for cases such + as authentication. + (PR [#3461](https://github.com/pipecat-ai/pipecat/pull/3461)) + +- Added `UserIdleController` for detecting user idle state, integrated into + `LLMUserAggregator` and `UserTurnProcessor` via optional `user_idle_timeout` + parameter. Emits `on_user_turn_idle` event for application-level handling. + Deprecated `UserIdleProcessor` in favor of the new compositional approach. + (PR [#3482](https://github.com/pipecat-ai/pipecat/pull/3482)) + +- Added `on_user_mute_started` and `on_user_mute_stopped` event handlers to + `LLMUserAggregator` for tracking user mute state changes. + (PR [#3490](https://github.com/pipecat-ai/pipecat/pull/3490)) + +### Changed + +- Enhanced interruption handling in `AsyncAITTSService` by supporting + multi-context WebSocket sessions for more robust context management. + (PR [#3287](https://github.com/pipecat-ai/pipecat/pull/3287)) + +- Throttle `UserSpeakingFrame` to broadcast at most every 200ms instead of on + every audio chunk, reducing frame processing overhead during user speech. + (PR [#3483](https://github.com/pipecat-ai/pipecat/pull/3483)) + +### Deprecated + +- For consistency with other package names, we just deprecated + `pipecat.turns.mute` (introduced in Pipecat 0.0.99) in favor of + `pipecat.turns.user_mute`. + (PR [#3479](https://github.com/pipecat-ai/pipecat/pull/3479)) + +### Fixed + +- Corrected TTFB metric calculation in `AsyncAIHttpTTSService`. + (PR [#3287](https://github.com/pipecat-ai/pipecat/pull/3287)) + +- Fixed an issue where the "bot-llm-text" RTVI event would not fire for + realtime (speech-to-speech) services: + + - `AWSNovaSonicLLMService` + - `GeminiLiveLLMService` + - `OpenAIRealtimeLLMService` + - `GrokRealtimeLLMService` + + The issue was that these services weren't pushing `LLMTextFrame`s. Now + they do. + (PR [#3446](https://github.com/pipecat-ai/pipecat/pull/3446)) + +- Fixed an issue where `on_user_turn_stop_timeout` could fire while a user is + talking when using `ExternalUserTurnStrategies`. + (PR [#3454](https://github.com/pipecat-ai/pipecat/pull/3454)) + +- Fixed an issue where user turn start strategies were not being reset after a + user turn started, causing incorrect strategy behavior. + (PR [#3455](https://github.com/pipecat-ai/pipecat/pull/3455)) + +- Fixed `MinWordsUserTurnStartStrategy` to not aggregate transcriptions, + preventing incorrect turn starts when words are spoken with pauses between + them. + (PR [#3462](https://github.com/pipecat-ai/pipecat/pull/3462)) + +- Fixed an issue where Grok Realtime would error out when running with + SmallWebRTC transport. + (PR [#3480](https://github.com/pipecat-ai/pipecat/pull/3480)) + +- Fixed a `Mem0MemoryService` issue where passing `async_mode: true` was + causing an error. See + https://docs.mem0.ai/platform/features/async-mode-default-change. + (PR [#3484](https://github.com/pipecat-ai/pipecat/pull/3484)) + +- Fixed `AWSNovaSonicLLMService.reset_conversation()`, which would previously + error out. Now it successfully reconnects and "rehydrates" from the context + object. + (PR [#3486](https://github.com/pipecat-ai/pipecat/pull/3486)) + +- Fixed `AzureTTSService` transcript formatting issues: + - Punctuation now appears without extra spaces (e.g., "Hello!" instead of + "Hello !") + - CJK languages (Chinese, Japanese, Korean) no longer have unwanted spaces + between characters + (PR [#3489](https://github.com/pipecat-ai/pipecat/pull/3489)) + +- Fixed an issue where `UninterruptibleFrame` frames would not be preserved in + some cases. + (PR [#3494](https://github.com/pipecat-ai/pipecat/pull/3494)) + +- Fixed memory leak in `LiveKitTransport` when `video_in_enabled` is `False`. + (PR [#3499](https://github.com/pipecat-ai/pipecat/pull/3499)) + +- Fixed an issue in `AIService` where unhandled exceptions in `start()`, + `stop()`, or `cancel()` implementations would prevent `process_frame()` to + continue and therefore `StartFrame`, `EndFrame`, or `CancelFrame` from being + pushed downstream, causing the pipeline to not start or stop properly. + (PR [#3503](https://github.com/pipecat-ai/pipecat/pull/3503)) + +- Moved `NVIDIATTSService` and `NVIDIASTTService` client initialization from + constructor to `start()` for better error handling. + (PR [#3504](https://github.com/pipecat-ai/pipecat/pull/3504)) + +- Optimized `NVIDIATTSService` to process incoming audio frames immediately. + (PR [#3509](https://github.com/pipecat-ai/pipecat/pull/3509)) + +- Optimized `NVIDIASTTService` by removing unnecessary queue and task. + (PR [#3509](https://github.com/pipecat-ai/pipecat/pull/3509)) + +- Fixed a `CambTTSService` issue where client was being initialized in the + constructor which wouldn't allow for proper Pipeline error handling. + (PR [#3511](https://github.com/pipecat-ai/pipecat/pull/3511)) + +## [0.0.99] - 2026-01-13 + +### Added + +- Introducing user turn strategies. User turn strategies indicate when the user + turn starts or stops. In conversational agents, these are often referred to + as start/stop speaking or turn-taking plans or policies. + + User turn start strategies indicate when the user starts speaking (e.g. + using VAD events or when a user says one or more words). + + User turn stop strategies indicate when the user stops speaking (e.g. using + an end-of-turn detection model or by observing incoming transcriptions). + + A list of strategies can be specified for both strategies; strategies are + evaluated in order until one evaluates to true. + + Available user turn start strategies: + + - VADUserTurnStartStrategy + - TranscriptionUserTurnStartStrategy + - MinWordsUserTurnStartStrategy + - ExternalUserTurnStartStrategy + + Available user turn stop strategies: + + - TranscriptionUserTurnStopStrategy + - TurnAnalyzerUserTurnStopStrategy + - ExternalUserTurnStopStrategy + + The default strategies are: + + - start: [VADUserTurnStartStrategy, TranscriptionUserTurnStartStrategy] + - stop: [TranscriptionUserTurnStopStrategy] + + Turn strategies are configured when setting up `LLMContextAggregatorPair`. + For example: + + ```python + context_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=UserTurnStrategies( + stop=[ + TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()) + ) + ], + ) + ), + ) + ``` + + In order to use the user turn strategies you must update to the new + universal `LLMContext` and `LLMContextAggregatorPair`. + (PR [#3045](https://github.com/pipecat-ai/pipecat/pull/3045)) + +- Added `RNNoiseFilter` for real-time noise suppression using RNNoise neural + network via pyrnnoise library. + (PR [#3205](https://github.com/pipecat-ai/pipecat/pull/3205)) + +- Added `GrokRealtimeLLMService` for xAI's Grok Voice Agent API with real-time + voice conversations: + + - Support for real-time audio streaming with WebSocket connection + - Built-in server-side VAD (Voice Activity Detection) + - Multiple voice options: Ara, Rex, Sal, Eve, Leo + - Built-in tools support: web_search, x_search, file_search + - Custom function calling with standard Pipecat tools schema + - Configurable audio formats (PCM at 8kHz-48kHz) + (PR [#3267](https://github.com/pipecat-ai/pipecat/pull/3267)) + +- Added an approximation of TTFB for Ultravox. + (PR [#3268](https://github.com/pipecat-ai/pipecat/pull/3268)) + +- Added a new `AudioContextTTSService` to the TTS service base classes. The + `AudioContextWordTTSService` now inherits from `AudioContextTTSService` and + `WebsocketWordTTSService`. + (PR [#3289](https://github.com/pipecat-ai/pipecat/pull/3289)) + +- `LLMUserAggregator` now exposes the following events: + + - `on_user_turn_started`: triggered when a user turn starts + - `on_user_turn_stopped`: triggered when a user turn ends + - `on_user_turn_stop_timeout`: triggered when a user turn does not stop + and times out + (PR [#3291](https://github.com/pipecat-ai/pipecat/pull/3291)) + +- Introducing user mute strategies. User mute strategies indicate when user + input should be muted based on the current system state. + + In conversational agents, user mute strategies are used to prevent user + input from interrupting bot speech, tool execution, or other critical system + operations. + + A list of strategies can be specified; all strategies are evaluated for + every frame so that each strategy can maintain its internal state. A user + frame is muted if any of the configured strategies indicates it should be + muted. + + Available user mute strategies: + + - `FirstSpeechUserMuteStrategy` + - `MuteUntilFirstBotCompleteUserMuteStrategy` + - `AlwaysUserMuteStrategy` + - `FunctionCallUserMuteStrategy` + + User mute strategies replace the legacy `STTMuteFilter` and provide a more + flexible and composable approach to muting user input. + + User mute strategies are configured when setting up the + `LLMContextAggregatorPair`. For example: + + ```python + context_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_mute_strategies=[ + FirstSpeechUserMuteStrategy(), + ] + ), + ) + ``` + + In order to use user mute strategies you should update to the new universal + `LLMContext` and `LLMContextAggregatorPair`. + (PR [#3292](https://github.com/pipecat-ai/pipecat/pull/3292)) + +- Added `use_ssl` parameter to `NvidiaSTTService`, `NvidiaSegmentedSTTService` + and `NvidiaTTSService`. + (PR [#3300](https://github.com/pipecat-ai/pipecat/pull/3300)) + +- Added `enable_interruptions` constructor argument to all user turn + strategies. This tells the `LLMUserAggregator` to push or not push an + `InterruptionFrame`. + (PR [#3316](https://github.com/pipecat-ai/pipecat/pull/3316)) + +- Added `split_sentences` parameter to `SpeechmaticsSTTService` to control + sentence splitting behavior for finals on sentence boundaries. + (PR [#3328](https://github.com/pipecat-ai/pipecat/pull/3328)) + +- Added word-level timestamp support to `AzureTTSService` for accurate + text-to-audio synchronization. + (PR [#3334](https://github.com/pipecat-ai/pipecat/pull/3334)) + +- Added `pronunciation_dict_id` parameter to `CartesiaTTSService.InputParams` + and `CartesiaHttpTTSService.InputParams` to support Cartesia's pronunciation + dictionary feature for custom pronunciations. + (PR [#3346](https://github.com/pipecat-ai/pipecat/pull/3346)) + +- Added support for using the HeyGen LiveAvatar API with the `HeyGenTransport` + (see https://www.liveavatar.com/). + (PR [#3357](https://github.com/pipecat-ai/pipecat/pull/3357)) + +- Added image support to `OpenAIRealtimeLLMService` via `InputImageRawFrame`: + + - New `start_video_paused` parameter to control initial video input state + - New `video_frame_detail` parameter to set image processing quality + ("auto", + "low", or "high"). This corresponds to OpenAI Realtime's `image_detail` + parameter. + - `set_video_input_paused()` method to pause/resume video input at runtime + - `set_video_frame_detail()` method to adjust video frame quality + dynamically + - Automatic rate limiting (1 frame per second) to prevent API overload + (PR [#3360](https://github.com/pipecat-ai/pipecat/pull/3360)) + +- Added `UserTurnProcessor`, a frame processor built on `UserTurnController` + that pushes `UserStartedSpeakingFrame` and `UserStoppedSpeakingFrame` frames + and interruptions based on the controller's user turn strategies. + (PR [#3372](https://github.com/pipecat-ai/pipecat/pull/3372)) + +- Added `UserTurnController` to manage user turns. It emits + `on_user_turn_started`, `on_user_turn_stopped`, and + `on_user_turn_stop_timeout` events, and can be integrated into processors to + detect and handle user turns. `LLMUserAggregator` and `UserTurnProcessor` are + implemented using this controller. + (PR [#3372](https://github.com/pipecat-ai/pipecat/pull/3372)) + +- Added `should_interrupt` property to `DeepgramFluxSTTService`, + `DeepgramSTTService`, and `SpeechmaticsSTTService` to configure whether the + bot should be interrupted when the external service detects user speech. + (PR [#3374](https://github.com/pipecat-ai/pipecat/pull/3374)) + +- `LLMAssistantAggregator` now exposes the following events: + + - `on_assistant_turn_started`: triggered when the assistant turn starts + - `on_assistant_turn_stopped`: triggered when the assistant turn ends + - `on_assistant_thought`: triggered when there's an assistant thought + available + (PR [#3385](https://github.com/pipecat-ai/pipecat/pull/3385)) + +- Added `KrispVivaTurn` analyzer for end of turn detection using the Krisp VIVA + SDK (requires `krisp_audio`). + (PR [#3391](https://github.com/pipecat-ai/pipecat/pull/3391)) + +- Added support for setting up a pipeline task from external files. You can now + register custom pipeline task setup files by setting the + `PIPECAT_SETUP_FILES` environment variable. This variable should contain a + colon-separated list of Python files (e.g. `export +PIPECAT_SETUP_FILES="setup1.py:setup.py:..."`). Each file must define a + function with the following signature: + + ```python + async def setup_pipeline_task(task: PipelineTask): + ... + ``` + + (PR [#3397](https://github.com/pipecat-ai/pipecat/pull/3397)) + +- Added a keepalive task for `InworldTTSService` to keep the service connected + in the event of no generations for longer periods of time. + (PR [#3403](https://github.com/pipecat-ai/pipecat/pull/3403)) + +- Added `enable_vad` to `Params` for use in the `GladiaSTTService`. When + enabled, `GladiaSTTService` acts as the turn controller, emitting + `UserStartedSpeakingFrame`, `UserStoppedSpeakingFrame`, and optionally + `InterruptionFrame`. + (PR [#3404](https://github.com/pipecat-ai/pipecat/pull/3404)) + +- Added `should_interrupt` property to `GladiaSTTService` to configure whether + the bot should be interrupted when the external service detects user speech. + (PR [#3404](https://github.com/pipecat-ai/pipecat/pull/3404)) + +- Added `VonageFrameSerializer` for the Vonage Video API Audio Connector + WebSocket protocol. + (PR [#3410](https://github.com/pipecat-ai/pipecat/pull/3410)) + +- Added `append_trailing_space` parameter to `TTSService` to automatically + append a trailing space to text before sending to TTS, helping prevent some + services from vocalizing trailing punctuation. + (PR [#3424](https://github.com/pipecat-ai/pipecat/pull/3424)) + +### Changed + +- Updated `ElevenLabsRealtimeSTTService` to accept the + `include_language_detection` parameter to detect language. + + ```python + stt = ElevenLabsRealtimeSTTService( + api_key=os.getenv("ELEVENLABS_API_KEY"), + include_language_detection=True + ) + ``` + + (PR [#3216](https://github.com/pipecat-ai/pipecat/pull/3216)) + +- Updated `SpeechmaticsSTTService` to use new Python Voice SDK with improved + VAD, Smart Turn capabilities, and brings dramatic improvements to latency + without any impact on accuracy. Use the `turn_detection_mode` parameter to control + the endpointing of speech, with `TurnDetectionMode.EXTERNAL` (default), + `TurnDetectionMode.ADAPTIVE`, or `TurnDetectionMode.SMART_TURN`. + + ```python + stt = SpeechmaticsSTTService( + api_key=os.getenv("SPEECHMATICS_API_KEY"), + params=SpeechmaticsSTTService.InputParams( + language=Language.EN, + turn_detection_mode=SpeechmaticsSTTService.TurnDetectionMode.ADAPTIVE, + speaker_active_format="<{speaker_id}>{text}", + ), + ) + ``` + + (PR [#3225](https://github.com/pipecat-ai/pipecat/pull/3225)) + +- `daily-python` updated to 0.23.0. + (PR [#3257](https://github.com/pipecat-ai/pipecat/pull/3257)) + +- `TranscriptionFrame` and `InterimTranscriptionFrame` produced by + `DailyTransport` now include the transport source (i.e., the originating + audio track). + (PR [#3257](https://github.com/pipecat-ai/pipecat/pull/3257)) + +- Updates to Inworld TTS services: + + - Improved `InworldTTSService`'s websocket implementation to better flush + and close context to better handle long inputs. + - Improved docstrings for `InworldTTSService` and `InworldHttpTTSService`. + (PR [#3288](https://github.com/pipecat-ai/pipecat/pull/3288)) + +- Improved the error handling and reconnection logic for `WebsocketServer` by + distinguishing between errors when disconnecting and websocket communication + errors. + (PR [#3392](https://github.com/pipecat-ai/pipecat/pull/3392)) + +- Updated `DeepgramSTTService` to push user started/stopped speaking and + interruption frames when `vad_enabled` is set to true. This centralizes the + frames into the service, removing the need to have your application code + handle Deepgram's events and push these frames. + (PR [#3314](https://github.com/pipecat-ai/pipecat/pull/3314)) + +- Added encoding validation to `DeepgramTTSService` to prevent unsupported + encodings from reaching the API. The service now raises `ValueError` at + initialization with a clear error message. + (PR [#3329](https://github.com/pipecat-ai/pipecat/pull/3329)) + +- Updated `read_audio_frame` & `read_video_frame` methods in + `SmallWebRTCClient` to check if the track is enabled before logging a + warning. + (PR [#3336](https://github.com/pipecat-ai/pipecat/pull/3336)) + +- Updated `CartesiaTTSService` to support setting `language=None`, resulting in + Cartesia auto-detecting the language of the conversation. + (PR [#3366](https://github.com/pipecat-ai/pipecat/pull/3366)) + +- The bundled Smart Turn weights are now updated to v3.2, which has better + handling of short utterances, and is more robust against background noise. + (PR [#3367](https://github.com/pipecat-ai/pipecat/pull/3367)) + +- Updated `SpeechmaticsSTTService` dependency to `speechmatics-voice[smart]>=0.2.6` + (PR [#3371](https://github.com/pipecat-ai/pipecat/pull/3371)) + +- Smart Turn now takes into account `vad_start_seconds` when buffering audio, + meaning that the start of the turn audio is not cut off. This improves + accuracy for short utterances. + +- The default value of `pre_speech_ms` is now set to 500ms for Smart Turn. + (PR [#3377](https://github.com/pipecat-ai/pipecat/pull/3377)) + +- Improved Krisp SDK management to allow `KrispVivaTurn` and `KrispVivaFilter` + to share a single SDK instance within the same process. + (PR [#3391](https://github.com/pipecat-ai/pipecat/pull/3391)) + +- Updated default model for `GroqTTSService` to `canopylabs/orpheus-v1-english` + and voice ID to `autumn`. + (PR [#3399](https://github.com/pipecat-ai/pipecat/pull/3399)) + +- Enhanced `FastAPIWebsocketTransport` with optional protocol-level audio + packetization via the `fixed_audio_packet_size` parameter to support media + endpoints requiring strict framing and real-time pacing. + (PR [#3410](https://github.com/pipecat-ai/pipecat/pull/3410)) + +- `DeepgramTTSService` and `RimeTTSService` now set `append_trailing_space` to + `True` to prevent punctuation (e.g., “dot”) from being pronounced. + (PR [#3424](https://github.com/pipecat-ai/pipecat/pull/3424)) + +- Updated `GeminiLiveLLMService` to push `LLMThoughtStartFrame`, + `LLMThoughtTextFrame`, and `LLMThoughtEndFrame` when the model returns + thought content. + (PR [#3431](https://github.com/pipecat-ai/pipecat/pull/3431)) + +### Deprecated + +- `pipecat.audio.interruptions.MinWordsInterruptionStrategy` is deprecated. Use + `pipecat.turns.user_start.MinWordsUserTurnStartStrategy` with + `LLMUserAggregator`'s new `user_turn_strategies` parameter instead. + (PR [#3045](https://github.com/pipecat-ai/pipecat/pull/3045)) + +- `FrameProcessor.interruption_strategies` is deprecated, use + `LLMUserAggregator`'s new `user_turn_strategies` parameter instead. + (PR [#3045](https://github.com/pipecat-ai/pipecat/pull/3045)) + +- The `LLMUserAggregatorParams` and `LLMAssistantAggregatorParams` classes in + `pipecat.processors.aggregators.llm_response` are now deprecated. Use the new + universal `LLMContext` and `LLMContextAggregatorPair` instead. + (PR [#3045](https://github.com/pipecat-ai/pipecat/pull/3045)) + +- Deprecated the `emulated` field in the `UserStartedSpeakingFrame` and + `UserStoppedSpeakingFrame` frames. + (PR [#3045](https://github.com/pipecat-ai/pipecat/pull/3045)) + +- `EmulateUserStartedSpeakingFrame` and `EmulateUserStoppedSpeakingFrame` + frames are deprecated. + (PR [#3045](https://github.com/pipecat-ai/pipecat/pull/3045)) + +- ⚠️ `TransportParams.turn_analyzer` is deprecated and might result in + unexpected behavior, use `LLMUserAggregator`'s new `user_turn_strategies` + parameter instead. + (PR [#3045](https://github.com/pipecat-ai/pipecat/pull/3045)) + +- For `SpeechmaticsSTTService`, the `end_of_utterance_mode` parameter is + deprecated. Use the new `turn_detection_mode` parameter instead, with + `TurnDetectionMode.EXTERNAL`,`TurnDetectionMode.ADAPTIVE`, or + `TurnDetectionMode.SMART_TURN`. The `enable_vad` parameter is also + deprecated and is inferred from the `turn_detection_mode`. + (PR [#3225](https://github.com/pipecat-ai/pipecat/pull/3225)) + +- `OpenAILLMContext` and its associated things (context aggregators, etc.) are + now deprecated in favor of the universal `LLMContext` and its associated + things. + + From the developer's point of view, switching to using `LLMContext` + machinery will usually be a matter of going from this: + + ```python + context = OpenAILLMContext(messages, tools) + context_aggregator = llm.create_context_aggregator(context) + ``` + + To this: + + ``` + context = LLMContext(messages, tools) + context_aggregator = LLMContextAggregatorPair(context) + ``` + + (PR [#3263](https://github.com/pipecat-ai/pipecat/pull/3263)) + +- `STTMuteFilter` is deprecated and will be removed in a future version. Use + `LLMUserAggregator`'s new `user_mute_strategies` instead. + (PR [#3292](https://github.com/pipecat-ai/pipecat/pull/3292)) + +- `FrameProcessor.interruptions_allowed` is now deprecated, use + `LLMUserAggregator`'s new parameter `user_mute_strategies` instead. + (PR [#3297](https://github.com/pipecat-ai/pipecat/pull/3297)) + +- `PipelineParams.allow_interruptions` is now deprecated, use + `LLMUserAggregator`'s new parameter `user_turn_strategies` instead. For + example, to disable interruptions but still get user turns you can do: + + ```python + context_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=UserTurnStrategies( + start=[TranscriptionUserTurnStartStrategy(enable_interruptions=False)], + ), + ), + ) + ``` + + (PR [#3297](https://github.com/pipecat-ai/pipecat/pull/3297)) + +- `TranscriptProcessor` and related data classes and frames + (`TranscriptionMessage`, `ThoughtTranscriptionMessage`, + `TranscriptionUpdateFrame`) are deprecated. Use `LLMUserAggregator`'s and + `LLMAssistantAggregator`'s new events (`on_user_turn_stopped` and + `on_assistant_turn_stopped`) instead. + (PR [#3385](https://github.com/pipecat-ai/pipecat/pull/3385)) + +- Deprecated support for the `vad_events` `LiveOptions` in + `DeepgramSTTService`. Instead, use a local Silero VAD for VAD events. + Additionally, deprecated `should_interrupt` which will be removed along with + `vad_events` support in a future release. + (PR [#3386](https://github.com/pipecat-ai/pipecat/pull/3386)) + +- Loading external observers from files is deprecated, use the new pipeline + task setup files and `PIPECAT_SETUP_FILES` environment variable instead. + (PR [#3397](https://github.com/pipecat-ai/pipecat/pull/3397)) + +### Fixed + +- Improved error handling in `ElevenLabsRealtimeSTTService` + (PR [#3233](https://github.com/pipecat-ai/pipecat/pull/3233)) + +- Fixed an issue in `ElevenLabsRealtimeSTTService` causing an infinite loop + that blocks the process if the websocket disconnects due to an error + (PR [#3233](https://github.com/pipecat-ai/pipecat/pull/3233)) + +- Fixed a bug in `STTMuteFilter` where the user was not always muted during + function calls, especially when there were multiple simultaneous calls. + (PR [#3292](https://github.com/pipecat-ai/pipecat/pull/3292)) + +- Fixed a `RNNoiseFilter` issue that would cause a "[Errno 12] Cannot allocate + memory" error when processing silence audio frames. + (PR [#3322](https://github.com/pipecat-ai/pipecat/pull/3322)) + +- Updated `SpeechmaticsSTTService` for version `0.0.99+`: + + - Fixed `SpeechmaticsSTTService` to listen for `VADUserStoppedSpeakingFrame` + in order to finalize transcription. + - Default to `TurnDetectionMode.FIXED` for Pipecat-controlled end of turn + detection. + - Only emit VAD + interruption frames if VAD is enabled within the plugin + (modes other than `TurnDetectionMode.FIXED` or `TurnDetectionMode.EXTERNAL`). + (PR [#3328](https://github.com/pipecat-ai/pipecat/pull/3328)) + +- Fixed an issue with function calling where a handler failing to invoke its + result callback could leave the context stuck in IN_PROGRESS, causing LLM + inference for subsequent function call results to block while waiting on the + unresolved call. + (PR [#3343](https://github.com/pipecat-ai/pipecat/pull/3343)) + +- Fixed an issue with DeepgramTTSService where the model would output "Dot" + instead of a period in some circumstances. + (PR [#3345](https://github.com/pipecat-ai/pipecat/pull/3345)) + +- Fixed an issue in `traced_stt` where `model_name` in OpenTelemetry appears as + `unknown`. + (PR [#3351](https://github.com/pipecat-ai/pipecat/pull/3351)) + +- Fixed an issue in GeminiLiveLLMService where TranscriptionFrames were + occasionally not pushed. + (PR [#3356](https://github.com/pipecat-ai/pipecat/pull/3356)) + +- Fixed potential memory leaks and initialization issues in `KrispVivaFilter` + by improving SDK lifecycle management. + (PR [#3391](https://github.com/pipecat-ai/pipecat/pull/3391)) + +- Fixed timing issue in `BaseOutputTransport` where the bot speaking flag was + set after awaiting, allowing the event loop to re-enter the method before the + guard was set. + (PR [#3400](https://github.com/pipecat-ai/pipecat/pull/3400)) + +- Fixed parallel function calling when using Gemini thinking. + (PR [3420](https://github.com/pipecat-ai/pipecat/pull/3420)) + +- Fixed an issue in `traced_llm` where `model_name` in OpenTelemetry appears as + `unknown`. + (PR [#3422](https://github.com/pipecat-ai/pipecat/pull/3422)) + +- Fixed an issue in `traced_tts`, `traced_gemini_live`, and + `traced_openai_realtime` where `model_name` in OpenTelemetry appears as + `unknown`. + (PR [#3428](https://github.com/pipecat-ai/pipecat/pull/3428)) + +- Fixed `request_image_frame` (for backwards compatibility) and restored + function-call–related fields in `UserImageRequestFrame` and + `UserImageRawFrame`, preventing a case where adding a non-LLM message to the + context could trigger duplicate LLM inferences (on image arrival and on + function-call result), potentially causing an infinite inference loop. + (PR [#3430](https://github.com/pipecat-ai/pipecat/pull/3430)) + +- Fixed `LLMContext.create_audio_message()` by correcting an internal helper + that was incorrectly declared async while being run in `asyncio.to_thread()`. + (PR [#3435](https://github.com/pipecat-ai/pipecat/pull/3435)) + +### Other + +- Added `52-live-transcription.py` foundational example demonstrating live + transcription and translation from English to Spanish. In this example, the + bot is not interruptible: as the user continues speaking, English + transcriptions are queued, and the bot continuously translates and speaks + each queued sentence in Spanish without being interrupted by new user speech. + (PR [#3316](https://github.com/pipecat-ai/pipecat/pull/3316)) + +- Added a new foundational example `53-concurrent-llm-evaluation.py` that shows + how to use `UserTurnProcessor`. + (PR [#3372](https://github.com/pipecat-ai/pipecat/pull/3372)) + +- Added a new foundational example `28-user-assistant-turns.py` that shows how + to use the new `LLMUserAggregator` and `LLMAssistantAggregator` events to + gather a conversation transcript. + (PR [#3385](https://github.com/pipecat-ai/pipecat/pull/3385)) + ## [0.0.98] - 2025-12-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..7727975b3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,157 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Pipecat is an open-source Python framework for building real-time voice and multimodal conversational AI agents. It orchestrates audio/video, AI services, transports, and conversation pipelines using a frame-based architecture. + +## Common Commands + +```bash +# Setup development environment +uv sync --group dev --all-extras --no-extra gstreamer --no-extra krisp + +# Install pre-commit hooks +uv run pre-commit install + +# Run all tests +uv run pytest + +# Run a single test file +uv run pytest tests/test_name.py + +# Run a specific test +uv run pytest tests/test_name.py::test_function_name + +# Preview changelog +uv run towncrier build --draft --version Unreleased + +# Lint and format check +uv run ruff check +uv run ruff format --check + +# Update dependencies (after editing pyproject.toml) +uv lock && uv sync +``` + +## Architecture + +### Frame-Based Pipeline Processing + +All data flows as **Frame** objects through a pipeline of **FrameProcessors**: + +``` +[Processor1] → [Processor2] → ... → [ProcessorN] +``` + +**Key components:** + +- **Frames** (`src/pipecat/frames/frames.py`): Data units (audio, text, video) and control signals. Flow DOWNSTREAM (input→output) or UPSTREAM (acknowledgments/errors). + +- **FrameProcessor** (`src/pipecat/processors/frame_processor.py`): Base processing unit. Each processor receives frames, processes them, and pushes results downstream. + +- **Pipeline** (`src/pipecat/pipeline/pipeline.py`): Chains processors together. + +- **ParallelPipeline** (`src/pipecat/pipeline/parallel_pipeline.py`): Runs multiple pipelines in parallel. + +- **Transports** (`src/pipecat/transports/`): Transports are frame processors used for external I/O layer (Daily WebRTC, LiveKit WebRTC, WebSocket, Local). Abstract interface via `BaseTransport`, `BaseInputTransport` and `BaseOutputTransport`. + +- **Pipeline Task (`src/pipecat/pipeline/task.py`)**: Runs and manages a pipeline. Pipeline tasks send the first frame, `StartFrame`, to the pipeline in order for processors to know they can start processing and pushing frames. Pipeline tasks internally create a pipeline with two additional processors, a source processor before the user-defined pipeline and a sink processor at the end. Those are used for multiple things: error handling, pipeline task level events, heartbeat monitoring, etc. + +- **Pipeline Runner (`src/pipecat/pipeline/runner.py`)**: High-level entry point for executing pipeline tasks. Handles signal management (SIGINT/SIGTERM) for graceful shutdown and optional garbage collection. Run a single pipeline task with `await runner.run(task)` or multiple concurrently with `await asyncio.gather(runner.run(task1), runner.run(task2))`. + +- **Services** (`src/pipecat/services/`): 60+ AI provider integrations (STT, TTS, LLM, etc.). Extend base classes: `AIService`, `LLMService`, `STTService`, `TTSService`, `VisionService`. + +- **Serializers** (`src/pipecat/serializers/`): Convert frames to/from wire formats for WebSocket transports. `FrameSerializer` base class defines `serialize()` and `deserialize()`. Telephony serializers (Twilio, Plivo, Vonage, Telnyx, Exotel, Genesys) handle provider-specific protocols and audio encoding (e.g., μ-law). + +- **RTVI** (`src/pipecat/processors/frameworks/rtvi.py`): Real-Time Voice Interface protocol bridging clients and the pipeline. `RTVIProcessor` handles incoming client messages (text input, audio, function call results). `RTVIObserver` converts pipeline frames to outgoing messages: user/bot speaking events, transcriptions, LLM/TTS lifecycle, function calls, metrics, and audio levels. + +- **Observers** (`src/pipecat/observers/`): Monitor frame flow without modifying the pipeline. Passed to `PipelineTask` via the `observers` parameter. Implement `on_process_frame()` and `on_push_frame()` callbacks. + +### Important Patterns + +- **Context Aggregation**: `LLMContext` accumulates messages for LLM calls; `UserResponse` aggregates user input + +- **Turn Management**: Turn management is done through `LLMUserAggregator` and + `LLMAssistantAggregator`, created with `LLMContextAggregatorPair` + +- **User turn strategies**: Detection of when the user starts and stops speaking is done via user turn start/stop strategies. They push `UserStartedSpeakingFrame` and `UserStoppedSpeakingFrame` respectively. + +- **Interruptions**: Interruptions are usually triggered by a user turn start strategy (e.g. `VADUserTurnStartStrategy`) but they can be triggered by other processors as well, in which case the user turn start strategies don't need to. An `InterruptionFrame` carries an optional `asyncio.Event` that is set when the frame reaches the pipeline sink. If a processor stops an `InterruptionFrame` from propagating downstream (i.e., doesn't push it), it **must** call `frame.complete()` to avoid stalling `push_interruption_task_frame_and_wait()` callers. + +- **Uninterruptible Frames**: These are frames that will not be removed from internal queues even if there's an interruption. For example, `EndFrame` and `StopFrame`. + +- **Events**: Most classes in Pipecat have `BaseObject` as the very base class. `BaseObject` has support for events. Events can run in the background in an async task (default) or synchronously (`sync=True`) if we want immediate action. Synchronous event handlers need to execute fast. + +- **Async Task Management**: Always use `self.create_task(coroutine, name)` instead of raw `asyncio.create_task()`. The `TaskManager` automatically tracks tasks and cleans them up on processor shutdown. Use `await self.cancel_task(task, timeout)` for cancellation. + +- **Error Handling**: Use `await self.push_error(msg, exception, fatal)` to push errors upstream. Services should use `fatal=False` (the default) so application code can handle errors and take action (e.g. switch to another service). + +### Key Directories + +| Directory | Purpose | +| -------------------------- | -------------------------------------------------- | +| `src/pipecat/frames/` | Frame definitions (100+ types) | +| `src/pipecat/processors/` | FrameProcessor base + aggregators, filters, audio | +| `src/pipecat/pipeline/` | Pipeline orchestration | +| `src/pipecat/services/` | AI service integrations (60+ providers) | +| `src/pipecat/transports/` | Transport layer (Daily, LiveKit, WebSocket, Local) | +| `src/pipecat/serializers/` | Frame serialization for WebSocket protocols | +| `src/pipecat/observers/` | Pipeline observers for monitoring frame flow | +| `src/pipecat/audio/` | VAD, filters, mixers, turn detection, DTMF | +| `src/pipecat/turns/` | User turn management | + +## Code Style + +- **Docstrings**: Google-style. Classes describe purpose; `__init__` has `Args:` section; dataclasses use `Parameters:` section. +- **Linting**: Ruff (line length 100). Pre-commit hooks enforce formatting. +- **Type hints**: Required for complex async code. +- **Dataclass vs Pydantic**: Use `@dataclass` for frames and internal pipeline data (high-frequency, no validation needed). Use Pydantic `BaseModel` for configuration, parameters, metrics, and external API data (benefits from validation and serialization). Specifically: + - `@dataclass`: Frame types, context aggregator pairs, internal data containers + - `BaseModel`: Service `InputParams`, transport/VAD/turn params, metrics data, API request/response models, serializer params + +### Docstring Example + +```python +class MyService(LLMService): + """Description of what the service does. + + More detailed description. + + Event handlers available: + + - on_connected: Called when we are connected + + Example:: + + @service.event_handler("on_connected") + async def on_connected(service, frame): + ... + """ + + def __init__(self, param1: str, **kwargs): + """Initialize the service. + + Args: + param1: Description of param1. + **kwargs: Additional arguments passed to parent. + """ + super().__init__(**kwargs) +``` + +## Service Implementation + +When adding a new service: + +1. Extend the appropriate base class (`STTService`, `TTSService`, `LLMService`, etc.) +2. Implement required abstract methods +3. Handle necessary frames +4. By default, all frames should be pushed in the direction they came +5. Push `ErrorFrame` on failures +6. Add metrics tracking via `MetricsData` if relevant +7. Follow the pattern of existing services in `src/pipecat/services/` + +## Testing + +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. diff --git a/COMMUNITY_INTEGRATIONS.md b/COMMUNITY_INTEGRATIONS.md index a26836a52..c7e4c8e90 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 @@ -233,24 +231,137 @@ def can_generate_metrics(self) -> bool: return True ``` -### Dynamic Settings Updates +### Service Settings -STT, LLM, and TTS services support `ServiceUpdateSettingsFrame` for dynamic configuration changes. The base STTService has an `_update_settings()` method that handles settings, and the private `_settings` `Dict` is used to store settings and provide access to the subclass. +Every AI service (STT, LLM, TTS, image generation, etc.) exposes a **Settings dataclass** that serves two roles: + +1. **Store mode** — the service's `self._settings` holds the current value of every runtime-updatable field. +2. **Delta mode** — an update frame (e.g. `TTSUpdateSettingsFrame`) specifies only the fields that should change; unspecified fields remain `NOT_GIVEN`. + +#### Defining your Settings class + +Extend `STTSettings`, `TTSSettings`, `LLMSettings`, or `ImageGenSettings` (or, if your service directly subclasses `AIService`, `ServiceSettings`). The base classes already provide common fields (e.g. `model`, `voice`, `language`). You only need to add **service-specific knobs that should be runtime-updatable**: ```python -async def set_language(self, language: Language): - """Set the recognition language and reconnect. +from dataclasses import dataclass, field - Args: - language: The language to use for speech recognition. +from pipecat.services.settings import TTSSettings, NOT_GIVEN + +@dataclass +class MyTTSSettings(TTSSettings): + """Settings for MyTTS service. + + Parameters: + speaking_rate: Speed multiplier (0.5–2.0). """ - logger.info(f"Switching STT language to: [{language}]") - self._settings["language"] = language - await self._disconnect() - await self._connect() + + speaking_rate: float | None = field(default_factory=lambda: NOT_GIVEN) ``` -Note that, in this example, Deepgram requires the websocket connection be disconnected and reconnected to reinitialize the service with the new value. Consider if your service requires reconnection. +**What goes in Settings vs. `__init__` params:** + +| Belongs in Settings | Stays as `__init__` params | +| -------------------------------------------------------- | ----------------------------------------- | +| Model name, voice, language | API keys, auth tokens | +| Service-specific tuning knobs (rate, pitch, temperature) | Base URLs, endpoint overrides | +| Anything users may want to change mid-session | Audio encoding, sample format | +| | Connection parameters (timeouts, retries) | + +The rule of thumb: if a caller might send an update frame to change it at runtime, it belongs in Settings. Everything else is init-only config stored as `self._xxx`. + +#### Wiring settings into `__init__` + +Accept an **optional** `settings` parameter. Build a `default_settings` object with all fields set to real values, then merge any caller overrides with `apply_update`. + +Add a `Settings` **class attribute** that points to your settings dataclass. This lets callers access the settings class through the service itself (e.g. `MyTTSService.Settings(...)`) without a separate import: + +```python +from typing import Optional + +class MyTTSService(TTSService): + Settings = MyTTSSettings + _settings: Settings + + def __init__( + self, + *, + api_key: str, + settings: Optional[Settings] = None, + **kwargs, + ): + # 1. Defaults — every field has a real value (store mode). + default_settings = self.Settings( + model="my-model-v1", + voice="default-voice", + language="en", + speaking_rate=1.0, + ) + + # 2. Merge caller overrides (only given fields win). + if settings is not None: + default_settings.apply_update(settings) + + # 3. Pass the fully-populated settings to the base class. + super().__init__(settings=default_settings, **kwargs) + + # 4. Init-only config stored separately. + self._api_key = api_key +``` + +This pattern lets callers override only what they care about: + +```python +# Uses all defaults +svc = MyTTSService(api_key="sk-xxx") + +# Overrides just the voice — access Settings through the service class +svc = MyTTSService( + api_key="sk-xxx", + settings=MyTTSService.Settings(voice="custom-voice"), +) +``` + +#### Reacting to runtime changes + +AI services support runtime configuration changes via `*UpdateSettingsFrame`s (e.g. `STTUpdateSettingsFrame`, `TTSUpdateSettingsFrame`, `LLMUpdateSettingsFrame`). + +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. A common implementation might look like: + +```python +async def _update_settings(self, update: TTSSettings) -> dict[str, Any]: + """Apply a settings update, reconfiguring the connection if needed.""" + changed = await super()._update_settings(update) + + if not changed: + return changed + + await self._disconnect() + await self._connect() + + return changed +``` + +The dict keys work like a set for membership tests (`"language" in changed`) and truthiness (`if changed`). Use `changed.keys() - {"language"}` for set difference, or `changed["language"]` to inspect the previous value of a field. + +Note that, in this example, the service requires a reconnect to apply the new language. Consider, for each setting, whether your service requires reconnection or can apply changes in-place. + +If your service can't yet apply certain settings at runtime, call `self._warn_unhandled_updated_settings(changed)` with any unhandled field names so users get a clear log message: + +```python +async def _update_settings(self, update: TTSSettings) -> dict[str, Any]: + changed = await super()._update_settings(update) + + if not changed: + return changed + + if "language" in changed: + await self._update_language() + else: + # TODO: this should be temporary - handle changes to other settings soon! + self._warn_unhandled_updated_settings(changed.keys() - {"language"}) + + return changed +``` ### Sample Rate Handling @@ -260,7 +371,7 @@ Sample rates are set via PipelineParams and passed to each frame processor at in async def start(self, frame: StartFrame): """Start the service.""" await super().start(frame) - self._settings["output_format"]["sample_rate"] = self.sample_rate + self._settings.output_sample_rate = self.sample_rate await self._connect() ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 937532ec9..936a652fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,12 +49,12 @@ Every pull request that makes a user-facing change should include a changelog en ``` 2. Choose the appropriate type: - - `added.md` - New features - `changed.md` - Changes in existing functionality - `deprecated.md` - Soon-to-be removed features - `removed.md` - Removed features - `fixed.md` - Bug fixes + - `performance.md` - Performance improvements - `security.md` - Security fixes - `other.md` - Other changes (documentation, dependencies, etc.) @@ -80,7 +80,6 @@ Every pull request that makes a user-facing change should include a changelog en ```markdown - Updated service configuration: - - Changed default timeout to 30 seconds - Added retry logic for failed connections ``` @@ -105,7 +104,6 @@ changelog/1234.changed.2.md ```markdown - Updated service configuration: - - Changed default timeout to 30 seconds - Added retry logic for failed connections ``` diff --git a/README.md b/README.md index 905265d43..1afc7f7a0 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,20 @@ Looking for help debugging your pipeline and processors? Check out [Whisker](htt Love terminal applications? Check out [Tail](https://github.com/pipecat-ai/tail), a terminal dashboard for Pipecat. +### 🤖 Claude Code Skills + +Use [Pipecat Skills](https://github.com/pipecat-ai/skills) with [Claude Code](https://claude.ai/code) to scaffold projects, deploy to Pipecat Cloud, and more. Install the marketplace with: + +``` +claude plugin marketplace add pipecat-ai/skills +``` + +and install any of the available plugins. + +### 🧩 Community Integrations + +Build and share your own Pipecat service integrations! Browse existing [community integrations](https://docs.pipecat.ai/server/services/community-integrations) or check out our [guide](COMMUNITY_INTEGRATIONS.md) to create your own. + ### 📺️ Pipecat TV Channel Catch new features, interviews, and how-tos on our [Pipecat TV](https://www.youtube.com/playlist?list=PLzU2zoMTQIHjqC3v4q2XVSR3hGSzwKFwH) channel. @@ -71,19 +85,20 @@ Catch new features, interviews, and how-tos on our [Pipecat TV](https://www.yout ## 🧩 Available services -| Category | Services | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Cartesia](https://docs.pipecat.ai/server/services/stt/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/stt/elevenlabs), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Gradium](https://docs.pipecat.ai/server/services/stt/gradium), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [NVIDIA Riva](https://docs.pipecat.ai/server/services/stt/riva), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [SambaNova (Whisper)](https://docs.pipecat.ai/server/services/stt/sambanova), [Sarvam](https://docs.pipecat.ai/server/services/stt/sarvam), [Soniox](https://docs.pipecat.ai/server/services/stt/soniox), [Speechmatics](https://docs.pipecat.ai/server/services/stt/speechmatics), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) | -| LLMs | [Anthropic](https://docs.pipecat.ai/server/services/llm/anthropic), [AWS](https://docs.pipecat.ai/server/services/llm/aws), [Azure](https://docs.pipecat.ai/server/services/llm/azure), [Cerebras](https://docs.pipecat.ai/server/services/llm/cerebras), [DeepSeek](https://docs.pipecat.ai/server/services/llm/deepseek), [Fireworks AI](https://docs.pipecat.ai/server/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/server/services/llm/gemini), [Grok](https://docs.pipecat.ai/server/services/llm/grok), [Groq](https://docs.pipecat.ai/server/services/llm/groq), [Mistral](https://docs.pipecat.ai/server/services/llm/mistral), [NVIDIA NIM](https://docs.pipecat.ai/server/services/llm/nim), [Ollama](https://docs.pipecat.ai/server/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/server/services/llm/openai), [OpenRouter](https://docs.pipecat.ai/server/services/llm/openrouter), [Perplexity](https://docs.pipecat.ai/server/services/llm/perplexity), [Qwen](https://docs.pipecat.ai/server/services/llm/qwen), [SambaNova](https://docs.pipecat.ai/server/services/llm/sambanova) [Together AI](https://docs.pipecat.ai/server/services/llm/together) | -| Text-to-Speech | [Async](https://docs.pipecat.ai/server/services/tts/asyncai), [AWS](https://docs.pipecat.ai/server/services/tts/aws), [Azure](https://docs.pipecat.ai/server/services/tts/azure), [Cartesia](https://docs.pipecat.ai/server/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/tts/elevenlabs), [Fish](https://docs.pipecat.ai/server/services/tts/fish), [Google](https://docs.pipecat.ai/server/services/tts/google), [Gradium](https://docs.pipecat.ai/server/services/tts/gradium), [Groq](https://docs.pipecat.ai/server/services/tts/groq), [Hume](https://docs.pipecat.ai/server/services/tts/hume), [Inworld](https://docs.pipecat.ai/server/services/tts/inworld), [LMNT](https://docs.pipecat.ai/server/services/tts/lmnt), [MiniMax](https://docs.pipecat.ai/server/services/tts/minimax), [Neuphonic](https://docs.pipecat.ai/server/services/tts/neuphonic), [NVIDIA Riva](https://docs.pipecat.ai/server/services/tts/riva), [OpenAI](https://docs.pipecat.ai/server/services/tts/openai), [Piper](https://docs.pipecat.ai/server/services/tts/piper), [PlayHT](https://docs.pipecat.ai/server/services/tts/playht), [Rime](https://docs.pipecat.ai/server/services/tts/rime), [Sarvam](https://docs.pipecat.ai/server/services/tts/sarvam), [Speechmatics](https://docs.pipecat.ai/server/services/tts/speechmatics), [XTTS](https://docs.pipecat.ai/server/services/tts/xtts) | -| Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [Grok Voice Agent](https://docs.pipecat.ai/server/services/s2s/grok), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai), [Ultravox](https://docs.pipecat.ai/server/services/s2s/ultravox), | -| Transport | [Daily (WebRTC)](https://docs.pipecat.ai/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/server/services/transport/fastapi-websocket), [SmallWebRTCTransport](https://docs.pipecat.ai/server/services/transport/small-webrtc), [WebSocket Server](https://docs.pipecat.ai/server/services/transport/websocket-server), Local | -| Serializers | [Plivo](https://docs.pipecat.ai/server/utilities/serializers/plivo), [Twilio](https://docs.pipecat.ai/server/utilities/serializers/twilio), [Telnyx](https://docs.pipecat.ai/server/utilities/serializers/telnyx) | -| Video | [HeyGen](https://docs.pipecat.ai/server/services/video/heygen), [Tavus](https://docs.pipecat.ai/server/services/video/tavus), [Simli](https://docs.pipecat.ai/server/services/video/simli) | -| Memory | [mem0](https://docs.pipecat.ai/server/services/memory/mem0) | -| Vision & Image | [fal](https://docs.pipecat.ai/server/services/image-generation/fal), [Google Imagen](https://docs.pipecat.ai/server/services/image-generation/fal), [Moondream](https://docs.pipecat.ai/server/services/vision/moondream) | -| Audio Processing | [Silero VAD](https://docs.pipecat.ai/server/utilities/audio/silero-vad-analyzer), [Krisp](https://docs.pipecat.ai/server/utilities/audio/krisp-filter), [Koala](https://docs.pipecat.ai/server/utilities/audio/koala-filter), [ai-coustics](https://docs.pipecat.ai/server/utilities/audio/aic-filter) | -| Analytics & Metrics | [OpenTelemetry](https://docs.pipecat.ai/server/utilities/opentelemetry), [Sentry](https://docs.pipecat.ai/server/services/analytics/sentry) | +| Category | Services | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Cartesia](https://docs.pipecat.ai/server/services/stt/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/stt/elevenlabs), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Gradium](https://docs.pipecat.ai/server/services/stt/gradium), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [NVIDIA Riva](https://docs.pipecat.ai/server/services/stt/riva), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [SambaNova (Whisper)](https://docs.pipecat.ai/server/services/stt/sambanova), [Sarvam](https://docs.pipecat.ai/server/services/stt/sarvam), [Soniox](https://docs.pipecat.ai/server/services/stt/soniox), [Speechmatics](https://docs.pipecat.ai/server/services/stt/speechmatics), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) | +| LLMs | [Anthropic](https://docs.pipecat.ai/server/services/llm/anthropic), [AWS](https://docs.pipecat.ai/server/services/llm/aws), [Azure](https://docs.pipecat.ai/server/services/llm/azure), [Cerebras](https://docs.pipecat.ai/server/services/llm/cerebras), [DeepSeek](https://docs.pipecat.ai/server/services/llm/deepseek), [Fireworks AI](https://docs.pipecat.ai/server/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/server/services/llm/gemini), [Grok](https://docs.pipecat.ai/server/services/llm/grok), [Groq](https://docs.pipecat.ai/server/services/llm/groq), [Mistral](https://docs.pipecat.ai/server/services/llm/mistral), [NVIDIA NIM](https://docs.pipecat.ai/server/services/llm/nim), [Ollama](https://docs.pipecat.ai/server/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/server/services/llm/openai), [OpenRouter](https://docs.pipecat.ai/server/services/llm/openrouter), [Perplexity](https://docs.pipecat.ai/server/services/llm/perplexity), [Qwen](https://docs.pipecat.ai/server/services/llm/qwen), [SambaNova](https://docs.pipecat.ai/server/services/llm/sambanova) [Together AI](https://docs.pipecat.ai/server/services/llm/together) | +| Text-to-Speech | [Async](https://docs.pipecat.ai/server/services/tts/asyncai), [AWS](https://docs.pipecat.ai/server/services/tts/aws), [Azure](https://docs.pipecat.ai/server/services/tts/azure), [Camb AI](https://docs.pipecat.ai/server/services/tts/camb), [Cartesia](https://docs.pipecat.ai/server/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/tts/elevenlabs), [Fish](https://docs.pipecat.ai/server/services/tts/fish), [Google](https://docs.pipecat.ai/server/services/tts/google), [Gradium](https://docs.pipecat.ai/server/services/tts/gradium), [Groq](https://docs.pipecat.ai/server/services/tts/groq), [Hume](https://docs.pipecat.ai/server/services/tts/hume), [Inworld](https://docs.pipecat.ai/server/services/tts/inworld), [LMNT](https://docs.pipecat.ai/server/services/tts/lmnt), [MiniMax](https://docs.pipecat.ai/server/services/tts/minimax), [Neuphonic](https://docs.pipecat.ai/server/services/tts/neuphonic), [NVIDIA Riva](https://docs.pipecat.ai/server/services/tts/riva), [OpenAI](https://docs.pipecat.ai/server/services/tts/openai), [Piper](https://docs.pipecat.ai/server/services/tts/piper), [Resemble](https://docs.pipecat.ai/server/services/tts/resemble), [Rime](https://docs.pipecat.ai/server/services/tts/rime), [Sarvam](https://docs.pipecat.ai/server/services/tts/sarvam), [Speechmatics](https://docs.pipecat.ai/server/services/tts/speechmatics), [XTTS](https://docs.pipecat.ai/server/services/tts/xtts) | +| Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [Grok Voice Agent](https://docs.pipecat.ai/server/services/s2s/grok), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai), [Ultravox](https://docs.pipecat.ai/server/services/s2s/ultravox), | +| Transport | [Daily (WebRTC)](https://docs.pipecat.ai/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/server/services/transport/fastapi-websocket), [SmallWebRTCTransport](https://docs.pipecat.ai/server/services/transport/small-webrtc), [WebSocket Server](https://docs.pipecat.ai/server/services/transport/websocket-server), Local | +| Serializers | [Exotel](https://docs.pipecat.ai/server/utilities/serializers/exotel), [Plivo](https://docs.pipecat.ai/server/utilities/serializers/plivo), [Twilio](https://docs.pipecat.ai/server/utilities/serializers/twilio), [Telnyx](https://docs.pipecat.ai/server/utilities/serializers/telnyx), [Vonage](https://docs.pipecat.ai/server/utilities/serializers/vonage) | +| Video | [HeyGen](https://docs.pipecat.ai/server/services/video/heygen), [LemonSlice](https://docs.pipecat.ai/server/services/video/lemonslice), [Tavus](https://docs.pipecat.ai/server/services/video/tavus), [Simli](https://docs.pipecat.ai/server/services/video/simli) | +| Memory | [mem0](https://docs.pipecat.ai/server/services/memory/mem0) | +| Vision & Image | [fal](https://docs.pipecat.ai/server/services/image-generation/fal), [Google Imagen](https://docs.pipecat.ai/server/services/image-generation/google-imagen), [Moondream](https://docs.pipecat.ai/server/services/vision/moondream) | +| Audio Processing | [Silero VAD](https://docs.pipecat.ai/server/utilities/audio/silero-vad-analyzer), [Krisp](https://docs.pipecat.ai/server/utilities/audio/krisp-filter), [Koala](https://docs.pipecat.ai/server/utilities/audio/koala-filter), [ai-coustics](https://docs.pipecat.ai/server/utilities/audio/aic-filter) | +| Analytics & Metrics | [OpenTelemetry](https://docs.pipecat.ai/server/utilities/opentelemetry), [Sentry](https://docs.pipecat.ai/server/services/analytics/sentry) | +| Community | [Browse community integrations →](https://docs.pipecat.ai/server/services/community-integrations) | 📚 [View full services documentation →](https://docs.pipecat.ai/server/services/supported-services) @@ -163,6 +178,15 @@ You can get started with Pipecat running on your local machine, then move your a > **Note**: Some extras (local, gstreamer) require system dependencies. See documentation if you encounter build errors. +### Claude Code Skills + +Install development workflow skills for contributing to Pipecat with [Claude Code](https://claude.ai/code): + +``` +claude plugin marketplace add pipecat-ai/pipecat +claude plugin install pipecat-dev@pipecat-dev-skills +``` + ### Running tests To run all tests, from the root directory: diff --git a/changelog/3045.added.md b/changelog/3045.added.md deleted file mode 100644 index 0dda476c7..000000000 --- a/changelog/3045.added.md +++ /dev/null @@ -1,42 +0,0 @@ -- Introducing user turn strategies. User turn strategies indicate when the user turn starts or stops. In conversational agents, these are often referred to as start/stop speaking or turn-taking plans or policies. - - User turn start strategies indicate when the user starts speaking (e.g. using VAD events or when a user says one or more words). - - User turn stop strategies indicate when the user stops speaking (e.g. using an end-of-turn detection model or by observing incoming transcriptions). - - A list of strategies can be specified for both strategies; strategies are evaluated in order until one evaluates to true. - - Available user turn start strategies: - - VADUserTurnStartStrategy - - TranscriptionUserTurnStartStrategy - - MinWordsUserTurnStartStrategy - - ExternalUserTurnStartStrategy - - Available user turn stop strategies: - - TranscriptionUserTurnStopStrategy - - TurnAnalyzerUserTurnStopStrategy - - ExternalUserTurnStopStrategy - - The default strategies are: - - - start: [VADUserTurnStartStrategy, TranscriptionUserTurnStartStrategy] - - stop: [TranscriptionUserTurnStopStrategy] - - Turn strategies are configured when setting up `LLMContextAggregatorPair`. For example: - - ```python - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy( - turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()) - ) - ], - ) - ), - ) - ``` - - In order to use the user turn strategies you must update to the new universal `LLMContext` and `LLMContextAggregatorPair`. diff --git a/changelog/3045.deprecated.2.md b/changelog/3045.deprecated.2.md deleted file mode 100644 index 7f03ff54a..000000000 --- a/changelog/3045.deprecated.2.md +++ /dev/null @@ -1 +0,0 @@ -- ⚠️ `TransportParams.turn_analyzer` is deprecated and might result in unexpected behavior, use `LLMUserAggregator`'s new `user_turn_strategies` parameter instead. diff --git a/changelog/3045.deprecated.3.md b/changelog/3045.deprecated.3.md deleted file mode 100644 index 594950603..000000000 --- a/changelog/3045.deprecated.3.md +++ /dev/null @@ -1 +0,0 @@ -- `FrameProcessor.interruption_strategies` is deprecated, use `LLMUserAggregator`'s new `user_turn_strategies` parameter instead. diff --git a/changelog/3045.deprecated.4.md b/changelog/3045.deprecated.4.md deleted file mode 100644 index fda634ce8..000000000 --- a/changelog/3045.deprecated.4.md +++ /dev/null @@ -1 +0,0 @@ -- `EmulateUserStartedSpeakingFrame` and `EmulateUserStoppedSpeakingFrame` frames are deprecated. diff --git a/changelog/3045.deprecated.5.md b/changelog/3045.deprecated.5.md deleted file mode 100644 index 57781a489..000000000 --- a/changelog/3045.deprecated.5.md +++ /dev/null @@ -1 +0,0 @@ -- Deprecated the `emulated` field in the `UserStartedSpeakingFrame` and `UserStoppedSpeakingFrame` frames. diff --git a/changelog/3045.deprecated.6.md b/changelog/3045.deprecated.6.md deleted file mode 100644 index 3bf804220..000000000 --- a/changelog/3045.deprecated.6.md +++ /dev/null @@ -1 +0,0 @@ -- The `LLMUserAggregatorParams` and `LLMAssistantAggregatorParams` classes in `pipecat.processors.aggregators.llm_response` are now deprecated. Use the new universal `LLMContext` and `LLMContextAggregatorPair` instead. diff --git a/changelog/3045.deprecated.md b/changelog/3045.deprecated.md deleted file mode 100644 index c58f1a4e2..000000000 --- a/changelog/3045.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- `pipecat.audio.interruptions.MinWordsInterruptionStrategy` is deprecated. Use `pipecat.turns.user_start.MinWordsUserTurnStartStrategy` with `LLMUserAggregator`'s new `user_turn_strategies` parameter instead. diff --git a/changelog/3205.added.md b/changelog/3205.added.md deleted file mode 100644 index dc72a1cf0..000000000 --- a/changelog/3205.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `RNNoiseFilter` for real-time noise suppression using RNNoise neural network via pyrnnoise library. diff --git a/changelog/3216.changed.md b/changelog/3216.changed.md deleted file mode 100644 index b8d480fe2..000000000 --- a/changelog/3216.changed.md +++ /dev/null @@ -1,7 +0,0 @@ -- Updated `ElevenLabsRealtimeSTTService` to accept the `include_language_detection` parameter to detect language. - ```python - stt = ElevenLabsRealtimeSTTService( - api_key=os.getenv("ELEVENLABS_API_KEY"), - include_language_detection=True - ) - ``` diff --git a/changelog/3225.changed.md b/changelog/3225.changed.md deleted file mode 100644 index c5063f58e..000000000 --- a/changelog/3225.changed.md +++ /dev/null @@ -1,15 +0,0 @@ -- Updated `SpeechmaticsSTTService` to use new Python Voice SDK with improved VAD, - Smart Turn capabilities, and brings dramatic improvements to latency without - any impact on accuracy. Use the `turn_detection_mode` parameter to control the - endpointing of speech, with `TurnDetectionMode.EXTERNAL` (default), - `TurnDetectionMode.ADAPTIVE`, or `TurnDetectionMode.SMART_TURN`. - ```python - stt = SpeechmaticsSTTService( - api_key=os.getenv("SPEECHMATICS_API_KEY"), - params=SpeechmaticsSTTService.InputParams( - language=Language.EN, - turn_detection_mode=SpeechmaticsSTTService.TurnDetectionMode.ADAPTIVE, - speaker_active_format="<{speaker_id}>{text}", - ), - ) - ``` diff --git a/changelog/3225.deprecated.md b/changelog/3225.deprecated.md deleted file mode 100644 index 162c82a82..000000000 --- a/changelog/3225.deprecated.md +++ /dev/null @@ -1,4 +0,0 @@ -- For `SpeechmaticsSTTService`, the `end_of_utterance_mode` parameter is deprecated. - Use the new `turn_detection_mode` parameter instead, with `TurnDetectionMode.EXTERNAL`, - `TurnDetectionMode.ADAPTIVE`, or `TurnDetectionMode.SMART_TURN`. The `enable_vad` - parameter is also deprecated and is inferred from the `turn_detection_mode`. diff --git a/changelog/3233.fixed.md b/changelog/3233.fixed.md deleted file mode 100644 index 3f17fd765..000000000 --- a/changelog/3233.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -- Improved error handling in `ElevenLabsRealtimeSTTService` -- Fixed an issue in `ElevenLabsRealtimeSTTService` causing an infinite loop that blocks the process if the websocket disconnects due to an error \ No newline at end of file diff --git a/changelog/3257.changed.2.md b/changelog/3257.changed.2.md deleted file mode 100644 index 333c69746..000000000 --- a/changelog/3257.changed.2.md +++ /dev/null @@ -1 +0,0 @@ -- `TranscriptionFrame` and `InterimTranscriptionFrame` produced by `DailyTransport` now include the transport source (i.e., the originating audio track). diff --git a/changelog/3257.changed.md b/changelog/3257.changed.md deleted file mode 100644 index fda547eef..000000000 --- a/changelog/3257.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `daily-python` updated to 0.23.0. diff --git a/changelog/3263.deprecated.md b/changelog/3263.deprecated.md deleted file mode 100644 index 11f659c2e..000000000 --- a/changelog/3263.deprecated.md +++ /dev/null @@ -1,15 +0,0 @@ -- `OpenAILLMContext` and its associated things (context aggregators, etc.) are now deprecated in favor of the universal `LLMContext` and its associated things. - - From the developer's point of view, switching to using `LLMContext` machinery will usually be a matter of going from this: - - ```python - context = OpenAILLMContext(messages, tools) - context_aggregator = llm.create_context_aggregator(context) - ``` - - To this: - - ``` - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair(context) - ``` diff --git a/changelog/3267.added.md b/changelog/3267.added.md deleted file mode 100644 index bdeccd6ed..000000000 --- a/changelog/3267.added.md +++ /dev/null @@ -1,8 +0,0 @@ -- Added `GrokRealtimeLLMService` for xAI's Grok Voice Agent API with real-time voice conversations: - - - Support for real-time audio streaming with WebSocket connection - - Built-in server-side VAD (Voice Activity Detection) - - Multiple voice options: Ara, Rex, Sal, Eve, Leo - - Built-in tools support: web_search, x_search, file_search - - Custom function calling with standard Pipecat tools schema - - Configurable audio formats (PCM at 8kHz-48kHz) diff --git a/changelog/3268.added.md b/changelog/3268.added.md deleted file mode 100644 index 6bbcd038c..000000000 --- a/changelog/3268.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added an approximation of TTFB for Ultravox. diff --git a/changelog/3288.changed.md b/changelog/3288.changed.md deleted file mode 100644 index 52a9694cd..000000000 --- a/changelog/3288.changed.md +++ /dev/null @@ -1,5 +0,0 @@ -- Updates to Inworld TTS services: - - - Improved `InworldTTSService`'s websocket implementation to better flush and - close context to better handle long inputs. - - Improved docstrings for `InworldTTSService` and `InworldHttpTTSService`. diff --git a/changelog/3289.added.md b/changelog/3289.added.md deleted file mode 100644 index fb19607eb..000000000 --- a/changelog/3289.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added a new `AudioContextTTSService` to the TTS service base classes. The `AudioContextWordTTSService` now inherits from `AudioContextTTSService` and `WebsocketWordTTSService`. diff --git a/changelog/3291.added.md b/changelog/3291.added.md deleted file mode 100644 index dacfd4f12..000000000 --- a/changelog/3291.added.md +++ /dev/null @@ -1,4 +0,0 @@ -- `LLMUserAggregator` now exposes the following events: - - `on_user_turn_started`: triggered when a user turn starts - - `on_user_turn_stopped`: triggered when a user turn ends - - `on_user_turn_stop_timeout`: triggered when a user turn does not stop and times out diff --git a/changelog/3292.added.md b/changelog/3292.added.md deleted file mode 100644 index 936d927d8..000000000 --- a/changelog/3292.added.md +++ /dev/null @@ -1,29 +0,0 @@ -- Introducing user mute strategies. User mute strategies indicate when user input should be muted based on the current system state. - - In conversational agents, user mute strategies are used to prevent user input from interrupting bot speech, tool execution, or other critical system operations. - - A list of strategies can be specified; all strategies are evaluated for every frame so that each strategy can maintain its internal state. A user frame is muted if any of the configured strategies indicates it should be muted. - - Available user mute strategies: - - * `FirstSpeechUserMuteStrategy` - * `MuteUntilFirstBotCompleteUserMuteStrategy` - * `AlwaysUserMuteStrategy` - * `FunctionCallUserMuteStrategy` - - User mute strategies replace the legacy `STTMuteFilter` and provide a more flexible and composable approach to muting user input. - - User mute strategies are configured when setting up the `LLMContextAggregatorPair`. For example: - - ```python - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_mute_strategies=[ - FirstSpeechUserMuteStrategy(), - ] - ), - ) - ``` - - In order to use user mute strategies you should update to the new universal `LLMContext` and `LLMContextAggregatorPair`. diff --git a/changelog/3292.deprecated.md b/changelog/3292.deprecated.md deleted file mode 100644 index 3aceea5f1..000000000 --- a/changelog/3292.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- `STTMuteFilter` is deprecated and will be removed in a future version. Use `LLMUserAggregator`'s new `user_mute_strategies` instead. diff --git a/changelog/3292.fixed.md b/changelog/3292.fixed.md deleted file mode 100644 index 4d3df66b0..000000000 --- a/changelog/3292.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed a bug in `STTMuteFilter` where the user was not always muted during function calls, especially when there were multiple simultaneous calls. diff --git a/changelog/3297.deprecated.2.md b/changelog/3297.deprecated.2.md deleted file mode 100644 index 49d5723af..000000000 --- a/changelog/3297.deprecated.2.md +++ /dev/null @@ -1 +0,0 @@ -- `FrameProcessor.interruptions_allowed` is now deprecated, use `LLMUserAggregator`'s new parameter `user_mute_strategies` instead. diff --git a/changelog/3297.deprecated.md b/changelog/3297.deprecated.md deleted file mode 100644 index d29a8a020..000000000 --- a/changelog/3297.deprecated.md +++ /dev/null @@ -1,12 +0,0 @@ -- `PipelineParams.allow_interruptions` is now deprecated, use `LLMUserAggregator`'s new parameter `user_turn_strategies` instead. For example, to disable interruptions but still get user turns you can do: - - ```python - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - start=[TranscriptionUserTurnStartStrategy(enable_interruptions=False)], - ), - ), - ) - ``` diff --git a/changelog/3300.added.md b/changelog/3300.added.md deleted file mode 100644 index 6e0066559..000000000 --- a/changelog/3300.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `use_ssl` parameter to `NvidiaSTTService`, `NvidiaSegmentedSTTService` and `NvidiaTTSService`. \ No newline at end of file diff --git a/changelog/3314.changed.md b/changelog/3314.changed.md deleted file mode 100644 index 3b9b074a8..000000000 --- a/changelog/3314.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated `DeepgramSTTService` to push user started/stopped speaking and interruption frames when `vad_enabled` is set to true. This centralizes the frames into the service, removing the need to have your application code handle Deepgram's events and push these frames. diff --git a/changelog/3316.added.md b/changelog/3316.added.md deleted file mode 100644 index d4c76c46a..000000000 --- a/changelog/3316.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `enable_interruptions` constructor argument to all user turn strategies. This tells the `LLMUserAggregator` to push or not push an `InterruptionFrame`. diff --git a/changelog/3316.other.md b/changelog/3316.other.md deleted file mode 100644 index 23c1be025..000000000 --- a/changelog/3316.other.md +++ /dev/null @@ -1 +0,0 @@ -- Added `52-live-transcription.py` foundational example demonstrating live transcription and translation from English to Spanish. In this example, the bot is not interruptible: as the user continues speaking, English transcriptions are queued, and the bot continuously translates and speaks each queued sentence in Spanish without being interrupted by new user speech. diff --git a/changelog/3322.fixed.md b/changelog/3322.fixed.md deleted file mode 100644 index 3ad2b75bc..000000000 --- a/changelog/3322.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed a `RNNoiseFilter` issue that would cause a "[Errno 12] Cannot allocate memory" error when processing silence audio frames. diff --git a/changelog/3328.added.md b/changelog/3328.added.md deleted file mode 100644 index db793e828..000000000 --- a/changelog/3328.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `split_sentences` parameter to `SpeechmaticsSTTService` to control sentence splitting behavior for finals on sentence boundaries. diff --git a/changelog/3328.fixed.md b/changelog/3328.fixed.md deleted file mode 100644 index 6f09c2386..000000000 --- a/changelog/3328.fixed.md +++ /dev/null @@ -1,4 +0,0 @@ -- Updated `SpeechmaticsSTTService` for version `0.0.99+`: - - Fixed `SpeechmaticsSTTService` to listen for `VADUserStoppedSpeakingFrame` in order to finalize transcription. - - Default to `TurnDetectionMode.FIXED` for Pipecat-controlled end of turn detection. - - Only emit VAD + interruption frames if VAD is enabled within the plugin (modes other than `TurnDetectionMode.FIXED` or `TurnDetectionMode.EXTERNAL`). diff --git a/changelog/3329.changed.md b/changelog/3329.changed.md deleted file mode 100644 index 8271decf2..000000000 --- a/changelog/3329.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Added encoding validation to `DeepgramTTSService` to prevent unsupported encodings from reaching the API. The service now raises `ValueError` at initialization with a clear error message. diff --git a/changelog/3334.added.md b/changelog/3334.added.md deleted file mode 100644 index 993bca20e..000000000 --- a/changelog/3334.added.md +++ /dev/null @@ -1,2 +0,0 @@ -- Added word-level timestamp support to `AzureTTSService` for accurate text-to-audio synchronization. - \ No newline at end of file diff --git a/changelog/3336.changed.md b/changelog/3336.changed.md deleted file mode 100644 index 2f6a28b30..000000000 --- a/changelog/3336.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated `read_audio_frame` & `read_video_frame` methods in `SmallWebRTCClient` to check if the track is enabled before logging a warning. \ No newline at end of file diff --git a/changelog/3343.fixed.md b/changelog/3343.fixed.md deleted file mode 100644 index ee037b304..000000000 --- a/changelog/3343.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed an issue with function calling where a handler failing to invoke its result callback could leave the context stuck in IN_PROGRESS, causing LLM inference for subsequent function call results to block while waiting on the unresolved call. diff --git a/changelog/3345.fixed.md b/changelog/3345.fixed.md deleted file mode 100644 index 61c0a575e..000000000 --- a/changelog/3345.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed an issue with DeepgramTTSService where the model would output "Dot" instead of a period in some circumstances. diff --git a/changelog/3346.added.md b/changelog/3346.added.md deleted file mode 100644 index b72d23b55..000000000 --- a/changelog/3346.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `pronunciation_dict_id` parameter to `CartesiaTTSService.InputParams` and `CartesiaHttpTTSService.InputParams` to support Cartesia's pronunciation dictionary feature for custom pronunciations. diff --git a/changelog/3356.fixed.md b/changelog/3356.fixed.md deleted file mode 100644 index 0532e742a..000000000 --- a/changelog/3356.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed an issue in GeminiLiveLLMService where TranscriptionFrames were occasionally not pushed. diff --git a/changelog/3357.added.md b/changelog/3357.added.md deleted file mode 100644 index 08a739573..000000000 --- a/changelog/3357.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added support for using the HeyGen LiveAvatar API with the `HeyGenTransport` (see https://www.liveavatar.com/). diff --git a/changelog/3360.added.md b/changelog/3360.added.md deleted file mode 100644 index c9f22e823..000000000 --- a/changelog/3360.added.md +++ /dev/null @@ -1,8 +0,0 @@ -- Added image support to `OpenAIRealtimeLLMService` via `InputImageRawFrame`: - - New `start_video_paused` parameter to control initial video input state - - New `video_frame_detail` parameter to set image processing quality ("auto", - "low", or "high"). This corresponds to OpenAI Realtime's `image_detail` - parameter. - - `set_video_input_paused()` method to pause/resume video input at runtime - - `set_video_frame_detail()` method to adjust video frame quality dynamically - - Automatic rate limiting (1 frame per second) to prevent API overload diff --git a/changelog/3366.changed.md b/changelog/3366.changed.md deleted file mode 100644 index d9a0a4d89..000000000 --- a/changelog/3366.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated `CartesiaTTSService` to support setting `language=None`, resulting in Cartesia auto-detecting the language of the conversation. diff --git a/changelog/3367.changed.md b/changelog/3367.changed.md deleted file mode 100644 index 036ff1c62..000000000 --- a/changelog/3367.changed.md +++ /dev/null @@ -1,3 +0,0 @@ -- The bundled Smart Turn weights are now updated to v3.2, which has better - handling of short utterances, and is more robust against background - noise. diff --git a/changelog/3371.changed.md b/changelog/3371.changed.md deleted file mode 100644 index eb96711fb..000000000 --- a/changelog/3371.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated `SpeechmaticsSTTService` dependency to `speechmatics-voice[smart]>=0.2.6` diff --git a/changelog/3372.added.2.md b/changelog/3372.added.2.md deleted file mode 100644 index 6b1bf1ed4..000000000 --- a/changelog/3372.added.2.md +++ /dev/null @@ -1 +0,0 @@ -- Added `UserTurnProcessor`, a frame processor built on `UserTurnController` that pushes `UserStartedSpeakingFrame` and `UserStoppedSpeakingFrame` frames and interruptions based on the controller's user turn strategies. diff --git a/changelog/3372.added.md b/changelog/3372.added.md deleted file mode 100644 index 3468f7ac1..000000000 --- a/changelog/3372.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `UserTurnController` to manage user turns. It emits `on_user_turn_started`, `on_user_turn_stopped`, and `on_user_turn_stop_timeout` events, and can be integrated into processors to detect and handle user turns. `LLMUserAggregator` and `UserTurnProcessor` are implemented using this controller. diff --git a/changelog/3372.other.md b/changelog/3372.other.md deleted file mode 100644 index d0e96c20b..000000000 --- a/changelog/3372.other.md +++ /dev/null @@ -1 +0,0 @@ -- Added a new foundational example `53-concurrent-llm-evaluation.py` that shows how to use `UserTurnProcessor`. diff --git a/changelog/3374.added.md b/changelog/3374.added.md deleted file mode 100644 index eb04650ef..000000000 --- a/changelog/3374.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `should_interrupt` property to `DeepgramFluxSTTService`, `DeepgramSTTService`, and `SpeechmaticsSTTService` to configure whether the bot should be interrupted when the external service detects user speech. \ No newline at end of file diff --git a/changelog/3377.changed.md b/changelog/3377.changed.md deleted file mode 100644 index 8a006bd42..000000000 --- a/changelog/3377.changed.md +++ /dev/null @@ -1,5 +0,0 @@ -- Smart Turn now takes into account `vad_start_seconds` when buffering audio, - meaning that the start of the turn audio is not cut off. This improves - accuracy for short utterances. - -- The default value of `pre_speech_ms` is now set to 500ms for Smart Turn. diff --git a/changelog/3385.added.md b/changelog/3385.added.md deleted file mode 100644 index b79a1d584..000000000 --- a/changelog/3385.added.md +++ /dev/null @@ -1,4 +0,0 @@ -- `LLMAssistantAggregator` now exposes the following events: - - `on_assistant_turn_started`: triggered when the assistant turn starts - - `on_assistant_turn_stopped`: triggered when the assistant turn ends - - `on_assistant_thought`: triggered when there's an assistant thought available diff --git a/changelog/3385.deprecated.md b/changelog/3385.deprecated.md deleted file mode 100644 index e810af08a..000000000 --- a/changelog/3385.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- `TranscriptProcessor` and related data classes and frames (`TranscriptionMessage`, `ThoughtTranscriptionMessage`, `TranscriptionUpdateFrame`) are deprecated. Use `LLMUserAggregator`'s and `LLMAssistantAggregator`'s new events (`on_user_turn_stopped` and `on_assistant_turn_stopped`) instead. diff --git a/changelog/3385.other.md b/changelog/3385.other.md deleted file mode 100644 index 69f612908..000000000 --- a/changelog/3385.other.md +++ /dev/null @@ -1 +0,0 @@ -- Added a new foundational example `28-user-assistant-turns.py` that shows how to use the new `LLMUserAggregator` and `LLMAssistantAggregator` events to gather a conversation transcript. diff --git a/changelog/3386.deprecated.md b/changelog/3386.deprecated.md deleted file mode 100644 index 3c9c141ce..000000000 --- a/changelog/3386.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- Deprecated support for the `vad_events` `LiveOptions` in `DeepgramSTTService`. Instead, use a local Silero VAD for VAD events. Additionally, deprecated `should_interrupt` which will be removed along with `vad_events` support in a future release. diff --git a/changelog/3391.added.md b/changelog/3391.added.md deleted file mode 100644 index 7dfaa9a2f..000000000 --- a/changelog/3391.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `KrispVivaTurn` analyzer for end of turn detection using the Krisp VIVA SDK (requires `krisp_audio`). diff --git a/changelog/3391.changed.md b/changelog/3391.changed.md deleted file mode 100644 index fb12beac0..000000000 --- a/changelog/3391.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Improved Krisp SDK management to allow `KrispVivaTurn` and `KrispVivaFilter` to share a single SDK instance within the same process. diff --git a/changelog/3391.fixed.md b/changelog/3391.fixed.md deleted file mode 100644 index 95c14ebd5..000000000 --- a/changelog/3391.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed potential memory leaks and initialization issues in `KrispVivaFilter` by improving SDK lifecycle management. \ No newline at end of file diff --git a/changelog/3397.added.md b/changelog/3397.added.md deleted file mode 100644 index 2f819cc43..000000000 --- a/changelog/3397.added.md +++ /dev/null @@ -1,6 +0,0 @@ -- Added support for setting up a pipeline task from external files. You can now register custom pipeline task setup files by setting the `PIPECAT_SETUP_FILES` environment variable. This variable should contain a colon-separated list of Python files (e.g. `export PIPECAT_SETUP_FILES="setup1.py:setup.py:..."`). Each file must define a function with the following signature: - - ```python - async def setup_pipeline_task(task: PipelineTask): - ... - ``` diff --git a/changelog/3397.deprecated.md b/changelog/3397.deprecated.md deleted file mode 100644 index b9028c5be..000000000 --- a/changelog/3397.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- Loading external observers from files is deprecated, use the new pipeline task setup files and `PIPECAT_SETUP_FILES` environment variable instead. diff --git a/changelog/3399.changed.md b/changelog/3399.changed.md deleted file mode 100644 index fecf505bc..000000000 --- a/changelog/3399.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated default model for `GroqTTSService` to `canopylabs/orpheus-v1-english` and voice ID to `autumn`. diff --git a/changelog/3400.fixed.md b/changelog/3400.fixed.md deleted file mode 100644 index aaf881bb7..000000000 --- a/changelog/3400.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed timing issue in `BaseOutputTransport` where the bot speaking flag was set after awaiting, allowing the event loop to re-enter the method before the guard was set. diff --git a/changelog/3449.changed.md b/changelog/3449.changed.md new file mode 100644 index 000000000..d5ea2f6d7 --- /dev/null +++ b/changelog/3449.changed.md @@ -0,0 +1 @@ +- Renamed tracing span attributes to align with OpenTelemetry GenAI semantic conventions: `gen_ai.system` to `gen_ai.provider.name`, `system` to `gen_ai.system_instructions`, `gen_ai.usage.cache_read_input_tokens` to `gen_ai.usage.cache_read.input_tokens`, and `gen_ai.usage.cache_creation_input_tokens` to `gen_ai.usage.cache_creation.input_tokens`. diff --git a/changelog/3449.fixed.md b/changelog/3449.fixed.md new file mode 100644 index 000000000..7cf01c0cb --- /dev/null +++ b/changelog/3449.fixed.md @@ -0,0 +1 @@ +- Fixed stale `system_instruction` in LLM tracing spans by reading from `_settings.system_instruction` instead of the removed `_system_instruction` attribute. diff --git a/changelog/4029.added.2.md b/changelog/4029.added.2.md new file mode 100644 index 000000000..1ae691442 --- /dev/null +++ b/changelog/4029.added.2.md @@ -0,0 +1 @@ +- Added `frame_order` parameter to `SyncParallelPipeline`. Set `frame_order=FrameOrder.PIPELINE` to push synchronized output frames in pipeline definition order (all frames from the first pipeline, then the second, etc.) instead of the default arrival order. diff --git a/changelog/4029.added.md b/changelog/4029.added.md new file mode 100644 index 000000000..ba3714483 --- /dev/null +++ b/changelog/4029.added.md @@ -0,0 +1 @@ +- Added `sync_with_audio` field to `OutputImageRawFrame`. When set to `True`, the output transport queues image frames with audio so they are displayed only after all preceding audio has been sent, enabling synchronized audio/image playback. diff --git a/changelog/4029.fixed.3.md b/changelog/4029.fixed.3.md new file mode 100644 index 000000000..3c812d590 --- /dev/null +++ b/changelog/4029.fixed.3.md @@ -0,0 +1 @@ +- Fixed `SyncParallelPipeline` breaking the Whisker debugger. diff --git a/changelog/4029.fixed.md b/changelog/4029.fixed.md new file mode 100644 index 000000000..57930a997 --- /dev/null +++ b/changelog/4029.fixed.md @@ -0,0 +1 @@ +- Fixed `SyncParallelPipeline` race condition where concurrent SystemFrame processing (e.g. from RTVI) could corrupt sink queues and cause deadlocks. SystemFrames now take a fast path that passes them through without draining queued output. diff --git a/changelog/4074.added.md b/changelog/4074.added.md new file mode 100644 index 000000000..c27a8e3cf --- /dev/null +++ b/changelog/4074.added.md @@ -0,0 +1 @@ +- Added `OpenAIResponsesLLMService`, a new LLM service that uses the OpenAI Responses API. Supports streaming text, function calling, usage metrics, and out-of-band inference. Works with the universal `LLMContext` and `LLMContextAggregatorPair`. See `examples/foundational/07-interruptible-openai-responses.py` and `14-function-calling-openai-responses.py`. diff --git a/changelog/4075.fixed.md b/changelog/4075.fixed.md new file mode 100644 index 000000000..97870d4bb --- /dev/null +++ b/changelog/4075.fixed.md @@ -0,0 +1 @@ +- Fixed TTS frame ordering so that non-system frames always arrive in correct order relative to the `TTSStartedFrame`/`TTSAudioRawFrame`/`TTSStoppedFrame` sequence. Previously these frames could race ahead of or behind audio context frames, producing out-of-order output downstream. diff --git a/changelog/4082.fixed.md b/changelog/4082.fixed.md new file mode 100644 index 000000000..e17e84fea --- /dev/null +++ b/changelog/4082.fixed.md @@ -0,0 +1 @@ +- Fixed `SarvamTTSService` audio and error frames now route through `append_to_audio_context()` instead of `push_frame()`, ensuring correct behavior with audio contexts and interruptions. diff --git a/changelog/4083.changed.md b/changelog/4083.changed.md new file mode 100644 index 000000000..d9d46957a --- /dev/null +++ b/changelog/4083.changed.md @@ -0,0 +1 @@ +- `DeepgramSageMakerTTSService` now correctly routes audio through the base `TTSService` audio context queue. Audio frames are delivered via `append_to_audio_context()` instead of being pushed directly, enabling proper ordering, interruption handling, and start/stop frame lifecycle management. Interruptions now trigger a `Clear` message to Deepgram (flushing its text buffer) at the right time via `on_audio_context_interrupted`. diff --git a/changelog/4090.fixed.md b/changelog/4090.fixed.md new file mode 100644 index 000000000..ff42f09fe --- /dev/null +++ b/changelog/4090.fixed.md @@ -0,0 +1 @@ +- Fixed audio frame ordering and interruption handling in Fish Audio, LMNT, Neuphonic, and Rime NonJson TTS services. These services were bypassing the base `TTSService` audio context serialization queue by pushing audio frames directly, which could cause out-of-order frames and broken interruptions during speech. diff --git a/changelog/4093.fixed.md b/changelog/4093.fixed.md new file mode 100644 index 000000000..39fabc029 --- /dev/null +++ b/changelog/4093.fixed.md @@ -0,0 +1,7 @@ +- Fixed Genesys AudioHook serializer to always include the `parameters` field in + protocol messages. The AudioHook protocol requires every message to carry a + `parameters` object (even if empty), but `_create_message` omitted it when no + parameters were provided. This caused clients that validate message structure + (including the Genesys reference implementation) to reject `pong` and + parameter-less `closed` responses, breaking server sequence tracking and + preventing `outputVariables` from reaching the Architect flow. diff --git a/docs/api/README.md b/docs/api/README.md index 22b62d45e..e181bc898 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -42,7 +42,7 @@ This script: - Creates a fresh virtual environment - Installs all dependencies as specified in requirements files -- Handles conflicting dependencies (like grpcio versions for Riva and PlayHT) +- Handles conflicting dependencies (like grpcio versions for Riva) - Builds the documentation in an isolated environment - Provides detailed logging of the build process @@ -74,7 +74,6 @@ start _build/html/index.html ├── index.rst # Main documentation entry point ├── requirements-base.txt # Base documentation dependencies ├── requirements-riva.txt # Riva-specific dependencies -├── requirements-playht.txt # PlayHT-specific dependencies ├── build-docs.sh # Local build script └── rtd-test.py # ReadTheDocs test build script ``` diff --git a/docs/api/conf.py b/docs/api/conf.py index 653727668..5f7a3c2dc 100644 --- a/docs/api/conf.py +++ b/docs/api/conf.py @@ -91,6 +91,25 @@ autodoc_mock_imports = [ # MLX dependencies (Apple Silicon specific) "mlx", "mlx_whisper", # Note: might need underscore format too + # Pydantic v2 compatibility issues in third-party SDKs + "hume", + "hume.tts", + "hume.tts.types", + "cartesia", + "camb", + "sarvamai", + "openpipe", + "openai.types.beta.realtime", + "langchain_core", + "langchain_core.messages", + # FastAPI - Pydantic v2 compatibility issues during Sphinx autodoc + "fastapi", + "fastapi.applications", + "fastapi.routing", + "fastapi.params", + "fastapi.middleware", + "fastapi.responses", + "uvicorn", ] # HTML output settings diff --git a/env.example b/env.example index 1110a1ed3..dda5bb084 100644 --- a/env.example +++ b/env.example @@ -31,6 +31,9 @@ AZURE_DALLE_API_KEY=... AZURE_DALLE_ENDPOINT=https://... AZURE_DALLE_MODEL=... +# Camb.ai +CAMB_API_KEY=... + # Cartesia CARTESIA_API_KEY=... CARTESIA_VOICE_ID=... @@ -40,11 +43,12 @@ CEREBRAS_API_KEY=... # Daily DAILY_API_KEY=... -DAILY_SAMPLE_ROOM_URL=https://... +DAILY_ROOM_URL=https://... # Deepgram DEEPGRAM_API_KEY=... -SAGEMAKER_ENDPOINT_NAME=... +SAGEMAKER_STT_ENDPOINT_NAME=... +SAGEMAKER_TTS_ENDPOINT_NAME=... # DeepSeek DEEPSEEK_API_KEY=... @@ -97,9 +101,14 @@ INWORLD_API_KEY=... KRISP_MODEL_PATH=... # Krisp Viva +KRISP_VIVA_API_KEY=... KRISP_VIVA_FILTER_MODEL_PATH=... KRISP_VIVA_TURN_MODEL_PATH=... +# LemonSlice +LEMONSLICE_API_KEY=... +LEMONSLICE_AGENT_ID=... + # LiveKit LIVEKIT_API_KEY=... LIVEKIT_API_SECRET=... @@ -139,10 +148,6 @@ KOALA_ACCESS_KEY=... # Piper PIPER_BASE_URL=... -# PlayHT -PLAYHT_USER_ID=... -PLAYHT_API_KEY=... - # Plivo PLIVO_AUTH_ID=... PLIVO_AUTH_TOKEN=... @@ -150,6 +155,10 @@ PLIVO_AUTH_TOKEN=... # Qwen QWEN_API_KEY=... +# Resemble AI +RESEMBLE_API_KEY= +RESEMBLE_VOICE_UUID= + # Rime RIME_API_KEY=... RIME_VOICE_ID=... diff --git a/examples/foundational/01-say-one-thing-piper.py b/examples/foundational/01-say-one-thing-piper.py index 5aa975b31..6c4e1836e 100644 --- a/examples/foundational/01-say-one-thing-piper.py +++ b/examples/foundational/01-say-one-thing-piper.py @@ -16,7 +16,7 @@ from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineTask from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport -from pipecat.services.piper.tts import PiperTTSService +from pipecat.services.piper.tts import PiperHttpTTSService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -24,9 +24,8 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams(audio_out_enabled=True), "twilio": lambda: FastAPIWebsocketParams(audio_out_enabled=True), @@ -39,8 +38,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Create an HTTP session async with aiohttp.ClientSession() as session: - tts = PiperTTSService( - base_url=os.getenv("PIPER_BASE_URL"), aiohttp_session=session, sample_rate=24000 + tts = PiperHttpTTSService( + base_url=os.getenv("PIPER_BASE_URL"), + aiohttp_session=session, + sample_rate=24000, ) task = PipelineTask( diff --git a/examples/foundational/01-say-one-thing-rime.py b/examples/foundational/01-say-one-thing-rime.py index d6b4bad5f..a9df51f7f 100644 --- a/examples/foundational/01-say-one-thing-rime.py +++ b/examples/foundational/01-say-one-thing-rime.py @@ -23,9 +23,8 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams(audio_out_enabled=True), "twilio": lambda: FastAPIWebsocketParams(audio_out_enabled=True), @@ -40,8 +39,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async with aiohttp.ClientSession() as session: tts = RimeHttpTTSService( api_key=os.getenv("RIME_API_KEY", ""), - voice_id="rex", aiohttp_session=session, + settings=RimeHttpTTSService.Settings( + voice="rex", + ), ) task = PipelineTask( diff --git a/examples/foundational/01-say-one-thing.py b/examples/foundational/01-say-one-thing.py index 4ad179bdb..fdfecde6c 100644 --- a/examples/foundational/01-say-one-thing.py +++ b/examples/foundational/01-say-one-thing.py @@ -23,9 +23,8 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams(audio_out_enabled=True), "twilio": lambda: FastAPIWebsocketParams(audio_out_enabled=True), @@ -38,7 +37,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) task = PipelineTask( diff --git a/examples/foundational/01a-local-audio.py b/examples/foundational/01a-local-audio.py index 432880203..1e18b4b03 100644 --- a/examples/foundational/01a-local-audio.py +++ b/examples/foundational/01a-local-audio.py @@ -29,7 +29,9 @@ async def main(): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) pipeline = Pipeline([tts, transport.output()]) diff --git a/examples/foundational/01b-livekit-audio.py b/examples/foundational/01b-livekit-audio.py index a7697646e..5f64a29ed 100644 --- a/examples/foundational/01b-livekit-audio.py +++ b/examples/foundational/01b-livekit-audio.py @@ -37,7 +37,9 @@ async def main(): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) runner = PipelineRunner() diff --git a/examples/foundational/01c-nvidia-riva-tts.py b/examples/foundational/01c-nvidia-riva-tts.py index 5a42fed19..2be99c5b4 100644 --- a/examples/foundational/01c-nvidia-riva-tts.py +++ b/examples/foundational/01c-nvidia-riva-tts.py @@ -23,9 +23,8 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams(audio_out_enabled=True), "twilio": lambda: FastAPIWebsocketParams(audio_out_enabled=True), diff --git a/examples/foundational/02-llm-say-one-thing.py b/examples/foundational/02-llm-say-one-thing.py index ef41d271e..d699277ce 100644 --- a/examples/foundational/02-llm-say-one-thing.py +++ b/examples/foundational/02-llm-say-one-thing.py @@ -25,9 +25,8 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams(audio_out_enabled=True), "twilio": lambda: FastAPIWebsocketParams(audio_out_enabled=True), @@ -40,17 +39,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - - messages = [ - { - "role": "system", - "content": "You are an LLM in a WebRTC session, and this is a 'hello world' demo. Say hello to the world.", - } - ] + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) task = PipelineTask( Pipeline([llm, tts, transport.output()]), @@ -60,7 +59,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Register an event handler so we can play the audio when the client joins @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): - await task.queue_frames([LLMContextFrame(LLMContext(messages)), EndFrame()]) + context = LLMContext() + context.add_message({"role": "user", "content": "Say hello to the world."}) + await task.queue_frames([LLMContextFrame(context), EndFrame()]) runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) diff --git a/examples/foundational/03-still-frame.py b/examples/foundational/03-still-frame.py index 985dcdc9e..dc1bf4c5f 100644 --- a/examples/foundational/03-still-frame.py +++ b/examples/foundational/03-still-frame.py @@ -23,9 +23,8 @@ from pipecat.transports.daily.transport import DailyParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams( video_out_enabled=True, @@ -46,7 +45,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Create an HTTP session async with aiohttp.ClientSession() as session: imagegen = FalImageGenService( - params=FalImageGenService.InputParams(image_size="square_hd"), + settings=FalImageGenService.Settings( + image_size="square_hd", + ), aiohttp_session=session, key=os.getenv("FAL_KEY"), ) diff --git a/examples/foundational/03a-local-still-frame.py b/examples/foundational/03a-local-still-frame.py index b25c51c4a..0b70e2297 100644 --- a/examples/foundational/03a-local-still-frame.py +++ b/examples/foundational/03a-local-still-frame.py @@ -37,7 +37,9 @@ async def main(): ) imagegen = FalImageGenService( - params=FalImageGenService.InputParams(image_size="square_hd"), + settings=FalImageGenService.Settings( + image_size="square_hd", + ), aiohttp_session=session, key=os.getenv("FAL_KEY"), ) diff --git a/examples/foundational/03b-still-frame-imagen.py b/examples/foundational/03b-still-frame-imagen.py index 0fa371344..259072959 100644 --- a/examples/foundational/03b-still-frame-imagen.py +++ b/examples/foundational/03b-still-frame-imagen.py @@ -22,9 +22,8 @@ from pipecat.transports.daily.transport import DailyParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams( video_out_enabled=True, diff --git a/examples/foundational/04-transports-small-webrtc.py b/examples/foundational/04-transports-small-webrtc.py index 26b3291dd..cc768a751 100644 --- a/examples/foundational/04-transports-small-webrtc.py +++ b/examples/foundational/04-transports-small-webrtc.py @@ -17,9 +17,7 @@ from fastapi.responses import RedirectResponse from loguru import logger from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -35,8 +33,6 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.base_transport import TransportParams from pipecat.transports.smallwebrtc.connection import IceServer, SmallWebRTCConnection from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -64,7 +60,6 @@ async def run_example(webrtc_connection: SmallWebRTCConnection): params=TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), ) @@ -72,37 +67,33 @@ async def run_example(webrtc_connection: SmallWebRTCConnection): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -118,7 +109,7 @@ async def run_example(webrtc_connection: SmallWebRTCConnection): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/04a-transports-daily.py b/examples/foundational/04a-transports-daily.py index 6f31bc6b4..83bc27aca 100644 --- a/examples/foundational/04a-transports-daily.py +++ b/examples/foundational/04a-transports-daily.py @@ -12,9 +12,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -28,8 +26,6 @@ from pipecat.runner.daily import configure from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.daily.transport import DailyParams, DailyTransport -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -49,44 +45,37 @@ async def main(): audio_in_enabled=True, audio_out_enabled=True, transcription_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), ) tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o") - - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -102,7 +91,9 @@ async def main(): async def on_first_participant_joined(transport, participant): await transport.capture_participant_transcription(participant["id"]) # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_participant_left") diff --git a/examples/foundational/04b-transports-livekit.py b/examples/foundational/04b-transports-livekit.py index c24a082c5..b2ec6b49c 100644 --- a/examples/foundational/04b-transports-livekit.py +++ b/examples/foundational/04b-transports-livekit.py @@ -12,9 +12,7 @@ import sys from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import ( InterruptionFrame, TranscriptionFrame, @@ -35,8 +33,6 @@ from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.livekit.transport import LiveKitParams, LiveKitTransport -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -54,48 +50,40 @@ async def main(): params=LiveKitParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), ) stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) diff --git a/examples/foundational/05-sync-speech-and-image.py b/examples/foundational/05-sync-speech-and-image.py index bc0a35ea3..f0e2ff9c7 100644 --- a/examples/foundational/05-sync-speech-and-image.py +++ b/examples/foundational/05-sync-speech-and-image.py @@ -16,11 +16,12 @@ from pipecat.frames.frames import ( Frame, LLMContextFrame, LLMFullResponseStartFrame, + OutputImageRawFrame, TextFrame, ) from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner -from pipecat.pipeline.sync_parallel_pipeline import SyncParallelPipeline +from pipecat.pipeline.sync_parallel_pipeline import FrameOrder, SyncParallelPipeline from pipecat.pipeline.task import PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.sentence import SentenceAggregator @@ -30,6 +31,7 @@ from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaHttpTTSService from pipecat.services.fal.image import FalImageGenService from pipecat.services.openai.llm import OpenAILLMService +from pipecat.services.tts_service import TextAggregationMode from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams @@ -44,6 +46,18 @@ class MonthFrame(DataFrame): return f"{self.name}(month: {self.month})" +class MarkImageForPlaybackSync(FrameProcessor): + """Marks output image frames to be synchronized with audio playback.""" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + + if isinstance(frame, OutputImageRawFrame): + frame.sync_with_audio = True + + await self.push_frame(frame, direction) + + class MonthPrepender(FrameProcessor): def __init__(self): super().__init__() @@ -65,9 +79,8 @@ class MonthPrepender(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams( audio_out_enabled=True, @@ -99,11 +112,19 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaHttpTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaHttpTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + # No need to aggregate by sentences (the default), as we already know we're getting full sentences + # (Otherwise the service will unnecessarily wait for follow-up input to confirm the sentence is complete, + # which, sadly, actually breaks the synchronization mechanism) + text_aggregation_mode=TextAggregationMode.TOKEN, ) imagegen = FalImageGenService( - params=FalImageGenService.InputParams(image_size="square_hd"), + settings=FalImageGenService.Settings( + image_size="square_hd", + ), aiohttp_session=session, key=os.getenv("FAL_KEY"), ) @@ -116,17 +137,26 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # that, each pipeline runs concurrently and `SyncParallelPipeline` will # wait for the input frame to be processed. # + # We use `FrameOrder.PIPELINE` so that each synchronized batch of output + # frames is pushed in the order the pipelines are listed: image first, + # then audio. This ensures the transport receives the image before the + # audio frames it should accompany. + # # Note that `SyncParallelPipeline` requires the last processor in each # of the pipelines to be synchronous. In this case, we use - # `CartesiaHttpTTSService` and `FalImageGenService` which make HTTP + # `FalImageGenService` and `CartesiaHttpTTSService` which make HTTP # requests and wait for the response. pipeline = Pipeline( [ llm, # LLM sentence_aggregator, # Aggregates LLM output into full sentences SyncParallelPipeline( # Run pipelines in parallel aggregating the result + [ + imagegen, # Generate image + MarkImageForPlaybackSync(), # Mark image as needing sync w/audio during playback + ], [month_prepender, tts], # Create "Month: sentence" and output audio - [imagegen], # Generate image + frame_order=FrameOrder.PIPELINE, ), transport.output(), # Transport output ] @@ -149,7 +179,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ]: messages = [ { - "role": "system", + "role": "user", "content": f"Describe a nature photograph suitable for use in a calendar, for the month of {month}. Include only the image description with no preamble. Limit the description to one sentence, please.", } ] diff --git a/examples/foundational/05a-local-sync-speech-and-image.py b/examples/foundational/05a-local-sync-speech-and-image.py deleted file mode 100644 index 56309c0a5..000000000 --- a/examples/foundational/05a-local-sync-speech-and-image.py +++ /dev/null @@ -1,198 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -import asyncio -import os -import sys -import tkinter as tk - -import aiohttp -from dotenv import load_dotenv -from loguru import logger - -from pipecat.frames.frames import ( - Frame, - LLMContextFrame, - OutputAudioRawFrame, - TextFrame, - TTSAudioRawFrame, - URLImageRawFrame, -) -from pipecat.pipeline.pipeline import Pipeline -from pipecat.pipeline.runner import PipelineRunner -from pipecat.pipeline.sync_parallel_pipeline import SyncParallelPipeline -from pipecat.pipeline.task import PipelineTask -from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.sentence import SentenceAggregator -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pipecat.services.cartesia.tts import CartesiaHttpTTSService -from pipecat.services.fal.image import FalImageGenService -from pipecat.services.openai.llm import OpenAILLMService -from pipecat.transports.local.tk import TkLocalTransport, TkTransportParams - -load_dotenv(override=True) - -logger.remove(0) -logger.add(sys.stderr, level="DEBUG") - - -async def main(): - async with aiohttp.ClientSession() as session: - tk_root = tk.Tk() - tk_root.title("Calendar") - - runner = PipelineRunner() - - async def get_month_data(month): - messages = [ - { - "role": "system", - "content": f"Describe a nature photograph suitable for use in a calendar, for the month of {month}. Include only the image description with no preamble. Limit the description to one sentence, please.", - } - ] - - class ImageDescription(FrameProcessor): - def __init__(self): - super().__init__() - self.text = "" - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - if isinstance(frame, TextFrame): - self.text = frame.text - await self.push_frame(frame, direction) - - class AudioGrabber(FrameProcessor): - def __init__(self): - super().__init__() - self.audio = bytearray() - self.frame = None - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - if isinstance(frame, TTSAudioRawFrame): - self.audio.extend(frame.audio) - self.frame = OutputAudioRawFrame( - bytes(self.audio), frame.sample_rate, frame.num_channels - ) - await self.push_frame(frame, direction) - - class ImageGrabber(FrameProcessor): - def __init__(self): - super().__init__() - self.frame = None - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - if isinstance(frame, URLImageRawFrame): - self.frame = frame - await self.push_frame(frame, direction) - - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - - tts = CartesiaHttpTTSService( - api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady - ) - - imagegen = FalImageGenService( - params=FalImageGenService.InputParams(image_size="square_hd"), - aiohttp_session=session, - key=os.getenv("FAL_KEY"), - ) - - sentence_aggregator = SentenceAggregator() - - description = ImageDescription() - - audio_grabber = AudioGrabber() - - image_grabber = ImageGrabber() - - # With `SyncParallelPipeline` we synchronize audio and images by - # pushing them basically in order (e.g. I1 A1 A1 A1 I2 A2 A2 A2 A2 - # I3 A3). To do that, each pipeline runs concurrently and - # `SyncParallelPipeline` will wait for the input frame to be - # processed. - # - # Note that `SyncParallelPipeline` requires the last processor in - # each of the pipelines to be synchronous. In this case, we use - # `CartesiaHttpTTSService` and `FalImageGenService` which make HTTP - # requests and wait for the response. - pipeline = Pipeline( - [ - llm, # LLM - sentence_aggregator, # Aggregates LLM output into full sentences - description, # Store sentence - SyncParallelPipeline( - [tts, audio_grabber], # Generate and store audio for the given sentence - [imagegen, image_grabber], # Generate and storeimage for the given sentence - ), - ] - ) - - task = PipelineTask(pipeline) - await task.queue_frame(LLMContextFrame(LLMContext(messages))) - await task.stop_when_done() - - await runner.run(task) - - return { - "month": month, - "text": description.text, - "image": image_grabber.frame, - "audio": audio_grabber.frame, - } - - transport = TkLocalTransport( - tk_root, - TkTransportParams( - audio_out_enabled=True, - video_out_enabled=True, - video_out_width=1024, - video_out_height=1024, - ), - ) - - pipeline = Pipeline([transport.output()]) - - task = PipelineTask(pipeline) - - # We only specify a few months as we create tasks all at once and we - # might get rate limited otherwise. - months: list[str] = [ - "January", - "February", - ] - - # We create one task per month. This will be executed concurrently. - month_tasks = [asyncio.create_task(get_month_data(month)) for month in months] - - # Now we wait for each month task in the order they're completed. The - # benefit is we'll have as little delay as possible before the first - # month, and likely no delay between months, but the months won't - # display in order. - async def show_images(month_tasks): - for month_data_task in asyncio.as_completed(month_tasks): - data = await month_data_task - await task.queue_frames([data["image"], data["audio"]]) - - await runner.stop_when_done() - - async def run_tk(): - while not task.has_finished(): - tk_root.update() - tk_root.update_idletasks() - await asyncio.sleep(0.1) - - await asyncio.gather(runner.run(task), show_images(month_tasks), run_tk()) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/foundational/06-listen-and-respond.py b/examples/foundational/06-listen-and-respond.py index 9290becb2..769975b9a 100644 --- a/examples/foundational/06-listen-and-respond.py +++ b/examples/foundational/06-listen-and-respond.py @@ -9,9 +9,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import Frame, LLMRunFrame, MetricsFrame from pipecat.metrics.metrics import ( LLMUsageMetricsData, @@ -36,8 +34,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -62,24 +58,20 @@ class MetricsLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -91,40 +83,36 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) ml = MetricsLogger() - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, ml, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -141,7 +129,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/06a-image-sync.py b/examples/foundational/06a-image-sync.py index 363502fac..473441fac 100644 --- a/examples/foundational/06a-image-sync.py +++ b/examples/foundational/06a-image-sync.py @@ -10,9 +10,7 @@ from dotenv import load_dotenv from loguru import logger from PIL import Image -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import ( BotStartedSpeakingFrame, BotStoppedSpeakingFrame, @@ -36,8 +34,6 @@ 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.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -77,9 +73,8 @@ class ImageSyncAggregator(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -87,7 +82,6 @@ transport_params = { video_out_enabled=True, video_out_width=1024, video_out_height=1024, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, @@ -95,7 +89,6 @@ transport_params = { video_out_enabled=True, video_out_width=1024, video_out_height=1024, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -107,28 +100,24 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + image_sync_aggregator = ImageSyncAggregator( os.path.join(os.path.dirname(__file__), "assets", "speaking.png"), os.path.join(os.path.dirname(__file__), "assets", "waiting.png"), @@ -138,12 +127,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, image_sync_aggregator, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/07-interruptible-cartesia-http.py b/examples/foundational/07-interruptible-cartesia-http.py index 919660b03..6b3daecf0 100644 --- a/examples/foundational/07-interruptible-cartesia-http.py +++ b/examples/foundational/07-interruptible-cartesia-http.py @@ -6,12 +6,11 @@ import os +import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -29,30 +28,24 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -60,68 +53,68 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - stt = CartesiaSTTService(api_key=os.getenv("CARTESIA_API_KEY")) + async with aiohttp.ClientSession() as session: + stt = CartesiaSTTService(api_key=os.getenv("CARTESIA_API_KEY")) - tts = CartesiaHttpTTSService( - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] + tts = CartesiaHttpTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + aiohttp_session=session, + settings=CartesiaHttpTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady ), - ), - ) + ) - pipeline = Pipeline( - [ - transport.input(), # Transport user input - stt, - context_aggregator.user(), # User responses - llm, # LLM - tts, # TTS - transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses - ] - ) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) - task = PipelineTask( - pipeline, - params=PipelineParams( - enable_metrics=True, - enable_usage_metrics=True, - ), - idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) - @transport.event_handler("on_client_connected") - async def on_client_connected(transport, client): - logger.info(f"Client connected") - # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) - await task.queue_frames([LLMRunFrame()]) + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) - @transport.event_handler("on_client_disconnected") - async def on_client_disconnected(transport, client): - logger.info(f"Client disconnected") - await task.cancel() + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) - runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) - await runner.run(task) + @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): diff --git a/examples/foundational/07-interruptible-openai-responses.py b/examples/foundational/07-interruptible-openai-responses.py new file mode 100644 index 000000000..baae3754a --- /dev/null +++ b/examples/foundational/07-interruptible-openai-responses.py @@ -0,0 +1,125 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.openai.responses.llm import OpenAIResponsesLLMService +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) + +# 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(f"Starting bot") + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAIResponsesLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIResponsesLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + context.add_message( + {"role": "developer", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/examples/foundational/07-interruptible.py b/examples/foundational/07-interruptible.py index b9e8bb0f7..c0b410f50 100644 --- a/examples/foundational/07-interruptible.py +++ b/examples/foundational/07-interruptible.py @@ -9,9 +9,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -29,29 +27,23 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -63,37 +55,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -110,7 +98,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07a-interruptible-speechmatics-vad.py b/examples/foundational/07a-interruptible-speechmatics-vad.py index 8f1898f3f..327701ff2 100644 --- a/examples/foundational/07a-interruptible-speechmatics-vad.py +++ b/examples/foundational/07a-interruptible-speechmatics-vad.py @@ -21,7 +21,6 @@ from pipecat.processors.aggregators.llm_response_universal import ( ) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport -from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.speechmatics.stt import SpeechmaticsSTTService from pipecat.services.speechmatics.tts import SpeechmaticsTTSService @@ -33,9 +32,8 @@ from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -94,7 +92,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async with aiohttp.ClientSession() as session: stt = SpeechmaticsSTTService( api_key=os.getenv("SPEECHMATICS_API_KEY"), - params=SpeechmaticsSTTService.InputParams( + settings=SpeechmaticsSTTService.Settings( language=Language.EN, turn_detection_mode=SpeechmaticsSTTService.TurnDetectionMode.ADAPTIVE, # focus_speakers=["S1"], @@ -105,33 +103,22 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = SpeechmaticsTTSService( api_key=os.getenv("SPEECHMATICS_API_KEY"), - voice_id="sarah", + settings=SpeechmaticsTTSService.Settings( + voice="sarah", + ), aiohttp_session=session, ) llm = OpenAILLMService( api_key=os.getenv("OPENAI_API_KEY"), - params=BaseOpenAILLMService.InputParams(temperature=0.75), + settings=OpenAILLMService.Settings( + temperature=0.75, + system_instruction="You are a helpful British assistant called Sarah in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Always include punctuation in your responses. Give very short replies - do not give longer replies unless strictly necessary. Respond to what the user said in a concise, funny, creative and helpful way. Use `` tags to identify different speakers - do not use tags in your replies. Do not respond to speakers within `` tags unless explicitly asked to.", + ), ) - messages = [ - { - "role": "system", - "content": ( - "You are a helpful British assistant called Sarah. " - "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. " - "Always include punctuation in your responses. " - "Give very short replies - do not give longer replies unless strictly necessary. " - "Respond to what the user said in a concise, funny, creative and helpful way. " - "Use `` tags to identify different speakers - do not use tags in your replies. " - "Do not respond to speakers within `` tags unless explicitly asked to. " - ), - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams(user_turn_strategies=ExternalUserTurnStrategies()), ) @@ -140,11 +127,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -161,7 +148,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Say a short hello to the user."}) + context.add_message({"role": "user", "content": "Say a short hello to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07a-interruptible-speechmatics.py b/examples/foundational/07a-interruptible-speechmatics.py index 370b2c998..5181bf36f 100644 --- a/examples/foundational/07a-interruptible-speechmatics.py +++ b/examples/foundational/07a-interruptible-speechmatics.py @@ -10,9 +10,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -24,7 +22,6 @@ from pipecat.processors.aggregators.llm_response_universal import ( ) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport -from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.speechmatics.stt import SpeechmaticsSTTService from pipecat.services.speechmatics.tts import SpeechmaticsTTSService @@ -32,29 +29,23 @@ 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -84,7 +75,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async with aiohttp.ClientSession() as session: stt = SpeechmaticsSTTService( api_key=os.getenv("SPEECHMATICS_API_KEY"), - params=SpeechmaticsSTTService.InputParams( + settings=SpeechmaticsSTTService.Settings( language=Language.EN, speaker_active_format="<{speaker_id}>{text}", ), @@ -92,51 +83,35 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = SpeechmaticsTTSService( api_key=os.getenv("SPEECHMATICS_API_KEY"), - voice_id="sarah", + settings=SpeechmaticsTTSService.Settings( + voice="sarah", + ), aiohttp_session=session, ) llm = OpenAILLMService( api_key=os.getenv("OPENAI_API_KEY"), - params=BaseOpenAILLMService.InputParams(temperature=0.75), + settings=OpenAILLMService.Settings( + temperature=0.75, + system_instruction="You are a helpful British assistant called Sarah in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Always include punctuation in your responses. Give very short replies - do not give longer replies unless strictly necessary. Respond to what the user said in a concise, funny, creative and helpful way. Use `` tags to identify different speakers - do not use tags in your replies. Do not respond to speakers within `` tags unless explicitly asked to.", + ), ) - messages = [ - { - "role": "system", - "content": ( - "You are a helpful British assistant called Sarah. " - "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. " - "Always include punctuation in your responses. " - "Give very short replies - do not give longer replies unless strictly necessary. " - "Respond to what the user said in a concise, funny, creative and helpful way. " - "Use `` tags to identify different speakers - do not use tags in your replies." - ), - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -153,7 +128,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Say a short hello to the user."}) + context.add_message({"role": "user", "content": "Say a short hello to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07b-interruptible-langchain.py b/examples/foundational/07b-interruptible-langchain.py index a5a8f3d0e..d78e678a2 100644 --- a/examples/foundational/07b-interruptible-langchain.py +++ b/examples/foundational/07b-interruptible-langchain.py @@ -15,9 +15,7 @@ from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_openai import ChatOpenAI from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMMessagesUpdateFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -35,8 +33,6 @@ from pipecat.services.deepgram.stt import DeepgramSTTService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -50,24 +46,20 @@ def get_session_history(session_id: str) -> BaseChatMessageHistory: return message_store[session_id] -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -79,15 +71,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) prompt = ChatPromptTemplate.from_messages( [ ( "system", - "Be nice and helpful. Answer very briefly and without special characters like `#` or `*`. " - "Your response will be synthesized to voice and those characters will create unnatural sounds.", + "You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), MessagesPlaceholder("chat_history"), ("human", "{input}"), @@ -103,24 +96,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): lc = LangchainProcessor(history_chain) context = LLMContext() - context_aggregator = LLMContextAggregatorPair( + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses lc, # Langchain tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) diff --git a/examples/foundational/07c-interruptible-deepgram-flux.py b/examples/foundational/07c-interruptible-deepgram-flux.py index 87c1ca7e9..2c4e63bb7 100644 --- a/examples/foundational/07c-interruptible-deepgram-flux.py +++ b/examples/foundational/07c-interruptible-deepgram-flux.py @@ -10,6 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger +from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,9 +33,8 @@ from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -56,35 +56,43 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramFluxSTTService( api_key=os.getenv("DEEPGRAM_API_KEY"), - params=DeepgramFluxSTTService.InputParams(min_confidence=0.3), + settings=DeepgramFluxSTTService.Settings( + min_confidence=0.3, + ), ) - tts = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY"), voice="aura-2-andromeda-en") + tts = DeepgramTTSService( + api_key=os.getenv("DEEPGRAM_API_KEY"), + settings=DeepgramTTSService.Settings( + voice="aura-2-andromeda-en", + ), + ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams(user_turn_strategies=ExternalUserTurnStrategies()), + user_params=LLMUserAggregatorParams( + user_turn_strategies=ExternalUserTurnStrategies(), + vad_analyzer=SileroVADAnalyzer(), + ), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -101,7 +109,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07c-interruptible-deepgram-http.py b/examples/foundational/07c-interruptible-deepgram-http.py index c0b607986..cb1026d7f 100644 --- a/examples/foundational/07c-interruptible-deepgram-http.py +++ b/examples/foundational/07c-interruptible-deepgram-http.py @@ -11,9 +11,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,30 +29,24 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -67,40 +59,34 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = DeepgramHttpTTSService( api_key=os.getenv("DEEPGRAM_API_KEY"), - voice="aura-2-andromeda-en", + settings=DeepgramHttpTTSService.Settings( + voice="aura-2-andromeda-en", + ), aiohttp_session=session, ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -117,7 +103,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07c-interruptible-deepgram-sagemaker.py b/examples/foundational/07c-interruptible-deepgram-sagemaker.py index 53da2dba7..b3b53b3db 100644 --- a/examples/foundational/07c-interruptible-deepgram-sagemaker.py +++ b/examples/foundational/07c-interruptible-deepgram-sagemaker.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -24,36 +22,30 @@ from pipecat.processors.aggregators.llm_response_universal import ( ) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport -from pipecat.services.aws.llm import AWSBedrockLLMService -from pipecat.services.deepgram.stt_sagemaker import DeepgramSageMakerSTTService -from pipecat.services.deepgram.tts import DeepgramTTSService +from pipecat.services.aws.llm import AWSBedrockLLMService, AWSBedrockLLMSettings +from pipecat.services.deepgram.sagemaker.stt import DeepgramSageMakerSTTService +from pipecat.services.deepgram.sagemaker.tts import DeepgramSageMakerTTSService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -66,44 +58,46 @@ 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"), + settings=DeepgramSageMakerTTSService.Settings( + voice="aura-2-andromeda-en", + ), + ) llm = AWSBedrockLLMService( aws_region=os.getenv("AWS_REGION"), - model="us.amazon.nova-pro-v1:0", - params=AWSBedrockLLMService.InputParams(temperature=0.8), + settings=AWSBedrockLLMSettings( + model="us.amazon.nova-pro-v1:0", + temperature=0.8, + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -120,7 +114,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07c-interruptible-deepgram-vad.py b/examples/foundational/07c-interruptible-deepgram-vad.py index 482ff2b26..420ec795b 100644 --- a/examples/foundational/07c-interruptible-deepgram-vad.py +++ b/examples/foundational/07c-interruptible-deepgram-vad.py @@ -7,7 +7,6 @@ import os -from deepgram import LiveOptions from dotenv import load_dotenv from loguru import logger @@ -33,9 +32,8 @@ from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -57,22 +55,28 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService( api_key=os.getenv("DEEPGRAM_API_KEY"), - live_options=LiveOptions(vad_events=True, utterance_end_ms="1000"), + settings=DeepgramSTTService.Settings( + vad_events=True, + utterance_end_ms="1000", + ), ) - tts = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY"), voice="aura-2-andromeda-en") + tts = DeepgramTTSService( + api_key=os.getenv("DEEPGRAM_API_KEY"), + settings=DeepgramTTSService.Settings( + voice="aura-2-andromeda-en", + ), + ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams(user_turn_strategies=ExternalUserTurnStrategies()), ) @@ -81,11 +85,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -102,7 +106,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07c-interruptible-deepgram.py b/examples/foundational/07c-interruptible-deepgram.py index 5754b33a9..a4d94f915 100644 --- a/examples/foundational/07c-interruptible-deepgram.py +++ b/examples/foundational/07c-interruptible-deepgram.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,30 +28,24 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -63,36 +55,35 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - tts = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY"), voice="aura-2-andromeda-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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + tts = DeepgramTTSService( + api_key=os.getenv("DEEPGRAM_API_KEY"), + settings=DeepgramTTSService.Settings( + voice="aura-2-andromeda-en", ), ) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -109,7 +100,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07d-interruptible-elevenlabs-http.py b/examples/foundational/07d-interruptible-elevenlabs-http.py index 933f6ffdb..a83df1465 100644 --- a/examples/foundational/07d-interruptible-elevenlabs-http.py +++ b/examples/foundational/07d-interruptible-elevenlabs-http.py @@ -11,9 +11,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,30 +29,24 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -71,40 +63,34 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = ElevenLabsHttpTTSService( api_key=os.getenv("ELEVENLABS_API_KEY", ""), - voice_id=os.getenv("ELEVENLABS_VOICE_ID", ""), aiohttp_session=session, + settings=ElevenLabsHttpTTSService.Settings( + voice=os.getenv("ELEVENLABS_VOICE_ID", ""), + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -121,7 +107,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07d-interruptible-elevenlabs.py b/examples/foundational/07d-interruptible-elevenlabs.py index aa519fd5b..ad1873788 100644 --- a/examples/foundational/07d-interruptible-elevenlabs.py +++ b/examples/foundational/07d-interruptible-elevenlabs.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,30 +28,24 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -65,37 +57,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = ElevenLabsTTSService( api_key=os.getenv("ELEVENLABS_API_KEY", ""), - voice_id=os.getenv("ELEVENLABS_VOICE_ID", ""), + settings=ElevenLabsTTSService.Settings( + voice=os.getenv("ELEVENLABS_VOICE_ID", ""), + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -112,7 +100,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07f-interruptible-azure-http.py b/examples/foundational/07f-interruptible-azure-http.py index 882aa61c0..c372c7c7e 100644 --- a/examples/foundational/07f-interruptible-azure-http.py +++ b/examples/foundational/07f-interruptible-azure-http.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,29 +28,23 @@ from pipecat.services.azure.tts import AzureHttpTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -73,35 +65,27 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = AzureLLMService( api_key=os.getenv("AZURE_CHATGPT_API_KEY"), endpoint=os.getenv("AZURE_CHATGPT_ENDPOINT"), - model=os.getenv("AZURE_CHATGPT_MODEL"), + settings=AzureLLMService.Settings( + model=os.getenv("AZURE_CHATGPT_MODEL"), + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -118,7 +102,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07f-interruptible-azure.py b/examples/foundational/07f-interruptible-azure.py index 7688731ae..6a0b39bb5 100644 --- a/examples/foundational/07f-interruptible-azure.py +++ b/examples/foundational/07f-interruptible-azure.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,29 +28,23 @@ from pipecat.services.azure.tts import AzureTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -73,35 +65,27 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = AzureLLMService( api_key=os.getenv("AZURE_CHATGPT_API_KEY"), endpoint=os.getenv("AZURE_CHATGPT_ENDPOINT"), - model=os.getenv("AZURE_CHATGPT_MODEL"), + settings=AzureLLMService.Settings( + model=os.getenv("AZURE_CHATGPT_MODEL"), + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -118,7 +102,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07g-interruptible-openai-http.py b/examples/foundational/07g-interruptible-openai-http.py new file mode 100644 index 000000000..127b9b0c0 --- /dev/null +++ b/examples/foundational/07g-interruptible-openai-http.py @@ -0,0 +1,131 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.services.openai.stt import OpenAISTTService +from pipecat.services.openai.tts import OpenAITTSService +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) + +# 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(f"Starting bot") + + stt = OpenAISTTService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAISTTService.Settings( + model="gpt-4o-transcribe", + prompt="Expect words related to dogs, such as breed names.", + ), + ) + + tts = OpenAITTSService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAITTSService.Settings( + voice="ballad", + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are very knowledgable about dogs. 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() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + audio_out_sample_rate=24000, + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/examples/foundational/07g-interruptible-openai.py b/examples/foundational/07g-interruptible-openai.py index 236787af6..9b8fbea09 100644 --- a/examples/foundational/07g-interruptible-openai.py +++ b/examples/foundational/07g-interruptible-openai.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -25,34 +23,29 @@ from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.openai.llm import OpenAILLMService -from pipecat.services.openai.stt import OpenAISTTService +from pipecat.services.openai.stt import OpenAIRealtimeSTTService from pipecat.services.openai.tts import OpenAITTSService +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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -60,42 +53,44 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - stt = OpenAISTTService( + stt = OpenAIRealtimeSTTService( api_key=os.getenv("OPENAI_API_KEY"), - model="gpt-4o-transcribe", - prompt="Expect words related to dogs, such as breed names.", + settings=OpenAIRealtimeSTTService.Settings( + model="gpt-4o-transcribe", + prompt="Expect words related to dogs, such as breed names.", + language=Language.EN, + ), ) - tts = OpenAITTSService(api_key=os.getenv("OPENAI_API_KEY"), voice="ballad") - - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - - messages = [ - { - "role": "system", - "content": "You are very knowledgable about dogs. 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + tts = OpenAITTSService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAITTSService.Settings( + voice="ballad", ), ) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are very knowledgable about dogs. 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() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -113,7 +108,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07h-interruptible-openpipe.py b/examples/foundational/07h-interruptible-openpipe.py index baa8c869c..8d31125f2 100644 --- a/examples/foundational/07h-interruptible-openpipe.py +++ b/examples/foundational/07h-interruptible-openpipe.py @@ -11,9 +11,7 @@ import time from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,29 +29,23 @@ from pipecat.services.openpipe.llm import OpenPipeLLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -65,7 +57,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) timestamp = int(time.time()) @@ -73,34 +67,26 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): api_key=os.getenv("OPENAI_API_KEY"), openpipe_api_key=os.getenv("OPENPIPE_API_KEY"), tags={"conversation_id": f"pipecat-{timestamp}"}, + settings=OpenPipeLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -117,7 +103,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07i-interruptible-xtts.py b/examples/foundational/07i-interruptible-xtts.py index fed73d2c3..0f51ff03e 100644 --- a/examples/foundational/07i-interruptible-xtts.py +++ b/examples/foundational/07i-interruptible-xtts.py @@ -11,9 +11,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,29 +29,23 @@ from pipecat.services.xtts.tts import XTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -67,40 +59,34 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = XTTSService( aiohttp_session=session, - voice_id="Claribel Dervla", + settings=XTTSService.Settings( + voice="Claribel Dervla", + ), base_url="http://localhost:8000", ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -117,7 +103,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07j-interruptible-gladia-vad.py b/examples/foundational/07j-interruptible-gladia-vad.py new file mode 100644 index 000000000..ec3cc80e1 --- /dev/null +++ b/examples/foundational/07j-interruptible-gladia-vad.py @@ -0,0 +1,138 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.gladia.config import LanguageConfig +from pipecat.services.gladia.stt import GladiaSTTService +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 +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(f"Starting bot") + + stt = GladiaSTTService( + api_key=os.getenv("GLADIA_API_KEY", ""), + region=os.getenv("GLADIA_REGION"), + settings=GladiaSTTService.Settings( + language_config=LanguageConfig( + languages=[Language.EN], + ), + enable_vad=True, + ), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY", ""), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY", ""), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=ExternalUserTurnStrategies(), + vad_analyzer=SileroVADAnalyzer(), + ), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/examples/foundational/07j-interruptible-gladia.py b/examples/foundational/07j-interruptible-gladia.py index 5a1cd305b..5ab2e16a3 100644 --- a/examples/foundational/07j-interruptible-gladia.py +++ b/examples/foundational/07j-interruptible-gladia.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -25,36 +23,30 @@ from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService -from pipecat.services.gladia.config import GladiaInputParams, LanguageConfig +from pipecat.services.gladia.config import LanguageConfig from pipecat.services.gladia.stt import GladiaSTTService 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -65,7 +57,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = GladiaSTTService( api_key=os.getenv("GLADIA_API_KEY", ""), region=os.getenv("GLADIA_REGION"), - params=GladiaInputParams( + settings=GladiaSTTService.Settings( language_config=LanguageConfig( languages=[Language.EN], ) @@ -74,37 +66,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY", ""), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY", "")) - - messages = [ - { - "role": "system", - "content": f"You are a helpful LLM. 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY", ""), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -121,7 +109,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07k-interruptible-lmnt.py b/examples/foundational/07k-interruptible-lmnt.py index 048e91d15..c6f931413 100644 --- a/examples/foundational/07k-interruptible-lmnt.py +++ b/examples/foundational/07k-interruptible-lmnt.py @@ -10,10 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,29 +28,23 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -63,36 +54,35 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - tts = LmntTTSService(api_key=os.getenv("LMNT_API_KEY"), voice_id="morgan") - - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + tts = LmntTTSService( + api_key=os.getenv("LMNT_API_KEY"), + settings=LmntTTSService.Settings( + voice="morgan", ), ) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User respones + user_aggregator, # User respones llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -109,7 +99,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07l-interruptible-groq.py b/examples/foundational/07l-interruptible-groq.py index 55791e0ce..59e1a7fca 100644 --- a/examples/foundational/07l-interruptible-groq.py +++ b/examples/foundational/07l-interruptible-groq.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,29 +28,23 @@ from pipecat.services.groq.tts import GroqTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -63,37 +55,30 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = GroqSTTService(api_key=os.getenv("GROQ_API_KEY")) llm = GroqLLMService( - api_key=os.getenv("GROQ_API_KEY"), model="meta-llama/llama-4-maverick-17b-128e-instruct" + api_key=os.getenv("GROQ_API_KEY"), + settings=GroqLLMService.Settings( + model="llama-3.1-8b-instant", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) tts = GroqTTSService(api_key=os.getenv("GROQ_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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -110,7 +95,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07m-interruptible-aws-strands.py b/examples/foundational/07m-interruptible-aws-strands.py index c3e830a1f..5d7b5fc8e 100644 --- a/examples/foundational/07m-interruptible-aws-strands.py +++ b/examples/foundational/07m-interruptible-aws-strands.py @@ -8,7 +8,6 @@ from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.frames.frames import LLMMessagesAppendFrame from pipecat.pipeline.pipeline import Pipeline @@ -27,8 +26,6 @@ from pipecat.services.aws.tts import AWSPollyTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies # Strands agent setup try: @@ -41,24 +38,20 @@ except ImportError: load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -102,13 +95,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = AWSPollyTTSService( region="us-west-2", # only specific regions support generative TTS - voice_id="Joanna", - params=AWSPollyTTSService.InputParams(engine="generative", rate="1.1"), + settings=AWSPollyTTSService.Settings( + voice="Joanna", + engine="generative", + rate="1.1", + ), ) # Create Strands agent processor try: - agent = build_agent(model_id="us.anthropic.claude-3-5-haiku-20241022-v1:0", max_tokens=8000) + agent = build_agent(model_id="us.anthropic.claude-sonnet-4-6", max_tokens=8000) llm = StrandsAgentsProcessor(agent=agent) logger.info("Successfully created Strands agent for NAB customer service coaching") except Exception as e: @@ -120,24 +116,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Setup context aggregators for message handling context = LLMContext() - context_aggregator = LLMContextAggregatorPair( + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # Speech-to-text - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # Strands Agents processor tts, # Text-to-speech transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -160,7 +152,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): messages=[ { "role": "user", - "content": f"Greet the user and introduce yourself.", + "content": f"Greet the user and introduce yourself. Don't use emojis.", } ], run_llm=True, diff --git a/examples/foundational/07m-interruptible-aws.py b/examples/foundational/07m-interruptible-aws.py index eb4974d8f..ca44fb448 100644 --- a/examples/foundational/07m-interruptible-aws.py +++ b/examples/foundational/07m-interruptible-aws.py @@ -8,9 +8,7 @@ from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -28,29 +26,23 @@ from pipecat.services.aws.tts import AWSPollyTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -62,42 +54,37 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = AWSPollyTTSService( region="us-west-2", # only specific regions support generative TTS - voice_id="Joanna", - params=AWSPollyTTSService.InputParams(engine="generative", rate="1.1"), + settings=AWSPollyTTSService.Settings( + voice="Joanna", + engine="generative", + rate="1.1", + ), ) llm = AWSBedrockLLMService( aws_region="us-west-2", - model="us.anthropic.claude-haiku-4-5-20251001-v1:0", - params=AWSBedrockLLMService.InputParams(temperature=0.8), + settings=AWSBedrockLLMService.Settings( + model="us.anthropic.claude-sonnet-4-6", + temperature=0.8, + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -114,7 +101,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "user", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07n-interruptible-gemini-image.py b/examples/foundational/07n-interruptible-gemini-image.py index 08ab8fb81..461e2d8fa 100644 --- a/examples/foundational/07n-interruptible-gemini-image.py +++ b/examples/foundational/07n-interruptible-gemini-image.py @@ -25,9 +25,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -45,15 +43,11 @@ from pipecat.services.google.tts import GoogleTTSService 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -61,7 +55,6 @@ transport_params = { video_out_enabled=True, video_out_width=1024, video_out_height=1024, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, @@ -69,7 +62,6 @@ transport_params = { video_out_enabled=True, video_out_width=1024, video_out_height=1024, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -78,48 +70,44 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") stt = GoogleSTTService( - params=GoogleSTTService.InputParams(languages=Language.EN_US), credentials=os.getenv("GOOGLE_TEST_CREDENTIALS"), + settings=GoogleSTTService.Settings( + languages=[Language.EN_US], + ), ) tts = GoogleTTSService( - voice_id="en-US-Chirp3-HD-Charon", - params=GoogleTTSService.InputParams(language=Language.EN_US), credentials=os.getenv("GOOGLE_TEST_CREDENTIALS"), + settings=GoogleTTSService.Settings( + voice="en-US-Chirp3-HD-Charon", + language=Language.EN_US, + ), ) llm = GoogleLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - model="gemini-2.5-flash-image", - # model="gemini-3-pro-image-preview", # A more powerful model, but slower + settings=GoogleLLMService.Settings( + model="gemini-2.5-flash-image", + # model="gemini-3-pro-image-preview", # A more powerful model, but slower, + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # Gemini TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -136,7 +124,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation with a styled introduction - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07n-interruptible-gemini.py b/examples/foundational/07n-interruptible-gemini.py index e8234a60d..00290bd3a 100644 --- a/examples/foundational/07n-interruptible-gemini.py +++ b/examples/foundational/07n-interruptible-gemini.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,29 +29,23 @@ 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -62,15 +54,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot with Gemini TTS") stt = GoogleSTTService( - params=GoogleSTTService.InputParams(languages=Language.EN_US), + settings=GoogleSTTService.Settings( + languages=[Language.EN_US], + ), credentials=os.getenv("GOOGLE_TEST_CREDENTIALS"), ) tts = GeminiTTSService( credentials=os.getenv("GOOGLE_TEST_CREDENTIALS"), - model="gemini-2.5-flash-tts", - voice_id="Charon", - params=GeminiTTSService.InputParams( + settings=GeminiTTSService.Settings( + model="gemini-2.5-flash-tts", + voice="Charon", language=Language.EN_US, prompt="You are a helpful AI assistant. Speak in a natural, conversational tone.", ), @@ -79,13 +73,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = GoogleLLMService( api_key=os.getenv("GOOGLE_API_KEY"), model="gemini-2.5-flash", - ) - - # System message that instructs the AI on how to speak - messages = [ - { - "role": "system", - "content": """You are a helpful AI assistant in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. + settings=GoogleLLMService.Settings( + system_instruction="""You are a helpful assistant in a voice conversation. IMPORTANT: You're using Gemini TTS which supports expressive markup tags. You can use these tags in your responses: - [sigh] - Insert a sigh sound @@ -102,29 +91,25 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - "[whispering] Let me tell you a secret." - "The answer is... [long pause] ...42!" - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Keep responses concise. Respond to what the user said in a creative and helpful way.""", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # Gemini TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -141,9 +126,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": "You are an AI assistant. You can help with a variety of tasks. Introduce yourself and ask the user what they would like to know.", } ) diff --git a/examples/foundational/07n-interruptible-google-http.py b/examples/foundational/07n-interruptible-google-http.py index 42f251612..d627f431f 100644 --- a/examples/foundational/07n-interruptible-google-http.py +++ b/examples/foundational/07n-interruptible-google-http.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,29 +29,23 @@ 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -62,52 +54,48 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") stt = GoogleSTTService( - params=GoogleSTTService.InputParams(languages=Language.EN_US, model="chirp_3"), + settings=GoogleSTTService.Settings( + languages=[Language.EN_US], + # Add model to use a specific model + # model="chirp_3", + ), credentials=os.getenv("GOOGLE_TEST_CREDENTIALS"), location="us", ) tts = GoogleHttpTTSService( - voice_id="en-US-Chirp3-HD-Charon", - params=GoogleHttpTTSService.InputParams(language=Language.EN_US), + settings=GoogleHttpTTSService.Settings( + voice="en-US-Chirp3-HD-Charon", + language=Language.EN_US, + ), credentials=os.getenv("GOOGLE_TEST_CREDENTIALS"), ) llm = GoogleLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - model="gemini-2.5-flash", - # force a certain amount of thinking if you want it - # params=GoogleLLMService.InputParams( - # thinking=GoogleLLMService.ThinkingConfig(thinking_budget=4096) - # ), + settings=GoogleLLMService.Settings( + model="gemini-2.5-flash", + # force a certain amount of thinking if you want it + # thinking=GoogleLLMService.ThinkingConfig(thinking_budget=4096) + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User respones + user_aggregator, # User respones llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -124,7 +112,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07n-interruptible-google.py b/examples/foundational/07n-interruptible-google.py index 4c5969a04..f8ec7b037 100644 --- a/examples/foundational/07n-interruptible-google.py +++ b/examples/foundational/07n-interruptible-google.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,29 +29,23 @@ 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -62,52 +54,48 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") stt = GoogleSTTService( - params=GoogleSTTService.InputParams(languages=Language.EN_US, model="chirp_3"), + settings=GoogleSTTService.Settings( + languages=[Language.EN_US], + # Add model to use a specific model + # model="chirp_3", + ), credentials=os.getenv("GOOGLE_TEST_CREDENTIALS"), location="us", ) tts = GoogleTTSService( - voice_id="en-US-Chirp3-HD-Charon", - params=GoogleTTSService.InputParams(language=Language.EN_US), + settings=GoogleTTSService.Settings( + voice="en-US-Chirp3-HD-Charon", + language=Language.EN_US, + ), credentials=os.getenv("GOOGLE_TEST_CREDENTIALS"), ) llm = GoogleLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - model="gemini-2.5-flash", - # force a certain amount of thinking if you want it - # params=GoogleLLMService.InputParams( - # thinking=GoogleLLMService.ThinkingConfig(thinking_budget=4096) - # ), + settings=GoogleLLMService.Settings( + model="gemini-2.5-flash", + # force a certain amount of thinking if you want it + # thinking=GoogleLLMService.ThinkingConfig(thinking_budget=4096), + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User respones + user_aggregator, # User respones llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -124,7 +112,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07o-interruptible-assemblyai-turn-detection.py b/examples/foundational/07o-interruptible-assemblyai-turn-detection.py new file mode 100644 index 000000000..14ec1af21 --- /dev/null +++ b/examples/foundational/07o-interruptible-assemblyai-turn-detection.py @@ -0,0 +1,178 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.assemblyai.stt import AssemblyAISTTService +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams +from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies + +load_dotenv(override=True) + + +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + """AssemblyAI u3-rt-pro with Built-in Turn Detection + + This example demonstrates using AssemblyAI's u3-rt-pro Speech-to-Text model + with AssemblyAI's built-in turn detection for more natural conversation flow. + + Key features: + + 1. AssemblyAI Turn Detection + - Set `vad_force_turn_endpoint=False` to use AssemblyAI's built-in turn detection + - AssemblyAI's model determines when user starts/stops speaking + - Uses `ExternalUserTurnStrategies` to delegate turn control to AssemblyAI + - More natural turn detection based on speech patterns and pauses + + 2. Advanced Turn Detection Tuning + - `min_turn_silence`: Minimum silence (ms) when confident about end-of-turn. + Lower values = faster responses. Default: 100ms + - `max_turn_silence`: Maximum silence (ms) before forcing end-of-turn. + Prevents long pauses. Default: 1000ms + + 3. Prompt-Based Transcription Enhancement + - Use `prompt` parameter to improve accuracy for specific names/terms + - Particularly useful for proper nouns, technical terms, domain vocabulary + - Example: "Names: Xiomara, Saoirse, Krzystof. Technical terms: API, OAuth." + + 4. Speaker Diarization (Optional) + - Enable with `speaker_labels=True` + - Automatically identifies different speakers in multi-party conversations + - TranscriptionFrame includes speaker_id field (e.g., "Speaker A", "Speaker B") + + 5. Language Detection (Optional, multilingual model only) + - Enable with `language_detection=True` + - Automatically detects spoken language + - Available with universal-streaming-multilingual model + + For more information: https://www.assemblyai.com/docs/speech-to-text/streaming + """ + logger.info(f"Starting bot") + + stt = AssemblyAISTTService( + api_key=os.getenv("ASSEMBLYAI_API_KEY"), + vad_force_turn_endpoint=False, # Use AssemblyAI's built-in turn detection + settings=AssemblyAISTTService.Settings( + model="u3-rt-pro", + # Optional: Tune turn detection timing (defaults shown below) + # min_turn_silence=100, # Default + # max_turn_silence=1000, # Default + # Optional: Boost accuracy for specific names/terms + # keyterms_prompt=["Xiomara", "Saoirse", "Krzystof", "API", "OAuth"], + # Optional: Enable speaker diarization + # speaker_labels=True, + ), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=ExternalUserTurnStrategies(), + vad_analyzer=SileroVADAnalyzer(), + ), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/examples/foundational/07o-interruptible-assemblyai.py b/examples/foundational/07o-interruptible-assemblyai.py index 36f90a183..dc8ecee12 100644 --- a/examples/foundational/07o-interruptible-assemblyai.py +++ b/examples/foundational/07o-interruptible-assemblyai.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,30 +28,24 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -67,37 +59,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -114,7 +102,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07p-interruptible-krisp-viva.py b/examples/foundational/07p-interruptible-krisp-viva.py index b8fe82d08..e67f0d8ef 100644 --- a/examples/foundational/07p-interruptible-krisp-viva.py +++ b/examples/foundational/07p-interruptible-krisp-viva.py @@ -28,10 +28,11 @@ from dotenv import load_dotenv from loguru import logger from pipecat.audio.filters.krisp_viva_filter import KrispVivaFilter -from pipecat.audio.turn.krisp_viva_turn import KrispTurnParams, KrispVivaTurn +from pipecat.audio.turn.krisp_viva_turn import KrispVivaTurn from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame +from pipecat.metrics.metrics import TurnMetricsData +from pipecat.observers.loggers.metrics_log_observer import MetricsLogObserver from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -42,8 +43,8 @@ from pipecat.processors.aggregators.llm_response_universal import ( ) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport +from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService -from pipecat.services.deepgram.tts import DeepgramTTSService from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams @@ -53,9 +54,11 @@ from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. + +krisp_viva_filter = KrispVivaFilter() + transport_params = { "daily": lambda: DailyParams( audio_in_enabled=True, @@ -83,24 +86,28 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - tts = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY"), voice="aura-helios-en") + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams( user_turn_strategies=UserTurnStrategies( stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=KrispVivaTurn())] ), + vad_analyzer=SileroVADAnalyzer(), ), ) @@ -108,11 +115,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -123,13 +130,14 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): enable_usage_metrics=True, ), idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + observers=[MetricsLogObserver(include_metrics={TurnMetricsData})], ) @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07p-interruptible-krisp.py b/examples/foundational/07p-interruptible-krisp.py index e66928bf2..229d50d17 100644 --- a/examples/foundational/07p-interruptible-krisp.py +++ b/examples/foundational/07p-interruptible-krisp.py @@ -11,9 +11,7 @@ from dotenv import load_dotenv from loguru import logger from pipecat.audio.filters.krisp_filter import KrispFilter -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,31 +29,25 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), audio_in_filter=KrispFilter(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), audio_in_filter=KrispFilter(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), audio_in_filter=KrispFilter(), ), } @@ -66,36 +58,35 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - tts = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY"), voice="aura-helios-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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + tts = DeepgramTTSService( + api_key=os.getenv("DEEPGRAM_API_KEY"), + settings=DeepgramTTSService.Settings( + voice="aura-helios-en", ), ) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -112,7 +103,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07q-interruptible-rime-http.py b/examples/foundational/07q-interruptible-rime-http.py index 45b68bfb1..f661c112b 100644 --- a/examples/foundational/07q-interruptible-rime-http.py +++ b/examples/foundational/07q-interruptible-rime-http.py @@ -11,9 +11,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,30 +29,24 @@ from pipecat.services.rime.tts import RimeHttpTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -68,41 +60,36 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = RimeHttpTTSService( api_key=os.getenv("RIME_API_KEY", ""), - voice_id="luna", + settings=RimeHttpTTSService.Settings( + voice="luna", + model="arcana", + ), model="arcana", aiohttp_session=session, ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -119,7 +106,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07q-interruptible-rime.py b/examples/foundational/07q-interruptible-rime.py index d2d1207e1..694f25c25 100644 --- a/examples/foundational/07q-interruptible-rime.py +++ b/examples/foundational/07q-interruptible-rime.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,29 +28,23 @@ from pipecat.services.rime.tts import RimeTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -64,37 +56,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = RimeTTSService( api_key=os.getenv("RIME_API_KEY", ""), - voice_id="rex", + settings=RimeTTSService.Settings( + voice="luna", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -111,7 +99,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07r-interruptible-nvidia.py b/examples/foundational/07r-interruptible-nvidia.py index d93ce19eb..ed0918ec1 100644 --- a/examples/foundational/07r-interruptible-nvidia.py +++ b/examples/foundational/07r-interruptible-nvidia.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,29 +28,23 @@ from pipecat.services.nvidia.tts import NvidiaTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -63,37 +55,30 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = NvidiaSTTService(api_key=os.getenv("NVIDIA_API_KEY")) llm = NvidiaLLMService( - api_key=os.getenv("NVIDIA_API_KEY"), model="meta/llama-3.1-405b-instruct" + api_key=os.getenv("NVIDIA_API_KEY"), + settings=NvidiaLLMService.Settings( + model="meta/llama-3.3-70b-instruct", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) tts = NvidiaTTSService(api_key=os.getenv("NVIDIA_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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -110,7 +95,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07s-interruptible-google-audio-in.py b/examples/foundational/07s-interruptible-google-audio-in.py index 3fb48354a..3f92872f0 100644 --- a/examples/foundational/07s-interruptible-google-audio-in.py +++ b/examples/foundational/07s-interruptible-google-audio-in.py @@ -12,9 +12,7 @@ from dotenv import load_dotenv from google.genai.types import Content, Part from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import ( Frame, InputAudioRawFrame, @@ -44,15 +42,13 @@ 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) marker = "|----|" system_message = f""" -You are a helpful LLM in a WebRTC call. Your goals are to be helpful and brief in your responses. +You are a helpful LLM in a voice call. Your goals are to be helpful and brief in your responses. You are expert at transcribing audio to text. You will receive a mixture of audio and text input. When asked to transcribe what the user said, output an exact, word-for-word transcription. @@ -100,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): @@ -197,24 +193,20 @@ class TranscriptionContextFixup(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -224,40 +216,29 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = GoogleLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - model="gemini-2.5-flash", - # force a certain amount of thinking if you want it - # params=GoogleLLMService.InputParams( - # thinking=GoogleLLMService.ThinkingConfig(thinking_budget=4096) - # ), + settings=GoogleLLMService.Settings( + model="gemini-2.5-flash", + system_instruction=system_message, + # force a certain amount of thinking if you want it + # thinking=GoogleLLMService.ThinkingConfig(thinking_budget=4096) + ), ) tts = GoogleTTSService( - voice_id="en-US-Chirp3-HD-Charon", + settings=GoogleTTSService.Settings( + voice="en-US-Chirp3-HD-Charon", + language=Language.EN_US, + ), params=GoogleTTSService.InputParams(language=Language.EN_US), credentials=os.getenv("GOOGLE_TEST_CREDENTIALS"), ) - messages = [ - { - "role": "system", - "content": system_message, - }, - { - "role": "user", - "content": "Start by saying hello.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) - audio_collector = UserAudioCollector(context, context_aggregator.user()) + audio_collector = UserAudioCollector(context, user_aggregator) pull_transcript_out_of_llm_output = TranscriptExtractor(context) fixup_context_messages = TranscriptionContextFixup(context) @@ -265,12 +246,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input audio_collector, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM pull_transcript_out_of_llm_output, tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses fixup_context_messages, ] ) @@ -288,7 +269,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07t-interruptible-fish.py b/examples/foundational/07t-interruptible-fish.py index 9e2bc6e5c..13612a887 100644 --- a/examples/foundational/07t-interruptible-fish.py +++ b/examples/foundational/07t-interruptible-fish.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,30 +28,24 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -65,37 +57,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = FishAudioTTSService( api_key=os.getenv("FISH_API_KEY"), - model="4ce7e917cedd4bc2bb2e6ff3a46acaa1", # Barack Obama + settings=FishAudioTTSService.Settings( + voice="4ce7e917cedd4bc2bb2e6ff3a46acaa1", # Barack Obama + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -112,7 +100,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07v-interruptible-neuphonic-http.py b/examples/foundational/07v-interruptible-neuphonic-http.py index c97b6e381..ad5b7e996 100644 --- a/examples/foundational/07v-interruptible-neuphonic-http.py +++ b/examples/foundational/07v-interruptible-neuphonic-http.py @@ -11,9 +11,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,30 +29,24 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -68,40 +60,34 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = NeuphonicHttpTTSService( api_key=os.getenv("NEUPHONIC_API_KEY"), - voice_id="fc854436-2dac-4d21-aa69-ae17b54e98eb", # Emily + settings=NeuphonicHttpTTSService.Settings( + voice="fc854436-2dac-4d21-aa69-ae17b54e98eb", # Emily + ), aiohttp_session=session, ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -118,7 +104,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07v-interruptible-neuphonic.py b/examples/foundational/07v-interruptible-neuphonic.py index 9a142f1bf..ba3350754 100644 --- a/examples/foundational/07v-interruptible-neuphonic.py +++ b/examples/foundational/07v-interruptible-neuphonic.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,29 +28,23 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -64,37 +56,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = NeuphonicTTSService( api_key=os.getenv("NEUPHONIC_API_KEY"), - voice_id="fc854436-2dac-4d21-aa69-ae17b54e98eb", # Emily + settings=NeuphonicTTSService.Settings( + voice="fc854436-2dac-4d21-aa69-ae17b54e98eb", # Emily + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -111,7 +99,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07w-interruptible-fal.py b/examples/foundational/07w-interruptible-fal.py index 62c33d1c9..08f24fd79 100644 --- a/examples/foundational/07w-interruptible-fal.py +++ b/examples/foundational/07w-interruptible-fal.py @@ -7,12 +7,11 @@ import os +import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,30 +29,24 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -61,70 +54,70 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - stt = FalSTTService( - api_key=os.getenv("FAL_KEY"), - ) + async with aiohttp.ClientSession() as session: + stt = FalSTTService( + api_key=os.getenv("FAL_KEY"), + aiohttp_session=session, + ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady ), - ), - ) + ) - pipeline = Pipeline( - [ - transport.input(), # Transport user input - stt, # STT - context_aggregator.user(), # User responses - llm, # LLM - tts, # TTS - transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses - ] - ) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) - task = PipelineTask( - pipeline, - params=PipelineParams( - enable_metrics=True, - enable_usage_metrics=True, - ), - idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) - @transport.event_handler("on_client_connected") - async def on_client_connected(transport, client): - logger.info(f"Client connected") - # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) - await task.queue_frames([LLMRunFrame()]) + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) - @transport.event_handler("on_client_disconnected") - async def on_client_disconnected(transport, client): - logger.info(f"Client disconnected") - await task.cancel() + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) - runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) - await runner.run(task) + @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): diff --git a/examples/foundational/07x-interruptible-local.py b/examples/foundational/07x-interruptible-local.py index b0cd28e55..28e970403 100644 --- a/examples/foundational/07x-interruptible-local.py +++ b/examples/foundational/07x-interruptible-local.py @@ -11,9 +11,7 @@ import sys from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -27,8 +25,6 @@ from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -41,7 +37,6 @@ async def main(): LocalAudioTransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ) ) @@ -49,37 +44,33 @@ async def main(): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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. 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -91,7 +82,7 @@ async def main(): ), ) - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) runner = PipelineRunner() diff --git a/examples/foundational/07y-interruptible-minimax.py b/examples/foundational/07y-interruptible-minimax.py index ba8d56c28..f8323369e 100644 --- a/examples/foundational/07y-interruptible-minimax.py +++ b/examples/foundational/07y-interruptible-minimax.py @@ -11,9 +11,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,30 +30,24 @@ 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -71,39 +63,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): api_key=os.getenv("MINIMAX_API_KEY", ""), group_id=os.getenv("MINIMAX_GROUP_ID", ""), aiohttp_session=session, - params=MiniMaxHttpTTSService.InputParams(language=Language.EN), + settings=MiniMaxHttpTTSService.Settings( + language=Language.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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -120,7 +106,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07z-interruptible-sarvam-http.py b/examples/foundational/07z-interruptible-sarvam-http.py index 8ad54ab4c..f8a806493 100644 --- a/examples/foundational/07z-interruptible-sarvam-http.py +++ b/examples/foundational/07z-interruptible-sarvam-http.py @@ -11,9 +11,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,30 +30,24 @@ 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -67,45 +59,41 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async with aiohttp.ClientSession() as session: stt = SarvamSTTService( api_key=os.getenv("SARVAM_API_KEY"), - model="saarika:v2.5", + settings=SarvamSTTService.Settings( + model="saarika:v2.5", + ), ) tts = SarvamHttpTTSService( api_key=os.getenv("SARVAM_API_KEY"), aiohttp_session=session, - params=SarvamHttpTTSService.InputParams(language=Language.EN), + settings=SarvamHttpTTSService.Settings( + language=Language.EN_IN, + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -122,7 +110,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07z-interruptible-sarvam.py b/examples/foundational/07z-interruptible-sarvam.py index 500f08b41..5f144f73f 100644 --- a/examples/foundational/07z-interruptible-sarvam.py +++ b/examples/foundational/07z-interruptible-sarvam.py @@ -9,9 +9,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -29,30 +27,24 @@ from pipecat.services.sarvam.tts import SarvamTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -62,42 +54,40 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = SarvamSTTService( api_key=os.getenv("SARVAM_API_KEY"), - model="saarika:v2.5", + settings=SarvamSTTService.Settings( + model="saarika:v2.5", + ), ) tts = SarvamTTSService( api_key=os.getenv("SARVAM_API_KEY"), - model="bulbul:v2", - voice_id="manisha", - ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + settings=SarvamTTSService.Settings( + model="bulbul:v2", + voice="manisha", ), ) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -113,7 +103,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) # Optionally, you can wait for 30 seconds and then change the voice. diff --git a/examples/foundational/07aa-interruptible-soniox.py b/examples/foundational/07za-interruptible-soniox.py similarity index 66% rename from examples/foundational/07aa-interruptible-soniox.py rename to examples/foundational/07za-interruptible-soniox.py index ba0e2be27..c29fea9fb 100644 --- a/examples/foundational/07aa-interruptible-soniox.py +++ b/examples/foundational/07za-interruptible-soniox.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -27,11 +25,10 @@ from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.soniox.stt import SonioxSTTService +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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -39,17 +36,14 @@ transport_params = { "daily": lambda: DailyParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -59,41 +53,43 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = SonioxSTTService( api_key=os.getenv("SONIOX_API_KEY"), + settings=SonioxSTTService.Settings( + # Add language hints to use a specific language + # Add strict mode to enforce the language hints + language_hints=[Language.EN], + language_hints_strict=True, + ), ) tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) task = PipelineTask( @@ -109,7 +105,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07ab-interruptible-inworld-http.py b/examples/foundational/07zb-interruptible-inworld-http.py similarity index 68% rename from examples/foundational/07ab-interruptible-inworld-http.py rename to examples/foundational/07zb-interruptible-inworld-http.py index a947f0edf..9f0027b42 100644 --- a/examples/foundational/07ab-interruptible-inworld-http.py +++ b/examples/foundational/07zb-interruptible-inworld-http.py @@ -10,9 +10,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSTextFrame from pipecat.observers.loggers.debug_log_observer import DebugLogObserver, FrameEndpoint from pipecat.pipeline.pipeline import Pipeline @@ -23,7 +21,6 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.frameworks.rtvi import RTVIObserver, RTVIProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService @@ -33,8 +30,6 @@ from pipecat.transports.base_output import BaseOutputTransport 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -42,17 +37,14 @@ transport_params = { "daily": lambda: DailyParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -66,45 +58,36 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = InworldHttpTTSService( api_key=os.getenv("INWORLD_API_KEY", ""), aiohttp_session=session, - voice_id="Ashley", - model="inworld-tts-1", - # Set to False for non-streaming mode or True for streaming mode. streaming=True, + settings=InworldHttpTTSService.Settings( + voice="Ashley", + model="inworld-tts-1", + ), + # Set to False for non-streaming mode or True for streaming mode. ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - - messages = [ - { - "role": "system", - "content": "You are a helpful AI demonstrating Inworld AI's TTS. 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 friendly and helpful way.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful AI demonstrating Inworld AI's TTS. 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 friendly and helpful way.", ), ) - rtvi = RTVIProcessor() + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), - rtvi, stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -115,7 +98,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): enable_usage_metrics=True, ), observers=[ - RTVIObserver(rtvi), DebugLogObserver( frame_types={ TTSTextFrame: (BaseOutputTransport, FrameEndpoint.SOURCE), @@ -129,7 +111,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info("Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07ab-interruptible-inworld.py b/examples/foundational/07zb-interruptible-inworld.py similarity index 58% rename from examples/foundational/07ab-interruptible-inworld.py rename to examples/foundational/07zb-interruptible-inworld.py index f830ed485..9f9384554 100644 --- a/examples/foundational/07ab-interruptible-inworld.py +++ b/examples/foundational/07zb-interruptible-inworld.py @@ -9,11 +9,8 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, TTSTextFrame -from pipecat.observers.loggers.debug_log_observer import DebugLogObserver, FrameEndpoint +from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -22,18 +19,14 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.inworld.tts import InworldTTSService from pipecat.services.openai.llm import OpenAILLMService -from pipecat.transports.base_output import BaseOutputTransport 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -42,17 +35,14 @@ transport_params = { "daily": lambda: DailyParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -64,42 +54,35 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = InworldTTSService( api_key=os.getenv("INWORLD_API_KEY", ""), - voice_id="Ashley", - model="inworld-tts-1", - temperature=1.1, - ) - - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - - messages = [ - { - "role": "system", - "content": "You are a helpful AI demonstrating Inworld AI's TTS. 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 friendly and helpful way.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + settings=InworldTTSService.Settings( + voice="Ashley", + model="inworld-tts-1", + temperature=1.1, ), ) - rtvi = RTVIProcessor(config=RTVIConfig(config=[])) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful AI demonstrating Inworld AI's TTS. 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 friendly and helpful way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), - rtvi, stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -109,14 +92,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): enable_metrics=True, enable_usage_metrics=True, ), - observers=[ - RTVIObserver(rtvi), - DebugLogObserver( - frame_types={ - TTSTextFrame: (BaseOutputTransport, FrameEndpoint.SOURCE), - } - ), - ], idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, ) @@ -124,7 +99,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info("Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07ac-interruptible-asyncai-http.py b/examples/foundational/07zc-interruptible-asyncai-http.py similarity index 64% rename from examples/foundational/07ac-interruptible-asyncai-http.py rename to examples/foundational/07zc-interruptible-asyncai-http.py index 3ac659b3f..96964f5e8 100644 --- a/examples/foundational/07ac-interruptible-asyncai-http.py +++ b/examples/foundational/07zc-interruptible-asyncai-http.py @@ -11,9 +11,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,30 +29,24 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -68,40 +60,34 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = AsyncAIHttpTTSService( api_key=os.getenv("ASYNCAI_API_KEY", ""), - voice_id=os.getenv("ASYNCAI_VOICE_ID", "e0f39dc4-f691-4e78-bba5-5c636692cc04"), + settings=AsyncAIHttpTTSService.Settings( + voice="e0f39dc4-f691-4e78-bba5-5c636692cc04", + ), aiohttp_session=session, ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -118,7 +104,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07zc-interruptible-asyncai.py b/examples/foundational/07zc-interruptible-asyncai.py new file mode 100644 index 000000000..39052720c --- /dev/null +++ b/examples/foundational/07zc-interruptible-asyncai.py @@ -0,0 +1,125 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.asyncai.tts import AsyncAITTSService +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 + +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(f"Starting bot") + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = AsyncAITTSService( + api_key=os.getenv("ASYNCAI_API_KEY", ""), + settings=AsyncAITTSService.Settings( + voice="e0f39dc4-f691-4e78-bba5-5c636692cc04", + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/examples/foundational/07ad-interruptible-aicoustics.py b/examples/foundational/07zd-interruptible-aicoustics.py similarity index 58% rename from examples/foundational/07ad-interruptible-aicoustics.py rename to examples/foundational/07zd-interruptible-aicoustics.py index 75686469b..eeb45a4c7 100644 --- a/examples/foundational/07ad-interruptible-aicoustics.py +++ b/examples/foundational/07zd-interruptible-aicoustics.py @@ -13,7 +13,6 @@ from dotenv import load_dotenv from loguru import logger from pipecat.audio.filters.aic_filter import AICFilter -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,56 +31,42 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# Create audio buffer processor so we can hear the audio fitler results. -audiobuffer = AudioBufferProcessor( - num_channels=2, # 1 for mono, 2 for stereo (user left, bot right) - enable_turn_audio=False, # Enable per-turn audio recording -) - - def _create_aic_filter() -> AICFilter: license_key = os.getenv("AICOUSTICS_LICENSE_KEY", "") return AICFilter( license_key=license_key, - enhancement_level=0.5, + model_id="quail-vf-2.0-l-16khz", ) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +aic_filter = _create_aic_filter() +aic_vad_analyzer = aic_filter.create_vad_analyzer( + speech_hold_duration=0.05, minimum_speech_duration=0.0, sensitivity=6.0 +) + +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { - "daily": lambda: ( - lambda aic: DailyParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=aic.create_vad_analyzer(lookback_buffer_size=6.0, sensitivity=6.0), - audio_in_filter=aic, - ) - )(_create_aic_filter()), - "twilio": lambda: ( - lambda aic: FastAPIWebsocketParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=aic.create_vad_analyzer(lookback_buffer_size=6.0, sensitivity=6.0), - audio_in_filter=aic, - ) - )(_create_aic_filter()), - "webrtc": lambda: ( - lambda aic: TransportParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=aic.create_vad_analyzer(lookback_buffer_size=6.0, sensitivity=6.0), - audio_in_filter=aic, - ) - )(_create_aic_filter()), + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + audio_in_filter=aic_filter, + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + audio_in_filter=aic_filter, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + audio_in_filter=aic_filter, + ), } @@ -92,38 +77,40 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=aic_vad_analyzer), + ) + + # Create audio buffer processor so we can hear the audio fitler results. + audiobuffer = AudioBufferProcessor( + num_channels=2, # 1 for mono, 2 for stereo (user left, bot right) + enable_turn_audio=False, # Enable per-turn audio recording + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output audiobuffer, # write audio data to a file - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -141,7 +128,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Client connected") await audiobuffer.start_recording() # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @audiobuffer.event_handler("on_audio_data") diff --git a/examples/foundational/07ae-interruptible-hume.py b/examples/foundational/07ze-interruptible-hume.py similarity index 65% rename from examples/foundational/07ae-interruptible-hume.py rename to examples/foundational/07ze-interruptible-hume.py index 238e45637..a5e4253f6 100644 --- a/examples/foundational/07ae-interruptible-hume.py +++ b/examples/foundational/07ze-interruptible-hume.py @@ -9,9 +9,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSTextFrame from pipecat.observers.loggers.debug_log_observer import DebugLogObserver, FrameEndpoint from pipecat.pipeline.pipeline import Pipeline @@ -22,7 +20,6 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService @@ -32,30 +29,24 @@ from pipecat.transports.base_output import BaseOutputTransport 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -68,40 +59,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = HumeTTSService( api_key=os.getenv("HUME_API_KEY"), # Replace with your Hume voice ID - voice_id="f898a92e-685f-43fa-985b-a46920f0650b", - ) - - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + settings=HumeTTSService.Settings( + voice="f898a92e-685f-43fa-985b-a46920f0650b", ), ) - rtvi = RTVIProcessor(config=RTVIConfig(config=[])) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), # Transport user input - rtvi, stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS (HumeTTSService with word timestamps) transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -114,7 +98,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ), idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, observers=[ - RTVIObserver(rtvi), DebugLogObserver( frame_types={ TTSTextFrame: (BaseOutputTransport, FrameEndpoint.SOURCE), @@ -123,10 +106,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ], ) - @rtvi.event_handler("on_client_ready") - async def on_client_ready(rtvi): - await rtvi.set_bot_ready() - @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") @@ -134,7 +113,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): "💡 Word timestamps are enabled! Watch the console for TTSTextFrame logs showing each word with its PTS." ) # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07af-interruptible-gradium.py b/examples/foundational/07zf-interruptible-gradium.py similarity index 63% rename from examples/foundational/07af-interruptible-gradium.py rename to examples/foundational/07zf-interruptible-gradium.py index 4b21e887b..1a067d1b2 100644 --- a/examples/foundational/07af-interruptible-gradium.py +++ b/examples/foundational/07zf-interruptible-gradium.py @@ -9,9 +9,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -26,32 +24,27 @@ from pipecat.runner.utils import create_transport from pipecat.services.gradium.stt import GradiumSTTService from pipecat.services.gradium.tts import GradiumTTSService 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -59,41 +52,44 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - stt = GradiumSTTService(api_key=os.getenv("GRADIUM_API_KEY")) + stt = GradiumSTTService( + api_key=os.getenv("GRADIUM_API_KEY"), + api_endpoint_base_url="wss://us.api.gradium.ai/api/speech/asr", + settings=GradiumSTTService.Settings( + language=Language.EN, + ), + ) tts = GradiumTTSService( api_key=os.getenv("GRADIUM_API_KEY"), - voice_id="YTpq7expH9539ERJ", + url="wss://us.api.gradium.ai/api/speech/tts", + settings=GradiumTTSService.Settings( + voice="YTpq7expH9539ERJ", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -110,7 +106,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07zg-interruptible-camb.py b/examples/foundational/07zg-interruptible-camb.py new file mode 100644 index 000000000..ff9b7162d --- /dev/null +++ b/examples/foundational/07zg-interruptible-camb.py @@ -0,0 +1,124 @@ +# +# Copyright (c) 2024–2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.camb.tts import CambTTSService +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 + +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 Camb AI TTS bot") + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = CambTTSService( + api_key=os.getenv("CAMB_API_KEY"), + settings=CambTTSService.Settings( + model="mars-flash", + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful voice assistant powered by Camb AI text-to-speech. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Keep responses concise.", + ), + ) + + context = LLMContext() + 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, + audio_out_sample_rate=22050, + ), + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info("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/07ac-interruptible-asyncai.py b/examples/foundational/07zi-interruptible-piper.py similarity index 64% rename from examples/foundational/07ac-interruptible-asyncai.py rename to examples/foundational/07zi-interruptible-piper.py index b13615110..53f21811c 100644 --- a/examples/foundational/07ac-interruptible-asyncai.py +++ b/examples/foundational/07zi-interruptible-piper.py @@ -4,15 +4,12 @@ # SPDX-License-Identifier: BSD 2-Clause License # - import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -24,18 +21,15 @@ from pipecat.processors.aggregators.llm_response_universal import ( ) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport -from pipecat.services.asyncai.tts import AsyncAITTSService from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.openai.llm import OpenAILLMService +from pipecat.services.piper.tts import PiperTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) - # We store functions so objects (e.g. SileroVADAnalyzer) don't get # instantiated. The function will be called when the desired transport gets # selected. @@ -43,17 +37,14 @@ transport_params = { "daily": lambda: DailyParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -63,39 +54,34 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - tts = AsyncAITTSService( - api_key=os.getenv("ASYNCAI_API_KEY", ""), - voice_id=os.getenv("ASYNCAI_VOICE_ID", "e0f39dc4-f691-4e78-bba5-5c636692cc04"), + tts = PiperTTSService( + settings=PiperTTSService.Settings( + voice="en_US-ryan-high", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -112,7 +98,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07e-interruptible-playht-http.py b/examples/foundational/07zj-interruptible-kokoro.py similarity index 63% rename from examples/foundational/07e-interruptible-playht-http.py rename to examples/foundational/07zj-interruptible-kokoro.py index 9e6f94c63..5fb0ca55b 100644 --- a/examples/foundational/07e-interruptible-playht-http.py +++ b/examples/foundational/07zj-interruptible-kokoro.py @@ -4,15 +4,12 @@ # SPDX-License-Identifier: BSD 2-Clause License # - import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -25,13 +22,11 @@ from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.kokoro.tts import KokoroTTSService from pipecat.services.openai.llm import OpenAILLMService -from pipecat.services.playht.tts import PlayHTHttpTTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -42,17 +37,14 @@ transport_params = { "daily": lambda: DailyParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -62,40 +54,34 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - tts = PlayHTHttpTTSService( - user_id=os.getenv("PLAYHT_USER_ID"), - api_key=os.getenv("PLAYHT_API_KEY"), - voice_url="s3://voice-cloning-zero-shot/d9ff78ba-d016-47f6-b0ef-dd630f59414e/female-cs/manifest.json", + tts = KokoroTTSService( + settings=KokoroTTSService.Settings( + voice="af_heart", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -112,7 +98,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/07zk-interruptible-resemble.py b/examples/foundational/07zk-interruptible-resemble.py new file mode 100644 index 000000000..60d1d8495 --- /dev/null +++ b/examples/foundational/07zk-interruptible-resemble.py @@ -0,0 +1,123 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.services.resembleai.tts import ResembleAITTSService +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) + +# 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(f"Starting bot") + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = ResembleAITTSService( + api_key=os.getenv("RESEMBLE_API_KEY"), + settings=ResembleAITTSService.Settings( + voice=os.getenv("RESEMBLE_VOICE_UUID"), + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/examples/foundational/08-custom-frame-processor.py b/examples/foundational/08-custom-frame-processor.py index ae44b4fba..f07711657 100644 --- a/examples/foundational/08-custom-frame-processor.py +++ b/examples/foundational/08-custom-frame-processor.py @@ -9,9 +9,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import ( Frame, LLMRunFrame, @@ -33,8 +31,6 @@ 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.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -77,20 +73,17 @@ class MetricsFrameLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -102,39 +95,35 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + metrics_frame_processor = MetricsFrameLogger() pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, metrics_frame_processor, # pretty print metrics frames ] ) @@ -152,7 +141,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected: {client}") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/09-mirror.py b/examples/foundational/09-mirror.py index 80b76b3b8..478fe9daf 100644 --- a/examples/foundational/09-mirror.py +++ b/examples/foundational/09-mirror.py @@ -47,9 +47,8 @@ class MirrorProcessor(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, diff --git a/examples/foundational/09a-local-mirror.py b/examples/foundational/09a-local-mirror.py index 42d9af41e..217136e58 100644 --- a/examples/foundational/09a-local-mirror.py +++ b/examples/foundational/09a-local-mirror.py @@ -50,9 +50,8 @@ class MirrorProcessor(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, diff --git a/examples/foundational/10-wake-phrase.py b/examples/foundational/10-wake-phrase.py index ace7f6f4f..5b93efb0e 100644 --- a/examples/foundational/10-wake-phrase.py +++ b/examples/foundational/10-wake-phrase.py @@ -9,10 +9,8 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import TTSSpeakFrame +from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -21,7 +19,6 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.filters.wake_check_filter import WakeCheckFilter from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService @@ -30,29 +27,28 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies +from pipecat.turns.user_start import WakePhraseUserTurnStartStrategy +from pipecat.turns.user_turn_strategies import ( + UserTurnStrategies, + default_user_turn_start_strategies, +) load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -60,44 +56,54 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + stt = DeepgramSTTService( + api_key=os.getenv("DEEPGRAM_API_KEY"), + settings=DeepgramSTTService.Settings( + keyterm=["pipecat"], + ), + ) tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) - messages = [ - { - "role": "system", - "content": "You are a helpful assistant. Respond to what the user said in a creative and helpful way. Keep your responses brief.", - }, - ] - - hey_robot_filter = WakeCheckFilter(["hey robot", "hey, robot"]) - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams( user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] + start=[ + WakePhraseUserTurnStartStrategy( + phrases=["pipecat"], + # Timeout before wake phrase must be spoken again + timeout=5.0, + ), + *default_user_turn_start_strategies(), + ] ), + vad_analyzer=SileroVADAnalyzer(), ), ) pipeline = Pipeline( [ transport.input(), # Transport user input - stt, # STT - hey_robot_filter, # Filter out speech not directed at the robot - context_aggregator.user(), # User responses + stt, + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -114,7 +120,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - await task.queue_frame(TTSSpeakFrame("Hi! If you want to talk to me, just say 'Hey Robot'")) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") async def on_client_disconnected(transport, client): diff --git a/examples/foundational/11-sound-effects.py b/examples/foundational/11-sound-effects.py index 0dee4264e..1f7fdd339 100644 --- a/examples/foundational/11-sound-effects.py +++ b/examples/foundational/11-sound-effects.py @@ -10,9 +10,7 @@ import wave from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import ( Frame, LLMContextFrame, @@ -22,7 +20,7 @@ from pipecat.frames.frames import ( ) from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner -from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.pipeline.task import PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, @@ -38,8 +36,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -85,24 +81,20 @@ class InboundSoundEffectWrapper(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -112,28 +104,24 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) out_sound = OutboundSoundEffectWrapper() in_sound = InboundSoundEffectWrapper() @@ -144,7 +132,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, in_sound, fl2, llm, @@ -152,7 +140,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts, out_sound, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/12-describe-image-openai-responses.py b/examples/foundational/12-describe-image-openai-responses.py new file mode 100644 index 000000000..a3c113cb2 --- /dev/null +++ b/examples/foundational/12-describe-image-openai-responses.py @@ -0,0 +1,139 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import os + +from dotenv import load_dotenv +from loguru import logger +from PIL import Image + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.openai.responses.llm import OpenAIResponsesLLMService +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams + +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, + ), + "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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAIResponsesLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIResponsesLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You are also able to describe images.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + 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") + + if not runner_args.body: + script_dir = os.path.dirname(__file__) + runner_args.body = { + "image_path": os.path.join(script_dir, "assets", "cat.jpg"), + "question": "Describe this image", + } + + image_path = runner_args.body["image_path"] + question = runner_args.body["question"] + + # Kick off the conversation. + image = Image.open(image_path) + message = await LLMContext.create_image_message( + image=image.tobytes(), + format="RGB", + size=image.size, + text=question, + ) + context.add_message(message) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/examples/foundational/12-describe-image-openai.py b/examples/foundational/12-describe-image-openai.py index 2df4ccaea..8c8af6352 100644 --- a/examples/foundational/12-describe-image-openai.py +++ b/examples/foundational/12-describe-image-openai.py @@ -11,9 +11,7 @@ from dotenv import load_dotenv from loguru import logger from PIL import Image -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,25 +28,20 @@ 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.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -60,37 +53,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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. You are also able to describe images.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You are also able to describe images.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -125,7 +114,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): size=image.size, text=question, ) - messages.append(message) + context.add_message(message) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/12a-describe-image-anthropic.py b/examples/foundational/12a-describe-image-anthropic.py index 2263040ba..642885dcf 100644 --- a/examples/foundational/12a-describe-image-anthropic.py +++ b/examples/foundational/12a-describe-image-anthropic.py @@ -11,9 +11,7 @@ from dotenv import load_dotenv from loguru import logger from PIL import Image -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,25 +28,20 @@ from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -60,37 +53,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = AnthropicLLMService(api_key=os.getenv("ANTHROPIC_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. You are also able to describe images.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = AnthropicLLMService( + api_key=os.getenv("ANTHROPIC_API_KEY"), + settings=AnthropicLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You are also able to describe images.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -125,7 +114,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): size=image.size, text=question, ) - messages.append(message) + context.add_message(message) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/12b-describe-image-aws.py b/examples/foundational/12b-describe-image-aws.py index 8e37bf5d3..47d2b7970 100644 --- a/examples/foundational/12b-describe-image-aws.py +++ b/examples/foundational/12b-describe-image-aws.py @@ -11,9 +11,7 @@ from dotenv import load_dotenv from loguru import logger from PIL import Image -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,25 +28,20 @@ from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -60,44 +53,35 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = AWSBedrockLLMService( aws_region="us-west-2", - model="us.anthropic.claude-3-7-sonnet-20250219-v1:0", - # Note: usually, prefer providing latency="optimized" param. - # Here we can't because AWS Bedrock doesn't support it for Claude 3.7, - # which we need for image input. - params=AWSBedrockLLMService.InputParams(temperature=0.8), + settings=AWSBedrockLLMService.Settings( + model="us.anthropic.claude-sonnet-4-6", + temperature=0.8, + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You are also able to describe images.", + ), ) - 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. You are also able to describe images.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -132,7 +116,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): size=image.size, text=question, ) - messages.append(message) + context.add_message(message) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/12c-describe-image-gemini-flash.py b/examples/foundational/12c-describe-image-gemini-flash.py index a97480a4e..248bc08a7 100644 --- a/examples/foundational/12c-describe-image-gemini-flash.py +++ b/examples/foundational/12c-describe-image-gemini-flash.py @@ -11,9 +11,7 @@ from dotenv import load_dotenv from loguru import logger from PIL import Image -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,25 +28,20 @@ from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.google.llm import GoogleLLMService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -60,37 +53,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = GoogleLLMService(api_key=os.getenv("GOOGLE_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. You are also able to describe images.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You are also able to describe images.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -125,7 +114,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): size=image.size, text=question, ) - messages.append(message) + context.add_message(message) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/12d-describe-image-moondream.py b/examples/foundational/12d-describe-image-moondream.py index 4b9ff0912..6a73de7b3 100644 --- a/examples/foundational/12d-describe-image-moondream.py +++ b/examples/foundational/12d-describe-image-moondream.py @@ -11,8 +11,6 @@ from dotenv import load_dotenv from loguru import logger from PIL import Image -from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import UserImageRawFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -27,17 +25,14 @@ from pipecat.transports.daily.transport import DailyParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams( audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -47,7 +42,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) vision = MoondreamService() diff --git a/examples/foundational/13-whisper-transcription.py b/examples/foundational/13-whisper-transcription.py index 79d706a5a..f61cec132 100644 --- a/examples/foundational/13-whisper-transcription.py +++ b/examples/foundational/13-whisper-transcription.py @@ -13,6 +13,7 @@ from pipecat.frames.frames import Frame, TranscriptionFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineTask +from pipecat.processors.audio.vad_processor import VADProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport @@ -35,21 +36,17 @@ class TranscriptionLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -60,8 +57,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = WhisperSTTService() tl = TranscriptionLogger() + vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer()) - pipeline = Pipeline([transport.input(), stt, tl]) + pipeline = Pipeline([transport.input(), vad_processor, stt, tl]) task = PipelineTask( pipeline, diff --git a/examples/foundational/13a-whisper-local.py b/examples/foundational/13a-whisper-local.py index ec9ddb603..0882542fc 100644 --- a/examples/foundational/13a-whisper-local.py +++ b/examples/foundational/13a-whisper-local.py @@ -15,6 +15,7 @@ from pipecat.frames.frames import Frame, TranscriptionFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineTask +from pipecat.processors.audio.vad_processor import VADProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.services.whisper.stt import WhisperSTTService from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams @@ -40,15 +41,15 @@ async def main(): transport = LocalAudioTransport( LocalAudioTransportParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ) ) stt = WhisperSTTService() tl = TranscriptionLogger() + vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer()) - pipeline = Pipeline([transport.input(), stt, tl]) + pipeline = Pipeline([transport.input(), vad_processor, stt, tl]) task = PipelineTask(pipeline) diff --git a/examples/foundational/13b-deepgram-transcription.py b/examples/foundational/13b-deepgram-transcription.py index ce18b3f16..04246d237 100644 --- a/examples/foundational/13b-deepgram-transcription.py +++ b/examples/foundational/13b-deepgram-transcription.py @@ -16,7 +16,7 @@ from pipecat.pipeline.task import PipelineTask from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport -from pipecat.services.deepgram.stt import DeepgramSTTService, Language, LiveOptions +from pipecat.services.deepgram.stt import DeepgramSTTService, Language from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -35,9 +35,8 @@ class TranscriptionLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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), "twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True), @@ -50,7 +49,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService( api_key=os.getenv("DEEPGRAM_API_KEY"), - live_options=LiveOptions(language=Language.EN), + settings=DeepgramSTTService.Settings( + language=Language.EN, + ), ) tl = TranscriptionLogger() diff --git a/examples/foundational/13c-gladia-transcription.py b/examples/foundational/13c-gladia-transcription.py index 24833fc5d..8771cb8d4 100644 --- a/examples/foundational/13c-gladia-transcription.py +++ b/examples/foundational/13c-gladia-transcription.py @@ -35,9 +35,8 @@ class TranscriptionLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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), "twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True), @@ -51,7 +50,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = GladiaSTTService( api_key=os.getenv("GLADIA_API_KEY"), region=os.getenv("GLADIA_REGION"), - # live_options=LiveOptions(language=Language.FR), + # settings=GladiaSTTSettings( + # language_config=LanguageConfig( + # languages=[Language.FR], + # ), + # ), ) tl = TranscriptionLogger() diff --git a/examples/foundational/13c-gladia-translation.py b/examples/foundational/13c-gladia-translation.py index 0f69f0649..e6557cd15 100644 --- a/examples/foundational/13c-gladia-translation.py +++ b/examples/foundational/13c-gladia-translation.py @@ -17,7 +17,6 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.gladia.config import ( - GladiaInputParams, LanguageConfig, RealtimeProcessingConfig, TranslationConfig, @@ -44,9 +43,8 @@ class TranscriptionLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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), "twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True), @@ -60,16 +58,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = GladiaSTTService( api_key=os.getenv("GLADIA_API_KEY"), region=os.getenv("GLADIA_REGION"), - params=GladiaInputParams( + settings=GladiaSTTService.Settings( language_config=LanguageConfig( - languages=[Language.EN], # Input in English + languages=[Language.EN], code_switching=False, ), realtime_processing=RealtimeProcessingConfig( - translation=True, # Enable translation + translation=True, translation_config=TranslationConfig( - target_languages=[Language.ES], # Translate to Spanish - model="enhanced", # Use the enhanced translation model + target_languages=[Language.ES], + model="enhanced", ), ), ), diff --git a/examples/foundational/13d-assemblyai-transcription.py b/examples/foundational/13d-assemblyai-transcription.py index a7da1f996..f50f63380 100644 --- a/examples/foundational/13d-assemblyai-transcription.py +++ b/examples/foundational/13d-assemblyai-transcription.py @@ -35,9 +35,8 @@ class TranscriptionLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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), "twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True), @@ -50,6 +49,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = AssemblyAISTTService( api_key=os.getenv("ASSEMBLYAI_API_KEY"), + settings=AssemblyAISTTService.Settings( + model="u3-rt-pro", + ), ) tl = TranscriptionLogger() diff --git a/examples/foundational/13e-whisper-mlx.py b/examples/foundational/13e-whisper-mlx.py index 609ebf5f6..af7d2d79e 100644 --- a/examples/foundational/13e-whisper-mlx.py +++ b/examples/foundational/13e-whisper-mlx.py @@ -15,6 +15,7 @@ from pipecat.frames.frames import Frame, TranscriptionFrame, UserStoppedSpeaking from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.audio.vad_processor import VADProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport @@ -56,21 +57,17 @@ class TranscriptionLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=STOP_SECS)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=STOP_SECS)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=STOP_SECS)), ), } @@ -78,11 +75,18 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - stt = WhisperSTTServiceMLX(model=MLXModel.LARGE_V3_TURBO) + stt = WhisperSTTServiceMLX( + settings=WhisperSTTServiceMLX.Settings( + model=MLXModel.LARGE_V3_TURBO.value, + ), + ) tl = TranscriptionLogger() + vad_processor = VADProcessor( + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=STOP_SECS)) + ) - pipeline = Pipeline([transport.input(), stt, tl]) + pipeline = Pipeline([transport.input(), vad_processor, stt, tl]) task = PipelineTask( pipeline, diff --git a/examples/foundational/13f-cartesia-transcription.py b/examples/foundational/13f-cartesia-transcription.py index c8c39629a..d3b83abb0 100644 --- a/examples/foundational/13f-cartesia-transcription.py +++ b/examples/foundational/13f-cartesia-transcription.py @@ -35,9 +35,8 @@ class TranscriptionLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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), "twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True), diff --git a/examples/foundational/13g-sambanova-transcription.py b/examples/foundational/13g-sambanova-transcription.py index bcccf2963..26e961c5e 100644 --- a/examples/foundational/13g-sambanova-transcription.py +++ b/examples/foundational/13g-sambanova-transcription.py @@ -16,6 +16,7 @@ from pipecat.frames.frames import Frame, TranscriptionFrame, UserStoppedSpeaking from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.audio.vad_processor import VADProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport @@ -57,21 +58,17 @@ class TranscriptionLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=STOP_SECS)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=STOP_SECS)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=STOP_SECS)), ), } @@ -80,13 +77,18 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") stt = SambaNovaSTTService( - model="Whisper-Large-v3", + settings=SambaNovaSTTService.Settings( + model="Whisper-Large-v3", + ), api_key=os.getenv("SAMBANOVA_API_KEY"), ) tl = TranscriptionLogger() + vad_processor = VADProcessor( + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=STOP_SECS)) + ) - pipeline = Pipeline([transport.input(), stt, tl]) + pipeline = Pipeline([transport.input(), vad_processor, stt, tl]) task = PipelineTask( pipeline, diff --git a/examples/foundational/13h-speechmatics-transcription.py b/examples/foundational/13h-speechmatics-transcription.py index eb5b4148f..2df30f67b 100644 --- a/examples/foundational/13h-speechmatics-transcription.py +++ b/examples/foundational/13h-speechmatics-transcription.py @@ -36,9 +36,8 @@ class TranscriptionLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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), "twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True), @@ -66,7 +65,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = SpeechmaticsSTTService( api_key=os.getenv("SPEECHMATICS_API_KEY"), - params=SpeechmaticsSTTService.InputParams( + settings=SpeechmaticsSTTService.Settings( language=Language.EN, speaker_active_format="<{speaker_id}>{text}", ), diff --git a/examples/foundational/13i-soniox-transcription.py b/examples/foundational/13i-soniox-transcription.py index d8d46dfc3..9476e9441 100644 --- a/examples/foundational/13i-soniox-transcription.py +++ b/examples/foundational/13i-soniox-transcription.py @@ -14,6 +14,7 @@ from pipecat.frames.frames import Frame, TranscriptionFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineTask +from pipecat.processors.audio.vad_processor import VADProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport @@ -39,15 +40,12 @@ class TranscriptionLogger(FrameProcessor): transport_params = { "daily": lambda: DailyParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -60,8 +58,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tl = TranscriptionLogger() + vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer()) - pipeline = Pipeline([transport.input(), stt, tl]) + pipeline = Pipeline([transport.input(), vad_processor, stt, tl]) task = PipelineTask( pipeline, diff --git a/examples/foundational/13j-azure-transcription.py b/examples/foundational/13j-azure-transcription.py index d3df106bd..301c6effd 100644 --- a/examples/foundational/13j-azure-transcription.py +++ b/examples/foundational/13j-azure-transcription.py @@ -14,6 +14,7 @@ from pipecat.frames.frames import Frame, TranscriptionFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineTask +from pipecat.processors.audio.vad_processor import VADProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport @@ -39,15 +40,12 @@ class TranscriptionLogger(FrameProcessor): transport_params = { "daily": lambda: DailyParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -61,8 +59,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tl = TranscriptionLogger() + vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer()) - pipeline = Pipeline([transport.input(), stt, tl]) + pipeline = Pipeline([transport.input(), vad_processor, stt, tl]) task = PipelineTask( pipeline, diff --git a/examples/foundational/13k-elevenlabs-transcription.py b/examples/foundational/13k-elevenlabs-transcription.py index 2568508f2..dbfd32ec6 100644 --- a/examples/foundational/13k-elevenlabs-transcription.py +++ b/examples/foundational/13k-elevenlabs-transcription.py @@ -6,7 +6,6 @@ import os -import aiohttp from dotenv import load_dotenv from loguru import logger @@ -15,10 +14,11 @@ from pipecat.frames.frames import Frame, TranscriptionFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineTask +from pipecat.processors.audio.vad_processor import VADProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport -from pipecat.services.elevenlabs.stt import ElevenLabsSTTService +from pipecat.services.elevenlabs.stt import ElevenLabsRealtimeSTTService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -37,44 +37,38 @@ class TranscriptionLogger(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, vad_analyzer=SileroVADAnalyzer()), - "twilio": lambda: FastAPIWebsocketParams( - audio_in_enabled=True, vad_analyzer=SileroVADAnalyzer() - ), - "webrtc": lambda: TransportParams(audio_in_enabled=True, vad_analyzer=SileroVADAnalyzer()), + "daily": lambda: DailyParams(audio_in_enabled=True), + "twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True), + "webrtc": lambda: TransportParams(audio_in_enabled=True), } async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - async with aiohttp.ClientSession() as session: - stt = ElevenLabsSTTService( - api_key=os.getenv("ELEVENLABS_API_KEY"), - aiohttp_session=session, - ) + stt = ElevenLabsRealtimeSTTService(api_key=os.getenv("ELEVENLABS_API_KEY")) - tl = TranscriptionLogger() + tl = TranscriptionLogger() + vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer()) - pipeline = Pipeline([transport.input(), stt, tl]) + pipeline = Pipeline([transport.input(), vad_processor, stt, tl]) - task = PipelineTask( - pipeline, - idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - ) + task = PipelineTask( + pipeline, + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) - @transport.event_handler("on_client_disconnected") - async def on_client_disconnected(transport, client): - logger.info(f"Client disconnected") - await task.cancel() + @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) + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) - await runner.run(task) + await runner.run(task) async def bot(runner_args: RunnerArguments): diff --git a/examples/foundational/13l-gradium-transcription.py b/examples/foundational/13l-gradium-transcription.py new file mode 100644 index 000000000..59140466e --- /dev/null +++ b/examples/foundational/13l-gradium-transcription.py @@ -0,0 +1,89 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.frames.frames import Frame, TranscriptionFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineTask +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.gradium.stt import GradiumSTTService +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) + + +class TranscriptionLogger(FrameProcessor): + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + + if isinstance(frame, TranscriptionFrame): + print(f"Transcription: {frame.text}") + + # Push all frames through + await self.push_frame(frame, direction) + + +# We store functions so objects (e.g. SileroVADAnalyzer) don't get +# instantiated. The function will be called when the desired transport gets +# selected. +transport_params = { + "daily": lambda: DailyParams(audio_in_enabled=True), + "twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True), + "webrtc": lambda: TransportParams(audio_in_enabled=True), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info(f"Starting bot") + + stt = GradiumSTTService( + api_key=os.getenv("GRADIUM_API_KEY"), + api_endpoint_base_url="wss://us.api.gradium.ai/api/speech/asr", + settings=GradiumSTTService.Settings( + language=Language.EN, + delay_in_frames=8, + ), + ) + + tl = TranscriptionLogger() + + pipeline = Pipeline([transport.input(), stt, tl]) + + task = PipelineTask( + pipeline, + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @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/13m-openai-transcription.py b/examples/foundational/13m-openai-transcription.py new file mode 100644 index 000000000..cbbf0e13d --- /dev/null +++ b/examples/foundational/13m-openai-transcription.py @@ -0,0 +1,89 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import Frame, TranscriptionFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineTask +from pipecat.processors.audio.vad_processor import VADProcessor +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.openai.stt import OpenAIRealtimeSTTService +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) + + +class TranscriptionLogger(FrameProcessor): + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + + if isinstance(frame, TranscriptionFrame): + print(f"Transcription: {frame.text}") + + # Push all frames through + await self.push_frame(frame, direction) + + +# 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), + "twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True), + "webrtc": lambda: TransportParams(audio_in_enabled=True), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info(f"Starting bot") + + stt = OpenAIRealtimeSTTService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIRealtimeSTTService.Settings( + model="gpt-4o-transcribe", + prompt="Expect words related to dogs, such as breed names.", + ), + ) + + tl = TranscriptionLogger() + vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer()) + + pipeline = Pipeline([transport.input(), vad_processor, stt, tl]) + + task = PipelineTask( + pipeline, + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @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/24-stt-mute-filter.py b/examples/foundational/14-function-calling-openai-responses.py similarity index 55% rename from examples/foundational/24-stt-mute-filter.py rename to examples/foundational/14-function-calling-openai-responses.py index 72f9682bb..58cac774a 100644 --- a/examples/foundational/24-stt-mute-filter.py +++ b/examples/foundational/14-function-calling-openai-responses.py @@ -4,8 +4,6 @@ # SPDX-License-Identifier: BSD 2-Clause License # - -import asyncio import os from dotenv import load_dotenv @@ -13,10 +11,8 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame +from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -25,48 +21,41 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.filters.stt_mute_filter import STTMuteConfig, STTMuteFilter, STTMuteStrategy 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.deepgram.tts import DeepgramTTSService from pipecat.services.llm_service import FunctionCallParams -from pipecat.services.openai.llm import OpenAILLMService +from pipecat.services.openai.responses.llm import OpenAIResponsesLLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) async def fetch_weather_from_api(params: FunctionCallParams): - # Add a delay to test interruption during function calls - logger.info("Weather API call starting...") - await asyncio.sleep(5) # 5-second delay - logger.info("Weather API call completed") await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +async def fetch_restaurant_recommendation(params: FunctionCallParams): + 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 = { "daily": lambda: DailyParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -76,20 +65,28 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - # Configure the mute processor with both strategies - stt_mute_processor = STTMuteFilter( - config=STTMuteConfig( - strategies={ - STTMuteStrategy.MUTE_UNTIL_FIRST_BOT_COMPLETE, - STTMuteStrategy.FUNCTION_CALL, - } + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady ), ) - tts = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY"), voice="aura-helios-en") + llm = OpenAIResponsesLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIResponsesLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + # You can also register a function_name of None to get all functions + # sent to the same callback with an additional function_name parameter. 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", @@ -107,35 +104,34 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): }, required=["location", "format"], ) - tools = ToolsSchema(standard_tools=[weather_function]) - - messages = [ - { - "role": "system", - "content": "You are a helpful assistant who can check the weather. Always check the weather when a location is mentioned. Respond concisely and naturally. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points.", + 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]) - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ - transport.input(), # Transport user input - stt, # STT - stt_mute_processor, # Add the mute processor between STT and context aggregator - context_aggregator.user(), # User responses - llm, # LLM - tts, # TTS - transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + transport.input(), + stt, + user_aggregator, + llm, + tts, + transport.output(), + assistant_aggregator, ] ) @@ -151,12 +147,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") - # Kick off the conversation with a weather-related prompt - messages.append( - { - "role": "system", - "content": "Ask the user what city they'd like to know the weather for.", - } + # Kick off the conversation. + context.add_message( + {"role": "developer", "content": "Please introduce yourself to the user."} ) await task.queue_frames([LLMRunFrame()]) diff --git a/examples/foundational/14-function-calling.py b/examples/foundational/14-function-calling.py index e0bf7de2d..5001d5dad 100644 --- a/examples/foundational/14-function-calling.py +++ b/examples/foundational/14-function-calling.py @@ -11,9 +11,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,8 +30,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -46,24 +42,20 @@ async def fetch_restaurant_recommendation(params: FunctionCallParams): await params.result_callback({"name": "The Golden Dragon"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -75,10 +67,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -118,32 +117,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14a-function-calling-anthropic.py b/examples/foundational/14a-function-calling-anthropic.py index c86abe61a..6cf1e228f 100644 --- a/examples/foundational/14a-function-calling-anthropic.py +++ b/examples/foundational/14a-function-calling-anthropic.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -48,24 +44,20 @@ async def fetch_restaurant_recommendation(params: FunctionCallParams): await params.result_callback({"name": "The Golden Dragon"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -77,12 +69,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = AnthropicLLMService( api_key=os.getenv("ANTHROPIC_API_KEY"), - model="claude-3-7-sonnet-latest", + settings=AnthropicLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) llm.register_function("get_weather", get_weather) llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) @@ -111,34 +107,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) - # todo: test with very short initial user message - - # messages = [{"role": "system", - # "content": "You are a helpful assistant who can report the weather in any location in the universe. Respond concisely. Your response will be turned into speech so use only simple words and punctuation."}, - # {"role": "user", - # "content": " Start the conversation by introducing yourself."}] - - messages = [{"role": "user", "content": "Say 'hello' to start the conversation."}] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User spoken responses + user_aggregator, # User spoken responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses and tool context + assistant_aggregator, # Assistant spoken responses and tool context ] ) @@ -155,6 +138,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/14c-function-calling-together.py b/examples/foundational/14c-function-calling-together.py index dc9ec9f51..5bd176237 100644 --- a/examples/foundational/14c-function-calling-together.py +++ b/examples/foundational/14c-function-calling-together.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.together.llm import TogetherLLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -72,12 +64,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = TogetherLLMService( api_key=os.getenv("TOGETHER_API_KEY"), - model="meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", + settings=TogetherLLMService.Settings( + model="meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -104,32 +101,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): required=["location", "format"], ) tools = ToolsSchema(standard_tools=[weather_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14d-function-calling-anthropic-video.py b/examples/foundational/14d-function-calling-anthropic-video.py index a217f11b1..f2653101e 100644 --- a/examples/foundational/14d-function-calling-anthropic-video.py +++ b/examples/foundational/14d-function-calling-anthropic-video.py @@ -11,10 +11,8 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, UserImageRequestFrame +from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame, UserImageRequestFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -36,8 +34,6 @@ from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.llm_service import FunctionCallParams from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -48,41 +44,41 @@ async def fetch_user_image(params: FunctionCallParams): When called, this function pushes a UserImageRequestFrame upstream to the transport. As a result, the transport will request the user image and push a UserImageRawFrame downstream which will be added to the context by the LLM - assistant aggregator. + assistant aggregator. The result_callback will be invoked once the image is + retrieved and processed. """ user_id = params.arguments["user_id"] question = params.arguments["question"] logger.debug(f"Requesting image with user_id={user_id}, question={question}") # Request a user image frame and indicate that it should be added to the - # context. + # context. Also associate it to the function call. Pass the result_callback + # so it can be invoked when the image is actually retrieved. await params.llm.push_frame( - UserImageRequestFrame(user_id=user_id, text=question, append_to_context=True), + UserImageRequestFrame( + user_id=user_id, + text=question, + append_to_context=True, + function_name=params.function_name, + tool_call_id=params.tool_call_id, + result_callback=params.result_callback, + ), FrameDirection.UPSTREAM, ) - await params.result_callback(None) - # Instead of None, it's possible to also provide a tool call answer to - # tell the LLM that we are grabbing the image to analyze. - # await params.result_callback({"result": "Image is being captured."}) - - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -94,13 +90,24 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) # Anthropic for vision analysis - llm = AnthropicLLMService(api_key=os.getenv("ANTHROPIC_API_KEY")) + llm = AnthropicLLMService( + api_key=os.getenv("ANTHROPIC_API_KEY"), + settings=AnthropicLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You are able to describe images from the user camera.", + ), + ) llm.register_function("fetch_user_image", fetch_user_image) + @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.")) + fetch_image_function = FunctionSchema( name="fetch_user_image", description="Called when the user requests a description of their camera feed", @@ -118,32 +125,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[fetch_image_function]) - 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. You are able to describe images from the user camera.", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -166,9 +162,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): client_id = get_transport_client_id(transport, client) # Kick off the conversation. - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": f"Please introduce yourself to the user. Use '{client_id}' as the user ID during function calls.", } ) diff --git a/examples/foundational/14d-function-calling-aws-video.py b/examples/foundational/14d-function-calling-aws-video.py index a216b5485..9fccdecdf 100644 --- a/examples/foundational/14d-function-calling-aws-video.py +++ b/examples/foundational/14d-function-calling-aws-video.py @@ -11,10 +11,8 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, UserImageRequestFrame +from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame, UserImageRequestFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -36,8 +34,6 @@ from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.llm_service import FunctionCallParams from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -48,41 +44,41 @@ async def fetch_user_image(params: FunctionCallParams): When called, this function pushes a UserImageRequestFrame upstream to the transport. As a result, the transport will request the user image and push a UserImageRawFrame downstream which will be added to the context by the LLM - assistant aggregator. + assistant aggregator. The result_callback will be invoked once the image is + retrieved and processed. """ user_id = params.arguments["user_id"] question = params.arguments["question"] logger.debug(f"Requesting image with user_id={user_id}, question={question}") # Request a user image frame and indicate that it should be added to the - # context. + # context. Also associate it to the function call. Pass the result_callback + # so it can be invoked when the image is actually retrieved. await params.llm.push_frame( - UserImageRequestFrame(user_id=user_id, text=question, append_to_context=True), + UserImageRequestFrame( + user_id=user_id, + text=question, + append_to_context=True, + function_name=params.function_name, + tool_call_id=params.tool_call_id, + result_callback=params.result_callback, + ), FrameDirection.UPSTREAM, ) - await params.result_callback(None) - # Instead of None, it's possible to also provide a tool call answer to - # tell the LLM that we are grabbing the image to analyze. - # await params.result_callback({"result": "Image is being captured."}) - - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -94,20 +90,29 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) # AWS for vision analysis llm = AWSBedrockLLMService( aws_region="us-west-2", - model="us.anthropic.claude-3-7-sonnet-20250219-v1:0", - # Note: usually, prefer providing latency="optimized" param. - # Here we can't because AWS Bedrock doesn't support it for Claude 3.7, - # which we need for image input. - params=AWSBedrockLLMService.InputParams(temperature=0.8), + settings=AWSBedrockLLMService.Settings( + model="us.anthropic.claude-sonnet-4-6", + # Note: usually, prefer providing latency="optimized" param. + # Here we can't because AWS Bedrock doesn't support it for Claude 3.7, + # which we need for image input. + temperature=0.8, + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You are able to describe images from the user camera.", + ), ) llm.register_function("fetch_user_image", fetch_user_image) + @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.")) + fetch_image_function = FunctionSchema( name="fetch_user_image", description="Called when the user requests a description of their camera feed", @@ -125,32 +130,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[fetch_image_function]) - 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. You are able to describe images from the user camera.", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -173,10 +167,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): client_id = get_transport_client_id(transport, client) # Kick off the conversation. - messages.append( + context.add_message( { - "role": "system", - "content": f"Please introduce yourself to the user. Use '{client_id}' as the user ID during function calls.", + "role": "user", + "content": f"Please introduce yourself to the user briefly; don't mention the camera. Use '{client_id}' as the user ID during function calls.", } ) await task.queue_frames([LLMRunFrame()]) diff --git a/examples/foundational/14d-function-calling-gemini-flash-video.py b/examples/foundational/14d-function-calling-gemini-flash-video.py index d7885b0dc..ba76de44b 100644 --- a/examples/foundational/14d-function-calling-gemini-flash-video.py +++ b/examples/foundational/14d-function-calling-gemini-flash-video.py @@ -11,10 +11,8 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, UserImageRequestFrame +from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame, UserImageRequestFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -36,8 +34,6 @@ from pipecat.services.google.llm import GoogleLLMService from pipecat.services.llm_service import FunctionCallParams from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -48,41 +44,41 @@ async def fetch_user_image(params: FunctionCallParams): When called, this function pushes a UserImageRequestFrame upstream to the transport. As a result, the transport will request the user image and push a UserImageRawFrame downstream which will be added to the context by the LLM - assistant aggregator. + assistant aggregator. The result_callback will be invoked once the image is + retrieved and processed. """ user_id = params.arguments["user_id"] question = params.arguments["question"] logger.debug(f"Requesting image with user_id={user_id}, question={question}") # Request a user image frame and indicate that it should be added to the - # context. + # context. Also associate it to the function call. Pass the result_callback + # so it can be invoked when the image is actually retrieved. await params.llm.push_frame( - UserImageRequestFrame(user_id=user_id, text=question, append_to_context=True), + UserImageRequestFrame( + user_id=user_id, + text=question, + append_to_context=True, + function_name=params.function_name, + tool_call_id=params.tool_call_id, + result_callback=params.result_callback, + ), FrameDirection.UPSTREAM, ) - await params.result_callback(None) - # Instead of None, it's possible to also provide a tool call answer to - # tell the LLM that we are grabbing the image to analyze. - # await params.result_callback({"result": "Image is being captured."}) - - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -94,13 +90,24 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) # Google Gemini model for vision analysis - llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY")) + llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You are able to describe images from the user camera.", + ), + ) llm.register_function("fetch_user_image", fetch_user_image) + @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.")) + fetch_image_function = FunctionSchema( name="fetch_user_image", description="Called when the user requests a description of their camera feed", @@ -118,32 +125,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[fetch_image_function]) - 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. You are able to describe images from the user camera.", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -166,9 +162,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): client_id = get_transport_client_id(transport, client) # Kick off the conversation. - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": f"Please introduce yourself to the user. Use '{client_id}' as the user ID during function calls.", } ) diff --git a/examples/foundational/14d-function-calling-moondream-video.py b/examples/foundational/14d-function-calling-moondream-video.py index a6c2ef24b..2bbfbd426 100644 --- a/examples/foundational/14d-function-calling-moondream-video.py +++ b/examples/foundational/14d-function-calling-moondream-video.py @@ -11,15 +11,14 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import ( Frame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMRunFrame, TextFrame, + TTSSpeakFrame, UserImageRequestFrame, ) from pipecat.pipeline.parallel_pipeline import ParallelPipeline @@ -45,8 +44,6 @@ from pipecat.services.moondream.vision import MoondreamService from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -56,7 +53,8 @@ async def fetch_user_image(params: FunctionCallParams): When called, this function pushes a UserImageRequestFrame upstream to the transport. As a result, the transport will request the user image and push a - UserImageRawFrame downstream. + UserImageRawFrame downstream. The result_callback will be invoked once the + image is retrieved and processed. """ user_id = params.arguments["user_id"] question = params.arguments["question"] @@ -64,18 +62,20 @@ async def fetch_user_image(params: FunctionCallParams): # Request a user image frame. In this case, we don't want the requested # image to be added to the context because we will process it with - # Moondream. + # Moondream. Also associate it to the function call. Pass the result_callback + # so it can be invoked when the image is actually retrieved. await params.llm.push_frame( - UserImageRequestFrame(user_id=user_id, text=question, append_to_context=False), + UserImageRequestFrame( + user_id=user_id, + text=question, + append_to_context=False, + function_name=params.function_name, + tool_call_id=params.tool_call_id, + result_callback=params.result_callback, + ), FrameDirection.UPSTREAM, ) - await params.result_callback(None) - - # Instead of None, it's possible to also provide a tool call answer to - # tell the LLM that we are grabbing the image to analyze. - # await params.result_callback({"result": "Image is being captured."}) - class MoondreamTextFrameWrapper(FrameProcessor): """Wraps Moondream-provided TextFrames with LLM response start/end frames. @@ -98,21 +98,18 @@ class MoondreamTextFrameWrapper(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -124,12 +121,23 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You are able to describe images from the user camera.", + ), + ) llm.register_function("fetch_user_image", fetch_user_image) + @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.")) + fetch_image_function = FunctionSchema( name="fetch_user_image", description="Called when the user requests a description of their camera feed", @@ -147,21 +155,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[fetch_image_function]) - 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. You are able to describe images from the user camera.", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) # If you run into weird description, try with use_cpu=True @@ -177,14 +174,14 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses ParallelPipeline( [llm], # LLM [moondream, moondream_text_wrapper], ), tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -203,9 +200,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): client_id = get_transport_client_id(transport, client) # Kick off the conversation. - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": f"Please introduce yourself to the user. Use '{client_id}' as the user ID during function calls.", } ) diff --git a/examples/foundational/14d-function-calling-openai-responses-video.py b/examples/foundational/14d-function-calling-openai-responses-video.py new file mode 100644 index 000000000..440c51cc1 --- /dev/null +++ b/examples/foundational/14d-function-calling-openai-responses-video.py @@ -0,0 +1,195 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame, UserImageRequestFrame +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.frame_processor import FrameDirection +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import ( + create_transport, + get_transport_client_id, + maybe_capture_participant_camera, +) +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.responses.llm import OpenAIResponsesLLMService +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams + +load_dotenv(override=True) + + +async def fetch_user_image(params: FunctionCallParams): + """Fetch the user image and push it to the LLM. + + When called, this function pushes a UserImageRequestFrame upstream to the + transport. As a result, the transport will request the user image and push a + UserImageRawFrame downstream which will be added to the context by the LLM + assistant aggregator. The result_callback will be invoked once the image is + retrieved and processed. + """ + user_id = params.arguments["user_id"] + question = params.arguments["question"] + logger.debug(f"Requesting image with user_id={user_id}, question={question}") + + # Request a user image frame and indicate that it should be added to the + # context. Also associate it to the function call. Pass the result_callback + # so it can be invoked when the image is actually retrieved. + await params.llm.push_frame( + UserImageRequestFrame( + user_id=user_id, + text=question, + append_to_context=True, + function_name=params.function_name, + tool_call_id=params.tool_call_id, + result_callback=params.result_callback, + ), + FrameDirection.UPSTREAM, + ) + + +# 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, + video_in_enabled=True, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + video_in_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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAIResponsesLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIResponsesLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You are able to describe images from the user camera.", + ), + ) + llm.register_function("fetch_user_image", fetch_user_image) + + @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.", append_to_context=False)) + + fetch_image_function = FunctionSchema( + name="fetch_user_image", + description="Called when the user requests a description of their camera feed", + properties={ + "user_id": { + "type": "string", + "description": "The ID of the user to grab the image from", + }, + "question": { + "type": "string", + "description": "The question that the user is asking about the image", + }, + }, + required=["user_id", "question"], + ) + tools = ToolsSchema(standard_tools=[fetch_image_function]) + + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + 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") + + await maybe_capture_participant_camera(transport, client) + + client_id = get_transport_client_id(transport, client) + + # Kick off the conversation. + context.add_message( + { + "role": "user", + "content": f"Please introduce yourself to the user. Use '{client_id}' as the user ID during function calls.", + } + ) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + @tts.event_handler("on_tts_request") + async def on_tts_request(tts, context_id: str, text: str): + logger.debug(f"On TTS request: {context_id}: {text}") + + 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/14d-function-calling-openai-video.py b/examples/foundational/14d-function-calling-openai-video.py index 60c8f5952..59bef64b6 100644 --- a/examples/foundational/14d-function-calling-openai-video.py +++ b/examples/foundational/14d-function-calling-openai-video.py @@ -12,10 +12,8 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, UserImageRequestFrame +from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame, UserImageRequestFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -37,8 +35,6 @@ 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -49,41 +45,41 @@ async def fetch_user_image(params: FunctionCallParams): When called, this function pushes a UserImageRequestFrame upstream to the transport. As a result, the transport will request the user image and push a UserImageRawFrame downstream which will be added to the context by the LLM - assistant aggregator. + assistant aggregator. The result_callback will be invoked once the image is + retrieved and processed. """ user_id = params.arguments["user_id"] question = params.arguments["question"] logger.debug(f"Requesting image with user_id={user_id}, question={question}") # Request a user image frame and indicate that it should be added to the - # context. + # context. Also associate it to the function call. Pass the result_callback + # so it can be invoked when the image is actually retrieved. await params.llm.push_frame( - UserImageRequestFrame(user_id=user_id, text=question, append_to_context=True), + UserImageRequestFrame( + user_id=user_id, + text=question, + append_to_context=True, + function_name=params.function_name, + tool_call_id=params.tool_call_id, + result_callback=params.result_callback, + ), FrameDirection.UPSTREAM, ) - await params.result_callback(None) - # Instead of None, it's possible to also provide a tool call answer to - # tell the LLM that we are grabbing the image to analyze. - # await params.result_callback({"result": "Image is being captured."}) - - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -95,12 +91,23 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You are able to describe images from the user camera.", + ), + ) llm.register_function("fetch_user_image", fetch_user_image) + @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.", append_to_context=False)) + fetch_image_function = FunctionSchema( name="fetch_user_image", description="Called when the user requests a description of their camera feed", @@ -118,32 +125,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[fetch_image_function]) - 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. You are able to describe images from the user camera.", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -165,9 +161,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): client_id = get_transport_client_id(transport, client) # Kick off the conversation. - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": f"Please introduce yourself to the user. Use '{client_id}' as the user ID during function calls.", } ) @@ -178,6 +174,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Client disconnected") await task.cancel() + @tts.event_handler("on_tts_request") + async def on_tts_request(tts, context_id: str, text: str): + logger.debug(f"On TTS request: {context_id}: {text}") + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) await runner.run(task) diff --git a/examples/foundational/14e-function-calling-google.py b/examples/foundational/14e-function-calling-google.py index 49a9f98c3..e5a2cfb40 100644 --- a/examples/foundational/14e-function-calling-google.py +++ b/examples/foundational/14e-function-calling-google.py @@ -5,7 +5,6 @@ # -import asyncio import os from dotenv import load_dotenv @@ -13,10 +12,8 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame +from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame, UserImageRequestFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -25,6 +22,7 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) +from pipecat.processors.frame_processor import FrameDirection from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import ( create_transport, @@ -37,16 +35,10 @@ from pipecat.services.google.llm import GoogleLLMService from pipecat.services.llm_service import FunctionCallParams from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# Global variable to store the client ID -client_id = "" - - async def get_weather(params: FunctionCallParams): location = params.arguments["location"] await params.result_callback(f"The weather in {location} is currently 72 degrees and sunny.") @@ -57,41 +49,46 @@ async def fetch_restaurant_recommendation(params: FunctionCallParams): async def get_image(params: FunctionCallParams): + """Fetch the user image and push it to the LLM. + + When called, this function pushes a UserImageRequestFrame upstream to the + transport. As a result, the transport will request the user image and push a + UserImageRawFrame downstream which will be added to the context by the LLM + assistant aggregator. The result_callback will be invoked once the image is + retrieved and processed. + """ + user_id = params.arguments["user_id"] question = params.arguments["question"] - logger.debug(f"Requesting image with user_id={client_id}, question={question}") + logger.debug(f"Requesting image with user_id={user_id}, question={question}") - # Request the image frame - await params.llm.request_image_frame( - user_id=client_id, - function_name=params.function_name, - tool_call_id=params.tool_call_id, - text_content=question, - ) - - # Wait a short time for the frame to be processed - await asyncio.sleep(0.5) - - # Return a result to complete the function call - await params.result_callback( - f"I've captured an image from your camera and I'm analyzing what you asked about: {question}" + # Request a user image frame and indicate that it should be added to the + # context. Also associate it to the function call. Pass the result_callback + # so it can be invoked when the image is actually retrieved. + await params.llm.push_frame( + UserImageRequestFrame( + user_id=user_id, + text=question, + append_to_context=True, + function_name=params.function_name, + tool_call_id=params.tool_call_id, + result_callback=params.result_callback, + ), + FrameDirection.UPSTREAM, ) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -103,10 +100,36 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY")) + system_prompt = """\ +You are a helpful assistant who converses with a user and answers questions. Respond concisely to general questions. + +Your response will be turned into speech so use only simple words and punctuation. + +You have access to three tools: get_weather, get_restaurant_recommendation, and get_image. + +You can respond to questions about the weather using the get_weather tool. + +You can answer questions about the user's video stream using the get_image tool. Some examples of phrases that \ +indicate you should use the get_image tool are: +- What do you see? +- What's in the video? +- Can you describe the video? +- Tell me about what you see. +- Tell me something interesting about what you see. +- What's happening in the video? +""" + + llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings( + system_instruction=system_prompt, + ), + ) llm.register_function("get_weather", get_weather) llm.register_function("get_image", get_image) llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) @@ -144,59 +167,36 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) get_image_function = FunctionSchema( name="get_image", - description="Get an image from the video stream.", + description="Called when the user requests a description of their camera feed", properties={ + "user_id": { + "type": "string", + "description": "The ID of the user to grab the image from", + }, "question": { "type": "string", - "description": "The question that the user is asking about the image.", - } + "description": "The question that the user is asking about the image", + }, }, - required=["question"], + required=["user_id", "question"], ) tools = ToolsSchema(standard_tools=[weather_function, get_image_function, restaurant_function]) - system_prompt = """\ -You are a helpful assistant who converses with a user and answers questions. Respond concisely to general questions. - -Your response will be turned into speech so use only simple words and punctuation. - -You have access to three tools: get_weather, get_restaurant_recommendation, and get_image. - -You can respond to questions about the weather using the get_weather tool. - -You can answer questions about the user's video stream using the get_image tool. Some examples of phrases that \ -indicate you should use the get_image tool are: -- What do you see? -- What's in the video? -- Can you describe the video? -- Tell me about what you see. -- Tell me something interesting about what you see. -- What's happening in the video? -""" - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": "Say hello."}, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -215,10 +215,15 @@ indicate you should use the get_image tool are: await maybe_capture_participant_camera(transport, client) - global client_id client_id = get_transport_client_id(transport, client) # Kick off the conversation. + context.add_message( + { + "role": "user", + "content": f"Please introduce yourself to the user. Use '{client_id}' as the user ID during function calls.", + } + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/14f-function-calling-groq.py b/examples/foundational/14f-function-calling-groq.py index 91ed9caa6..1e6e84338 100644 --- a/examples/foundational/14f-function-calling-groq.py +++ b/examples/foundational/14f-function-calling-groq.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -72,10 +64,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = GroqLLMService(api_key=os.getenv("GROQ_API_KEY")) + llm = GroqLLMService( + api_key=os.getenv("GROQ_API_KEY"), + settings=GroqLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. llm.register_function("get_current_weather", fetch_weather_from_api) @@ -101,32 +100,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): required=["location"], ) tools = ToolsSchema(standard_tools=[weather_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14g-function-calling-grok.py b/examples/foundational/14g-function-calling-grok.py index 8ea41d6aa..4de1f6528 100644 --- a/examples/foundational/14g-function-calling-grok.py +++ b/examples/foundational/14g-function-calling-grok.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -72,10 +64,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = GrokLLMService(api_key=os.getenv("GROK_API_KEY")) + llm = GrokLLMService( + api_key=os.getenv("GROK_API_KEY"), + settings=GrokLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. llm.register_function("get_current_weather", fetch_weather_from_api) @@ -97,32 +96,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): required=["location", "format"], ) tools = ToolsSchema(standard_tools=[weather_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14h-function-calling-azure.py b/examples/foundational/14h-function-calling-azure.py index 77e7579de..23c156da4 100644 --- a/examples/foundational/14h-function-calling-azure.py +++ b/examples/foundational/14h-function-calling-azure.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -72,13 +64,18 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = AzureLLMService( api_key=os.getenv("AZURE_CHATGPT_API_KEY"), endpoint=os.getenv("AZURE_CHATGPT_ENDPOINT"), - model=os.getenv("AZURE_CHATGPT_MODEL"), + settings=AzureLLMService.Settings( + model=os.getenv("AZURE_CHATGPT_MODEL"), + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -105,32 +102,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): required=["location", "format"], ) tools = ToolsSchema(standard_tools=[weather_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14i-function-calling-fireworks.py b/examples/foundational/14i-function-calling-fireworks.py index a79ab925f..f58ef73d9 100644 --- a/examples/foundational/14i-function-calling-fireworks.py +++ b/examples/foundational/14i-function-calling-fireworks.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -72,12 +64,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = FireworksLLMService( api_key=os.getenv("FIREWORKS_API_KEY"), - model="accounts/fireworks/models/gpt-oss-20b", + settings=FireworksLLMService.Settings( + model="accounts/fireworks/models/gpt-oss-20b", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -108,32 +105,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): required=["location", "format"], ) tools = ToolsSchema(standard_tools=[weather_function]) - 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. Start by saying hello.", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -150,6 +136,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/14j-function-calling-nvidia.py b/examples/foundational/14j-function-calling-nvidia.py index 3f4dfe8c1..e3db6db8d 100644 --- a/examples/foundational/14j-function-calling-nvidia.py +++ b/examples/foundational/14j-function-calling-nvidia.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.nvidia.llm import NvidiaLLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -72,15 +64,19 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady - # text_filters=[MarkdownTextFilter()], + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = NvidiaLLMService( api_key=os.getenv("NVIDIA_API_KEY"), - model="nvidia/llama-3.3-nemotron-super-49b-v1.5", - # Recommended when turning thinking off - params=NvidiaLLMService.InputParams(temperature=0.0), + settings=NvidiaLLMService.Settings( + model="nvidia/llama-3.3-nemotron-super-49b-v1.5", + # Recommended when turning thinking off + temperature=0.0, + system_instruction="/no_think You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -107,35 +103,22 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): required=["location", "format"], ) tools = ToolsSchema(standard_tools=[weather_function]) - messages = [ - # Disable thinking by sending this message first - # Check the model for the corresponding "no thinking" message - {"role": "system", "content": "/no_think"}, - { - "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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14k-function-calling-cerebras.py b/examples/foundational/14k-function-calling-cerebras.py index 1a5fe4c38..ad8475185 100644 --- a/examples/foundational/14k-function-calling-cerebras.py +++ b/examples/foundational/14k-function-calling-cerebras.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -72,10 +64,27 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = CerebrasLLMService(api_key=os.getenv("CEREBRAS_API_KEY")) + llm = CerebrasLLMService( + api_key=os.getenv("CEREBRAS_API_KEY"), + settings=CerebrasLLMService.Settings( + system_instruction="""You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. + +You have one functions available: + +1. get_current_weather is used to get current weather information. + +Infer whether to use Fahrenheit or Celsius automatically based on the location, unless the user specifies a preference. + +Start by asking me for my location. Then, use 'get_weather_current' to give me a forecast. + + Respond to what the user said in a creative and helpful way.""", + ), + ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. llm.register_function("get_current_weather", fetch_weather_from_api) @@ -101,42 +110,22 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): required=["location", "format"], ) tools = ToolsSchema(standard_tools=[weather_function]) - messages = [ - { - "role": "system", - "content": """You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. -You have one functions available: - -1. get_current_weather is used to get current weather information. - -Infer whether to use Fahrenheit or Celsius automatically based on the location, unless the user specifies a preference. - -Start by asking me for my location. Then, use 'get_weather_current' to give me a forecast. - - Respond to what the user said in a creative and helpful way.""", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14l-function-calling-deepseek.py b/examples/foundational/14l-function-calling-deepseek.py index 497af8609..f115299e2 100644 --- a/examples/foundational/14l-function-calling-deepseek.py +++ b/examples/foundational/14l-function-calling-deepseek.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2025, Daily +# Copyright (c) 2024-2026, Daily # # SPDX-License-Identifier: BSD 2-Clause License # @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -72,10 +64,28 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = DeepSeekLLMService(api_key=os.getenv("DEEPSEEK_API_KEY"), model="deepseek-chat") + llm = DeepSeekLLMService( + api_key=os.getenv("DEEPSEEK_API_KEY"), + settings=DeepSeekLLMService.Settings( + model="deepseek-chat", + system_instruction="""You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. + +You have one functions available: + +1. get_current_weather is used to get current weather information. + +Infer whether to use Fahrenheit or Celsius automatically based on the location, unless the user specifies a preference. + +Start by asking me for my location. Then, use 'get_weather_current' to give me a forecast. + + Respond to what the user said in a creative and helpful way.""", + ), + ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. llm.register_function("get_current_weather", fetch_weather_from_api) @@ -101,42 +111,22 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): required=["location", "format"], ) tools = ToolsSchema(standard_tools=[weather_function]) - messages = [ - { - "role": "system", - "content": """You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. -You have one functions available: - -1. get_current_weather is used to get current weather information. - -Infer whether to use Fahrenheit or Celsius automatically based on the location, unless the user specifies a preference. - -Start by asking me for my location. Then, use 'get_weather_current' to give me a forecast. - - Respond to what the user said in a creative and helpful way.""", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14m-function-calling-openrouter.py b/examples/foundational/14m-function-calling-openrouter.py index 34c59ef9e..b341ca71c 100644 --- a/examples/foundational/14m-function-calling-openrouter.py +++ b/examples/foundational/14m-function-calling-openrouter.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.openrouter.llm import OpenRouterLLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -73,12 +65,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = AzureTTSService( api_key=os.getenv("AZURE_SPEECH_API_KEY"), region=os.getenv("AZURE_SPEECH_REGION"), - voice="en-US-JennyNeural", - params=AzureTTSService.InputParams(language="en-US", rate="1.1", style="cheerful"), + settings=AzureTTSService.Settings( + voice="en-US-JennyNeural", + language="en-US", + rate="1.1", + style="cheerful", + ), ) llm = OpenRouterLLMService( - api_key=os.getenv("OPENROUTER_API_KEY"), model="openai/gpt-4o-2024-11-20" + api_key=os.getenv("OPENROUTER_API_KEY"), + settings=OpenRouterLLMService.Settings( + model="openai/gpt-4o-2024-11-20", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -105,32 +105,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): required=["location", "format"], ) tools = ToolsSchema(standard_tools=[weather_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14n-function-calling-perplexity.py b/examples/foundational/14n-function-calling-perplexity.py index d768f3e77..a67e93ab8 100644 --- a/examples/foundational/14n-function-calling-perplexity.py +++ b/examples/foundational/14n-function-calling-perplexity.py @@ -16,9 +16,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -36,29 +34,23 @@ from pipecat.services.perplexity.llm import PerplexityLLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -70,37 +62,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = PerplexityLLMService(api_key=os.getenv("PERPLEXITY_API_KEY"), model="sonar") - - messages = [ - { - "role": "user", - "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, but try to be brief.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = PerplexityLLMService( + api_key=os.getenv("PERPLEXITY_API_KEY"), + settings=PerplexityLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -117,6 +105,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/14o-function-calling-gemini-openai-format.py b/examples/foundational/14o-function-calling-gemini-openai-format.py index 8c6356507..70e8af3d3 100644 --- a/examples/foundational/14o-function-calling-gemini-openai-format.py +++ b/examples/foundational/14o-function-calling-gemini-openai-format.py @@ -12,19 +12,21 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.elevenlabs.tts import ElevenLabsTTSService -from pipecat.services.google.llm_openai import GoogleLLMOpenAIBetaService +from pipecat.services.google.openai.llm import GoogleLLMOpenAIBetaService from pipecat.services.llm_service import FunctionCallParams from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams @@ -37,27 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - turn_analyzer=LocalSmartTurnAnalyzerV3(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - turn_analyzer=LocalSmartTurnAnalyzerV3(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - turn_analyzer=LocalSmartTurnAnalyzerV3(), ), } @@ -69,10 +64,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = ElevenLabsTTSService( api_key=os.getenv("ELEVENLABS_API_KEY", ""), - voice_id=os.getenv("ELEVENLABS_VOICE_ID", ""), + settings=ElevenLabsTTSService.Settings( + voice=os.getenv("ELEVENLABS_VOICE_ID", ""), + ), ) - llm = GoogleLLMOpenAIBetaService(api_key=os.getenv("GOOGLE_API_KEY")) + llm = GoogleLLMOpenAIBetaService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMOpenAIBetaService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) # You can aslo register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. llm.register_function("get_current_weather", fetch_weather_from_api) @@ -106,17 +108,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ] context = OpenAILLMContext(messages, tools) - context_aggregator = llm.create_context_aggregator(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14p-function-calling-gemini-vertex-ai.py b/examples/foundational/14p-function-calling-gemini-vertex-ai.py index bae9def68..fb3910ed3 100644 --- a/examples/foundational/14p-function-calling-gemini-vertex-ai.py +++ b/examples/foundational/14p-function-calling-gemini-vertex-ai.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -28,13 +26,11 @@ from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.elevenlabs.tts import ElevenLabsTTSService -from pipecat.services.google.llm_vertex import GoogleVertexLLMService +from pipecat.services.google.vertex.llm import GoogleVertexLLMService from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -72,13 +64,18 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = ElevenLabsTTSService( api_key=os.getenv("ELEVENLABS_API_KEY", ""), - voice_id=os.getenv("ELEVENLABS_VOICE_ID", ""), + settings=ElevenLabsTTSService.Settings( + voice=os.getenv("ELEVENLABS_VOICE_ID", ""), + ), ) llm = GoogleVertexLLMService( credentials=os.getenv("GOOGLE_VERTEX_TEST_CREDENTIALS"), project_id=os.getenv("GOOGLE_CLOUD_PROJECT_ID"), location=os.getenv("GOOGLE_CLOUD_LOCATION"), + settings=GoogleVertexLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) # You can aslo register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -114,24 +111,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ] context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14q-function-calling-qwen.py b/examples/foundational/14q-function-calling-qwen.py index 256ff1499..9a6bf6d8b 100644 --- a/examples/foundational/14q-function-calling-qwen.py +++ b/examples/foundational/14q-function-calling-qwen.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.qwen.llm import QwenLLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -72,10 +64,18 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = QwenLLMService(api_key=os.getenv("QWEN_API_KEY"), model="qwen2.5-72b-instruct") + llm = QwenLLMService( + api_key=os.getenv("QWEN_API_KEY"), + model="qwen2.5-72b-instruct", + settings=QwenLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -103,32 +103,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[weather_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14r-function-calling-aws.py b/examples/foundational/14r-function-calling-aws.py index 95b7f9d68..86ee0f0dc 100644 --- a/examples/foundational/14r-function-calling-aws.py +++ b/examples/foundational/14r-function-calling-aws.py @@ -10,9 +10,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,8 +29,6 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -45,24 +41,20 @@ async def fetch_restaurant_recommendation(params: FunctionCallParams): await params.result_callback({"name": "The Golden Dragon"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -74,14 +66,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = AWSPollyTTSService( region="us-west-2", # only specific regions support generative TTS - voice_id="Joanna", - params=AWSPollyTTSService.InputParams(engine="generative", rate="1.1"), + settings=AWSPollyTTSService.Settings( + voice="Joanna", + engine="generative", + rate="1.1", + ), ) llm = AWSBedrockLLMService( aws_region="us-west-2", - model="us.anthropic.claude-haiku-4-5-20251001-v1:0", - params=AWSBedrockLLMService.InputParams(temperature=0.8), + settings=AWSBedrockLLMService.Settings( + model="us.anthropic.claude-sonnet-4-6", + temperature=0.8, + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) # You can also register a function_name of None to get all functions @@ -118,32 +116,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -160,7 +147,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "user", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/14s-function-calling-sambanova.py b/examples/foundational/14s-function-calling-sambanova.py index fbd1a7176..3854d2d6e 100644 --- a/examples/foundational/14s-function-calling-sambanova.py +++ b/examples/foundational/14s-function-calling-sambanova.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.sambanova.stt import SambaNovaSTTService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -75,12 +67,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = SambaNovaLLMService( api_key=os.getenv("SAMBANOVA_API_KEY"), - model="Llama-4-Maverick-17B-128E-Instruct", + settings=SambaNovaLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -107,32 +103,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): required=["location"], ) tools = ToolsSchema(standard_tools=[weather_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14t-function-calling-direct.py b/examples/foundational/14t-function-calling-direct.py index 6f682327c..1c6ebe072 100644 --- a/examples/foundational/14t-function-calling-direct.py +++ b/examples/foundational/14t-function-calling-direct.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2025, Daily +# Copyright (c) 2024-2026, Daily # # SPDX-License-Identifier: BSD 2-Clause License # @@ -11,9 +11,7 @@ from dotenv import load_dotenv from loguru import logger from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,8 +30,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -59,24 +55,20 @@ async def get_restaurant_recommendation(params: FunctionCallParams, location: st await params.result_callback({"name": "The Golden Dragon"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -88,10 +80,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -104,32 +103,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tools = ToolsSchema(standard_tools=[get_current_weather, get_restaurant_recommendation]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14u-function-calling-ollama.py b/examples/foundational/14u-function-calling-ollama.py index 5a66b81fe..87491f3d6 100644 --- a/examples/foundational/14u-function-calling-ollama.py +++ b/examples/foundational/14u-function-calling-ollama.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.ollama.llm import OLLamaLLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -47,24 +43,20 @@ async def fetch_restaurant_recommendation(params: FunctionCallParams): await params.result_callback({"name": "The Golden Dragon"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -76,10 +68,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OLLamaLLMService(model="llama3.2") # Update to the model you're running locally + llm = OLLamaLLMService( + settings=OLLamaLLMService.Settings( + model="llama3.2", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) # Update to the model you're running locally # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -119,32 +118,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14v-function-calling-openai.py b/examples/foundational/14v-function-calling-openai.py index c84b6a78c..2b59d7072 100644 --- a/examples/foundational/14v-function-calling-openai.py +++ b/examples/foundational/14v-function-calling-openai.py @@ -11,9 +11,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,8 +30,6 @@ from pipecat.services.openai.tts import OpenAITTSService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -46,24 +42,20 @@ async def fetch_restaurant_recommendation(params: FunctionCallParams): await params.result_callback({"name": "The Golden Dragon"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -73,20 +65,26 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = OpenAISTTService( api_key=os.getenv("OPENAI_API_KEY"), - model="gpt-4o-transcribe", - prompt="Expect words related weather, such as temperature and conditions. And restaurant names.", + settings=OpenAISTTService.Settings( + model="gpt-4o-transcribe", + prompt="Expect words related weather, such as temperature and conditions. And restaurant names.", + ), ) - # voice choices: ash, ballad, or any other voice available in the OpenAI TTS API - # see https://www.openai.fm/ tts = OpenAITTSService( api_key=os.getenv("OPENAI_API_KEY"), - voice="ballad", + settings=OpenAITTSService.Settings( + voice="ballad", + ), instructions="Please speak clearly and at a moderate pace.", ) - # model choices: gpt-4o, gpt-4.1, etc. - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -126,32 +124,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14w-function-calling-mistral.py b/examples/foundational/14w-function-calling-mistral.py index 135328cf8..c52d46c03 100644 --- a/examples/foundational/14w-function-calling-mistral.py +++ b/examples/foundational/14w-function-calling-mistral.py @@ -11,9 +11,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,8 +30,6 @@ from pipecat.services.mistral.llm import MistralLLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -46,24 +42,20 @@ async def fetch_restaurant_recommendation(params: FunctionCallParams): await params.result_callback({"name": "The Golden Dragon"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -75,10 +67,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = MistralLLMService(api_key=os.getenv("MISTRAL_API_KEY")) + llm = MistralLLMService( + api_key=os.getenv("MISTRAL_API_KEY"), + settings=MistralLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) # You can also register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. @@ -114,32 +113,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/14x-function-calling-openpipe.py b/examples/foundational/14x-function-calling-openpipe.py index 0191e18fb..5074bda2f 100644 --- a/examples/foundational/14x-function-calling-openpipe.py +++ b/examples/foundational/14x-function-calling-openpipe.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ from pipecat.services.openpipe.llm import OpenPipeLLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -47,24 +43,20 @@ async def fetch_restaurant_recommendation(params: FunctionCallParams): await params.result_callback({"name": "The Golden Dragon"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -76,7 +68,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) timestamp = int(time.time()) @@ -84,6 +78,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): api_key=os.getenv("OPENAI_API_KEY"), openpipe_api_key=os.getenv("OPENPIPE_API_KEY"), tags={"conversation_id": f"pipecat-{timestamp}"}, + settings=OpenPipeLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) # You can also register a function_name of None to get all functions @@ -124,32 +121,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/15-switch-voices.py b/examples/foundational/15-switch-voices.py index 27e1464aa..daf77ecde 100644 --- a/examples/foundational/15-switch-voices.py +++ b/examples/foundational/15-switch-voices.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import Frame, LLMRunFrame from pipecat.pipeline.parallel_pipeline import ParallelPipeline from pipecat.pipeline.pipeline import Pipeline @@ -35,8 +33,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -47,17 +43,23 @@ class SwitchVoices(ParallelPipeline): news_lady = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="bf991597-6c13-47e4-8411-91ec2de5c466", # Newslady + settings=CartesiaTTSService.Settings( + voice="bf991597-6c13-47e4-8411-91ec2de5c466", # Newslady + ), ) british_lady = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) barbershop_man = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="a0e99841-438c-4a64-b679-ae501e7d6091", # Barbershop Man + settings=CartesiaTTSService.Settings( + voice="a0e99841-438c-4a64-b679-ae501e7d6091", # Barbershop Man + ), ) super().__init__( @@ -91,24 +93,20 @@ class SwitchVoices(ParallelPipeline): return self.current_voice == "Barbershop Man" -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -120,7 +118,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = SwitchVoices() - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative and helpful way. You can do the following voices: 'News Lady', 'British Lady' and 'Barbershop Man'.", + ), + ) llm.register_function("switch_voice", tts.switch_voice) switch_voice_function = FunctionSchema( @@ -136,32 +139,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[switch_voice_function]) - messages = [ - { - "role": "system", - "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities. Respond to what the user said in a creative and helpful way. Your output should not include non-alphanumeric characters. You can do the following voices: 'News Lady', 'British Lady' and 'Barbershop Man'.", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS with switch voice functionality transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -178,9 +170,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": f"Please introduce yourself to the user and let them know the voices you can do. Your initial responses should be as if you were a {tts.current_voice}.", } ) diff --git a/examples/foundational/15a-switch-languages.py b/examples/foundational/15a-switch-languages.py index 1ce8f4dff..0ff13df1a 100644 --- a/examples/foundational/15a-switch-languages.py +++ b/examples/foundational/15a-switch-languages.py @@ -7,15 +7,12 @@ import os -from deepgram import LiveOptions 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.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import Frame, LLMRunFrame from pipecat.pipeline.parallel_pipeline import ParallelPipeline from pipecat.pipeline.pipeline import Pipeline @@ -36,8 +33,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -48,12 +43,16 @@ class SwitchLanguage(ParallelPipeline): english_tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) spanish_tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="d4db5fb9-f44b-4bd1-85fa-192e0f0d75f9", # Spanish-speaking Lady + settings=CartesiaTTSService.Settings( + voice="d4db5fb9-f44b-4bd1-85fa-192e0f0d75f9", # Spanish-speaking Lady + ), ) super().__init__( @@ -80,24 +79,20 @@ class SwitchLanguage(ParallelPipeline): return self.current_language == "Spanish" -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -106,12 +101,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") stt = DeepgramSTTService( - api_key=os.getenv("DEEPGRAM_API_KEY"), live_options=LiveOptions(language="multi") + api_key=os.getenv("DEEPGRAM_API_KEY"), + settings=DeepgramSTTService.Settings( + language="multi", + ), ) tts = SwitchLanguage() - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You can speak the following languages: 'English' and 'Spanish'.", + ), + ) llm.register_function("switch_language", tts.switch_language) switch_language_function = FunctionSchema( @@ -126,32 +129,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): required=["language"], ) tools = ToolsSchema(standard_tools=[switch_language_function]) - messages = [ - { - "role": "system", - "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities. Respond to what the user said in a creative and helpful way. Your output should not include non-alphanumeric characters. You can speak the following languages: 'English' and 'Spanish'.", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS (bot will speak the chosen language) transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -168,9 +160,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": f"Please introduce yourself to the user and let them know the languages you speak. Your initial responses should be in {tts.current_language}.", } ) diff --git a/examples/foundational/16-gpu-container-local-bot.py b/examples/foundational/16-gpu-container-local-bot.py index 8c547300e..b12d85dff 100644 --- a/examples/foundational/16-gpu-container-local-bot.py +++ b/examples/foundational/16-gpu-container-local-bot.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -34,29 +32,23 @@ from pipecat.transports.daily.transport import ( DailyParams, ) from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -68,7 +60,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = DeepgramTTSService( api_key=os.getenv("DEEPGRAM_API_KEY"), - voice="aura-asteria-en", + settings=DeepgramTTSService.Settings( + voice="aura-asteria-en", + ), base_url="http://0.0.0.0:8080", ) @@ -76,36 +70,28 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # To use OpenAI # api_key=os.getenv("OPENAI_API_KEY"), # Or, to use a local vLLM (or similar) api server - model="meta-llama/Meta-Llama-3-8B-Instruct", + settings=OpenAILLMService.Settings( + model="meta-llama/Meta-Llama-3-8B-Instruct", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), base_url="http://0.0.0.0:8000/v1", ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), + user_aggregator, llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -123,7 +109,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) # Handle "latency-ping" messages. The client will send app messages that look like diff --git a/examples/foundational/17-detect-user-idle.py b/examples/foundational/17-detect-user-idle.py index ddef92a5b..1cd772af2 100644 --- a/examples/foundational/17-detect-user-idle.py +++ b/examples/foundational/17-detect-user-idle.py @@ -5,15 +5,22 @@ # +import asyncio import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 +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.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import EndFrame, LLMMessagesAppendFrame, LLMRunFrame, TTSSpeakFrame +from pipecat.frames.frames import ( + EndTaskFrame, + LLMMessagesAppendFrame, + LLMRunFrame, + TTSSpeakFrame, + UserIdleTimeoutUpdateFrame, +) from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -22,38 +29,81 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.user_idle_processor import UserIdleProcessor +from pipecat.processors.frame_processor import FrameDirection 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 from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. + +class IdleHandler: + """Helper class to manage user idle retry logic.""" + + def __init__(self): + self._retry_count = 0 + + def reset(self): + """Reset the retry count when user becomes active.""" + self._retry_count = 0 + + async def handle_idle(self, aggregator): + """Handle user idle event with escalating prompts.""" + self._retry_count += 1 + + if self._retry_count == 1: + # First attempt: Add a gentle prompt to the conversation + message = { + "role": "user", + "content": "The user has been quiet. Politely and briefly ask if they're still there.", + } + await aggregator.push_frame(LLMMessagesAppendFrame([message], run_llm=True)) + elif self._retry_count == 2: + # Second attempt: More direct prompt + message = { + "role": "user", + "content": "The user is still inactive. Ask if they'd like to continue our conversation.", + } + await aggregator.push_frame(LLMMessagesAppendFrame([message], run_llm=True)) + else: + # Third attempt: End the conversation + await aggregator.push_frame( + TTSSpeakFrame("It seems like you're busy right now. Have a nice day!") + ) + 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 = { "daily": lambda: DailyParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -65,65 +115,72 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady ), ) - async def handle_user_idle(user_idle: UserIdleProcessor, retry_count: int) -> bool: - if retry_count == 1: - # First attempt: Add a gentle prompt to the conversation - message = { - "role": "system", - "content": "The user has been quiet. Politely and briefly ask if they're still there.", - } - await user_idle.push_frame(LLMMessagesAppendFrame([message], run_llm=True)) - return True - elif retry_count == 2: - # Second attempt: More direct prompt - message = { - "role": "system", - "content": "The user is still inactive. Ask if they'd like to continue our conversation.", - } - await user_idle.push_frame(LLMMessagesAppendFrame([message], run_llm=True)) - return True - else: - # Third attempt: End the conversation - await user_idle.push_frame( - TTSSpeakFrame("It seems like you're busy right now. Have a nice day!") - ) - await task.queue_frame(EndFrame()) - return False + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) - user_idle = UserIdleProcessor(callback=handle_user_idle, timeout=5.0) + 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]) + + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_idle_timeout=5.0, # Detect user idle after 5 seconds + vad_analyzer=SileroVADAnalyzer(), + ), + ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, - user_idle, # Idle user check-in - context_aggregator.user(), + user_aggregator, # User aggregator with built-in idle detection llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -136,12 +193,30 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, ) + # Set up idle handling with retry logic + idle_handler = IdleHandler() + + @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") + async def on_user_turn_started(aggregator, strategy): + idle_handler.reset() + @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "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/18-gstreamer-filesrc.py b/examples/foundational/18-gstreamer-filesrc.py index ceb400c94..f1c3062b4 100644 --- a/examples/foundational/18-gstreamer-filesrc.py +++ b/examples/foundational/18-gstreamer-filesrc.py @@ -20,13 +20,8 @@ from pipecat.transports.daily.transport import DailyParams load_dotenv(override=True) -parser = argparse.ArgumentParser(description="Pipecat Video Streaming Bot") -parser.add_argument("-i", "--input", type=str, required=True, help="Input video file") -args = parser.parse_args() - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams( audio_out_enabled=True, @@ -46,10 +41,10 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - logger.info(f"Starting bot with video input: {args.input}") + logger.info(f"Starting bot with video input: {runner_args.cli_args.input}") gst = GStreamerPipelineSource( - pipeline=f"filesrc location={args.input}", + pipeline=f"filesrc location={runner_args.cli_args.input}", out_params=GStreamerPipelineSource.OutputParams( video_width=1280, video_height=720, @@ -68,6 +63,15 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): 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") + + @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) @@ -82,4 +86,7 @@ async def bot(runner_args: RunnerArguments): if __name__ == "__main__": from pipecat.runner.run import main - main() + parser = argparse.ArgumentParser(description="Pipecat Video Streaming Bot") + parser.add_argument("-i", "--input", type=str, required=True, help="Input video file") + + main(parser) diff --git a/examples/foundational/18a-gstreamer-videotestsrc.py b/examples/foundational/18a-gstreamer-videotestsrc.py index 3ab5cf8b7..8398f1f7f 100644 --- a/examples/foundational/18a-gstreamer-videotestsrc.py +++ b/examples/foundational/18a-gstreamer-videotestsrc.py @@ -19,9 +19,8 @@ from pipecat.transports.daily.transport import DailyParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams( video_out_enabled=True, diff --git a/examples/foundational/19-openai-realtime-beta.py b/examples/foundational/19-openai-realtime-beta.py index 37fe86f96..c69d0ca92 100644 --- a/examples/foundational/19-openai-realtime-beta.py +++ b/examples/foundational/19-openai-realtime-beta.py @@ -86,9 +86,8 @@ restaurant_function = FunctionSchema( tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, diff --git a/examples/foundational/19-openai-realtime.py b/examples/foundational/19-openai-realtime.py index cea164543..cb3abf953 100644 --- a/examples/foundational/19-openai-realtime.py +++ b/examples/foundational/19-openai-realtime.py @@ -15,14 +15,18 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import LLMRunFrame, LLMSetToolsFrame, TranscriptionMessage +from pipecat.frames.frames import LLMRunFrame, LLMSetToolsFrame from pipecat.observers.loggers.transcription_log_observer import TranscriptionLogObserver 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 -from pipecat.processors.transcript_processor import TranscriptProcessor +from pipecat.processors.aggregators.llm_response_universal import ( + AssistantTurnStoppedMessage, + LLMContextAggregatorPair, + LLMUserAggregatorParams, + UserTurnStoppedMessage, +) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.llm_service import FunctionCallParams @@ -110,24 +114,20 @@ restaurant_function = FunctionSchema( tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -135,22 +135,10 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - session_properties = SessionProperties( - audio=AudioConfiguration( - input=AudioInput( - transcription=InputAudioTranscription(), - # Set openai TurnDetection parameters. Not setting this at all will turn it - # on by default - turn_detection=SemanticTurnDetection(), - # Or set to False to disable openai turn detection and use transport VAD - # turn_detection=False, - noise_reduction=InputAudioNoiseReduction(type="near_field"), - ) - ), - # In this example we provide tools through the context, but you could - # alternatively provide them here. - # tools=tools, - instructions="""You are a helpful and friendly AI. + llm = OpenAIRealtimeLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIRealtimeLLMService.Settings( + system_instruction="""You are a helpful and friendly AI. Act like a human, but remember that you aren't a human and that you can't do human things in the real world. Your voice and personality should be warm and engaging, with a lively and @@ -164,11 +152,23 @@ You are participating in a voice conversation. Keep your responses concise, shor unless specifically asked to elaborate on a topic. Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""", - ) - - llm = OpenAIRealtimeLLMService( - api_key=os.getenv("OPENAI_API_KEY"), - session_properties=session_properties, + session_properties=SessionProperties( + audio=AudioConfiguration( + input=AudioInput( + transcription=InputAudioTranscription(), + # Set openai TurnDetection parameters. Not setting this at all will turn it + # on by default + turn_detection=SemanticTurnDetection(), + # Or set to False to disable openai turn detection and use transport VAD + # turn_detection=False, + noise_reduction=InputAudioNoiseReduction(type="near_field"), + ) + ), + # In this example we provide tools through the context, but you could + # alternatively provide them here. + # tools=tools, + ), + ), ) # you can either register a single function for all function calls, or specific functions @@ -177,8 +177,6 @@ Remember, your responses should be short. Just one or two sentences, usually. Re llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) llm.register_function("get_news", get_news) - transcript = TranscriptProcessor() - # Create a standard OpenAI LLM context object using the normal messages format. The # OpenAIRealtimeLLMService will convert this internally to messages that the # openai WebSocket API can understand. @@ -187,17 +185,18 @@ Remember, your responses should be short. Just one or two sentences, usually. Re tools, ) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), # Transport user input - context_aggregator.user(), - transcript.user(), # LLM pushes TranscriptionFrames upstream + user_aggregator, llm, # LLM transport.output(), # Transport bot output - transcript.assistant(), # After the transcript output, to time with the audio output - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -238,14 +237,18 @@ Remember, your responses should be short. Just one or two sentences, usually. Re logger.info(f"Client disconnected") await task.cancel() - # Register event handler for transcript updates - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - for msg in frame.messages: - if isinstance(msg, TranscriptionMessage): - timestamp = f"[{msg.timestamp}] " if msg.timestamp else "" - line = f"{timestamp}{msg.role}: {msg.content}" - logger.info(f"Transcript: {line}") + # Log transcript updates + @user_aggregator.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(aggregator, strategy, message: UserTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}user: {message.content}" + logger.info(f"Transcript: {line}") + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}assistant: {message.content}" + logger.info(f"Transcript: {line}") runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) diff --git a/examples/foundational/19a-azure-realtime-beta.py b/examples/foundational/19a-azure-realtime-beta.py index d287344cb..d25f53e96 100644 --- a/examples/foundational/19a-azure-realtime-beta.py +++ b/examples/foundational/19a-azure-realtime-beta.py @@ -84,9 +84,8 @@ restaurant_function = FunctionSchema( tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, diff --git a/examples/foundational/19a-azure-realtime.py b/examples/foundational/19a-azure-realtime.py index 6e245328a..ffba20f5d 100644 --- a/examples/foundational/19a-azure-realtime.py +++ b/examples/foundational/19a-azure-realtime.py @@ -19,7 +19,10 @@ 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 +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.azure.realtime.llm import AzureRealtimeLLMService @@ -87,24 +90,20 @@ restaurant_function = FunctionSchema( tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -112,19 +111,11 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - session_properties = SessionProperties( - audio=AudioConfiguration( - input=AudioInput( - transcription=InputAudioTranscription(model="whisper-1"), - # Set openai TurnDetection parameters. Not setting this at all will turn it - # on by default - # turn_detection=TurnDetection(silence_duration_ms=1000), - # Or set to False to disable openai turn detection and use transport VAD - # turn_detection=False, - ) - ), - # tools=tools, - instructions="""You are a helpful and friendly AI. + llm = AzureRealtimeLLMService( + api_key=os.getenv("AZURE_REALTIME_API_KEY"), + base_url=os.getenv("AZURE_REALTIME_BASE_URL"), + settings=AzureRealtimeLLMService.Settings( + system_instruction="""You are a helpful and friendly AI. Act like a human, but remember that you aren't a human and that you can't do human things in the real world. Your voice and personality should be warm and engaging, with a lively and @@ -142,12 +133,20 @@ You have access to the following tools: - get_restaurant_recommendation: Get a restaurant recommendation for a given location. Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""", - ) - - llm = AzureRealtimeLLMService( - api_key=os.getenv("AZURE_REALTIME_API_KEY"), - base_url=os.getenv("AZURE_REALTIME_BASE_URL"), - session_properties=session_properties, + session_properties=SessionProperties( + audio=AudioConfiguration( + input=AudioInput( + transcription=InputAudioTranscription(model="whisper-1"), + # Set openai TurnDetection parameters. Not setting this at all will turn it + # on by default + # turn_detection=TurnDetection(silence_duration_ms=1000), + # Or set to False to disable openai turn detection and use transport VAD + # turn_detection=False, + ) + ), + # tools=tools, + ), + ), ) # you can either register a single function for all function calls, or specific functions @@ -173,15 +172,18 @@ Remember, your responses should be short. Just one or two sentences, usually. Re tools, ) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), # Transport user input - context_aggregator.user(), + user_aggregator, llm, # LLM transport.output(), # Transport bot output - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/19b-openai-realtime-beta-text.py b/examples/foundational/19b-openai-realtime-beta-text.py index 0c66385d2..9e425537f 100644 --- a/examples/foundational/19b-openai-realtime-beta-text.py +++ b/examples/foundational/19b-openai-realtime-beta-text.py @@ -14,12 +14,11 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import LLMRunFrame, TranscriptionMessage +from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext -from pipecat.processors.transcript_processor import TranscriptProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService @@ -87,9 +86,8 @@ restaurant_function = FunctionSchema( tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -149,7 +147,9 @@ Remember, your responses should be short. Just one or two sentences, usually. Re tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) # you can either register a single function for all function calls, or specific functions @@ -157,8 +157,6 @@ Remember, your responses should be short. Just one or two sentences, usually. Re llm.register_function("get_current_weather", fetch_weather_from_api) llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) - transcript = TranscriptProcessor() - # Create a standard OpenAI LLM context object using the normal messages format. The # OpenAIRealtimeBetaLLMService will convert this internally to messages that the # openai WebSocket API can understand. @@ -175,9 +173,7 @@ Remember, your responses should be short. Just one or two sentences, usually. Re context_aggregator.user(), llm, # LLM tts, # TTS - transcript.user(), # Placed after the LLM, as LLM pushes TranscriptionFrames downstream transport.output(), # Transport bot output - transcript.assistant(), # After the transcript output, to time with the audio output context_aggregator.assistant(), ] ) @@ -202,15 +198,6 @@ Remember, your responses should be short. Just one or two sentences, usually. Re logger.info(f"Client disconnected") await task.cancel() - # Register event handler for transcript updates - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - for msg in frame.messages: - if isinstance(msg, TranscriptionMessage): - timestamp = f"[{msg.timestamp}] " if msg.timestamp else "" - line = f"{timestamp}{msg.role}: {msg.content}" - logger.info(f"Transcript: {line}") - runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) await runner.run(task) diff --git a/examples/foundational/19b-openai-realtime-text.py b/examples/foundational/19b-openai-realtime-text.py index 927e5f5c1..1fa6ea545 100644 --- a/examples/foundational/19b-openai-realtime-text.py +++ b/examples/foundational/19b-openai-realtime-text.py @@ -14,13 +14,15 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import LLMRunFrame, TranscriptionMessage +from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair -from pipecat.processors.transcript_processor import TranscriptProcessor +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 @@ -33,7 +35,10 @@ from pipecat.services.openai.realtime.events import ( SemanticTurnDetection, SessionProperties, ) -from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService +from pipecat.services.openai.realtime.llm import ( + OpenAIRealtimeLLMService, + OpenAIRealtimeLLMSettings, +) from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -90,24 +95,20 @@ restaurant_function = FunctionSchema( tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -115,21 +116,10 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - session_properties = SessionProperties( - audio=AudioConfiguration( - input=AudioInput( - transcription=InputAudioTranscription(), - # Set openai TurnDetection parameters. Not setting this at all will turn it - # on by default - turn_detection=SemanticTurnDetection(), - # Or set to False to disable openai turn detection and use transport VAD - # turn_detection=False, - noise_reduction=InputAudioNoiseReduction(type="near_field"), - ) - ), - output_modalities=["text"], - # tools=tools, - instructions="""You are a helpful and friendly AI. + llm = OpenAIRealtimeLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIRealtimeLLMSettings( + system_instruction="""You are a helpful and friendly AI. Act like a human, but remember that you aren't a human and that you can't do human things in the real world. Your voice and personality should be warm and engaging, with a lively and @@ -147,16 +137,29 @@ You have access to the following tools: - get_restaurant_recommendation: Get a restaurant recommendation for a given location. Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""", - ) - - llm = OpenAIRealtimeLLMService( - api_key=os.getenv("OPENAI_API_KEY"), - session_properties=session_properties, + session_properties=SessionProperties( + audio=AudioConfiguration( + input=AudioInput( + transcription=InputAudioTranscription(), + # Set openai TurnDetection parameters. Not setting this at all will turn it + # on by default + turn_detection=SemanticTurnDetection(), + # Or set to False to disable openai turn detection and use transport VAD + # turn_detection=False, + noise_reduction=InputAudioNoiseReduction(type="near_field"), + ) + ), + output_modalities=["text"], + # tools=tools, + ), + ), ) tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) # you can either register a single function for all function calls, or specific functions @@ -164,8 +167,6 @@ Remember, your responses should be short. Just one or two sentences, usually. Re llm.register_function("get_current_weather", fetch_weather_from_api) llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) - transcript = TranscriptProcessor() - # Create a standard OpenAI LLM context object using the normal messages format. The # OpenAIRealtimeLLMService will convert this internally to messages that the # openai WebSocket API can understand. @@ -174,18 +175,19 @@ Remember, your responses should be short. Just one or two sentences, usually. Re tools, ) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), # Transport user input - context_aggregator.user(), - transcript.user(), # LLM pushes TranscriptionFrames upstream + user_aggregator, llm, # LLM tts, # TTS transport.output(), # Transport bot output - transcript.assistant(), # After the transcript output, to time with the audio output - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -209,15 +211,6 @@ Remember, your responses should be short. Just one or two sentences, usually. Re logger.info(f"Client disconnected") await task.cancel() - # Register event handler for transcript updates - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - for msg in frame.messages: - if isinstance(msg, TranscriptionMessage): - timestamp = f"[{msg.timestamp}] " if msg.timestamp else "" - line = f"{timestamp}{msg.role}: {msg.content}" - logger.info(f"Transcript: {line}") - runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) await runner.run(task) diff --git a/examples/foundational/19c-openai-realtime-live-video.py b/examples/foundational/19c-openai-realtime-live-video.py index af48b2355..f862b5511 100644 --- a/examples/foundational/19c-openai-realtime-live-video.py +++ b/examples/foundational/19c-openai-realtime-live-video.py @@ -17,7 +17,10 @@ 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 +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import ( create_transport, @@ -39,21 +42,18 @@ from pipecat.transports.daily.transport import DailyParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -61,22 +61,10 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - session_properties = SessionProperties( - audio=AudioConfiguration( - input=AudioInput( - transcription=InputAudioTranscription(), - # Set openai TurnDetection parameters. Not setting this at all will turn it - # on by default - turn_detection=SemanticTurnDetection(), - # Or set to False to disable openai turn detection and use transport VAD - # turn_detection=False, - noise_reduction=InputAudioNoiseReduction(type="near_field"), - ) - ), - # In this example we provide tools through the context, but you could - # alternatively provide them here. - # tools=tools, - instructions="""You are a helpful and friendly AI. + llm = OpenAIRealtimeLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIRealtimeLLMService.Settings( + system_instruction="""You are a helpful and friendly AI. Act like a human, but remember that you aren't a human and that you can't do human things in the real world. Your voice and personality should be warm and engaging, with a lively and @@ -90,11 +78,23 @@ You are participating in a voice conversation. Keep your responses concise, shor unless specifically asked to elaborate on a topic. Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""", - ) - - llm = OpenAIRealtimeLLMService( - api_key=os.getenv("OPENAI_API_KEY"), - session_properties=session_properties, + session_properties=SessionProperties( + audio=AudioConfiguration( + input=AudioInput( + transcription=InputAudioTranscription(), + # Set openai TurnDetection parameters. Not setting this at all will turn it + # on by default + turn_detection=SemanticTurnDetection(), + # Or set to False to disable openai turn detection and use transport VAD + # turn_detection=False, + noise_reduction=InputAudioNoiseReduction(type="near_field"), + ) + ), + # In this example we provide tools through the context, but you could + # alternatively provide them here. + # tools=tools, + ), + ), ) # Create a standard OpenAI LLM context object using the normal messages format. The @@ -104,15 +104,18 @@ Remember, your responses should be short. Just one or two sentences, usually. Re [{"role": "user", "content": "Say hello!"}], ) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), # Transport user input - context_aggregator.user(), + user_aggregator, llm, # LLM transport.output(), # Transport bot output - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/20a-persistent-context-openai-responses.py b/examples/foundational/20a-persistent-context-openai-responses.py new file mode 100644 index 000000000..5fd9c7657 --- /dev/null +++ b/examples/foundational/20a-persistent-context-openai-responses.py @@ -0,0 +1,249 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import glob +import json +import os +from datetime import datetime + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame +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 import DeepgramSTTService +from pipecat.services.llm_service import FunctionCallParams +from pipecat.services.openai.responses.llm import OpenAIResponsesLLMService +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) + + +BASE_FILENAME = "/tmp/pipecat_conversation_" + + +async def fetch_weather_from_api(params: FunctionCallParams): + temperature = 75 if params.arguments["format"] == "fahrenheit" else 24 + await params.result_callback( + { + "conditions": "nice", + "temperature": temperature, + "format": params.arguments["format"], + "timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"), + } + ) + + +async def get_saved_conversation_filenames(params: FunctionCallParams): + # Construct the full pattern including the BASE_FILENAME + full_pattern = f"{BASE_FILENAME}*.json" + + # Use glob to find all matching files + matching_files = glob.glob(full_pattern) + logger.debug(f"matching files: {matching_files}") + + await params.result_callback({"filenames": matching_files}) + + +async def save_conversation(params: FunctionCallParams): + timestamp = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") + filename = f"{BASE_FILENAME}{timestamp}.json" + logger.debug( + f"writing conversation to {filename}\n{json.dumps(params.context.get_messages(), indent=4)}" + ) + try: + with open(filename, "w") as file: + messages = params.context.get_messages() + # remove the last message, which is the instruction we just gave to save the conversation + messages.pop() + json.dump(messages, file, indent=2) + await params.result_callback({"success": True}) + except Exception as e: + await params.result_callback({"success": False, "error": str(e)}) + + +async def load_conversation(params: FunctionCallParams): + global tts + filename = params.arguments["filename"] + logger.debug(f"loading conversation from {filename}") + try: + with open(filename, "r") as file: + params.context.set_messages(json.load(file)) + logger.debug( + f"loaded conversation from {filename}\n{json.dumps(params.context.get_messages(), indent=4)}" + ) + await params.llm.queue_frame(TTSSpeakFrame("Ok, I've loaded that conversation.")) + except Exception as e: + await params.result_callback({"success": False, "error": str(e)}) + + +system_instruction = "You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way." + +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 users location.", + }, + }, + required=["location", "format"], +) + +save_conversation_function = FunctionSchema( + name="save_conversation", + description="Save the current conversation. Use this function to persist the current conversation to external storage.", + properties={}, + required=[], +) + +get_filenames_function = FunctionSchema( + name="get_saved_conversation_filenames", + description="Get a list of saved conversation histories. Returns a list of filenames. Each filename includes a date and timestamp. Each file is conversation history that can be loaded into this session.", + properties={}, + required=[], +) + +load_conversation_function = FunctionSchema( + name="load_conversation", + description="Load a conversation history. Use this function to load a conversation history into the current session.", + properties={ + "filename": { + "type": "string", + "description": "The filename of the conversation history to load.", + } + }, + required=["filename"], +) + +tools = ToolsSchema( + standard_tools=[ + weather_function, + save_conversation_function, + get_filenames_function, + load_conversation_function, + ] +) + + +# 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(f"Starting bot") + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAIResponsesLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIResponsesLLMService.Settings( + system_instruction=system_instruction, + ), + ) + + # you can either register a single function for all function calls, or specific functions + # llm.register_function(None, fetch_weather_from_api) + llm.register_function("get_current_weather", fetch_weather_from_api) + llm.register_function("save_conversation", save_conversation) + llm.register_function("get_saved_conversation_filenames", get_saved_conversation_filenames) + llm.register_function("load_conversation", load_conversation) + + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, + llm, # LLM + tts, + transport.output(), # Transport bot 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") + # Kick off the conversation. + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/examples/foundational/20a-persistent-context-openai.py b/examples/foundational/20a-persistent-context-openai.py index 502d2d354..7f744fd46 100644 --- a/examples/foundational/20a-persistent-context-openai.py +++ b/examples/foundational/20a-persistent-context-openai.py @@ -14,9 +14,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -35,8 +33,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -99,12 +95,7 @@ async def load_conversation(params: FunctionCallParams): await params.result_callback({"success": False, "error": str(e)}) -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.", - }, -] +system_instruction = "You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way." weather_function = FunctionSchema( name="get_current_weather", @@ -125,7 +116,7 @@ weather_function = FunctionSchema( save_conversation_function = FunctionSchema( name="save_conversation", - description="Save the current conversatione. Use this function to persist the current conversation to external storage.", + description="Save the current conversation. Use this function to persist the current conversation to external storage.", properties={}, required=[], ) @@ -159,24 +150,20 @@ tools = ToolsSchema( ) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -188,10 +175,15 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + system_instruction=system_instruction, + ) # you can either register a single function for all function calls, or specific functions # llm.register_function(None, fetch_weather_from_api) @@ -200,25 +192,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm.register_function("get_saved_conversation_filenames", get_saved_conversation_filenames) llm.register_function("load_conversation", load_conversation) - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), + user_aggregator, llm, # LLM tts, transport.output(), # Transport bot output - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/20b-persistent-context-openai-realtime-beta.py b/examples/foundational/20b-persistent-context-openai-realtime-beta.py index 19ccf81f7..4b05db618 100644 --- a/examples/foundational/20b-persistent-context-openai-realtime-beta.py +++ b/examples/foundational/20b-persistent-context-openai-realtime-beta.py @@ -31,7 +31,6 @@ from pipecat.services.openai_realtime_beta import ( SessionProperties, TurnDetection, ) -from pipecat.services.openai_realtime_beta.events import AudioConfiguration, AudioInput from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -120,7 +119,7 @@ tools = [ { "type": "function", "name": "save_conversation", - "description": "Save the current conversatione. Use this function to persist the current conversation to external storage.", + "description": "Save the current conversation. Use this function to persist the current conversation to external storage.", "parameters": { "type": "object", "properties": {}, @@ -155,9 +154,8 @@ tools = [ ] -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -183,16 +181,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) session_properties = SessionProperties( - audio=AudioConfiguration( - input=AudioInput( - transcription=InputAudioTranscription(), - # Set openai TurnDetection parameters. Not setting this at all will turn it - # on by default - turn_detection=TurnDetection(silence_duration_ms=1000), - # Or set to False to disable openai turn detection and use transport VAD - # turn_detection=False, - ) - ), + input_audio_transcription=InputAudioTranscription(), + # Set openai TurnDetection parameters. Not setting this at all will turn + # it on by default + turn_detection=TurnDetection(silence_duration_ms=1000), + # Or set to False to disable openai turn detection and use transport VAD + # turn_detection=False, # tools=tools, instructions="""Your knowledge cutoff is 2023-10. You are a helpful and friendly AI. diff --git a/examples/foundational/20b-persistent-context-openai-realtime.py b/examples/foundational/20b-persistent-context-openai-realtime.py index 2133fa628..bceca410d 100644 --- a/examples/foundational/20b-persistent-context-openai-realtime.py +++ b/examples/foundational/20b-persistent-context-openai-realtime.py @@ -21,7 +21,10 @@ 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 +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 @@ -122,7 +125,7 @@ tools = ToolsSchema( ), FunctionSchema( name="save_conversation", - description="Save the current conversatione. Use this function to persist the current conversation to external storage.", + description="Save the current conversation. Use this function to persist the current conversation to external storage.", properties={}, required=[], ), @@ -147,24 +150,20 @@ tools = ToolsSchema( ) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -174,19 +173,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - session_properties = SessionProperties( - audio=AudioConfiguration( - input=AudioInput( - transcription=InputAudioTranscription(), - # Set openai TurnDetection parameters. Not setting this at all will turn it - # on by default - turn_detection=TurnDetection(silence_duration_ms=1000), - # Or set to False to disable openai turn detection and use transport VAD - # turn_detection=False, - ) - ), - # tools=tools, - instructions="""Your knowledge cutoff is 2023-10. You are a helpful and friendly AI. + llm = OpenAIRealtimeLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIRealtimeLLMService.Settings( + system_instruction="""Your knowledge cutoff is 2023-10. You are a helpful and friendly AI. Act like a human, but remember that you aren't a human and that you can't do human things in the real world. Your voice and personality should be warm and engaging, with a lively and @@ -200,11 +190,20 @@ You are participating in a voice conversation. Keep your responses concise, shor unless specifically asked to elaborate on a topic. Remember, your responses should be short. Just one or two sentences, usually.""", - ) - - llm = OpenAIRealtimeLLMService( - api_key=os.getenv("OPENAI_API_KEY"), - session_properties=session_properties, + session_properties=SessionProperties( + audio=AudioConfiguration( + input=AudioInput( + transcription=InputAudioTranscription(), + # Set openai TurnDetection parameters. Not setting this at all will turn it + # on by default + turn_detection=TurnDetection(silence_duration_ms=1000), + # Or set to False to disable openai turn detection and use transport VAD + # turn_detection=False, + ) + ), + # tools=tools, + ), + ), ) # you can either register a single function for all function calls, or specific functions @@ -215,16 +214,19 @@ Remember, your responses should be short. Just one or two sentences, usually.""" llm.register_function("load_conversation", load_conversation) context = LLMContext([{"role": "user", "content": "Say hello!"}], tools) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), + user_aggregator, llm, # LLM transport.output(), # Transport bot output - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/20c-persistent-context-anthropic.py b/examples/foundational/20c-persistent-context-anthropic.py index 6dac15aa5..1b74c87e1 100644 --- a/examples/foundational/20c-persistent-context-anthropic.py +++ b/examples/foundational/20c-persistent-context-anthropic.py @@ -14,9 +14,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -35,14 +33,11 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) BASE_FILENAME = "/tmp/pipecat_conversation_" -tts = None async def fetch_weather_from_api(params: FunctionCallParams): @@ -86,7 +81,6 @@ async def save_conversation(params: FunctionCallParams): async def load_conversation(params: FunctionCallParams): - global tts filename = params.arguments["filename"] logger.debug(f"loading conversation from {filename}") try: @@ -100,18 +94,7 @@ async def load_conversation(params: FunctionCallParams): await params.result_callback({"success": False, "error": str(e)}) -# Test message munging ... -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 succinct, creative and helpful way. Prefer responses that are one sentence long unless you are asked for a longer or more detailed response.", - }, - {"role": "user", "content": "Start the call by saying the word 'hello'. Say only that word."}, - # {"role": "user", "content": ""}, - # {"role": "assistant", "content": []}, - # {"role": "user", "content": "Tell me"}, - # {"role": "user", "content": "a joke"}, -] +system_instruction = "You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a succinct, creative and helpful way. Prefer responses that are one sentence long unless you are asked for a longer or more detailed response." weather_function = FunctionSchema( name="get_current_weather", @@ -166,24 +149,20 @@ tools = ToolsSchema( ) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -191,17 +170,20 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - global tts - 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 + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = AnthropicLLMService( - api_key=os.getenv("ANTHROPIC_API_KEY"), model="claude-3-5-sonnet-latest" + api_key=os.getenv("ANTHROPIC_API_KEY"), + settings=AnthropicLLMService.Settings( + system_instruction=system_instruction, + ), ) # you can either register a single function for all function calls, or specific functions @@ -211,25 +193,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm.register_function("get_saved_conversation_filenames", get_saved_conversation_filenames) llm.register_function("load_conversation", load_conversation) - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), + user_aggregator, llm, # LLM tts, transport.output(), # Transport bot output - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -246,6 +224,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. + context.add_message( + { + "role": "user", + "content": "Start the call by saying the word 'hello'. Say only that word.", + } + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/20d-persistent-context-gemini.py b/examples/foundational/20d-persistent-context-gemini.py index 35f3a22db..ae37e19ce 100644 --- a/examples/foundational/20d-persistent-context-gemini.py +++ b/examples/foundational/20d-persistent-context-gemini.py @@ -14,10 +14,8 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame +from pipecat.frames.frames import LLMRunFrame, UserImageRequestFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -26,6 +24,7 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) +from pipecat.processors.frame_processor import FrameDirection from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import ( create_transport, @@ -38,17 +37,12 @@ from pipecat.services.google.llm import GoogleLLMService from pipecat.services.llm_service import FunctionCallParams from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) BASE_FILENAME = "/tmp/pipecat_conversation_" -# Global variable to store the client ID -client_id = "" - async def fetch_weather_from_api(params: FunctionCallParams): temperature = 75 if params.arguments["format"] == "fahrenheit" else 24 @@ -63,15 +57,23 @@ async def fetch_weather_from_api(params: FunctionCallParams): async def get_image(params: FunctionCallParams): + user_id = params.arguments["user_id"] question = params.arguments["question"] - logger.debug(f"Requesting image with user_id={client_id}, question={question}") + logger.debug(f"Requesting image with user_id={user_id}, question={question}") - # Request the image frame - await params.llm.request_image_frame( - user_id=client_id, - function_name=params.function_name, - tool_call_id=params.tool_call_id, - text_content=question, + # Request a user image frame and indicate that it should be added to the + # context. Also associate it to the function call. Pass the result_callback + # so it can be invoked when the image is actually retrieved. + await params.llm.push_frame( + UserImageRequestFrame( + user_id=user_id, + text=question, + append_to_context=True, + function_name=params.function_name, + tool_call_id=params.tool_call_id, + result_callback=params.result_callback, + ), + FrameDirection.UPSTREAM, ) @@ -120,12 +122,8 @@ async def load_conversation(params: FunctionCallParams): await params.result_callback({"success": False, "error": str(e)}) -# Test message munging ... -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 +system_instruction = """You are a helpful assistant in a voice conversation. Your goal is to demonstrate your +capabilities in a succinct way. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Keep responses concise. Respond to what the user said in a creative can't easily be spoken, such as emojis or bullet points. Respond to what the user said in a creative and helpful way. @@ -149,13 +147,7 @@ indicate you should use the get_image tool are: - Tell me about what you see. - Tell me something interesting about what you see. - What's happening in the video? - """, - }, - # {"role": "user", "content": ""}, - # {"role": "assistant", "content": []}, - # {"role": "user", "content": "Tell me"}, - # {"role": "user", "content": "a joke"}, -] +""" weather_function = FunctionSchema( name="get_current_weather", @@ -207,14 +199,18 @@ load_conversation_function = FunctionSchema( get_image_function = FunctionSchema( name="get_image", - description="Get and image from the camera or video stream.", + description="Called when the user requests a description of their camera feed", properties={ + "user_id": { + "type": "string", + "description": "The ID of the user to grab the image from", + }, "question": { "type": "string", - "description": "The question to to use when running inference on the acquired image.", + "description": "The question that the user is asking about the image", }, }, - required=["question"], + required=["user_id", "question"], ) tools = ToolsSchema( @@ -228,21 +224,18 @@ tools = ToolsSchema( ) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -254,10 +247,15 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = GoogleLLMService(model="gemini-2.0-flash-001", api_key=os.getenv("GOOGLE_API_KEY")) + llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + system_instruction=system_instruction, + ) # you can either register a single function for all function calls, or specific functions # llm.register_function(None, fetch_weather_from_api) @@ -267,25 +265,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm.register_function("load_conversation", load_conversation) llm.register_function("get_image", get_image) - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), + user_aggregator, llm, # LLM tts, transport.output(), # Transport bot output - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -304,10 +298,15 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): await maybe_capture_participant_camera(transport, client) - global client_id client_id = get_transport_client_id(transport, client) # Kick off the conversation. + context.add_message( + { + "role": "user", + "content": f"Please introduce yourself to the user. Use '{client_id}' as the user ID during function calls.", + } + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/20e-persistent-context-aws-nova-sonic.py b/examples/foundational/20e-persistent-context-aws-nova-sonic.py index 16801d377..3d1043d18 100644 --- a/examples/foundational/20e-persistent-context-aws-nova-sonic.py +++ b/examples/foundational/20e-persistent-context-aws-nova-sonic.py @@ -21,8 +21,10 @@ 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 -from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext +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.aws.nova_sonic.llm import AWSNovaSonicLLMService @@ -114,6 +116,14 @@ async def load_conversation(params: FunctionCallParams): # "content": f"{AWSNovaSonicLLMService.AWAIT_TRIGGER_ASSISTANT_RESPONSE_INSTRUCTION}", # } # ) + # If the last message isn't from the user, add a message asking for a recap + if messages and messages[-1].get("role") != "user": + messages.append( + { + "role": "user", + "content": "Can you catch me up on what we were talking about?", + } + ) params.context.set_messages(messages) await params.llm.reset_conversation() # await params.llm.trigger_assistant_response() @@ -176,24 +186,20 @@ tools = ToolsSchema( ) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -216,9 +222,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), region=os.getenv("AWS_REGION"), # as of 2025-05-06, us-east-1 is the only supported region - voice_id="tiffany", # matthew, tiffany, amy - # you could choose to pass instruction here rather than via context - # system_instruction=system_instruction, + settings=AWSNovaSonicLLMService.Settings( + voice="tiffany", # matthew, tiffany, amy + system_instruction=system_instruction, + ), # you could choose to pass tools here rather than via context # tools=tools ) @@ -228,22 +235,19 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm.register_function("get_saved_conversation_filenames", get_saved_conversation_filenames) llm.register_function("load_conversation", load_conversation) - context = LLMContext( - messages=[ - {"role": "system", "content": f"{system_instruction}"}, - {"role": "user", "content": "Hello!"}, - ], - tools=tools, + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) - context_aggregator = LLMContextAggregatorPair(context) pipeline = Pipeline( [ transport.input(), # Transport user input - context_aggregator.user(), + user_aggregator, llm, # LLM transport.output(), # Transport bot output - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -260,6 +264,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) # HACK: if using the older Nova Sonic (pre-2) model, you need this special way of # triggering the first assistant response. Note that this trigger requires a special diff --git a/examples/foundational/20f-persistent-context-grok-realtime.py b/examples/foundational/20f-persistent-context-grok-realtime.py index 3fd73eed5..efa04c732 100644 --- a/examples/foundational/20f-persistent-context-grok-realtime.py +++ b/examples/foundational/20f-persistent-context-grok-realtime.py @@ -203,15 +203,15 @@ Remember, your responses should be short - just one or two sentences usually.""" llm.register_function("load_conversation", load_conversation) context = LLMContext([{"role": "user", "content": "Say hello!"}], tools) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair(context) pipeline = Pipeline( [ transport.input(), - context_aggregator.user(), + user_aggregator, llm, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/21-tavus-transport.py b/examples/foundational/21-tavus-transport.py index 940d57bb9..f09902fcd 100644 --- a/examples/foundational/21-tavus-transport.py +++ b/examples/foundational/21-tavus-transport.py @@ -12,9 +12,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -28,8 +26,6 @@ from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.google.llm import GoogleLLMService from pipecat.transports.tavus.transport import TavusParams, TavusTransport -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -48,7 +44,6 @@ async def main(): audio_in_enabled=True, audio_out_enabled=True, microphone_out_enabled=False, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), ) @@ -56,39 +51,33 @@ async def main(): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="a167e0f3-df7e-4d52-a9c3-f949145efdab", + settings=CartesiaTTSService.Settings( + voice="a167e0f3-df7e-4d52-a9c3-f949145efdab", + ), ) - llm = GoogleLLMService(api_key=os.getenv("GOOGLE_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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -106,9 +95,9 @@ async def main(): async def on_client_connected(transport, participant): logger.info(f"Client connected") # Kick off the conversation. - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": "Start by greeting the user and ask how you can help.", } ) diff --git a/examples/foundational/21a-tavus-video-service.py b/examples/foundational/21a-tavus-video-service.py index 64393d49b..6e03a2418 100644 --- a/examples/foundational/21a-tavus-video-service.py +++ b/examples/foundational/21a-tavus-video-service.py @@ -11,9 +11,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,14 +29,11 @@ from pipecat.services.google.llm import GoogleLLMService from pipecat.services.tavus.video import TavusVideoService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -47,7 +42,6 @@ transport_params = { video_out_is_live=True, video_out_width=1280, video_out_height=720, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, @@ -56,7 +50,6 @@ transport_params = { video_out_is_live=True, video_out_width=1280, video_out_height=720, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -68,10 +61,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="a167e0f3-df7e-4d52-a9c3-f949145efdab", + settings=CartesiaTTSService.Settings( + voice="a167e0f3-df7e-4d52-a9c3-f949145efdab", + ), ) - llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY")) + llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) tavus = TavusVideoService( api_key=os.getenv("TAVUS_API_KEY"), @@ -79,35 +79,22 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): session=session, ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS tavus, # Tavus output layer transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -126,9 +113,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": "Start by greeting the user and ask how you can help.", } ) diff --git a/examples/foundational/22-filter-incomplete-turns.py b/examples/foundational/22-filter-incomplete-turns.py new file mode 100644 index 000000000..8a3001366 --- /dev/null +++ b/examples/foundational/22-filter-incomplete-turns.py @@ -0,0 +1,170 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Example 22: Filter Incomplete Turns + +Demonstrates LLM-based turn completion detection to suppress bot responses when +the user was cut off mid-thought. The LLM outputs one of three markers: +- ✓ (complete): User finished their thought, respond normally +- ○ (incomplete short): User was cut off, wait ~5s then prompt +- ◐ (incomplete long): User needs time to think, wait ~15s then prompt + +When incomplete is detected, the bot's response is suppressed. After the timeout +expires, the LLM is automatically prompted to re-engage the user. +""" + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + AssistantTurnStoppedMessage, + LLMContextAggregatorPair, + LLMUserAggregatorParams, + UserTurnStoppedMessage, +) +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 + +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(f"Starting bot") + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + vad_analyzer=SileroVADAnalyzer(), + # Enable turn completion filtering - the LLM will output: + # ✓ = complete (respond normally) + # ○ = incomplete short (wait 5s, then prompt) + # ◐ = incomplete long (wait 15s, then prompt) + filter_incomplete_user_turns=True, + # Optional: customize turn completion behavior + # turn_completion_config=TurnCompletionConfig( + # incomplete_short_timeout=5.0, + # incomplete_long_timeout=15.0, + # incomplete_short_prompt="Custom prompt...", + # incomplete_long_prompt="Custom prompt...", + # instructions="Custom turn completion instructions...", + # ), + ), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + context.add_message( + { + "role": "user", + "content": "Please introduce yourself to the user, asking them a question that will require a complete response. To start, say 'Let me start with a fun one. If you could travel anywhere in the world right now, where would you go and why?'", + } + ) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + @user_aggregator.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(aggregator, strategy, message: UserTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}user: {message.content}" + logger.info(f"Transcript: {line}") + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}assistant: {message.content}" + logger.info(f"Transcript: {line}") + + 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/22-natural-conversation.py b/examples/foundational/22-natural-conversation.py deleted file mode 100644 index 1be12b7fa..000000000 --- a/examples/foundational/22-natural-conversation.py +++ /dev/null @@ -1,214 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - - -import os - -from dotenv import load_dotenv -from loguru import logger - -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 -from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import LLMRunFrame, TextFrame -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.gated_llm_context import GatedLLMContextAggregator -from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import ( - LLMContextAggregatorPair, - LLMUserAggregatorParams, -) -from pipecat.processors.filters.null_filter import NullFilter -from pipecat.processors.filters.wake_notifier_filter import WakeNotifierFilter -from pipecat.processors.user_idle_processor import UserIdleProcessor -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 LLMService -from pipecat.services.openai.llm import OpenAIContextAggregatorPair, 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies -from pipecat.utils.sync.event_notifier import EventNotifier - -load_dotenv(override=True) - - -class TurnDetectionLLM(Pipeline): - def __init__(self, llm: LLMService, context_aggregator: OpenAIContextAggregatorPair): - # This is the LLM that will be used to detect if the user has finished a - # statement. This doesn't really need to be an LLM, we could use NLP - # libraries for that, but it was easier as an example because we - # leverage the context aggregators. - statement_llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - - statement_messages = [ - { - "role": "system", - "content": "Determine if the user's statement is a complete sentence or question, ending in a natural pause or punctuation. Return 'YES' if it is complete and 'NO' if it seems to leave a thought unfinished.", - }, - ] - - statement_context = LLMContext(statement_messages) - statement_context_aggregator = LLMContextAggregatorPair(statement_context) - - # We have instructed the LLM to return 'YES' if it thinks the user - # completed a sentence. So, if it's 'YES' we will return true in this - # predicate which will wake up the notifier. - async def wake_check_filter(frame): - logger.debug(f"Completeness check frame: {frame}") - return frame.text == "YES" - - # This is a notifier that we use to synchronize the two LLMs. - notifier = EventNotifier() - - # This a filter that will wake up the notifier if the given predicate - # (wake_check_filter) returns true. - completness_check = WakeNotifierFilter( - notifier, types=(TextFrame,), filter=wake_check_filter - ) - - # This processor keeps the last context and will let it through once the - # notifier is woken up. We start with the gate open because we send an - # initial context frame to start the conversation. - gated_context_aggregator = GatedLLMContextAggregator(notifier=notifier, start_open=True) - - # Notify if the user hasn't said anything. - async def user_idle_notifier(frame): - await notifier.notify() - - # Sometimes the LLM will fail detecting if a user has completed a - # sentence, this will wake up the notifier if that happens. - user_idle = UserIdleProcessor(callback=user_idle_notifier, timeout=3.0) - - # The ParallePipeline input are the user transcripts. We have two - # contexts. The first one will be used to determine if the user finished - # a statement and if so the notifier will be woken up. The second - # context is simply the regular context but it's gated waiting for the - # notifier to be woken up. - super().__init__( - [ - ParallelPipeline( - [ - statement_context_aggregator.user(), - statement_llm, - completness_check, - NullFilter(), - ], - [context_aggregator.user(), gated_context_aggregator, llm], - ), - user_idle, - ] - ) - - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. -transport_params = { - "daily": lambda: DailyParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), - "webrtc": lambda: TransportParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), - "twilio": lambda: FastAPIWebsocketParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), -} - - -async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - logger.info(f"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 - ) - - # This is the regular LLM. - llm_main = 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), - ) - - # LLM + turn detection (with an extra LLM as a judge) - llm = TurnDetectionLLM(llm_main, context_aggregator) - - pipeline = Pipeline( - [ - transport.input(), # Transport user input - stt, # STT - llm, # LLM with turn detection - tts, # TTS - transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses - ] - ) - - task = PipelineTask( - pipeline, - params=PipelineParams( - enable_metrics=True, - enable_usage_metrics=True, - ), - idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - ) - - @transport.event_handler("on_client_connected") - async def on_client_connected(transport, client): - logger.info(f"Client connected") - # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) - await task.queue_frames([LLMRunFrame()]) - - @transport.event_handler("on_client_disconnected") - async def on_client_disconnected(transport, client): - logger.info(f"Client disconnected") - await task.cancel() - - runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) - - await runner.run(task) - - -async def bot(runner_args: RunnerArguments): - """Main bot entry point compatible with Pipecat Cloud.""" - transport = await create_transport(runner_args, transport_params) - await run_bot(transport, runner_args) - - -if __name__ == "__main__": - from pipecat.runner.run import main - - main() diff --git a/examples/foundational/22b-natural-conversation-proposal.py b/examples/foundational/22b-natural-conversation-proposal.py deleted file mode 100644 index 75f53ad4d..000000000 --- a/examples/foundational/22b-natural-conversation-proposal.py +++ /dev/null @@ -1,416 +0,0 @@ -# -# 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.adapters.schemas.function_schema import FunctionSchema -from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 -from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import ( - CancelFrame, - EndFrame, - Frame, - FunctionCallInProgressFrame, - FunctionCallResultFrame, - InterruptionFrame, - LLMContextFrame, - LLMRunFrame, - StartFrame, - SystemFrame, - TextFrame, - TranscriptionFrame, - TTSSpeakFrame, - UserStartedSpeakingFrame, - UserStoppedSpeakingFrame, -) -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.filters.function_filter import FunctionFilter -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pipecat.processors.user_idle_processor import UserIdleProcessor -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, LLMService -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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies -from pipecat.utils.sync.base_notifier import BaseNotifier -from pipecat.utils.sync.event_notifier import EventNotifier -from pipecat.utils.time import time_now_iso8601 - -load_dotenv(override=True) - - -classifier_statement = "Determine if the user's statement ends with a complete thought and you should respond. The user text is transcribed speech. It may contain multiple fragments concatentated together. You are trying to determine only the completeness of the last user statement. The previous assistant statement is provided only for context. Categorize the text as either complete with the user now expecting a response, or incomplete. Return 'YES' if text is likely complete and the user is expecting a response. Return 'NO' if the text seems to be a partial expression or unfinished thought." - - -class StatementJudgeContextFilter(FrameProcessor): - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - # We must not block system frames. - if isinstance(frame, SystemFrame): - await self.push_frame(frame, direction) - return - - # We only want to handle LLMContextFrames, and only want to push through a simplified - # context frame that contains a system prompt and the most recent user messages, - # concatenated. - if isinstance(frame, LLMContextFrame): - logger.debug(f"Context Frame: {frame}") - # Take text content from the most recent user messages. - messages = frame.context.get_messages() - user_text_messages = [] - last_assistant_message = None - for message in reversed(messages): - if message["role"] != "user": - if message["role"] == "assistant": - last_assistant_message = message - break - if isinstance(message["content"], str): - user_text_messages.append(message["content"]) - elif isinstance(message["content"], list): - for content in message["content"]: - if content["type"] == "text": - user_text_messages.insert(0, content["text"]) - # If we have any user text content, push a context frame with the simplified context. - if user_text_messages: - logger.debug(f"User text messages: {user_text_messages}") - user_message = " ".join(reversed(user_text_messages)) - logger.debug(f"User message: {user_message}") - messages = [ - { - "role": "system", - "content": classifier_statement, - } - ] - if last_assistant_message: - messages.append(last_assistant_message) - messages.append({"role": "user", "content": user_message}) - await self.push_frame(LLMContextFrame(LLMContext(messages))) - - -class CompletenessCheck(FrameProcessor): - def __init__(self, notifier: BaseNotifier): - super().__init__() - self._notifier = notifier - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - if isinstance(frame, TextFrame) and frame.text == "YES": - logger.debug("Completeness check YES") - await self.push_frame(UserStoppedSpeakingFrame()) - await self._notifier.notify() - elif isinstance(frame, TextFrame) and frame.text == "NO": - logger.debug("Completeness check NO") - else: - await self.push_frame(frame, direction) - - -class OutputGate(FrameProcessor): - def __init__(self, *, notifier: BaseNotifier, start_open: bool = False, **kwargs): - super().__init__(**kwargs) - self._gate_open = start_open - self._frames_buffer = [] - self._notifier = notifier - self._gate_task = None - - def close_gate(self): - self._gate_open = False - - def open_gate(self): - self._gate_open = True - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - # We must not block system frames. - if isinstance(frame, SystemFrame): - if isinstance(frame, StartFrame): - await self._start() - if isinstance(frame, (EndFrame, CancelFrame)): - await self._stop() - if isinstance(frame, InterruptionFrame): - self._frames_buffer = [] - self.close_gate() - await self.push_frame(frame, direction) - return - - # Don't block function call frames - if isinstance(frame, (FunctionCallInProgressFrame, FunctionCallResultFrame)): - await self.push_frame(frame, direction) - return - - # Ignore frames that are not following the direction of this gate. - if direction != FrameDirection.DOWNSTREAM: - await self.push_frame(frame, direction) - return - - if self._gate_open: - await self.push_frame(frame, direction) - return - - self._frames_buffer.append((frame, direction)) - - async def _start(self): - self._frames_buffer = [] - if not self._gate_task: - self._gate_task = self.create_task(self._gate_task_handler()) - - async def _stop(self): - if self._gate_task: - await self.cancel_task(self._gate_task) - self._gate_task = None - - async def _gate_task_handler(self): - while True: - try: - await self._notifier.wait() - self.open_gate() - for frame, direction in self._frames_buffer: - await self.push_frame(frame, direction) - self._frames_buffer = [] - except asyncio.CancelledError: - break - - -async def fetch_weather_from_api(params: FunctionCallParams): - await params.result_callback({"conditions": "nice", "temperature": "75"}) - - -class TurnDetectionLLM(Pipeline): - def __init__(self, llm: LLMService): - # This is the LLM that will be used to detect if the user has finished a - # statement. This doesn't really need to be an LLM, we could use NLP - # libraries for that, but we have the machinery to use an LLM, so we - # might as well! - statement_llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - - # We have instructed the LLM to return 'YES' if it thinks the user - # completed a sentence. So, if it's 'YES' we will return true in this - # predicate which will wake up the notifier. - async def wake_check_filter(frame): - logger.debug(f"Completeness check frame: {frame}") - return frame.text == "YES" - - # This is a notifier that we use to synchronize the two LLMs. - notifier = EventNotifier() - - # This turns the LLM context into an inference request to classify the user's speech - # as complete or incomplete. - statement_judge_context_filter = StatementJudgeContextFilter() - - # This sends a UserStoppedSpeakingFrame and triggers the notifier event - completeness_check = CompletenessCheck(notifier=notifier) - - # # Notify if the user hasn't said anything. - async def user_idle_notifier(frame): - await notifier.notify() - - # Sometimes the LLM will fail detecting if a user has completed a - # sentence, this will wake up the notifier if that happens. - user_idle = UserIdleProcessor(callback=user_idle_notifier, timeout=5.0) - - # We start with the gate open because we send an initial context frame - # to start the conversation. - bot_output_gate = OutputGate(notifier=notifier, start_open=True) - - async def pass_only_llm_trigger_frames(frame): - return ( - isinstance(frame, LLMContextFrame) - or isinstance(frame, InterruptionFrame) - or isinstance(frame, FunctionCallInProgressFrame) - or isinstance(frame, FunctionCallResultFrame) - ) - - async def filter_all(frame): - return False - - super().__init__( - [ - ParallelPipeline( - [ - # Ignore everything except an LLMContextFrame. Pass a specially constructed - # simplified context frame to the statement classifier LLM. The only frame this - # sub-pipeline will output is a UserStoppedSpeakingFrame. - statement_judge_context_filter, - statement_llm, - completeness_check, - FunctionFilter(filter=filter_all, direction=FrameDirection.UPSTREAM), - ], - [ - # Block everything except frames that trigger LLM inference. - FunctionFilter(filter=pass_only_llm_trigger_frames), - llm, - bot_output_gate, # Buffer all llm/tts output until notified. - ], - ), - user_idle, - ] - ) - - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. -transport_params = { - "daily": lambda: DailyParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), - "twilio": lambda: FastAPIWebsocketParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), - "webrtc": lambda: TransportParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), -} - - -async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - logger.info(f"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 - ) - - # This is the regular LLM. - llm_main = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - # You can also register a function_name of None to get all functions - # sent to the same callback with an additional function_name parameter. - llm_main.register_function("get_current_weather", fetch_weather_from_api) - - @llm_main.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 users location.", - }, - }, - required=["location", "format"], - ) - tools = ToolsSchema(standard_tools=[weather_function]) - - 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, tools) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), - ) - - # LLM + turn detection (with an extra LLM as a judge) - llm = TurnDetectionLLM(llm_main) - - pipeline = Pipeline( - [ - transport.input(), - stt, - context_aggregator.user(), - llm, - tts, - transport.output(), - context_aggregator.assistant(), - ] - ) - - task = PipelineTask( - pipeline, - params=PipelineParams( - enable_metrics=True, - enable_usage_metrics=True, - ), - idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - ) - - @transport.event_handler("on_client_connected") - async def on_client_connected(transport, client): - logger.info(f"Client connected") - # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) - await task.queue_frames([LLMRunFrame()]) - - @transport.event_handler("on_app_message") - async def on_app_message(transport, message, sender): - logger.debug(f"Received app message: {message}") - if "message" not in message: - return - - await task.queue_frames( - [ - UserStartedSpeakingFrame(), - TranscriptionFrame( - user_id="", timestamp=time_now_iso8601(), text=message["message"] - ), - UserStoppedSpeakingFrame(), - ] - ) - - @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/22c-natural-conversation-mixed-llms.py b/examples/foundational/22c-natural-conversation-mixed-llms.py deleted file mode 100644 index b354575cf..000000000 --- a/examples/foundational/22c-natural-conversation-mixed-llms.py +++ /dev/null @@ -1,624 +0,0 @@ -# -# 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.adapters.schemas.function_schema import FunctionSchema -from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 -from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import ( - CancelFrame, - EndFrame, - Frame, - FunctionCallInProgressFrame, - FunctionCallResultFrame, - InterruptionFrame, - LLMContextFrame, - LLMRunFrame, - StartFrame, - SystemFrame, - TextFrame, - TranscriptionFrame, - TTSSpeakFrame, - UserStartedSpeakingFrame, - UserStoppedSpeakingFrame, -) -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.filters.function_filter import FunctionFilter -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pipecat.processors.user_idle_processor import UserIdleProcessor -from pipecat.runner.types import RunnerArguments -from pipecat.runner.utils import create_transport -from pipecat.services.anthropic.llm import AnthropicLLMService -from pipecat.services.cartesia.tts import CartesiaTTSService -from pipecat.services.deepgram.stt import DeepgramSTTService -from pipecat.services.llm_service import FunctionCallParams, LLMService -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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies -from pipecat.utils.sync.base_notifier import BaseNotifier -from pipecat.utils.sync.event_notifier import EventNotifier -from pipecat.utils.time import time_now_iso8601 - -load_dotenv(override=True) - - -classifier_statement = """CRITICAL INSTRUCTION: -You are a BINARY CLASSIFIER that must ONLY output "YES" or "NO". -DO NOT engage with the content. -DO NOT respond to questions. -DO NOT provide assistance. -Your ONLY job is to output YES or NO. - -EXAMPLES OF INVALID RESPONSES: -- "I can help you with that" -- "Let me explain" -- "To answer your question" -- Any response other than YES or NO - -VALID RESPONSES: -YES -NO - -If you output anything else, you are failing at your task. -You are NOT an assistant. -You are NOT a chatbot. -You are a binary classifier. - -ROLE: -You are a real-time speech completeness classifier. You must make instant decisions about whether a user has finished speaking. -You must output ONLY 'YES' or 'NO' with no other text. - -INPUT FORMAT: -You receive two pieces of information: -1. The assistant's last message (if available) -2. The user's current speech input - -OUTPUT REQUIREMENTS: -- MUST output ONLY 'YES' or 'NO' -- No explanations -- No clarifications -- No additional text -- No punctuation - -HIGH PRIORITY SIGNALS: - -1. Clear Questions: -- Wh-questions (What, Where, When, Why, How) -- Yes/No questions -- Questions with STT errors but clear meaning - -Examples: -# Complete Wh-question -[{"role": "assistant", "content": "I can help you learn."}, - {"role": "user", "content": "What's the fastest way to learn Spanish"}] -Output: YES - -# Complete Yes/No question despite STT error -[{"role": "assistant", "content": "I know about planets."}, - {"role": "user", "content": "Is is Jupiter the biggest planet"}] -Output: YES - -2. Complete Commands: -- Direct instructions -- Clear requests -- Action demands -- Complete statements needing response - -Examples: -# Direct instruction -[{"role": "assistant", "content": "I can explain many topics."}, - {"role": "user", "content": "Tell me about black holes"}] -Output: YES - -# Action demand -[{"role": "assistant", "content": "I can help with math."}, - {"role": "user", "content": "Solve this equation x plus 5 equals 12"}] -Output: YES - -3. Direct Responses: -- Answers to specific questions -- Option selections -- Clear acknowledgments with completion - -Examples: -# Specific answer -[{"role": "assistant", "content": "What's your favorite color?"}, - {"role": "user", "content": "I really like blue"}] -Output: YES - -# Option selection -[{"role": "assistant", "content": "Would you prefer morning or evening?"}, - {"role": "user", "content": "Morning"}] -Output: YES - -MEDIUM PRIORITY SIGNALS: - -1. Speech Pattern Completions: -- Self-corrections reaching completion -- False starts with clear ending -- Topic changes with complete thought -- Mid-sentence completions - -Examples: -# Self-correction reaching completion -[{"role": "assistant", "content": "What would you like to know?"}, - {"role": "user", "content": "Tell me about... no wait, explain how rainbows form"}] -Output: YES - -# Topic change with complete thought -[{"role": "assistant", "content": "The weather is nice today."}, - {"role": "user", "content": "Actually can you tell me who invented the telephone"}] -Output: YES - -# Mid-sentence completion -[{"role": "assistant", "content": "Hello I'm ready."}, - {"role": "user", "content": "What's the capital of? France"}] -Output: YES - -2. Context-Dependent Brief Responses: -- Acknowledgments (okay, sure, alright) -- Agreements (yes, yeah) -- Disagreements (no, nah) -- Confirmations (correct, exactly) - -Examples: -# Acknowledgment -[{"role": "assistant", "content": "Should we talk about history?"}, - {"role": "user", "content": "Sure"}] -Output: YES - -# Disagreement with completion -[{"role": "assistant", "content": "Is that what you meant?"}, - {"role": "user", "content": "No not really"}] -Output: YES - -LOW PRIORITY SIGNALS: - -1. STT Artifacts (Consider but don't over-weight): -- Repeated words -- Unusual punctuation -- Capitalization errors -- Word insertions/deletions - -Examples: -# Word repetition but complete -[{"role": "assistant", "content": "I can help with that."}, - {"role": "user", "content": "What what is the time right now"}] -Output: YES - -# Missing punctuation but complete -[{"role": "assistant", "content": "I can explain that."}, - {"role": "user", "content": "Please tell me how computers work"}] -Output: YES - -2. Speech Features: -- Filler words (um, uh, like) -- Thinking pauses -- Word repetitions -- Brief hesitations - -Examples: -# Filler words but complete -[{"role": "assistant", "content": "What would you like to know?"}, - {"role": "user", "content": "Um uh how do airplanes fly"}] -Output: YES - -# Thinking pause but incomplete -[{"role": "assistant", "content": "I can explain anything."}, - {"role": "user", "content": "Well um I want to know about the"}] -Output: NO - -DECISION RULES: - -1. Return YES if: -- ANY high priority signal shows clear completion -- Medium priority signals combine to show completion -- Meaning is clear despite low priority artifacts - -2. Return NO if: -- No high priority signals present -- Thought clearly trails off -- Multiple incomplete indicators -- User appears mid-formulation - -3. When uncertain: -- If you can understand the intent → YES -- If meaning is unclear → NO -- Always make a binary decision -- Never request clarification - -Examples: -# Incomplete despite corrections -[{"role": "assistant", "content": "What would you like to know about?"}, - {"role": "user", "content": "Can you tell me about"}] -Output: NO - -# Complete despite multiple artifacts -[{"role": "assistant", "content": "I can help you learn."}, - {"role": "user", "content": "How do you I mean what's the best way to learn programming"}] -Output: YES - -# Trailing off incomplete -[{"role": "assistant", "content": "I can explain anything."}, - {"role": "user", "content": "I was wondering if you could tell me why"}] -Output: NO -""" - -conversational_system_message = """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. - -Please be very concise in your responses. Unless you are explicitly asked to do otherwise, give me the shortest complete answer possible without unnecessary elaboration. Generally you should answer with a single sentence. -""" - - -class StatementJudgeContextFilter(FrameProcessor): - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - # We must not block system frames. - if isinstance(frame, SystemFrame): - await self.push_frame(frame, direction) - return - - # We only want to handle LLMContextFrames, and only want to push through a simplified - # context frame that contains a system prompt and the most recent user messages, - if isinstance(frame, LLMContextFrame): - # Take text content from the most recent user messages. - messages = frame.context.get_messages() - user_text_messages = [] - last_assistant_message = None - for message in reversed(messages): - if message["role"] != "user": - if message["role"] == "assistant": - last_assistant_message = message - break - if isinstance(message["content"], str): - user_text_messages.append(message["content"]) - elif isinstance(message["content"], list): - for content in message["content"]: - if content["type"] == "text": - user_text_messages.insert(0, content["text"]) - # If we have any user text content, push a context frame with the simplified context. - if user_text_messages: - user_message = " ".join(reversed(user_text_messages)) - logger.debug(f"!!! {user_message}") - messages = [ - { - "role": "system", - "content": classifier_statement, - } - ] - if last_assistant_message: - messages.append(last_assistant_message) - messages.append({"role": "user", "content": user_message}) - await self.push_frame(LLMContextFrame(LLMContext(messages))) - - -class CompletenessCheck(FrameProcessor): - def __init__(self, notifier: BaseNotifier): - super().__init__() - self._notifier = notifier - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - if isinstance(frame, TextFrame) and frame.text == "YES": - logger.debug("!!! Completeness check YES") - await self.push_frame(UserStoppedSpeakingFrame()) - await self._notifier.notify() - elif isinstance(frame, TextFrame) and frame.text == "NO": - logger.debug("!!! Completeness check NO") - else: - await self.push_frame(frame, direction) - - -class OutputGate(FrameProcessor): - def __init__(self, *, notifier: BaseNotifier, start_open: bool = False, **kwargs): - super().__init__(**kwargs) - self._gate_open = start_open - self._frames_buffer = [] - self._notifier = notifier - self._gate_task = None - - def close_gate(self): - self._gate_open = False - - def open_gate(self): - self._gate_open = True - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - # We must not block system frames. - if isinstance(frame, SystemFrame): - if isinstance(frame, StartFrame): - await self._start() - if isinstance(frame, (EndFrame, CancelFrame)): - await self._stop() - if isinstance(frame, InterruptionFrame): - self._frames_buffer = [] - self.close_gate() - await self.push_frame(frame, direction) - return - - # Don't block function call frames - if isinstance(frame, (FunctionCallInProgressFrame, FunctionCallResultFrame)): - await self.push_frame(frame, direction) - return - - # Ignore frames that are not following the direction of this gate. - if direction != FrameDirection.DOWNSTREAM: - await self.push_frame(frame, direction) - return - - if self._gate_open: - await self.push_frame(frame, direction) - return - - self._frames_buffer.append((frame, direction)) - - async def _start(self): - self._frames_buffer = [] - if not self._gate_task: - self._gate_task = self.create_task(self._gate_task_handler()) - - async def _stop(self): - if self._gate_task: - await self.cancel_task(self._gate_task) - self._gate_task = None - - async def _gate_task_handler(self): - while True: - try: - await self._notifier.wait() - self.open_gate() - for frame, direction in self._frames_buffer: - await self.push_frame(frame, direction) - self._frames_buffer = [] - except asyncio.CancelledError: - break - - -class TurnDetectionLLM(Pipeline): - def __init__(self, llm: LLMService): - # This is the LLM that will be used to detect if the user has finished a - # statement. This doesn't really need to be an LLM, we could use NLP - # libraries for that, but we have the machinery to use an LLM, so we might as well! - statement_llm = AnthropicLLMService(api_key=os.getenv("ANTHROPIC_API_KEY")) - - # This is a notifier that we use to synchronize the two LLMs. - notifier = EventNotifier() - - # This turns the LLM context into an inference request to classify the user's speech - # as complete or incomplete. - statement_judge_context_filter = StatementJudgeContextFilter() - - # This sends a UserStoppedSpeakingFrame and triggers the notifier event - completeness_check = CompletenessCheck(notifier=notifier) - - # # Notify if the user hasn't said anything. - async def user_idle_notifier(frame): - await notifier.notify() - - # Sometimes the LLM will fail detecting if a user has completed a - # sentence, this will wake up the notifier if that happens. - user_idle = UserIdleProcessor(callback=user_idle_notifier, timeout=5.0) - - # We start with the gate open because we send an initial context frame - # to start the conversation. - bot_output_gate = OutputGate(notifier=notifier, start_open=True) - - async def block_user_stopped_speaking(frame): - return not isinstance(frame, UserStoppedSpeakingFrame) - - async def pass_only_llm_trigger_frames(frame): - return ( - isinstance(frame, LLMContextFrame) - or isinstance(frame, InterruptionFrame) - or isinstance(frame, FunctionCallInProgressFrame) - or isinstance(frame, FunctionCallResultFrame) - ) - - async def filter_all(frame): - return False - - super().__init__( - [ - ParallelPipeline( - [ - # Pass everything except UserStoppedSpeaking to the elements after - # this ParallelPipeline - FunctionFilter(filter=block_user_stopped_speaking), - ], - [ - # Ignore everything except an LLMContextFrame. Pass a specially constructed - # simplified context frame to the statement classifier LLM. The only frame this - # sub-pipeline will output is a UserStoppedSpeakingFrame. - statement_judge_context_filter, - statement_llm, - completeness_check, - FunctionFilter(filter=filter_all, direction=FrameDirection.UPSTREAM), - ], - [ - # Block everything except frames that trigger LLM inference. - FunctionFilter(filter=pass_only_llm_trigger_frames), - llm, - bot_output_gate, # Buffer all llm/tts output until notified. - ], - ), - user_idle, - ] - ) - - -async def fetch_weather_from_api(params: FunctionCallParams): - await params.result_callback({"conditions": "nice", "temperature": "75"}) - - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. -transport_params = { - "daily": lambda: DailyParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), - "twilio": lambda: FastAPIWebsocketParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), - "webrtc": lambda: TransportParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), -} - - -async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - logger.info(f"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 - ) - - # This is the regular LLM. - llm_main = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - # Register a function_name of None to get all functions - # sent to the same callback with an additional function_name parameter. - llm_main.register_function("get_current_weather", fetch_weather_from_api) - - @llm_main.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 users location.", - }, - }, - required=["location", "format"], - ) - tools = ToolsSchema(standard_tools=[weather_function]) - - messages = [ - { - "role": "system", - "content": conversational_system_message, - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), - ) - - # LLM + turn detection (with an extra LLM as a judge) - llm = TurnDetectionLLM(llm_main) - - pipeline = Pipeline( - [ - transport.input(), - stt, - context_aggregator.user(), - llm, - tts, - transport.output(), - context_aggregator.assistant(), - ] - ) - - task = PipelineTask( - pipeline, - params=PipelineParams( - enable_metrics=True, - enable_usage_metrics=True, - ), - idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - ) - - @transport.event_handler("on_client_connected") - async def on_client_connected(transport, client): - logger.info(f"Client connected") - # Kick off the conversation. - messages.append( - { - "role": "user", - "content": "Start by just saying \"Hello I'm ready.\" Don't say anything else.", - } - ) - await task.queue_frames([LLMRunFrame()]) - - @transport.event_handler("on_app_message") - async def on_app_message(transport, message, sender): - logger.debug(f"Received app message: {message}") - if "message" not in message: - return - - await task.queue_frames( - [ - UserStartedSpeakingFrame(), - TranscriptionFrame( - user_id="", timestamp=time_now_iso8601(), text=message["message"] - ), - UserStoppedSpeakingFrame(), - ] - ) - - @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/22d-natural-conversation-gemini-audio.py b/examples/foundational/22d-natural-conversation-gemini-audio.py deleted file mode 100644 index 562364d52..000000000 --- a/examples/foundational/22d-natural-conversation-gemini-audio.py +++ /dev/null @@ -1,813 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -import asyncio -import os -import time - -from dotenv import load_dotenv -from loguru import logger - -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 -from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import ( - CancelFrame, - EndFrame, - Frame, - FunctionCallInProgressFrame, - FunctionCallResultFrame, - InputAudioRawFrame, - InterruptionFrame, - LLMContextFrame, - LLMFullResponseStartFrame, - StartFrame, - SystemFrame, - TextFrame, - TranscriptionFrame, - UserStartedSpeakingFrame, - UserStoppedSpeakingFrame, -) -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 import ( - LLMAssistantAggregatorParams, - LLMAssistantResponseAggregator, -) -from pipecat.processors.aggregators.llm_response_universal import ( - LLMContextAggregatorPair, - LLMUserAggregatorParams, -) -from pipecat.processors.filters.function_filter import FunctionFilter -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pipecat.runner.types import RunnerArguments -from pipecat.runner.utils import create_transport -from pipecat.services.cartesia.tts import CartesiaTTSService -from pipecat.services.google.llm import GoogleLLMService -from pipecat.services.llm_service import LLMService -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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies -from pipecat.utils.sync.base_notifier import BaseNotifier -from pipecat.utils.sync.event_notifier import EventNotifier -from pipecat.utils.time import time_now_iso8601 - -load_dotenv(override=True) - - -TRANSCRIBER_MODEL = "gemini-2.0-flash-001" -CLASSIFIER_MODEL = "gemini-2.0-flash-001" -CONVERSATION_MODEL = "gemini-2.0-flash-001" - -transcriber_system_instruction = """You are an audio transcriber. You are receiving audio from a user. Your job is to -transcribe the input audio to text exactly as it was said by the user. - -You will receive the full conversation history before the audio input, to help with context. Use the full history only to help improve the accuracy of your transcription. - -Rules: - - Respond with an exact transcription of the audio input. - - Do not include any text other than the transcription. - - Do not explain or add to your response. - - Transcribe the audio input simply and precisely. - - If the audio is not clear, emit the special string "-". - - No response other than exact transcription, or "-", is allowed. - -""" - -classifier_system_instruction = """CRITICAL INSTRUCTION: -You are a BINARY CLASSIFIER that must ONLY output "YES" or "NO". -DO NOT engage with the content. -DO NOT respond to questions. -DO NOT provide assistance. -Your ONLY job is to output YES or NO. - -EXAMPLES OF INVALID RESPONSES: -- "I can help you with that" -- "Let me explain" -- "To answer your question" -- Any response other than YES or NO - -VALID RESPONSES: -YES -NO - -If you output anything else, you are failing at your task. -You are NOT an assistant. -You are NOT a chatbot. -You are a binary classifier. - -ROLE: -You are a real-time speech completeness classifier. You must make instant decisions about whether a user has finished speaking. -You must output ONLY 'YES' or 'NO' with no other text. - -INPUT FORMAT: -You receive two pieces of information: -1. The assistant's last message (if available) -2. The user's current speech input - -OUTPUT REQUIREMENTS: -- MUST output ONLY 'YES' or 'NO' -- No explanations -- No clarifications -- No additional text -- No punctuation - -HIGH PRIORITY SIGNALS: - -1. Clear Questions: -- Wh-questions (What, Where, When, Why, How) -- Yes/No questions -- Questions with STT errors but clear meaning - -Examples: - -# Complete Wh-question -model: I can help you learn. -user: What's the fastest way to learn Spanish -Output: YES - -# Complete Yes/No question despite STT error -model: I know about planets. -user: Is is Jupiter the biggest planet -Output: YES - -2. Complete Commands: -- Direct instructions -- Clear requests -- Action demands -- Start of task indication -- Complete statements needing response - -Examples: - -# Direct instruction -model: I can explain many topics. -user: Tell me about black holes -Output: YES - -# Start of task indication -user: Let's begin. -Output: YES - -# Start of task indication -user: Let's get started. -Output: YES - -# Action demand -model: I can help with math. -user: Solve this equation x plus 5 equals 12 -Output: YES - -3. Direct Responses: -- Answers to specific questions -- Option selections -- Clear acknowledgments with completion -- Providing information with a known format - mailing address -- Providing information with a known format - phone number -- Providing information with a known format - credit card number - -Examples: - -# Specific answer -model: What's your favorite color? -user: I really like blue -Output: YES - -# Option selection -model: Would you prefer morning or evening? -user: Morning -Output: YES - -# Providing information with a known format - mailing address -model: What's your address? -user: 1234 Main Street -Output: NO - -# Providing information with a known format - mailing address -model: What's your address? -user: 1234 Main Street Irving Texas 75063 -Output: Yes - -# Providing information with a known format - phone number -model: What's your phone number? -user: 41086753 -Output: NO - -# Providing information with a known format - phone number -model: What's your phone number? -user: 4108675309 -Output: Yes - -# Providing information with a known format - phone number -model: What's your phone number? -user: 220 -Output: No - -# Providing information with a known format - credit card number -model: What's your credit card number? -user: 5556 -Output: NO - -# Providing information with a known format - phone number -model: What's your credit card number? -user: 5556710454680800 -Output: Yes - -model: What's your credit card number? -user: 414067 -Output: NO - - -MEDIUM PRIORITY SIGNALS: - -1. Speech Pattern Completions: -- Self-corrections reaching completion -- False starts with clear ending -- Topic changes with complete thought -- Mid-sentence completions - -Examples: - -# Self-correction reaching completion -model: What would you like to know? -user: Tell me about... no wait, explain how rainbows form -Output: YES - -# Topic change with complete thought -model: The weather is nice today. -user: Actually can you tell me who invented the telephone -Output: YES - -# Mid-sentence completion -model: Hello I'm ready. -user: What's the capital of? France -Output: YES - -2. Context-Dependent Brief Responses: -- Acknowledgments (okay, sure, alright) -- Agreements (yes, yeah) -- Disagreements (no, nah) -- Confirmations (correct, exactly) - -Examples: - -# Acknowledgment -model: Should we talk about history? -user: Sure -Output: YES - -# Disagreement with completion -model: Is that what you meant? -user: No not really -Output: YES - -LOW PRIORITY SIGNALS: - -1. STT Artifacts (Consider but don't over-weight): -- Repeated words -- Unusual punctuation -- Capitalization errors -- Word insertions/deletions - -Examples: - -# Word repetition but complete -model: I can help with that. -user: What what is the time right now -Output: YES - -# Missing punctuation but complete -model: I can explain that. -user: Please tell me how computers work -Output: YES - -2. Speech Features: -- Filler words (um, uh, like) -- Thinking pauses -- Word repetitions -- Brief hesitations - -Examples: - -# Filler words but complete -model: What would you like to know? -user: Um uh how do airplanes fly -Output: YES - -# Thinking pause but incomplete -model: I can explain anything. -user: Well um I want to know about the -Output: NO - -DECISION RULES: - -1. Return YES if: -- ANY high priority signal shows clear completion -- Medium priority signals combine to show completion -- Meaning is clear despite low priority artifacts - -2. Return NO if: -- No high priority signals present -- Thought clearly trails off -- Multiple incomplete indicators -- User appears mid-formulation - -3. When uncertain: -- If you can understand the intent → YES -- If meaning is unclear → NO -- Always make a binary decision -- Never request clarification - -Examples: - -# Incomplete despite corrections -model: What would you like to know about? -user: Can you tell me about -Output: NO - -# Complete despite multiple artifacts -model: I can help you learn. -user: How do you I mean what's the best way to learn programming -Output: YES - -# Trailing off incomplete -model: I can explain anything. -user: I was wondering if you could tell me why -Output: NO -""" - -conversation_system_instruction = """You are a helpful assistant participating in a voice converation. - -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. - -If you know that a number string is a phone number from the context of the conversation, write it as a phone number. For example 210-333-4567. - -If you know that a number string is a credit card number, write it as a credit card number. For example 4111-1111-1111-1111. - -Please be very concise in your responses. Unless you are explicitly asked to do otherwise, give me shortest complete answer possible without unnecessary elaboration. Generally you should answer with a single sentence. -""" - - -class AudioAccumulator(FrameProcessor): - """Buffers user audio until the user stops speaking. - - Always pushes a fresh context with a single audio message. - """ - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self._audio_frames = [] - self._start_secs = 0.2 # this should match VAD start_secs (hardcoding for now) - self._max_buffer_size_secs = 30 - self._user_speaking_vad_state = False - self._user_speaking_utterance_state = False - - async def reset(self): - self._audio_frames = [] - self._user_speaking_vad_state = False - self._user_speaking_utterance_state = False - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - # ignore context frame - if isinstance(frame, LLMContextFrame): - return - - if isinstance(frame, TranscriptionFrame): - # We could gracefully handle both audio input and text/transcription input ... - # but let's leave that as an exercise to the reader. :-) - return - if isinstance(frame, UserStartedSpeakingFrame): - self._user_speaking_vad_state = True - self._user_speaking_utterance_state = True - - elif isinstance(frame, UserStoppedSpeakingFrame): - data = b"".join(frame.audio for frame in self._audio_frames) - logger.debug( - f"Processing audio buffer seconds: ({len(self._audio_frames)}) ({len(data)}) {len(data) / 2 / 16000}" - ) - self._user_speaking = False - context = LLMContext() - await context.add_audio_frames_message(audio_frames=self._audio_frames) - await self.push_frame(LLMContextFrame(context=context)) - elif isinstance(frame, InputAudioRawFrame): - # Append the audio frame to our buffer. Treat the buffer as a ring buffer, dropping the oldest - # frames as necessary. - # Use a small buffer size when an utterance is not in progress. Just big enough to backfill the start_secs. - # Use a larger buffer size when an utterance is in progress. - # Assume all audio frames have the same duration. - self._audio_frames.append(frame) - frame_duration = len(frame.audio) / 2 * frame.num_channels / frame.sample_rate - buffer_duration = frame_duration * len(self._audio_frames) - # logger.debug(f"!!! Frame duration: {frame_duration}") - if self._user_speaking_utterance_state: - while buffer_duration > self._max_buffer_size_secs: - self._audio_frames.pop(0) - buffer_duration -= frame_duration - else: - while buffer_duration > self._start_secs: - self._audio_frames.pop(0) - buffer_duration -= frame_duration - - await self.push_frame(frame, direction) - - -class CompletenessCheck(FrameProcessor): - """Checks the result of the classifier LLM to determine if the user has finished speaking. - - Triggers the notifier if the user has finished speaking. Also triggers the notifier if an - idle timeout is reached. - """ - - wait_time = 5.0 - - def __init__(self, notifier: BaseNotifier, audio_accumulator: AudioAccumulator, **kwargs): - super().__init__() - self._notifier = notifier - self._audio_accumulator = audio_accumulator - self._idle_task = None - self._wakeup_time = 0 - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - if isinstance(frame, (EndFrame, CancelFrame)): - if self._idle_task: - await self.cancel_task(self._idle_task) - self._idle_task = None - elif isinstance(frame, UserStartedSpeakingFrame): - if self._idle_task: - await self.cancel_task(self._idle_task) - elif isinstance(frame, TextFrame) and frame.text.startswith("YES"): - logger.debug("Completeness check YES") - if self._idle_task: - await self.cancel_task(self._idle_task) - await self.push_frame(UserStoppedSpeakingFrame()) - await self._audio_accumulator.reset() - await self._notifier.notify() - elif isinstance(frame, TextFrame): - if frame.text.strip(): - logger.debug(f"Completeness check NO - '{frame.text}'") - # start timer to wake up if necessary - if self._wakeup_time: - self._wakeup_time = time.time() + self.wait_time - else: - # logger.debug("!!! CompletenessCheck idle wait START") - self._wakeup_time = time.time() + self.wait_time - self._idle_task = self.create_task(self._idle_task_handler()) - else: - await self.push_frame(frame, direction) - - async def _idle_task_handler(self): - try: - while time.time() < self._wakeup_time: - await asyncio.sleep(0.01) - # logger.debug(f"!!! CompletenessCheck idle wait OVER") - await self._audio_accumulator.reset() - await self._notifier.notify() - except asyncio.CancelledError: - # logger.debug(f"!!! CompletenessCheck idle wait CANCEL") - pass - except Exception as e: - logger.error(f"CompletenessCheck idle wait error: {e}") - raise e - finally: - # logger.debug(f"!!! CompletenessCheck idle wait FINALLY") - self._wakeup_time = 0 - self._idle_task = None - - -class LLMAggregatorBuffer(LLMAssistantResponseAggregator): - """Buffers the output of the transcription LLM. Used by the bot output gate.""" - - def __init__(self, **kwargs): - super().__init__(params=LLMAssistantAggregatorParams(expect_stripped_words=False)) - self._transcription = "" - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - # parent method pushes frames - if isinstance(frame, UserStartedSpeakingFrame): - self._transcription = "" - - async def push_aggregation(self): - if self._aggregation: - self._transcription = self._aggregation - self._aggregation = "" - - logger.debug(f"[Transcription] {self._transcription}") - - async def wait_for_transcription(self): - while not self._transcription: - await asyncio.sleep(0.01) - tx = self._transcription - self._transcription = "" - return tx - - -class ConversationAudioContextAssembler(FrameProcessor): - """Takes the single-message context generated by the AudioAccumulator and adds it to the conversation LLM's context.""" - - def __init__(self, context: LLMContext, **kwargs): - super().__init__(**kwargs) - self._context = context - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - # We must not block system frames. - if isinstance(frame, SystemFrame): - await self.push_frame(frame, direction) - return - - if isinstance(frame, LLMContextFrame): - last_message = frame.context.get_messages()[-1] - self._context._messages.append(last_message) - await self.push_frame(LLMContextFrame(context=self._context)) - - -class OutputGate(FrameProcessor): - """Buffers output frames until the notifier is triggered. - - When the notifier fires, waits until a transcription is ready, then: - 1. Replaces the last user audio message with the transcription. - 2. Flushes the frames buffer. - """ - - def __init__( - self, - notifier: BaseNotifier, - context: LLMContext, - llm_transcription_buffer: LLMAggregatorBuffer, - **kwargs, - ): - super().__init__(**kwargs) - self._gate_open = False - self._frames_buffer = [] - self._notifier = notifier - self._context = context - self._transcription_buffer = llm_transcription_buffer - self._gate_task = None - - def close_gate(self): - self._gate_open = False - - def open_gate(self): - self._gate_open = True - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - # We must not block system frames. - if isinstance(frame, SystemFrame): - if isinstance(frame, StartFrame): - await self._start() - if isinstance(frame, (EndFrame, CancelFrame)): - await self._stop() - if isinstance(frame, InterruptionFrame): - self._frames_buffer = [] - self.close_gate() - await self.push_frame(frame, direction) - return - - # Don't block function call frames - if isinstance(frame, (FunctionCallInProgressFrame, FunctionCallResultFrame)): - await self.push_frame(frame, direction) - return - - # Ignore frames that are not following the direction of this gate. - if direction != FrameDirection.DOWNSTREAM: - await self.push_frame(frame, direction) - return - - if isinstance(frame, LLMFullResponseStartFrame): - # Remove the audio message from the context. We will never need it again. - # If the completeness check fails, a new audio message will be appended to the context. - # If the completeness check succeeds, our notifier will fire and we will append the - # transcription to the context. - self._context._messages.pop() - - if self._gate_open: - await self.push_frame(frame, direction) - return - - self._frames_buffer.append((frame, direction)) - - async def _start(self): - self._frames_buffer = [] - if not self._gate_task: - self._gate_task = self.create_task(self._gate_task_handler()) - - async def _stop(self): - if self._gate_task: - await self.cancel_task(self._gate_task) - self._gate_task = None - - async def _gate_task_handler(self): - while True: - try: - await self._notifier.wait() - - transcription = await self._transcription_buffer.wait_for_transcription() or "-" - self._context.add_message({"role": "user", "content": transcription}) - - self.open_gate() - for frame, direction in self._frames_buffer: - await self.push_frame(frame, direction) - self._frames_buffer = [] - except asyncio.CancelledError: - break - - -class TurnDetectionLLM(Pipeline): - def __init__(self, llm: LLMService, context: LLMContext): - # This is the LLM that will transcribe user speech. - tx_llm = GoogleLLMService( - name="Transcriber", - model=TRANSCRIBER_MODEL, - api_key=os.getenv("GOOGLE_API_KEY"), - temperature=0.0, - system_instruction=transcriber_system_instruction, - ) - - # This is the LLM that will classify user speech as complete or incomplete. - classifier_llm = GoogleLLMService( - name="Classifier", - model=CLASSIFIER_MODEL, - api_key=os.getenv("GOOGLE_API_KEY"), - temperature=0.0, - system_instruction=classifier_system_instruction, - ) - - # This is a notifier that we use to synchronize the two LLMs. - notifier = EventNotifier() - - # This turns the LLM context into an inference request to classify the user's speech - # as complete or incomplete. - # statement_judge_context_filter = StatementJudgeAudioContextAccumulator(notifier=notifier) - - audio_accumulator = AudioAccumulator() - # This sends a UserStoppedSpeakingFrame and triggers the notifier event - completeness_check = CompletenessCheck( - notifier=notifier, audio_accumulator=audio_accumulator - ) - - async def block_user_stopped_speaking(frame): - return not isinstance(frame, UserStoppedSpeakingFrame) - - conversation_audio_context_assembler = ConversationAudioContextAssembler(context=context) - - llm_aggregator_buffer = LLMAggregatorBuffer() - - bot_output_gate = OutputGate( - notifier=notifier, context=context, llm_transcription_buffer=llm_aggregator_buffer - ) - - super().__init__( - [ - audio_accumulator, - ParallelPipeline( - [ - # Pass everything except UserStoppedSpeaking to the elements after - # this ParallelPipeline - FunctionFilter(filter=block_user_stopped_speaking), - ], - [ - ParallelPipeline( - [ - classifier_llm, - completeness_check, - ], - [ - tx_llm, - llm_aggregator_buffer, - ], - ) - ], - [ - conversation_audio_context_assembler, - llm, - bot_output_gate, # buffer output until notified, then flush frames and update context - ], - ), - ] - ) - - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. -transport_params = { - "daily": lambda: DailyParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), - "twilio": lambda: FastAPIWebsocketParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), - "webrtc": lambda: TransportParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), -} - - -async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - logger.info(f"Starting bot") - - tts = CartesiaTTSService( - api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady - ) - - # This is the regular LLM that responds conversationally. - conversation_llm = GoogleLLMService( - name="Conversation", - model=CONVERSATION_MODEL, - api_key=os.getenv("GOOGLE_API_KEY"), - system_instruction=conversation_system_instruction, - ) - - context = LLMContext() - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), - ) - - llm = TurnDetectionLLM(conversation_llm, context) - - pipeline = Pipeline( - [ - transport.input(), - llm, - tts, - transport.output(), - context_aggregator.assistant(), - ], - ) - - 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") - - @transport.event_handler("on_app_message") - async def on_app_message(transport, message, sender): - logger.debug(f"Received app message: {message}, sender: {sender}") # TODO: revert - if "message" not in message: - return - - await task.queue_frames( - [ - UserStartedSpeakingFrame(), - TranscriptionFrame( - user_id="", timestamp=time_now_iso8601(), text=message["message"] - ), - UserStoppedSpeakingFrame(), - ] - ) - - @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/23-bot-background-sound.py b/examples/foundational/23-bot-background-sound.py index c19a91498..0229db419 100644 --- a/examples/foundational/23-bot-background-sound.py +++ b/examples/foundational/23-bot-background-sound.py @@ -12,9 +12,7 @@ from dotenv import load_dotenv from loguru import logger from pipecat.audio.mixers.soundfile_mixer import SoundfileMixer -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, MixerEnableFrame, MixerUpdateSettingsFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,8 +30,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -41,9 +37,8 @@ OFFICE_SOUND_FILE = os.path.join( os.path.dirname(__file__), "assets", "office-ambience-24000-mono.mp3" ) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -53,7 +48,6 @@ transport_params = { default_sound="office", volume=2.0, ), - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, @@ -63,7 +57,6 @@ transport_params = { default_sound="office", volume=2.0, ), - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, @@ -73,7 +66,6 @@ transport_params = { default_sound="office", volume=2.0, ), - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -83,37 +75,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -140,7 +128,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Re-enabling background sound and starting bot...") await task.queue_frame(MixerEnableFrame(True)) # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/24-user-mute-strategy.py b/examples/foundational/24-user-mute-strategy.py index 621a2c682..f7e29c171 100644 --- a/examples/foundational/24-user-mute-strategy.py +++ b/examples/foundational/24-user-mute-strategy.py @@ -13,9 +13,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -34,12 +32,10 @@ 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.mute import ( +from pipecat.turns.user_mute import ( FunctionCallUserMuteStrategy, MuteUntilFirstBotCompleteUserMuteStrategy, ) -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -52,24 +48,20 @@ async def fetch_weather_from_api(params: FunctionCallParams): await params.result_callback({"conditions": "nice", "temperature": "75"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -79,9 +71,19 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - tts = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY"), voice="aura-helios-en") + tts = DeepgramTTSService( + api_key=os.getenv("DEEPGRAM_API_KEY"), + settings=DeepgramTTSService.Settings( + voice="aura-2-helena-en", + ), + ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant who can check the weather. Always check the weather when a location is mentioned. Respond concisely and naturally. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points.", + ), + ) llm.register_function("get_current_weather", fetch_weather_from_api) weather_function = FunctionSchema( @@ -102,24 +104,15 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[weather_function]) - messages = [ - { - "role": "system", - "content": "You are a helpful assistant who can check the weather. Always check the weather when a location is mentioned. Respond concisely and naturally. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points.", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), user_mute_strategies=[ MuteUntilFirstBotCompleteUserMuteStrategy(), FunctionCallUserMuteStrategy(), ], + vad_analyzer=SileroVADAnalyzer(), ), ) @@ -127,11 +120,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -148,9 +141,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation with a weather-related prompt - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": "Ask the user what city they'd like to know the weather for.", } ) @@ -161,6 +154,14 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Client disconnected") await task.cancel() + @user_aggregator.event_handler("on_user_mute_started") + async def on_user_mute_started(aggregator): + logger.info(f"User mute started") + + @user_aggregator.event_handler("on_user_mute_stopped") + async def on_user_mute_stopped(aggregator): + logger.info(f"User mute stopped") + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) await runner.run(task) diff --git a/examples/foundational/25-google-audio-in.py b/examples/foundational/25-google-audio-in.py index 09f337d90..7a6c910e7 100644 --- a/examples/foundational/25-google-audio-in.py +++ b/examples/foundational/25-google-audio-in.py @@ -28,7 +28,10 @@ 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 +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) from pipecat.processors.frame_processor import FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport @@ -44,7 +47,7 @@ load_dotenv(override=True) # The system prompt for the main conversation. # conversation_system_message = """ -You are a helpful LLM in a WebRTC call. Your goals are to be helpful and brief in your responses. Respond with one or two sentences at most, unless you are asked to +You are a helpful LLM in a voice call. Your goals are to be helpful and brief in your responses. Respond with one or two sentences at most, unless you are asked to respond at more length. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. """ @@ -98,7 +101,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: @@ -264,24 +267,20 @@ class TranscriptionContextFixup(FrameProcessor): await self.push_frame(frame, direction) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -291,26 +290,30 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) conversation_llm = GoogleLLMService( name="Conversation", - model="gemini-2.0-flash-001", - # model="gemini-exp-1121", + settings=GoogleLLMService.Settings( + model="gemini-2.5-flash", + system_instruction=conversation_system_message, + ), api_key=os.getenv("GOOGLE_API_KEY"), # we can give the GoogleLLMService a system instruction to use directly # in the GenerativeModel constructor. Let's do that rather than put # our system message in the messages list. - system_instruction=conversation_system_message, ) input_transcription_llm = GoogleLLMService( name="Transcription", - model="gemini-2.0-flash-001", - # model="gemini-exp-1121", + settings=GoogleLLMService.Settings( + model="gemini-2.5-flash", + system_instruction=transcriber_system_message, + ), api_key=os.getenv("GOOGLE_API_KEY"), - system_instruction=transcriber_system_message, ) messages = [ @@ -321,7 +324,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ] context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair(context) + context_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) audio_collector = UserAudioCollector(context, context_aggregator.user()) input_transcription_context_filter = InputTranscriptionContextFilter() transcription_frames_emitter = InputTranscriptionFrameEmitter() diff --git a/examples/foundational/26-gemini-live.py b/examples/foundational/26-gemini-live.py index a99d0db36..e8ca05407 100644 --- a/examples/foundational/26-gemini-live.py +++ b/examples/foundational/26-gemini-live.py @@ -15,6 +15,7 @@ from pipecat.frames.frames import LLMMessagesAppendFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.audio.vad_processor import VADProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.google.gemini_live.llm import GeminiLiveLLMService @@ -26,30 +27,26 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, # set stop_secs to something roughly similar to the internal setting # of the Multimodal Live api, just to align events. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, # set stop_secs to something roughly similar to the internal setting # of the Multimodal Live api, just to align events. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, # set stop_secs to something roughly similar to the internal setting # of the Multimodal Live api, just to align events. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), } @@ -67,14 +64,19 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = GeminiLiveLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - system_instruction=system_instruction, - voice_id="Puck", # Aoede, Charon, Fenrir, Kore, Puck + settings=GeminiLiveLLMService.Settings( + system_instruction=system_instruction, + voice="Puck", # Aoede, Charon, Fenrir, Kore, Puck + ), ) + vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5))) + # Build the pipeline pipeline = Pipeline( [ transport.input(), + vad_processor, llm, transport.output(), ] diff --git a/examples/foundational/26a-gemini-live-transcription.py b/examples/foundational/26a-gemini-live-transcription.py index 0248ca358..5d9adbc7f 100644 --- a/examples/foundational/26a-gemini-live-transcription.py +++ b/examples/foundational/26a-gemini-live-transcription.py @@ -12,13 +12,17 @@ from loguru import logger from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, TranscriptionMessage +from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair -from pipecat.processors.transcript_processor import TranscriptProcessor +from pipecat.processors.aggregators.llm_response_universal import ( + AssistantTurnStoppedMessage, + LLMContextAggregatorPair, + LLMUserAggregatorParams, + UserTurnStoppedMessage, +) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.google.gemini_live.llm import GeminiLiveLLMService @@ -29,36 +33,20 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), } @@ -68,9 +56,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = GeminiLiveLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - voice_id="Aoede", # Puck, Charon, Kore, Fenrir, Aoede - # system_instruction="Talk like a pirate." - # inference_on_context_initialization=False, + settings=GeminiLiveLLMService.Settings( + voice="Aoede", # Puck, Charon, Kore, Fenrir, Aoede + # system_instruction="Talk like a pirate." + # inference_on_context_initialization=False, + ), ) context = LLMContext( @@ -79,31 +69,26 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): "role": "user", "content": "Say hello. Then ask if I want to hear a joke.", }, - # {"role": "assistant", "content": "Hello! Why don't scientists trust atoms?"}, - # { - # "role": "user", - # "content": [ - # { - # "type": "text", - # "text": "Oh, I know this one: because they make up everything.", - # } - # ], - # }, ], ) - context_aggregator = LLMContextAggregatorPair(context) - - transcript = TranscriptProcessor() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + # Set stop_secs to something roughly similar to the internal setting + # of the Multimodal Live api, just to align events. This doesn't + # really matter because we can only use the Multimodal Live API's + # phrase endpointing, for now. + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)) + ), + ) pipeline = Pipeline( [ transport.input(), - context_aggregator.user(), - transcript.user(), + user_aggregator, llm, transport.output(), - transcript.assistant(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -127,14 +112,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Client disconnected") await task.cancel() - # Register event handler for transcript updates - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - for msg in frame.messages: - if isinstance(msg, TranscriptionMessage): - timestamp = f"[{msg.timestamp}] " if msg.timestamp else "" - line = f"{timestamp}{msg.role}: {msg.content}" - logger.info(f"Transcript: {line}") + @user_aggregator.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(aggregator, strategy, message: UserTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}user: {message.content}" + logger.info(f"Transcript: {line}") + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}assistant: {message.content}" + logger.info(f"Transcript: {line}") runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) diff --git a/examples/foundational/26b-gemini-live-function-calling.py b/examples/foundational/26b-gemini-live-function-calling.py index 3f054dd65..75481c2cd 100644 --- a/examples/foundational/26b-gemini-live-function-calling.py +++ b/examples/foundational/26b-gemini-live-function-calling.py @@ -20,7 +20,10 @@ 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 +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.google.gemini_live.llm import GeminiLiveLLMService @@ -58,36 +61,20 @@ You have three tools available to you: """ -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), } @@ -133,7 +120,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = GeminiLiveLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - system_instruction=system_instruction, + settings=GeminiLiveLLMService.Settings( + system_instruction=system_instruction, + ), tools=tools, ) @@ -144,22 +133,25 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # than as arguments to GeminiLiveLLMService, but note that doing so will # trigger a (fast) reconnection when the GeminiLiveLLMService first # receives the context (i.e. when we send the LLMRunFrame below). - context = LLMContext( - [ - # {"role": "system", "content": system_instruction}, - {"role": "user", "content": "Say hello."}, - ], - # tools, + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + # Set stop_secs to something roughly similar to the internal setting + # of the Multimodal Live api, just to align events. This doesn't + # really matter because we can only use the Multimodal Live API's + # phrase endpointing, for now. + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)) + ), ) - context_aggregator = LLMContextAggregatorPair(context) pipeline = Pipeline( [ transport.input(), - context_aggregator.user(), + user_aggregator, llm, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -176,6 +168,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/26c-gemini-live-video.py b/examples/foundational/26c-gemini-live-video.py index 5b3161cc7..dc5044617 100644 --- a/examples/foundational/26c-gemini-live-video.py +++ b/examples/foundational/26c-gemini-live-video.py @@ -18,7 +18,10 @@ 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 +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import ( create_transport, @@ -31,29 +34,18 @@ from pipecat.transports.daily.transport import DailyParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, video_in_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), } @@ -61,8 +53,10 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = GeminiLiveLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - voice_id="Aoede", # Puck, Charon, Kore, Fenrir, Aoede - # system_instruction="Talk like a pirate." + settings=GeminiLiveLLMService.Settings( + voice="Aoede", # Puck, Charon, Kore, Fenrir, Aoede + # system_instruction="Talk like a pirate." + ), # inference_on_context_initialization=False, ) @@ -74,15 +68,24 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): }, ], ) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + # Set stop_secs to something roughly similar to the internal setting + # of the Multimodal Live api, just to align events. This doesn't + # really matter because we can only use the Multimodal Live API's + # phrase endpointing, for now. + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)) + ), + ) pipeline = Pipeline( [ transport.input(), - context_aggregator.user(), + user_aggregator, llm, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/26d-gemini-live-text.py b/examples/foundational/26d-gemini-live-text.py index c4eb62fb7..29a4fd112 100644 --- a/examples/foundational/26d-gemini-live-text.py +++ b/examples/foundational/26d-gemini-live-text.py @@ -17,15 +17,14 @@ 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 +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.google.gemini_live.llm import ( - GeminiLiveLLMService, - GeminiModalities, - InputParams, -) +from pipecat.services.google.gemini_live.llm import GeminiLiveLLMService, GeminiModalities from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -44,36 +43,20 @@ Respond to what the user said in a creative and helpful way. Keep your responses """ -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), } @@ -87,9 +70,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # https://cloud.google.com/vertex-ai/generative-ai/docs/live-api/tools#native-audio). llm = GeminiLiveLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - system_instruction=SYSTEM_INSTRUCTION, + settings=GeminiLiveLLMService.Settings( + system_instruction=SYSTEM_INSTRUCTION, + modalities=GeminiModalities.TEXT, + ), tools=[{"google_search": {}}, {"code_execution": {}}], - params=InputParams(modalities=GeminiModalities.TEXT), ) # Optionally, you can set the response modalities via a function @@ -111,16 +96,25 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Set up conversation context and management # The context_aggregator will automatically collect conversation context context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + # Set stop_secs to something roughly similar to the internal setting + # of the Multimodal Live api, just to align events. This doesn't + # really matter because we can only use the Multimodal Live API's + # phrase endpointing, for now. + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)) + ), + ) pipeline = Pipeline( [ transport.input(), - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/26e-gemini-live-google-search.py b/examples/foundational/26e-gemini-live-google-search.py index bc14cf713..28ab77c2a 100644 --- a/examples/foundational/26e-gemini-live-google-search.py +++ b/examples/foundational/26e-gemini-live-google-search.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2024-2025, Daily +# Copyright (c) 2024-2026, Daily # # SPDX-License-Identifier: BSD 2-Clause License # @@ -17,7 +17,10 @@ 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 +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.google.gemini_live.llm import GeminiLiveLLMService @@ -46,36 +49,20 @@ Start each interaction by asking the user about which place they would like to k """ -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), } @@ -86,8 +73,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Initialize the Gemini Multimodal Live model llm = GeminiLiveLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - voice_id="Puck", # Aoede, Charon, Fenrir, Kore, Puck - system_instruction=system_instruction, + settings=GeminiLiveLLMService.Settings( + voice="Puck", # Aoede, Charon, Fenrir, Kore, Puck + system_instruction=system_instruction, + ), tools=tools, ) @@ -99,15 +88,24 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): } ], ) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + # Set stop_secs to something roughly similar to the internal setting + # of the Multimodal Live api, just to align events. This doesn't + # really matter because we can only use the Multimodal Live API's + # phrase endpointing, for now. + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)) + ), + ) pipeline = Pipeline( [ transport.input(), # Transport user input - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) diff --git a/examples/foundational/26f-gemini-live-files-api.py b/examples/foundational/26f-gemini-live-files-api.py index 7f5f3eae2..5abb50788 100644 --- a/examples/foundational/26f-gemini-live-files-api.py +++ b/examples/foundational/26f-gemini-live-files-api.py @@ -17,7 +17,10 @@ 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 +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.google.gemini_live.llm import GeminiLiveLLMService @@ -28,27 +31,23 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, video_in_enabled=False, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=False, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=False, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), } @@ -111,9 +110,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Initialize Gemini service with File API support llm = GeminiLiveLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - system_instruction=system_instruction, - voice_id="Charon", # Aoede, Charon, Fenrir, Kore, Puck - transcribe_user_audio=True, + settings=GeminiLiveLLMService.Settings( + system_instruction=system_instruction, + voice="Charon", # Aoede, Charon, Fenrir, Kore, Puck + ), ) # Upload the sample file to Gemini File API @@ -163,16 +163,25 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) # Create context aggregator - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + # Set stop_secs to something roughly similar to the internal setting + # of the Multimodal Live api, just to align events. This doesn't + # really matter because we can only use the Multimodal Live API's + # phrase endpointing, for now. + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)) + ), + ) # Build the pipeline pipeline = Pipeline( [ transport.input(), - context_aggregator.user(), + user_aggregator, llm, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/26g-gemini-live-groundingMetadata.py b/examples/foundational/26g-gemini-live-groundingMetadata.py index 6626e9b39..0285483e5 100644 --- a/examples/foundational/26g-gemini-live-groundingMetadata.py +++ b/examples/foundational/26g-gemini-live-groundingMetadata.py @@ -11,7 +11,10 @@ from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport @@ -24,27 +27,23 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, video_in_enabled=False, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=False, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=False, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), } @@ -108,9 +107,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = GeminiLiveLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - system_instruction=SYSTEM_INSTRUCTION, - voice_id="Charon", # Aoede, Charon, Fenrir, Kore, Puck - transcribe_user_audio=True, + settings=GeminiLiveLLMService.Settings( + system_instruction=SYSTEM_INSTRUCTION, + voice="Charon", # Aoede, Charon, Fenrir, Kore, Puck + ), tools=tools, ) @@ -126,16 +126,25 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Set up conversation context and management context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + # Set stop_secs to something roughly similar to the internal setting + # of the Multimodal Live api, just to align events. This doesn't + # really matter because we can only use the Multimodal Live API's + # phrase endpointing, for now. + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)) + ), + ) pipeline = Pipeline( [ transport.input(), - context_aggregator.user(), + user_aggregator, llm, grounding_processor, # Add our grounding processor here transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/26h-gemini-live-vertex-function-calling.py b/examples/foundational/26h-gemini-live-vertex-function-calling.py index 35eb4ade0..7dd894fd1 100644 --- a/examples/foundational/26h-gemini-live-vertex-function-calling.py +++ b/examples/foundational/26h-gemini-live-vertex-function-calling.py @@ -20,10 +20,13 @@ 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 +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.google.gemini_live.llm_vertex import GeminiLiveVertexLLMService +from pipecat.services.google.gemini_live.vertex.llm import GeminiLiveVertexLLMService from pipecat.services.llm_service import FunctionCallParams from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams @@ -57,36 +60,20 @@ You have three tools available to you: """ -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), } @@ -130,8 +117,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): credentials=os.getenv("GOOGLE_VERTEX_TEST_CREDENTIALS"), project_id=os.getenv("GOOGLE_CLOUD_PROJECT_ID"), location=os.getenv("GOOGLE_CLOUD_LOCATION"), - system_instruction=system_instruction, - voice_id="Puck", # Aoede, Charon, Fenrir, Kore, Puck + settings=GeminiLiveVertexLLMService.Settings( + system_instruction=system_instruction, + voice="Puck", # Aoede, Charon, Fenrir, Kore, Puck + ), tools=tools, ) @@ -139,15 +128,24 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) context = LLMContext([{"role": "user", "content": "Say hello."}]) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + # Set stop_secs to something roughly similar to the internal setting + # of the Multimodal Live api, just to align events. This doesn't + # really matter because we can only use the Multimodal Live API's + # phrase endpointing, for now. + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)) + ), + ) pipeline = Pipeline( [ transport.input(), - context_aggregator.user(), + user_aggregator, llm, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/26i-gemini-live-graceful-end.py b/examples/foundational/26i-gemini-live-graceful-end.py index 5363d7538..5359f431c 100644 --- a/examples/foundational/26i-gemini-live-graceful-end.py +++ b/examples/foundational/26i-gemini-live-graceful-end.py @@ -19,7 +19,10 @@ 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 +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) from pipecat.processors.frame_processor import FrameDirection from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport @@ -67,36 +70,20 @@ After you've responded to the user three times, do two things, in order: """ -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - # set stop_secs to something roughly similar to the internal setting - # of the Multimodal Live api, just to align events. This doesn't really - # matter because we can only use the Multimodal Live API's phrase - # endpointing, for now. - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), ), } @@ -145,7 +132,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = GeminiLiveLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - system_instruction=system_instruction, + settings=GeminiLiveLLMService.Settings( + system_instruction=system_instruction, + ), tools=tools, ) @@ -156,15 +145,24 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): context = LLMContext( [{"role": "user", "content": "Say hello."}], ) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + # Set stop_secs to something roughly similar to the internal setting + # of the Multimodal Live api, just to align events. This doesn't + # really matter because we can only use the Multimodal Live API's + # phrase endpointing, for now. + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)) + ), + ) pipeline = Pipeline( [ transport.input(), - context_aggregator.user(), + user_aggregator, llm, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/27-simli-layer.py b/examples/foundational/27-simli-layer.py index e056e2f20..76c2dd99d 100644 --- a/examples/foundational/27-simli-layer.py +++ b/examples/foundational/27-simli-layer.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,14 +28,11 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.simli.video import SimliVideoService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -46,7 +41,6 @@ transport_params = { video_out_is_live=True, video_out_width=512, video_out_height=512, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, @@ -55,7 +49,6 @@ transport_params = { video_out_is_live=True, video_out_width=512, video_out_height=512, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -67,7 +60,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", + ), ) simli_ai = SimliVideoService( @@ -75,35 +70,29 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): face_id="cace3ef7-a4c4-425d-a8cf-a5358eb0c427", ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o-mini") - - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, simli_ai, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/28-transcription-processor.py b/examples/foundational/28-transcription-processor.py deleted file mode 100644 index 0b057a621..000000000 --- a/examples/foundational/28-transcription-processor.py +++ /dev/null @@ -1,209 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -import os -from typing import List, Optional - -from dotenv import load_dotenv -from loguru import logger - -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 -from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, TranscriptionMessage, TranscriptionUpdateFrame -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.transcript_processor import TranscriptProcessor -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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies - -load_dotenv(override=True) - - -class TranscriptHandler: - """Handles real-time transcript processing and output. - - Maintains a list of conversation messages and outputs them either to a log - or to a file as they are received. Each message includes its timestamp and role. - - Attributes: - messages: List of all processed transcript messages - output_file: Optional path to file where transcript is saved. If None, outputs to log only. - """ - - def __init__(self, output_file: Optional[str] = None): - """Initialize handler with optional file output. - - Args: - output_file: Path to output file. If None, outputs to log only. - """ - self.messages: List[TranscriptionMessage] = [] - self.output_file: Optional[str] = output_file - logger.debug( - f"TranscriptHandler initialized {'with output_file=' + output_file if output_file else 'with log output only'}" - ) - - async def save_message(self, message: TranscriptionMessage): - """Save a single transcript message. - - Outputs the message to the log and optionally to a file. - - Args: - message: The message to save - """ - timestamp = f"[{message.timestamp}] " if message.timestamp else "" - line = f"{timestamp}{message.role}: {message.content}" - - # Always log the message - logger.info(f"Transcript: {line}") - - # Optionally write to file - if self.output_file: - try: - with open(self.output_file, "a", encoding="utf-8") as f: - f.write(line + "\n") - except Exception as e: - logger.error(f"Error saving transcript message to file: {e}") - - async def on_transcript_update( - self, processor: TranscriptProcessor, frame: TranscriptionUpdateFrame - ): - """Handle new transcript messages. - - Args: - processor: The TranscriptProcessor that emitted the update - frame: TranscriptionUpdateFrame containing new messages - """ - logger.debug(f"Received transcript update with {len(frame.messages)} new messages") - - for msg in frame.messages: - self.messages.append(msg) - await self.save_message(msg) - - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. -transport_params = { - "daily": lambda: DailyParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - ), - "twilio": lambda: FastAPIWebsocketParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - ), - "webrtc": lambda: TransportParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - ), -} - - -async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - logger.info(f"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 - ) - - 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, helpful, and brief way. Say hello.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), - ) - - # Create transcript processor and handler - transcript = TranscriptProcessor() - transcript_handler = TranscriptHandler() # Output to log only - # transcript_handler = TranscriptHandler(output_file="transcript.txt") # Output to file and log - - pipeline = Pipeline( - [ - transport.input(), # Transport user input - stt, # STT - transcript.user(), # User transcripts - context_aggregator.user(), # User responses - llm, # LLM - tts, # TTS - transport.output(), # Transport bot output - transcript.assistant(), # Assistant transcripts - context_aggregator.assistant(), # Assistant spoken responses - ] - ) - - task = PipelineTask( - pipeline, - params=PipelineParams( - enable_metrics=True, - enable_usage_metrics=True, - ), - idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - ) - - @transport.event_handler("on_client_connected") - async def on_client_connected(transport, client): - logger.info(f"Client connected") - # Start conversation - empty prompt to let LLM follow system instructions - await task.queue_frames([LLMRunFrame()]) - - # Register event handler for transcript updates - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - await transcript_handler.on_transcript_update(processor, frame) - - @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/28-user-assistant-turns.py b/examples/foundational/28-user-assistant-turns.py index da405088f..d1c053710 100644 --- a/examples/foundational/28-user-assistant-turns.py +++ b/examples/foundational/28-user-assistant-turns.py @@ -10,9 +10,7 @@ from typing import Optional from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,8 +30,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -101,24 +97,20 @@ class TranscriptHandler: await self.save_message("assistant", message.content, message.timestamp) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -130,30 +122,23 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): 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, helpful, and brief way. Say hello.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady ), ) - user_aggregator = context_aggregator.user() - assistant_aggregator = context_aggregator.assistant() + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) # Create transcript processor and handler transcript_handler = TranscriptHandler() # Output to log only @@ -184,6 +169,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Start conversation - empty prompt to let LLM follow system instructions + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/29-turn-tracking-observer.py b/examples/foundational/29-turn-tracking-observer.py index d20996e31..e4c33a379 100644 --- a/examples/foundational/29-turn-tracking-observer.py +++ b/examples/foundational/29-turn-tracking-observer.py @@ -5,16 +5,18 @@ # +import asyncio import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 +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.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame -from pipecat.observers.loggers.user_bot_latency_log_observer import UserBotLatencyLogObserver +from pipecat.observers.startup_timing_observer import StartupTimingObserver +from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -27,33 +29,39 @@ 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 from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. + +async def fetch_weather_from_api(params: FunctionCallParams): + await asyncio.sleep(0.25) + await params.result_callback({"conditions": "nice", "temperature": "75"}) + + +async def fetch_restaurant_recommendation(params: FunctionCallParams): + await asyncio.sleep(0.1) + await params.result_callback({"name": "The Golden Dragon"}) + + +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. transport_params = { "daily": lambda: DailyParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -65,40 +73,71 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + llm.register_function("get_current_weather", fetch_weather_from_api) + llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) + + weather_function = FunctionSchema( + name="get_current_weather", + description="Get the current weather", + properties={ + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "format": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The temperature unit to use. Infer this from the user's location.", + }, + }, + required=["location", "format"], + ) + restaurant_function = FunctionSchema( + name="get_restaurant_recommendation", + description="Get a restaurant recommendation", + properties={ + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + }, + required=["location"], + ) + tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) + + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) + latency_observer = UserBotLatencyObserver() + startup_observer = StartupTimingObserver() + task = PipelineTask( pipeline, params=PipelineParams( @@ -106,9 +145,29 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): enable_usage_metrics=True, ), idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - observers=[UserBotLatencyLogObserver()], + observers=[latency_observer, startup_observer], ) + @latency_observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech_latency(observer, latency_seconds): + logger.info(f"First bot speech: {latency_seconds:.3f}s after client connected") + + @latency_observer.event_handler("on_latency_measured") + async def on_latency_measured(observer, latency_seconds): + logger.info(f"⏱️ User-to-bot latency: {latency_seconds:.3f}s") + + @startup_observer.event_handler("on_startup_timing_report") + async def on_startup_timing_report(observer, report): + logger.info(f"Total startup: {report.total_duration_secs:.3f}s") + for timing in report.processor_timings: + logger.info(f" {timing.processor_name}: {timing.duration_secs:.3f}s") + + @startup_observer.event_handler("on_transport_timing_report") + async def on_transport_timing_report(observer, report): + if report.bot_connected_secs is not None: + logger.info(f"Bot connected: {report.bot_connected_secs:.3f}s") + logger.info(f"Client connected: {report.client_connected_secs:.3f}s") + turn_observer = task.turn_tracking_observer if turn_observer: @@ -123,11 +182,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): else: logger.info(f"🏁 Turn {turn_number} completed in {duration:.2f}s") + @latency_observer.event_handler("on_latency_breakdown") + async def on_latency_breakdown(observer, breakdown): + for event in breakdown.chronological_events(): + logger.info(f" {event}") + @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/30-observer.py b/examples/foundational/30-observer.py index 3a19e3fa3..523e09583 100644 --- a/examples/foundational/30-observer.py +++ b/examples/foundational/30-observer.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import ( BotStartedSpeakingFrame, BotStoppedSpeakingFrame, @@ -44,8 +42,6 @@ from pipecat.transports.base_output import BaseOutputTransport 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -83,24 +79,20 @@ class CustomObserver(BaseObserver): logger.info(f"🤖 BOT STOP SPEAKING: {src} {arrow} {dst} at {time_sec:.2f}s") -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -112,37 +104,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -170,7 +158,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/32-gemini-grounding-metadata.py b/examples/foundational/32-gemini-grounding-metadata.py index fb515b6af..89afacfc3 100644 --- a/examples/foundational/32-gemini-grounding-metadata.py +++ b/examples/foundational/32-gemini-grounding-metadata.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2024-2025, Daily +# Copyright (c) 2024-2026, Daily # # SPDX-License-Identifier: BSD 2-Clause License # @@ -12,9 +12,7 @@ from pathlib import Path from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.pipeline.pipeline import Pipeline @@ -34,8 +32,6 @@ from pipecat.services.llm_service import LLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies sys.path.append(str(Path(__file__).parent.parent)) @@ -78,24 +74,20 @@ class LLMSearchLoggerObserver(BaseObserver): logger.debug(f"🧠 {arrow} {dst} LLM SEARCH RESPONSE FRAME: {frame} at {time_sec:.2f}s") -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -107,13 +99,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) # Initialize the Gemini Multimodal Live model llm = GoogleLLMService( api_key=os.getenv("GOOGLE_API_KEY"), - system_instruction=system_instruction, + settings=GoogleLLMService.Settings( + system_instruction=system_instruction, + ), tools=tools, ) @@ -125,24 +121,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): } ], ) - context_aggregator = LLMContextAggregatorPair( + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/33-gemini-rag.py b/examples/foundational/33-gemini-rag.py index 5c50859f2..4c107d35f 100644 --- a/examples/foundational/33-gemini-rag.py +++ b/examples/foundational/33-gemini-rag.py @@ -57,9 +57,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -78,8 +76,6 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -162,24 +158,20 @@ async def query_knowledge_base(params: FunctionCallParams): await params.result_callback(response.text) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -191,12 +183,25 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="f9836c6e-a0bd-460e-9d3c-f7299fa60f94", # Southern Lady + settings=CartesiaTTSService.Settings( + voice="f9836c6e-a0bd-460e-9d3c-f7299fa60f94", # Southern Lady + ), ) + system_prompt = """\ +You are a helpful assistant who converses with a user and answers questions. + +You have access to the tool, query_knowledge_base, that allows you to query the knowledge base for the answer to the user's question. + +Your response will be turned into speech so use only simple words and punctuation. +""" + llm = GoogleLLMService( - model=VOICE_MODEL, api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings( + model=VOICE_MODEL, + system_instruction=system_prompt, + ), ) llm.register_function("query_knowledge_base", query_knowledge_base) @@ -213,37 +218,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[query_function]) - system_prompt = """\ -You are a helpful assistant who converses with a user and answers questions. - -You have access to the tool, query_knowledge_base, that allows you to query the knowledge base for the answer to the user's question. - -Your response will be turned into speech so use only simple words and punctuation. -""" - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": "Greet the user."}, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) task = PipelineTask( @@ -259,6 +248,7 @@ Your response will be turned into speech so use only simple words and punctuatio async def on_client_connected(transport, client): logger.info(f"Client connected") # Start conversation - empty prompt to let LLM follow system instructions + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/34-audio-recording.py b/examples/foundational/34-audio-recording.py index d85812d70..68354e0dd 100644 --- a/examples/foundational/34-audio-recording.py +++ b/examples/foundational/34-audio-recording.py @@ -50,9 +50,7 @@ import aiofiles from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -71,8 +69,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -91,24 +87,20 @@ async def save_audio_file(audio: bytes, filename: str, sample_rate: int, num_cha logger.info(f"Audio saved to {filename}") -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -120,41 +112,37 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4") + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) # Create audio buffer processor audiobuffer = AudioBufferProcessor() - messages = [ - { - "role": "system", - "content": "You are a helpful assistant demonstrating audio recording capabilities. Keep your responses brief and clear.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), audiobuffer, # Add audio buffer to pipeline - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/35-pattern-pair-voice-switching.py b/examples/foundational/35-pattern-pair-voice-switching.py index d35a9955c..c246ce599 100644 --- a/examples/foundational/35-pattern-pair-voice-switching.py +++ b/examples/foundational/35-pattern-pair-voice-switching.py @@ -24,7 +24,7 @@ The PatternPairAggregator: - Returns processed text at sentence boundaries Requirements: - - OpenAI API key (for GPT-4o) + - OpenAI API key - Cartesia API key (for text-to-speech) - Daily API key (for video/audio transport) @@ -44,9 +44,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -64,8 +62,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies from pipecat.utils.text.pattern_pair_aggregator import ( MatchAction, PatternMatch, @@ -82,24 +78,20 @@ VOICE_IDS = { "male": "7cf0e2b1-8daf-4fe4-89ad-f6039398f359", # Male character voice } -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -125,7 +117,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # First flush any existing audio to finish the current context await tts.flush_audio() # Then set the new voice - tts.set_voice(VOICE_IDS[voice_name]) + await tts.set_voice(VOICE_IDS[voice_name]) logger.info(f"Switched to {voice_name} voice") else: logger.warning(f"Unknown voice: {voice_name}") @@ -137,13 +129,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Initialize TTS with narrator voice as default tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id=VOICE_IDS["narrator"], + settings=CartesiaTTSService.Settings( + voice=VOICE_IDS["narrator"], + ), text_aggregator=pattern_aggregator, ) - # Initialize LLM - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - # System prompt for storytelling with voice switching system_prompt = """You are an engaging storyteller that uses different voices to bring stories to life. @@ -192,34 +183,30 @@ FOLLOW THESE RULES: Remember: Use narrator voice for EVERYTHING except the actual quoted dialogue.""" - # Set up LLM context - messages = [ - { - "role": "system", - "content": system_prompt, - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + # Initialize LLM + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction=system_prompt, ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + # Create pipeline pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, # TTS with pattern aggregator transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/36-user-email-gathering.py b/examples/foundational/36-user-email-gathering.py index 2908bce76..7e8a69011 100644 --- a/examples/foundational/36-user-email-gathering.py +++ b/examples/foundational/36-user-email-gathering.py @@ -12,9 +12,7 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -33,8 +31,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -43,24 +39,20 @@ async def store_user_emails(params: FunctionCallParams): print(f"User emails: {params.arguments}") -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -75,7 +67,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # (see https://docs.cartesia.ai/build-with-sonic/formatting-text-for-sonic/spelling-out-input-text) tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) # Rime offers a function `spell()` that we can use to ask the user @@ -83,11 +77,18 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # (see https://docs.rime.ai/api-reference/spell) # tts = RimeHttpTTSService( # api_key=os.getenv("RIME_API_KEY", ""), - # voice_id="eva", + # settings=RimeTTSSettings( + # voice="eva", + # ), # aiohttp_session=session, # ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You need to gather a valid email or emails from the user. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. If the user provides one or more email addresses confirm them with the user. Enclose all emails with tags, for example a@a.com.", + ), + ) # You can aslo register a function_name of None to get all functions # sent to the same callback with an additional function_name parameter. llm.register_function("store_user_emails", store_user_emails) @@ -106,35 +107,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) tools = ToolsSchema(standard_tools=[store_emails_function]) - messages = [ - { - "role": "system", - # Cartesia - "content": "You need to gather a valid email or emails from the user. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. If the user provides one or more email addresses confirm them with the user. Enclose all emails with tags, for example a@a.com.", - # Rime spell() - # "content": "You need to gather a valid email or emails from the user. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. If the user provides one or more email addresses confirm them with the user. Enclose all emails with spell(), for example spell(a@a.com).", - }, - ] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), stt, - context_aggregator.user(), + user_aggregator, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/37-mem0.py b/examples/foundational/37-mem0.py index 0ba192dda..e572d2dbe 100644 --- a/examples/foundational/37-mem0.py +++ b/examples/foundational/37-mem0.py @@ -24,14 +24,14 @@ Example usage (run from pipecat root directory): $ python examples/foundational/37-mem0.py Requirements: - - OpenAI API key (for GPT-4o-mini) + - OpenAI API key - ElevenLabs API key (for text-to-speech) - Daily API key (for video/audio transport) - Mem0 API key (for cloud-based memory storage) - [Optional] Anthropic API key (if using Claude with local config) Environment variables (set in .env or in your terminal using `export`): - DAILY_SAMPLE_ROOM_URL=daily_sample_room_url + DAILY_ROOM_URL=daily_room_url DAILY_API_KEY=daily_api_key OPENAI_API_KEY=openai_api_key ELEVENLABS_API_KEY=elevenlabs_api_key @@ -47,9 +47,7 @@ from typing import Union from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -59,7 +57,6 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService @@ -69,8 +66,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -133,24 +128,20 @@ async def get_initial_greeting( return "Hello! How can I help you today?" -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -175,7 +166,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Initialize text-to-speech service tts = ElevenLabsTTSService( api_key=os.getenv("ELEVENLABS_API_KEY"), - voice_id="pNInz6obpgDQGcFmaJgB", + settings=ElevenLabsTTSService.Settings( + voice="pNInz6obpgDQGcFmaJgB", + ), ) # ===================================================================== @@ -230,44 +223,36 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # ) # Initialize LLM service - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o-mini") - - messages = [ - { - "role": "system", - "content": """You are a personal assistant. You can remember things about the person you are talking to. + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="""You are a personal assistant. You can remember things about the person you are talking to. Some Guidelines: - Make sure your responses are friendly yet short and concise. - If the user asks you to remember something, make sure to remember it. - Greet the user by their name if you know about it. """, - }, - ] + ), + ) # Set up conversation context and management # The context_aggregator will automatically collect conversation context - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) - rtvi = RTVIProcessor(config=RTVIConfig(config=[])) pipeline = Pipeline( [ transport.input(), - rtvi, stt, - context_aggregator.user(), + user_aggregator, memory, llm, tts, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -278,12 +263,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): enable_usage_metrics=True, ), idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - observers=[RTVIObserver(rtvi)], ) - @rtvi.event_handler("on_client_ready") + @task.rtvi.event_handler("on_client_ready") async def on_client_ready(rtvi): - await rtvi.set_bot_ready() # Get personalized greeting based on user memories. Can pass agent_id and run_id as per requirement of the application to manage short term memory or agent specific memory. greeting = await get_initial_greeting( memory_client=memory.memory_client, user_id=USER_ID, agent_id=None, run_id=None diff --git a/examples/foundational/38-smart-turn-fal.py b/examples/foundational/38-smart-turn-fal.py index 6c20e5a86..b8a62626a 100644 --- a/examples/foundational/38-smart-turn-fal.py +++ b/examples/foundational/38-smart-turn-fal.py @@ -13,7 +13,6 @@ from loguru import logger from pipecat.audio.turn.smart_turn.fal_smart_turn import FalSmartTurnAnalyzer from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -37,24 +36,20 @@ from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -66,20 +61,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams( user_turn_strategies=UserTurnStrategies( @@ -92,6 +87,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) ] ), + vad_analyzer=SileroVADAnalyzer(), ), ) @@ -99,11 +95,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -120,7 +116,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/38a-smart-turn-local-coreml.py b/examples/foundational/38a-smart-turn-local-coreml.py index 1561e3c92..fc2f1d14d 100644 --- a/examples/foundational/38a-smart-turn-local-coreml.py +++ b/examples/foundational/38a-smart-turn-local-coreml.py @@ -12,7 +12,6 @@ from loguru import logger from pipecat.audio.turn.smart_turn.local_coreml_smart_turn import LocalCoreMLSmartTurnAnalyzer from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -52,24 +51,20 @@ load_dotenv(override=True) # or add it to your .env file smart_turn_model_path = os.getenv("LOCAL_SMART_TURN_MODEL_PATH") -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -81,20 +76,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams( user_turn_strategies=UserTurnStrategies( @@ -106,6 +101,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) ] ), + vad_analyzer=SileroVADAnalyzer(), ), ) @@ -113,11 +109,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -134,7 +130,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/38b-smart-turn-local.py b/examples/foundational/38b-smart-turn-local.py index 5a0d76639..7e4a0abd8 100644 --- a/examples/foundational/38b-smart-turn-local.py +++ b/examples/foundational/38b-smart-turn-local.py @@ -10,10 +10,10 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame +from pipecat.metrics.metrics import TurnMetricsData +from pipecat.observers.loggers.metrics_log_observer import MetricsLogObserver from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -22,7 +22,6 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.frameworks.rtvi import RTVIObserver, RTVIProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService @@ -31,29 +30,23 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -65,40 +58,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady ), ) - rtvi = RTVIProcessor() + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), # Transport user input - rtvi, stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -108,20 +94,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): enable_metrics=True, enable_usage_metrics=True, ), - observers=[RTVIObserver(rtvi)], idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + observers=[MetricsLogObserver(include_metrics={TurnMetricsData})], ) - @rtvi.event_handler("on_client_ready") - async def on_client_ready(rtvi): - await rtvi.set_bot_ready() - # Kick off the conversation - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) - await task.queue_frames([LLMRunFrame()]) - @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") + # Kick off the conversation + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") async def on_client_disconnected(transport, client): diff --git a/examples/foundational/39-mcp-stdio.py b/examples/foundational/39-mcp-stdio.py index 6d29cded1..48382046d 100644 --- a/examples/foundational/39-mcp-stdio.py +++ b/examples/foundational/39-mcp-stdio.py @@ -17,9 +17,7 @@ from loguru import logger from mcp import StdioServerParameters from PIL import Image -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import ( Frame, FunctionCallResultFrame, @@ -43,8 +41,6 @@ from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.mcp_service import MCPClient from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -74,7 +70,7 @@ class UrlToImageProcessor(FrameProcessor): return data["artObject"]["webImage"]["url"] if "artworks" in data and len(data["artworks"]): return data["artworks"][0]["webImage"]["url"] - except: + except (json.JSONDecodeError, KeyError, TypeError): pass return None @@ -112,9 +108,8 @@ def open_image_output_filter(output: str): print(f"🖼️ link to high resolution artwork: {text_to_print}") -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -122,7 +117,6 @@ transport_params = { video_out_enabled=True, video_out_width=1024, video_out_height=1024, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, @@ -130,7 +124,6 @@ transport_params = { video_out_enabled=True, video_out_width=1024, video_out_height=1024, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -144,11 +137,29 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) + system_prompt = f""" + You are a helpful LLM in a voice call. + Your goal is to demonstrate your capabilities in a succinct way. + You have access to tools to search the Rijksmuseum collection. + Offer, for example, to show a floral still life, use the `search_artwork` tool. + The tool may respond with a JSON object with an `artworks` array. Choose the art from that array. + Once the tool has responded, tell the user the title and use the `open_image_in_browser` tool. + 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. + Don't overexplain what you are doing. + Just respond with short sentences when you are carrying out tool calls. + """ + llm = AnthropicLLMService( - api_key=os.getenv("ANTHROPIC_API_KEY"), model="claude-3-7-sonnet-latest" + api_key=os.getenv("ANTHROPIC_API_KEY"), + settings=AnthropicLLMService.Settings( + system_instruction=system_prompt, + ), ) try: @@ -176,43 +187,22 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.error(f"error registering tools") logger.exception("error trace:") - system = f""" - You are a helpful LLM in a WebRTC call. - Your goal is to demonstrate your capabilities in a succinct way. - You have access to tools to search the Rijksmuseum collection. - Offer, for example, to show a floral still life, use the `search_artwork` tool. - The tool may respond with a JSON object with an `artworks` array. Choose the art from that array. - Once the tool has responded, tell the user the title and use the `open_image_in_browser` tool. - 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. - Don't overexplain what you are doing. - Just respond with short sentences when you are carrying out tool calls. - """ - - messages = [{"role": "system", "content": system}] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User spoken responses + user_aggregator, # User spoken responses llm, # LLM tts, # TTS mcp_image, # URL image -> output transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses and tool context + assistant_aggregator, # Assistant spoken responses and tool context ] ) diff --git a/examples/foundational/39a-mcp-streamable-http.py b/examples/foundational/39a-mcp-streamable-http.py index 6b59e66bd..5ddf53264 100644 --- a/examples/foundational/39a-mcp-streamable-http.py +++ b/examples/foundational/39a-mcp-streamable-http.py @@ -11,9 +11,7 @@ from dotenv import load_dotenv from loguru import logger from mcp.client.session_group import StreamableHttpParameters -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,29 +30,23 @@ from pipecat.services.mcp_service import MCPClient 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -66,10 +58,24 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY"), model="gemini-2.0-flash") + system_prompt = f""" + You are a helpful LLM in a voice call. + Your goal is to answer questions about the user's GitHub repositories and account. + You have access to a number of tools provided by Github. Use any and all tools to help users. + Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. + Don't overexplain what you are doing. + Just respond with short sentences when you are carrying out tool calls. + """ + + llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + system_instruction=system_prompt, + ) try: # Github MCP docs: https://github.com/github/github-mcp-server @@ -93,36 +99,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.error(f"error registering tools") logger.exception("error trace:") - system = f""" - You are a helpful LLM in a WebRTC call. - Your goal is to answer questions about the user's GitHub repositories and account. - You have access to a number of tools provided by Github. Use any and all tools to help users. - Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. - Don't overexplain what you are doing. - Just respond with short sentences when you are carrying out tool calls. - """ - - messages = [{"role": "system", "content": system}] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User spoken responses + user_aggregator, # User spoken responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses and tool context + assistant_aggregator, # Assistant spoken responses and tool context ] ) diff --git a/examples/foundational/39b-mcp-streamable-http-gemini-live.py b/examples/foundational/39b-mcp-streamable-http-gemini-live.py index f83b741a9..eb276a91a 100644 --- a/examples/foundational/39b-mcp-streamable-http-gemini-live.py +++ b/examples/foundational/39b-mcp-streamable-http-gemini-live.py @@ -11,9 +11,7 @@ from dotenv import load_dotenv from loguru import logger from mcp.client.session_group import StreamableHttpParameters -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,29 +30,23 @@ from pipecat.services.mcp_service import MCPClient 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -66,7 +58,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) try: @@ -92,7 +86,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.exception("error trace:") system = f""" - You are a helpful LLM in a WebRTC call. + You are a helpful LLM in a voice call. Your goal is to answer questions about the user's GitHub repositories and account. You have access to a number of tools provided by Github. Use any and all tools to help users. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. @@ -109,22 +103,18 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): await mcp.register_tools_schema(tools, llm) context = LLMContext([{"role": "user", "content": "Please introduce yourself."}]) - context_aggregator = LLMContextAggregatorPair( + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input - context_aggregator.user(), # User spoken responses + user_aggregator, # User spoken responses llm, # LLM transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses and tool context + assistant_aggregator, # Assistant spoken responses and tool context ] ) diff --git a/examples/foundational/39c-multiple-mcp.py b/examples/foundational/39c-multiple-mcp.py index 9a6fe0d12..9d449251d 100644 --- a/examples/foundational/39c-multiple-mcp.py +++ b/examples/foundational/39c-multiple-mcp.py @@ -9,7 +9,6 @@ import asyncio import io import json import os -import re import shutil import aiohttp @@ -20,9 +19,7 @@ from mcp.client.session_group import StreamableHttpParameters from PIL import Image from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import ( Frame, FunctionCallResultFrame, @@ -46,8 +43,6 @@ from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.mcp_service import MCPClient from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -77,7 +72,7 @@ class UrlToImageProcessor(FrameProcessor): return data["artObject"]["webImage"]["url"] if "artworks" in data and len(data["artworks"]): return data["artworks"][0]["webImage"]["url"] - except: + except (json.JSONDecodeError, KeyError, TypeError): pass async def run_image_process(self, image_url: str): @@ -96,9 +91,8 @@ class UrlToImageProcessor(FrameProcessor): logger.error(error_msg) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -106,7 +100,6 @@ transport_params = { video_out_enabled=True, video_out_width=1024, video_out_height=1024, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, @@ -114,7 +107,6 @@ transport_params = { video_out_enabled=True, video_out_width=1024, video_out_height=1024, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -128,15 +120,13 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = AnthropicLLMService( - api_key=os.getenv("ANTHROPIC_API_KEY"), model="claude-3-7-sonnet-latest" - ) - - system = f""" - You are a helpful LLM in a WebRTC call. + system_prompt = f""" + You are a helpful LLM in a voice call. Your goal is to demonstrate your capabilities in a succinct way. You have access to tools to search the Rijksmuseum collection and the user's GitHub repositories and account. Offer, for example, to show a floral still life, use the `search_artwork` tool. @@ -149,7 +139,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): Just respond with short sentences when you are carrying out tool calls. """ - messages = [{"role": "system", "content": system}] + llm = AnthropicLLMService( + api_key=os.getenv("ANTHROPIC_API_KEY"), + settings=AnthropicLLMService.Settings( + system_instruction=system_prompt, + ), + ) try: rijksmuseum_mcp = MCPClient( @@ -192,16 +187,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): all_standard_tools = rijksmuseum_tools.standard_tools + github_tools.standard_tools all_tools = ToolsSchema(standard_tools=all_standard_tools) - context = LLMContext(messages, all_tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=all_tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) mcp_image_processor = UrlToImageProcessor(aiohttp_session=session) @@ -209,12 +198,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User spoken responses + user_aggregator, # User spoken responses llm, # LLM tts, # TTS mcp_image_processor, # URL image -> output transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses and tool context + assistant_aggregator, # Assistant spoken responses and tool context ] ) diff --git a/examples/foundational/40-aws-nova-sonic.py b/examples/foundational/40-aws-nova-sonic.py index 253b0870a..6f7c745ce 100644 --- a/examples/foundational/40-aws-nova-sonic.py +++ b/examples/foundational/40-aws-nova-sonic.py @@ -21,7 +21,12 @@ 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 +from pipecat.processors.aggregators.llm_response_universal import ( + AssistantTurnStoppedMessage, + LLMContextAggregatorPair, + LLMUserAggregatorParams, + UserTurnStoppedMessage, +) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.aws.nova_sonic.llm import AWSNovaSonicLLMService @@ -77,24 +82,20 @@ weather_function = FunctionSchema( tools = ToolsSchema(standard_tools=[weather_function]) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -127,9 +128,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # - ap-northeast-1 region=os.getenv("AWS_REGION"), session_token=os.getenv("AWS_SESSION_TOKEN"), - voice_id="tiffany", - # you could choose to pass instruction here rather than via context - # system_instruction=system_instruction + settings=AWSNovaSonicLLMService.Settings( + voice="tiffany", + system_instruction=system_instruction, + ), # you could choose to pass tools here rather than via context # tools=tools ) @@ -142,26 +144,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) # Set up context and context management. - context = LLMContext( - messages=[ - {"role": "system", "content": f"{system_instruction}"}, - { - "role": "user", - "content": "Tell me a fun fact!", - }, - ], - tools=tools, + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) - context_aggregator = LLMContextAggregatorPair(context) # Build the pipeline pipeline = Pipeline( [ transport.input(), - context_aggregator.user(), + user_aggregator, llm, transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -180,6 +176,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) # HACK: if using the older Nova Sonic (pre-2) model, you need this special way of # triggering the first assistant response. Note that this trigger requires a special @@ -192,6 +189,18 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Client disconnected") await task.cancel() + @user_aggregator.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(aggregator, strategy, message: UserTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}user: {message.content}" + logger.info(f"Transcript: {line}") + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}assistant: {message.content}" + logger.info(f"Transcript: {line}") + # Run the pipeline runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) await runner.run(task) diff --git a/examples/foundational/41a-text-only-webrtc.py b/examples/foundational/41a-text-only-webrtc.py deleted file mode 100644 index 18ac367b5..000000000 --- a/examples/foundational/41a-text-only-webrtc.py +++ /dev/null @@ -1,164 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -import os - -from dotenv import load_dotenv -from loguru import logger - -from pipecat.frames.frames import ( - LLMMessagesAppendFrame, - LLMRunFrame, -) -from pipecat.pipeline.pipeline import Pipeline -from pipecat.pipeline.runner import PipelineRunner -from pipecat.pipeline.task import PipelineParams, PipelineTask -from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair -from pipecat.processors.frameworks.rtvi import ( - ActionResult, - RTVIAction, - RTVIActionArgument, - RTVIConfig, - RTVIObserver, - RTVIProcessor, - RTVIServerMessageFrame, -) -from pipecat.runner.types import RunnerArguments -from pipecat.runner.utils import create_transport -from pipecat.services.openai.llm import OpenAIContextAggregatorPair, OpenAILLMService -from pipecat.transports.base_transport import BaseTransport, TransportParams - -load_dotenv(override=True) - - -# This is an example of a text-only chatbot using small webrtc tranport. -# It uses the small webrtc transport prebuilt web UI. -# https://github.com/pipecat-ai/small-webrtc-prebuilt - - -def create_action_llm_append_to_messages(context_aggregator: OpenAIContextAggregatorPair): - async def action_llm_append_to_messages_handler( - rtvi: RTVIProcessor, service: str, arguments: dict[str, any] - ) -> ActionResult: - run_immediately = arguments["run_immediately"] if "run_immediately" in arguments else True - logger.info(f"run_immediately: {run_immediately}") - if run_immediately: - await rtvi.interrupt_bot() - # We just interrupted the bot so it should be fine to use the - # context directly instead of through frame. - if "messages" in arguments and arguments["messages"]: - frame = LLMMessagesAppendFrame(messages=arguments["messages"]) - await rtvi.push_frame(frame) - - frame = LLMRunFrame() - await rtvi.push_frame(frame) - return True - - action_llm_append_to_messages = RTVIAction( - service="llm", - action="append_to_messages", - result="bool", - arguments=[ - RTVIActionArgument(name="messages", type="array"), - RTVIActionArgument(name="run_immediately", type="bool"), - ], - handler=action_llm_append_to_messages_handler, - ) - return action_llm_append_to_messages - - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. -transport_params = { - "webrtc": lambda: TransportParams(), -} - - -async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - logger.info(f"Starting bot") - - 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. Respond to what the user said in a creative and helpful way.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair(context) - - action_llm_append_to_messages = create_action_llm_append_to_messages(context_aggregator) - rtvi = RTVIProcessor(config=RTVIConfig(config=[])) - rtvi.register_action(action_llm_append_to_messages) - - pipeline = Pipeline( - [ - transport.input(), - rtvi, - context_aggregator.user(), - llm, - transport.output(), - context_aggregator.assistant(), - ] - ) - - task = PipelineTask( - pipeline, - params=PipelineParams( - enable_metrics=True, - enable_usage_metrics=True, - ), - idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - observers=[RTVIObserver(rtvi)], - ) - - @rtvi.event_handler("on_client_ready") - async def on_client_ready(rtvi): - logger.info("Pipecat client ready.") - await rtvi.set_bot_ready() - - # This block is frontend UI specific - # These messages are intended for small webrtc UI to only handle text - # https://github.com/pipecat-ai/small-webrtc-prebuilt - messages = { - "show_text_container": True, - "show_video_container": False, - "show_debug_container": False, - } - - rtvi_frame = RTVIServerMessageFrame(data=messages) - await task.queue_frames([rtvi_frame]) - - @transport.event_handler("on_client_connected") - async def on_client_connected(transport, client): - logger.info(f"Client connected: {client}") - # Kick off the conversation. - await task.queue_frames([LLMRunFrame()]) - - @transport.event_handler("on_client_disconnected") - async def on_client_disconnected(transport, client): - logger.info(f"Client disconnected") - await task.cancel() - - runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) - - await runner.run(task) - - -async def bot(runner_args: RunnerArguments): - """Main bot entry point compatible with Pipecat Cloud.""" - transport = await create_transport(runner_args, transport_params) - await run_bot(transport, runner_args) - - -if __name__ == "__main__": - from pipecat.runner.run import main - - main() diff --git a/examples/foundational/41b-text-and-audio-webrtc.py b/examples/foundational/41b-text-and-audio-webrtc.py deleted file mode 100644 index d98bfdedd..000000000 --- a/examples/foundational/41b-text-and-audio-webrtc.py +++ /dev/null @@ -1,180 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -import os - -from dotenv import load_dotenv -from loguru import logger - -from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import ( - LLMMessagesAppendFrame, - LLMRunFrame, -) -from pipecat.pipeline.pipeline import Pipeline -from pipecat.pipeline.runner import PipelineRunner -from pipecat.pipeline.task import PipelineParams, PipelineTask -from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair -from pipecat.processors.frameworks.rtvi import ( - ActionResult, - RTVIAction, - RTVIActionArgument, - RTVIConfig, - RTVIObserver, - RTVIProcessor, - RTVIServerMessageFrame, -) -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 OpenAIContextAggregatorPair, OpenAILLMService -from pipecat.transports.base_transport import BaseTransport, TransportParams - -load_dotenv(override=True) - -# This is an example of a chatbot in which a user can speak and/or type text to communicate with the bot. -# It uses the small webrtc transport prebuilt web UI. -# https://github.com/pipecat-ai/small-webrtc-prebuilt - - -def create_action_llm_append_to_messages(context_aggregator: OpenAIContextAggregatorPair): - async def action_llm_append_to_messages_handler( - rtvi: RTVIProcessor, service: str, arguments: dict[str, any] - ) -> ActionResult: - run_immediately = arguments["run_immediately"] if "run_immediately" in arguments else True - - if run_immediately: - await rtvi.interrupt_bot() - - # We just interrupted the bot so it should be fine to use the - # context directly instead of through frame. - if "messages" in arguments and arguments["messages"]: - mess = arguments["messages"] - frame = LLMMessagesAppendFrame(messages=arguments["messages"]) - await rtvi.push_frame(frame) - - if run_immediately: - frame = LLMRunFrame() - await rtvi.push_frame(frame) - - return True - - action_llm_append_to_messages = RTVIAction( - service="llm", - action="append_to_messages", - result="bool", - arguments=[ - RTVIActionArgument(name="messages", type="array"), - RTVIActionArgument(name="run_immediately", type="bool"), - ], - handler=action_llm_append_to_messages_handler, - ) - return action_llm_append_to_messages - - -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. -transport_params = { - "webrtc": lambda: TransportParams( - audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), - ), -} - - -async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - logger.info(f"Starting bot") - - stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - - tts = CartesiaTTSService( - api_key=os.getenv("CARTESIA_API_KEY"), voice_id="71a7ad14-091c-4e8e-a314-022ece01c121" - ) - - messages = [ - { - "role": "system", - "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Respond to what the user says in a creative and helpful way. Explain to the User they can speak or type text to communicate with you.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair(context) - - action_llm_append_to_messages = create_action_llm_append_to_messages(context_aggregator) - rtvi = RTVIProcessor(config=RTVIConfig(config=[])) - rtvi.register_action(action_llm_append_to_messages) - - pipeline = Pipeline( - [ - transport.input(), - rtvi, - stt, - context_aggregator.user(), - llm, - tts, - transport.output(), - context_aggregator.assistant(), - ] - ) - - task = PipelineTask( - pipeline, - params=PipelineParams( - enable_metrics=True, - enable_usage_metrics=True, - ), - idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - observers=[RTVIObserver(rtvi)], - ) - - @rtvi.event_handler("on_client_ready") - async def on_client_ready(rtvi): - logger.info("Pipecat client ready.") - await rtvi.set_bot_ready() - - # This block is frontend UI specific - # These messages are intended for small webrtc UI to only handle text - # https://github.com/pipecat-ai/small-webrtc-prebuilt - messages = { - "show_text_container": True, - "show_debug_container": False, - } - rtvi_frame = RTVIServerMessageFrame(data=messages) - await task.queue_frames([rtvi_frame]) - - @transport.event_handler("on_client_connected") - async def on_client_connected(transport, client): - logger.info(f"Client connected: {client}") - # Kick off the conversation. - await task.queue_frames([LLMRunFrame()]) - - @transport.event_handler("on_client_disconnected") - async def on_client_disconnected(transport, client): - logger.info(f"Client disconnected") - await task.cancel() - - runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) - - await runner.run(task) - - -async def bot(runner_args: RunnerArguments): - """Main bot entry point compatible with Pipecat Cloud.""" - transport = await create_transport(runner_args, transport_params) - await run_bot(transport, runner_args) - - -if __name__ == "__main__": - from pipecat.runner.run import main - - main() diff --git a/examples/foundational/42-interruption-config.py b/examples/foundational/42-interruption-config.py index d2c95eecb..64ea2cee0 100644 --- a/examples/foundational/42-interruption-config.py +++ b/examples/foundational/42-interruption-config.py @@ -9,10 +9,9 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame +from pipecat.observers.loggers.transcription_log_observer import TranscriptionLogObserver from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -21,7 +20,6 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.transcript_processor import TranscriptProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService @@ -31,29 +29,25 @@ 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_start import MinWordsUserTurnStartStrategy -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. + +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -65,28 +59,26 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) - transcript = TranscriptProcessor() - - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams( user_turn_strategies=UserTurnStrategies( start=[MinWordsUserTurnStartStrategy(min_words=3)], - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())], ), + vad_analyzer=SileroVADAnalyzer(), ), ) @@ -94,12 +86,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input stt, - transcript.user(), # User transcripts - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -110,13 +101,14 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): enable_usage_metrics=True, ), idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + observers=[TranscriptionLogObserver()], ) @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") @@ -124,12 +116,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Client disconnected") await task.cancel() - # Register event handler for transcript updates - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - for message in frame.messages: - logger.info(f"Transcription [{message.role}]: {message.content}") - runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) await runner.run(task) diff --git a/examples/foundational/43-heygen-transport.py b/examples/foundational/43-heygen-transport.py index cda625f1f..07190da16 100644 --- a/examples/foundational/43-heygen-transport.py +++ b/examples/foundational/43-heygen-transport.py @@ -12,9 +12,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -27,9 +25,8 @@ from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.google.llm import GoogleLLMService +from pipecat.services.heygen.api_liveavatar import LiveAvatarNewSessionRequest from pipecat.transports.heygen.transport import HeyGenParams, HeyGenTransport, ServiceType -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -46,7 +43,12 @@ async def main(): params=HeyGenParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), + ), + session_request=LiveAvatarNewSessionRequest( + is_sandbox=True, + # Sandbox mode only works with this specific avatar + # https://docs.liveavatar.com/docs/developing-in-sandbox-mode#sandbox-mode-behaviors + avatar_id="dd73ea75-1218-4ef3-92ce-606d5f7fbc0a", ), ) @@ -54,39 +56,33 @@ async def main(): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="00967b2f-88a6-4a31-8153-110a92134b9f", + settings=CartesiaTTSService.Settings( + voice="00967b2f-88a6-4a31-8153-110a92134b9f", + ), ) - llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY")) - - messages = [ - { - "role": "system", - "content": "You are a helpful assistant. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Be succinct and respond to what the user said in a creative and helpful way.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), + llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings( + system_instruction="You are a helpful assistant. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Be succinct and respond to what the user said in a creative and helpful way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -102,9 +98,9 @@ async def main(): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": "Start by saying 'Hello' and then a short greeting.", } ) diff --git a/examples/foundational/43a-heygen-video-service.py b/examples/foundational/43a-heygen-video-service.py index 10ce8b677..580aa52fc 100644 --- a/examples/foundational/43a-heygen-video-service.py +++ b/examples/foundational/43a-heygen-video-service.py @@ -10,9 +10,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -27,18 +25,16 @@ from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.google.llm import GoogleLLMService +from pipecat.services.heygen.api_liveavatar import LiveAvatarNewSessionRequest from pipecat.services.heygen.client import ServiceType from pipecat.services.heygen.video import HeyGenVideoService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams, DailyTransport -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -48,7 +44,6 @@ transport_params = { video_out_width=1280, video_out_height=720, video_out_bitrate=2_000_000, # 2MBps - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, @@ -57,7 +52,6 @@ transport_params = { video_out_is_live=True, video_out_width=1280, video_out_height=720, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -69,46 +63,46 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="00967b2f-88a6-4a31-8153-110a92134b9f", + settings=CartesiaTTSService.Settings( + voice="00967b2f-88a6-4a31-8153-110a92134b9f", + ), ) - llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY")) + llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings( + system_instruction="You are a helpful assistant. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Be succinct and respond to what the user said in a creative and helpful way.", + ), + ) heyGen = HeyGenVideoService( api_key=os.getenv("HEYGEN_LIVE_AVATAR_API_KEY"), service_type=ServiceType.LIVE_AVATAR, session=session, + session_request=LiveAvatarNewSessionRequest( + is_sandbox=True, + # Sandbox mode only works with this specific avatar + # https://docs.liveavatar.com/docs/developing-in-sandbox-mode#sandbox-mode-behaviors + avatar_id="dd73ea75-1218-4ef3-92ce-606d5f7fbc0a", + ), ) - messages = [ - { - "role": "system", - "content": "You are a helpful assistant. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Be succinct and respond to what the user said in a creative and helpful way.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[ - TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3()) - ] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS heyGen, # Avatar transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -137,9 +131,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ) # Kick off the conversation. - messages.append( + context.add_message( { - "role": "system", + "role": "user", "content": "Start by saying 'Hello' and then a short greeting.", } ) diff --git a/examples/foundational/44-voicemail-detection.py b/examples/foundational/44-voicemail-detection.py index 9c57343cc..854e546ec 100644 --- a/examples/foundational/44-voicemail-detection.py +++ b/examples/foundational/44-voicemail-detection.py @@ -9,9 +9,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.extensions.voicemail.voicemail_detector import VoicemailDetector from pipecat.frames.frames import TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline @@ -30,29 +28,23 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -64,29 +56,25 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) classifier_llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) voicemail = VoicemailDetector(llm=classifier_llm) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( @@ -94,12 +82,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): transport.input(), stt, voicemail.detector(), # Voicemail detection — between STT and User context aggregator - context_aggregator.user(), + user_aggregator, llm, tts, voicemail.gate(), # TTS gating — Immediately after the TTS service transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) diff --git a/examples/foundational/45-before-and-after-events.py b/examples/foundational/45-before-and-after-events.py index a62cee730..3a36a626a 100644 --- a/examples/foundational/45-before-and-after-events.py +++ b/examples/foundational/45-before-and-after-events.py @@ -10,9 +10,7 @@ from dataclasses import dataclass from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import DataFrame, LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,8 +28,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -46,24 +42,20 @@ class CustomAfterPushFrame(DataFrame): pass -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -75,37 +67,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -132,7 +120,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) # Custom frames are pushed in order so they can be used for synchronization purposes. await task.queue_frames([CustomBeforeProcessFrame(), LLMRunFrame(), CustomAfterPushFrame()]) diff --git a/examples/foundational/46-video-processing.py b/examples/foundational/46-video-processing.py index e2afc4d0e..0551728a8 100644 --- a/examples/foundational/46-video-processing.py +++ b/examples/foundational/46-video-processing.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2025, Daily +# Copyright (c) 2024-2026, Daily # # SPDX-License-Identifier: BSD 2-Clause License # @@ -10,7 +10,6 @@ import numpy as np from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.frames.frames import Frame, InputImageRawFrame, LLMRunFrame, OutputImageRawFrame from pipecat.pipeline.pipeline import Pipeline @@ -22,14 +21,11 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMUserAggregatorParams, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pipecat.processors.frameworks.rtvi import RTVIObserver, RTVIProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.google.gemini_live.llm import GeminiLiveLLMService from pipecat.transports.base_transport import TransportParams from pipecat.transports.daily.transport import DailyParams, DailyTransport -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -41,7 +37,6 @@ transport_params = { video_in_enabled=True, video_out_enabled=True, video_out_is_live=True, - vad_analyzer=SileroVADAnalyzer(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, @@ -50,7 +45,6 @@ transport_params = { video_in_enabled=True, video_out_enabled=True, video_out_is_live=True, - vad_analyzer=SileroVADAnalyzer(), ), } @@ -116,30 +110,22 @@ async def run_bot(pipecat_transport): ] context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair( + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) - # RTVI events for Pipecat client UI - rtvi = RTVIProcessor() - pipeline = Pipeline( [ pipecat_transport.input(), - context_aggregator.user(), - rtvi, + user_aggregator, llm, # LLM EdgeDetectionProcessor( pipecat_transport._params.video_out_width, pipecat_transport._params.video_out_height, ), # Sending the video back to the user pipecat_transport.output(), - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -149,13 +135,11 @@ async def run_bot(pipecat_transport): enable_metrics=True, enable_usage_metrics=True, ), - observers=[RTVIObserver(rtvi)], ) - @rtvi.event_handler("on_client_ready") + @task.rtvi.event_handler("on_client_ready") async def on_client_ready(rtvi): logger.info("Pipecat client ready.") - await rtvi.set_bot_ready() # Kick off the conversation. await task.queue_frames([LLMRunFrame()]) diff --git a/examples/foundational/47-sentry-metrics.py b/examples/foundational/47-sentry-metrics.py index ac0a9e6b5..3294727dc 100644 --- a/examples/foundational/47-sentry-metrics.py +++ b/examples/foundational/47-sentry-metrics.py @@ -10,9 +10,7 @@ import sentry_sdk from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -31,29 +29,23 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -74,41 +66,35 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), metrics=SentryMetrics(), ) llm = OpenAILLMService( api_key=os.getenv("OPENAI_API_KEY"), metrics=SentryMetrics(), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), ) - 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) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -125,7 +111,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/examples/foundational/48-service-switcher.py b/examples/foundational/48-service-switcher.py index a28d5750a..9225963f7 100644 --- a/examples/foundational/48-service-switcher.py +++ b/examples/foundational/48-service-switcher.py @@ -12,14 +12,12 @@ from loguru import logger from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame, ManuallySwitchServiceFrame from pipecat.pipeline.llm_switcher import LLMSwitcher from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner -from pipecat.pipeline.service_switcher import ServiceSwitcher, ServiceSwitcherStrategyManual +from pipecat.pipeline.service_switcher import ServiceSwitcher from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_response_universal import ( @@ -38,8 +36,6 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -60,24 +56,20 @@ async def get_restaurant_recommendation(params: FunctionCallParams, location: st await params.result_callback({"name": "The Golden Dragon"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -104,56 +96,58 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt_cartesia = CartesiaSTTService(api_key=os.getenv("CARTESIA_API_KEY")) stt_deepgram = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - stt_switcher = ServiceSwitcher( - services=[stt_cartesia, stt_deepgram], strategy_type=ServiceSwitcherStrategyManual - ) + # Uses ServiceSwitcherStrategyManual by default + stt_switcher = ServiceSwitcher(services=[stt_cartesia, stt_deepgram]) tts_cartesia = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - tts_deepgram = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY")) - tts_switcher = ServiceSwitcher( - services=[tts_cartesia, tts_deepgram], strategy_type=ServiceSwitcherStrategyManual + tts_deepgram = DeepgramTTSService( + api_key=os.getenv("DEEPGRAM_API_KEY"), + settings=DeepgramTTSService.Settings( + voice="aura-2-helena-en", + ), ) + # Uses ServiceSwitcherStrategyManual by default + tts_switcher = ServiceSwitcher(services=[tts_cartesia, tts_deepgram]) - llm_openai = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - llm_google = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY")) - llm_switcher = LLMSwitcher( - llms=[llm_openai, llm_google], strategy_type=ServiceSwitcherStrategyManual + system_prompt = "You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way." + + llm_openai = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings(system_instruction=system_prompt), ) + llm_google = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings(system_instruction=system_prompt), + ) + # Uses ServiceSwitcherStrategyManual by default + llm_switcher = LLMSwitcher(llms=[llm_openai, llm_google]) # Register a "classic" function llm_switcher.register_function("get_current_weather", fetch_weather_from_api) # Register a "direct" function llm_switcher.register_direct_function(get_restaurant_recommendation) - 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.", - }, - ] tools = ToolsSchema(standard_tools=[weather_function, get_restaurant_recommendation]) - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( [ transport.input(), # Transport user input stt_switcher, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm_switcher, # LLM tts_switcher, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -170,7 +164,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) await asyncio.sleep(15) print(f"Switching to {stt_deepgram}") diff --git a/examples/foundational/49a-thinking-anthropic.py b/examples/foundational/49a-thinking-anthropic.py index 80477ff14..0495d5e97 100644 --- a/examples/foundational/49a-thinking-anthropic.py +++ b/examples/foundational/49a-thinking-anthropic.py @@ -9,9 +9,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,29 +28,23 @@ from pipecat.services.deepgram.stt import DeepgramSTTService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -64,35 +56,27 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = AnthropicLLMService( api_key=os.getenv("ANTHROPIC_API_KEY"), - params=AnthropicLLMService.InputParams( - thinking=AnthropicLLMService.ThinkingConfig(type="enabled", budget_tokens=2048) - ), - ) - - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] + settings=AnthropicLLMService.Settings( + thinking=AnthropicLLMService.ThinkingConfig( + type="enabled", + budget_tokens=2048, ), + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) - user_aggregator = context_aggregator.user() - assistant_aggregator = context_aggregator.assistant() + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ @@ -119,15 +103,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append( - { - "role": "user", - "content": "Say hello briefly.", - } - ) + context.add_message({"role": "user", "content": "Say hello briefly."}) # Here are some example prompts conducive to demonstrating # thinking (picked from Google and Anthropic docs). - # messages.append( + # context.add_message( # { # "role": "user", # "content": "Analogize photosynthesis and growing up. Keep your answer concise.", diff --git a/examples/foundational/49b-thinking-google.py b/examples/foundational/49b-thinking-google.py index e0c9dcd6e..1cfeba456 100644 --- a/examples/foundational/49b-thinking-google.py +++ b/examples/foundational/49b-thinking-google.py @@ -9,9 +9,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -30,29 +28,23 @@ from pipecat.services.google.llm import GoogleLLMService 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -64,40 +56,28 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = GoogleLLMService( api_key=os.getenv("GOOGLE_API_KEY"), # model="gemini-3-pro-preview", # A more powerful reasoning model, but slower - params=GoogleLLMService.InputParams( + settings=GoogleLLMService.Settings( thinking=GoogleLLMService.ThinkingConfig( - # thinking_level="low", # Use this field instead of thinking_budget for Gemini 3 Pro. Defaults to "high". thinking_budget=-1, # Dynamic thinking include_thoughts=True, - ) - ), - ) - - 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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] ), + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) - user_aggregator = context_aggregator.user() - assistant_aggregator = context_aggregator.assistant() + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ @@ -124,16 +104,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append( - { - "role": "user", - "content": "Say hello briefly.", - } - ) + context.add_message({"role": "user", "content": "Say hello briefly."}) # Replace the above with one of these example prompts to demonstrate # thinking. # These examples come from Gemini and Anthropic docs. - # messages.append( + # context.add_message( # { # "role": "user", # "content": "Analogize photosynthesis and growing up. Keep your answer concise.", diff --git a/examples/foundational/49c-thinking-functions-anthropic.py b/examples/foundational/49c-thinking-functions-anthropic.py index b18f672f6..1ce5832fc 100644 --- a/examples/foundational/49c-thinking-functions-anthropic.py +++ b/examples/foundational/49c-thinking-functions-anthropic.py @@ -10,9 +10,7 @@ from dotenv import load_dotenv from loguru import logger from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -32,8 +30,6 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -56,24 +52,20 @@ async def book_taxi(params: FunctionCallParams, time: str): await params.result_callback({"status": "done"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -85,13 +77,19 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = AnthropicLLMService( api_key=os.getenv("ANTHROPIC_API_KEY"), - params=AnthropicLLMService.InputParams( - thinking=AnthropicLLMService.ThinkingConfig(type="enabled", budget_tokens=2048) + settings=AnthropicLLMService.Settings( + thinking=AnthropicLLMService.ThinkingConfig( + type="enabled", + budget_tokens=2048, + ), + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) @@ -100,26 +98,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tools = ToolsSchema(standard_tools=[check_flight_status, book_taxi]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) - user_aggregator = context_aggregator.user() - assistant_aggregator = context_aggregator.assistant() - pipeline = Pipeline( [ transport.input(), # Transport user input @@ -145,16 +129,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append( - { - "role": "user", - "content": "Say hello briefly.", - } - ) + context.add_message({"role": "user", "content": "Say hello briefly."}) # Here is an example prompt conducive to demonstrating thinking and # function calling. # This example comes from Gemini docs. - # messages.append( + # context.add_message( # { # "role": "user", # "content": "Check the status of flight AA100 and, if it's delayed, book me a taxi 2 hours before its departure time.", diff --git a/examples/foundational/49d-thinking-functions-google.py b/examples/foundational/49d-thinking-functions-google.py index 794d4e4f7..0b62fbf2e 100644 --- a/examples/foundational/49d-thinking-functions-google.py +++ b/examples/foundational/49d-thinking-functions-google.py @@ -10,10 +10,8 @@ from dotenv import load_dotenv from loguru import logger from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame, ThoughtTranscriptionMessage, TranscriptionMessage +from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -23,7 +21,6 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) -from pipecat.processors.transcript_processor import TranscriptProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService @@ -33,8 +30,6 @@ from pipecat.services.llm_service import FunctionCallParams 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_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -57,24 +52,20 @@ async def book_taxi(params: FunctionCallParams, time: str): await params.result_callback({"status": "done"}) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -86,18 +77,20 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) llm = GoogleLLMService( api_key=os.getenv("GOOGLE_API_KEY"), # model="gemini-3-pro-preview", # A more powerful reasoning model, but slower - params=GoogleLLMService.InputParams( + settings=GoogleLLMService.Settings( thinking=GoogleLLMService.ThinkingConfig( - # thinking_level="low", # Use this field instead of thinking_budget for Gemini 3 Pro. Defaults to "high". thinking_budget=-1, # Dynamic thinking include_thoughts=True, - ) + ), + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) @@ -106,26 +99,12 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tools = ToolsSchema(standard_tools=[check_flight_status, book_taxi]) - 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, tools) - context_aggregator = LLMContextAggregatorPair( + context = LLMContext(tools=tools) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ), + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) - user_aggregator = context_aggregator.user() - assistant_aggregator = context_aggregator.assistant() - pipeline = Pipeline( [ transport.input(), # Transport user input @@ -151,16 +130,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append( - { - "role": "user", - "content": "Say hello briefly.", - } - ) + context.add_message({"role": "user", "content": "Say hello briefly."}) # Replace the above with one of these example prompts to demonstrate # thinking and function calling. # This example comes from Gemini docs. - # messages.append( + # context.add_message( # { # "role": "user", # "content": "Check the status of flight AA100 and, if it's delayed, book me a taxi 2 hours before its departure time.", diff --git a/examples/foundational/50-ultravox-realtime.py b/examples/foundational/50-ultravox-realtime.py index 095131cc6..7aaacdeaf 100644 --- a/examples/foundational/50-ultravox-realtime.py +++ b/examples/foundational/50-ultravox-realtime.py @@ -12,11 +12,17 @@ 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.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 +from pipecat.processors.aggregators.llm_response_universal import ( + AssistantTurnStoppedMessage, + LLMContextAggregatorPair, + LLMUserAggregatorParams, + UserTurnStoppedMessage, +) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.llm_service import FunctionCallParams @@ -24,14 +30,15 @@ from pipecat.services.ultravox.llm import OneShotInputParams, UltravoxRealtimeLL 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_stop import SpeechTimeoutUserTurnStopStrategy +from pipecat.turns.user_turn_strategies import UserTurnStrategies # Load environment variables load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, @@ -169,17 +176,30 @@ There is also a secret menu that changes daily. If the user asks about it, use t llm.register_function("get_secret_menu", get_secret_menu) - # Necessary to complete the function call lifecycle in Pipecat. - context_aggregator = LLMContextAggregatorPair(LLMContext([])) + context = LLMContext([]) + + # Necessary to complete the function call lifecycle in Pipecat and + # to produce user and assistant turn stopped events. + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=UserTurnStrategies( + stop=[SpeechTimeoutUserTurnStopStrategy()], + ), + # Set the VAD analyzer to create reliable TTFB measurements and + # user stop events. + vad_analyzer=SileroVADAnalyzer(), + ), + ) # Build the pipeline pipeline = Pipeline( [ transport.input(), - context_aggregator.user(), + user_aggregator, llm, - context_aggregator.assistant(), transport.output(), + assistant_aggregator, ] ) @@ -204,6 +224,18 @@ There is also a secret menu that changes daily. If the user asks about it, use t logger.info(f"Client disconnected") await task.cancel() + @user_aggregator.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(aggregator, strategy, message: UserTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}user: {message.content}" + logger.info(f"Transcript: {line}") + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}assistant: {message.content}" + logger.info(f"Transcript: {line}") + # Run the pipeline runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) await runner.run(task) diff --git a/examples/foundational/50a-ultravox-realtime-text.py b/examples/foundational/50a-ultravox-realtime-text.py new file mode 100644 index 000000000..8b876048a --- /dev/null +++ b/examples/foundational/50a-ultravox-realtime-text.py @@ -0,0 +1,263 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import datetime +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.audio.vad.vad_analyzer import VADParams +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 ( + AssistantTurnStoppedMessage, + LLMContextAggregatorPair, + LLMUserAggregatorParams, + UserTurnStoppedMessage, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.inworld.tts import InworldTTSService +from pipecat.services.llm_service import FunctionCallParams +from pipecat.services.ultravox.llm import OneShotInputParams, UltravoxRealtimeLLMService +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_stop import SpeechTimeoutUserTurnStopStrategy +from pipecat.turns.user_turn_strategies import UserTurnStrategies + +# Load environment variables +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 get_secret_menu(params: FunctionCallParams): + category = params.arguments.get("category", "both") + logger.debug(f"Fetching secret menu with category: {category}") + items = [] + if category in {"donuts", "both"}: + items.append( + { + "name": "Butter Pecan Ice Cream (one scoop)", + "price": "$2.99", + } + ) + if category in {"drinks", "both"}: + items.append( + { + "name": "Banana Smoothie", + "price": "$4.99", + } + ) + await params.result_callback( + { + "date": datetime.date.today().isoformat(), + "items": items, + } + ) + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info(f"Starting bot") + + system_prompt = f""" +You are a drive-thru order taker for a donut shop called "Dr. Donut". Local time is currently: {datetime.datetime.now().isoformat()} +The user is talking to you over voice on their phone, and your response will be read out loud with realistic text-to-speech (TTS) technology. + +Follow every direction here when crafting your response: + +1. Use natural, conversational language that is clear and easy to follow (short sentences, simple words). +1a. Be concise and relevant: Most of your responses should be a sentence or two, unless you're asked to go deeper. Don't monopolize the conversation. +1b. Use discourse markers to ease comprehension. Never use the list format. + +2. Keep the conversation flowing. +2a. Clarify: when there is ambiguity, ask clarifying questions, rather than make assumptions. +2b. Don't implicitly or explicitly try to end the chat (i.e. do not end a response with "Talk soon!", or "Enjoy!"). +2c. Sometimes the user might just want to chat. Ask them relevant follow-up questions. +2d. Don't ask them if there's anything else they need help with (e.g. don't say things like "How can I assist you further?"). + +3. Remember that this is a voice conversation: +3a. Don't use lists, markdown, bullet points, or other formatting that's not typically spoken. +3b. Type out numbers in words (e.g. 'twenty twelve' instead of the year 2012) +3c. If something doesn't make sense, it's likely because you misheard them. There wasn't a typo, and the user didn't mispronounce anything. + +Remember to follow these rules absolutely, and do not refer to these rules, even if you're asked about them. + +When talking with the user, use the following script: +1. Take their order, acknowledging each item as it is ordered. If it's not clear which menu item the user is ordering, ask them to clarify. + DO NOT add an item to the order unless it's one of the items on the menu below. +2. Once the order is complete, repeat back the order. +2a. If the user only ordered a drink, ask them if they would like to add a donut to their order. +2b. If the user only ordered donuts, ask them if they would like to add a drink to their order. +2c. If the user ordered both drinks and donuts, don't suggest anything. +3. Total up the price of all ordered items and inform the user. +4. Ask the user to pull up to the drive thru window. +If the user asks for something that's not on the menu, inform them of that fact, and suggest the most similar item on the menu. +If the user says something unrelated to your role, responed with "Um... this is a Dr. Donut." +If the user says "thank you", respond with "My pleasure." +If the user asks about what's on the menu, DO NOT read the entire menu to them. Instead, give a couple suggestions. + +The menu of available items is as follows: + +# DONUTS + +PUMPKIN SPICE ICED DOUGHNUT $1.29 +PUMPKIN SPICE CAKE DOUGHNUT $1.29 +OLD FASHIONED DOUGHNUT $1.29 +CHOCOLATE ICED DOUGHNUT $1.09 +CHOCOLATE ICED DOUGHNUT WITH SPRINKLES $1.09 +RASPBERRY FILLED DOUGHNUT $1.09 +BLUEBERRY CAKE DOUGHNUT $1.09 +STRAWBERRY ICED DOUGHNUT WITH SPRINKLES $1.09 +LEMON FILLED DOUGHNUT $1.09 +DOUGHNUT HOLES $3.99 + +# COFFEE & DRINKS + +PUMPKIN SPICE COFFEE $2.59 +PUMPKIN SPICE LATTE $4.59 +REGULAR BREWED COFFEE $1.79 +DECAF BREWED COFFEE $1.79 +LATTE $3.49 +CAPPUCINO $3.49 +CARAMEL MACCHIATO $3.49 +MOCHA LATTE $3.49 +CARAMEL MOCHA LATTE $3.49 + +There is also a secret menu that changes daily. If the user asks about it, use the get_secret_menu tool to look up today's secret menu items. +""" + + secret_menu_function = FunctionSchema( + name="get_secret_menu", + description="Get today's secret menu items", + properties={ + "category": { + "type": "string", + "enum": ["donuts", "drinks", "both"], + "description": "The category of secret menu items to retrieve. Defaults to both.", + }, + }, + required=[], + ) + + llm = UltravoxRealtimeLLMService( + params=OneShotInputParams( + api_key=os.getenv("ULTRAVOX_API_KEY"), + system_prompt=system_prompt, + temperature=0.3, + max_duration=datetime.timedelta(minutes=3), + output_medium="text", + ), + one_shot_selected_tools=ToolsSchema(standard_tools=[secret_menu_function]), + ) + + llm.register_function("get_secret_menu", get_secret_menu) + + tts = InworldTTSService( + api_key=os.getenv("INWORLD_API_KEY", ""), + voice_id="Ashley", + model="inworld-tts-1", + temperature=1.1, + ) + + context = LLMContext([]) + + # Necessary to complete the function call lifecycle in Pipecat and + # to produce user and assistant turn stopped events. + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=UserTurnStrategies( + stop=[SpeechTimeoutUserTurnStopStrategy()], + ), + # Set the VAD analyzer to emulate timing of the model. + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), + ), + ) + + # Build the pipeline + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + tts, + transport.output(), + assistant_aggregator, + ] + ) + + # Configure the pipeline task + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + # Handle client connection event + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + + # Handle client disconnection events + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + @user_aggregator.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(aggregator, strategy, message: UserTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}user: {message.content}" + logger.info(f"Transcript: {line}") + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}assistant: {message.content}" + logger.info(f"Transcript: {line}") + + # Run the pipeline + 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/51-grok-realtime.py b/examples/foundational/51-grok-realtime.py index f56b2cb8e..81a2c28c4 100644 --- a/examples/foundational/51-grok-realtime.py +++ b/examples/foundational/51-grok-realtime.py @@ -36,7 +36,7 @@ from pipecat.adapters.schemas.tools_schema import ToolsSchema # Note: Grok has built-in server-side VAD, so we don't need local VAD # from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import LLMRunFrame, TranscriptionMessage +from pipecat.frames.frames import LLMRunFrame from pipecat.observers.loggers.transcription_log_observer import ( TranscriptionLogObserver, ) @@ -45,15 +45,14 @@ 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 ( + AssistantTurnStoppedMessage, LLMContextAggregatorPair, + UserTurnStoppedMessage, ) -from pipecat.processors.transcript_processor import TranscriptProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.grok.realtime.events import ( SessionProperties, - WebSearchTool, - XSearchTool, ) from pipecat.services.grok.realtime.llm import GrokRealtimeLLMService from pipecat.services.llm_service import FunctionCallParams @@ -200,7 +199,9 @@ Always be helpful and proactive in offering assistance.""", # Create the Grok Realtime LLM service llm = GrokRealtimeLLMService( api_key=os.getenv("GROK_API_KEY"), - session_properties=session_properties, + settings=GrokRealtimeLLMService.Settings( + session_properties=session_properties, + ), ) # Register function handlers @@ -208,16 +209,13 @@ Always be helpful and proactive in offering assistance.""", llm.register_function("get_current_time", get_current_time) llm.register_function("get_restaurant_recommendation", get_restaurant_recommendation) - # Create transcript processor for logging - transcript = TranscriptProcessor() - # Create context with initial message and tools context = LLMContext( [{"role": "user", "content": "Say hello and introduce yourself!"}], tools, ) - context_aggregator = LLMContextAggregatorPair(context) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair(context) # Build the pipeline # Note: In realtime mode, transcription comes from Grok (upstream), @@ -225,12 +223,10 @@ Always be helpful and proactive in offering assistance.""", pipeline = Pipeline( [ transport.input(), # Transport user input (audio) - context_aggregator.user(), - transcript.user(), # Transcription from Grok goes upstream + user_aggregator, llm, # Grok Realtime LLM (handles STT + LLM + TTS) transport.output(), # Transport bot output (audio) - transcript.assistant(), # Log assistant speech - context_aggregator.assistant(), + assistant_aggregator, ] ) @@ -256,13 +252,17 @@ Always be helpful and proactive in offering assistance.""", await task.cancel() # Log transcript updates - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - for msg in frame.messages: - if isinstance(msg, TranscriptionMessage): - timestamp = f"[{msg.timestamp}] " if msg.timestamp else "" - line = f"{timestamp}{msg.role}: {msg.content}" - logger.info(f"Transcript: {line}") + @user_aggregator.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(aggregator, strategy, message: UserTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}user: {message.content}" + logger.info(f"Transcript: {line}") + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}assistant: {message.content}" + logger.info(f"Transcript: {line}") runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) diff --git a/examples/foundational/52-live-translation.py b/examples/foundational/52-live-translation.py index d92acb309..7a4a4b112 100644 --- a/examples/foundational/52-live-translation.py +++ b/examples/foundational/52-live-translation.py @@ -10,9 +10,8 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams +from pipecat.frames.frames import TTSSpeakFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -30,30 +29,25 @@ 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_start import TranscriptionUserTurnStartStrategy -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -65,31 +59,31 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="d4db5fb9-f44b-4bd1-85fa-192e0f0d75f9", # Spanish-speaking Lady + settings=CartesiaTTSService.Settings( + voice="d4db5fb9-f44b-4bd1-85fa-192e0f0d75f9", # Spanish-speaking Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a live translation assistant. Your sole purpose is to translate English text into Spanish. When you receive English text from the user, immediately translate it into natural, fluent Spanish. Do not add explanations, commentary, or extra information—only provide the Spanish translation of the text you receive.", + ), + ) - messages = [ - { - "role": "system", - "content": "You are a live translation assistant. Your sole purpose is to translate English text into Spanish. When you receive English text from the user, immediately translate it into natural, fluent Spanish. Do not add explanations, commentary, or extra information—only provide the Spanish translation of the text you receive.", - }, - ] - - context = LLMContext(messages) + context = LLMContext() # We use the TranscriptionUserTurnStartStrategy to start a new user turn # every time a transcription is received. We disable interruptions, so the # user can continue speaking while the bot is transcribing, without # interrupting the bot. - context_aggregator = LLMContextAggregatorPair( + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams( user_turn_strategies=UserTurnStrategies( start=[TranscriptionUserTurnStartStrategy(enable_interruptions=False)], - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())], ), + vad_analyzer=SileroVADAnalyzer(), ), ) @@ -97,11 +91,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input stt, # STT - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS (bot will speak the chosen language) transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -117,6 +111,14 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") + await task.queue_frames( + [ + TTSSpeakFrame( + text="Hello, welcome to live translation. Everything you say will be automatically translated to Spanish. Let's begin!", + append_to_context=True, + ), + ] + ) @transport.event_handler("on_client_disconnected") async def on_client_disconnected(transport, client): diff --git a/examples/foundational/53-concurrent-llm-evaluation.py b/examples/foundational/53-concurrent-llm-evaluation.py index 432088574..69cebd0ac 100644 --- a/examples/foundational/53-concurrent-llm-evaluation.py +++ b/examples/foundational/53-concurrent-llm-evaluation.py @@ -10,9 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.parallel_pipeline import ParallelPipeline from pipecat.pipeline.pipeline import Pipeline @@ -23,6 +21,7 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, LLMUserAggregatorParams, ) +from pipecat.processors.audio.vad_processor import VADProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService @@ -32,31 +31,26 @@ 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_stop import TurnAnalyzerUserTurnStopStrategy from pipecat.turns.user_turn_processor import UserTurnProcessor -from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies, UserTurnStrategies +from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. +# 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, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -68,41 +62,40 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="d4db5fb9-f44b-4bd1-85fa-192e0f0d75f9", # Spanish-speaking Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - openai_llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - - openai_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.", - }, - ] + openai_llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) groq_llm = GroqLLMService( - api_key=os.getenv("GROQ_API_KEY"), model="meta-llama/llama-4-maverick-17b-128e-instruct" + api_key=os.getenv("GROQ_API_KEY"), + settings=GroqLLMService.Settings( + model="meta-llama/llama-4-maverick-17b-128e-instruct", + system_instruction="You are a very helpful assistant. Your goal is to demonstrate your capabilities in detail in a creative and helpful way.", + ), ) - groq_messages = [ - { - "role": "system", - "content": "You are a very helpful assistant. Your goal is to demonstrate your capabilities in detail in a creative and helpful way.", - }, - ] + openai_context = LLMContext() + groq_context = LLMContext() - openai_context = LLMContext(openai_messages) - groq_context = LLMContext(groq_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( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), - ) + user_turn_processor = UserTurnProcessor() # We use external user turn strategies for both aggregators since the turn # management is done by the common UserTurnProcessor. @@ -119,6 +112,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): [ transport.input(), # Transport user input stt, # STT + vad_processor, user_turn_processor, ParallelPipeline( [ @@ -150,11 +144,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - openai_messages.append( - {"role": "system", "content": "Please introduce yourself to the user."} + openai_context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} ) - groq_messages.append( - {"role": "system", "content": "Please introduce yourself to the user."} + groq_context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} ) await task.queue_frames([LLMRunFrame()]) 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..481d865e1 --- /dev/null +++ b/examples/foundational/53-concurrent-llm-rtvi-ignored-sources.py @@ -0,0 +1,184 @@ +# +# 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"), + settings=CartesiaTTSService.Settings( + voice="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"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief 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", + settings=OpenAILLMService.Settings( + system_instruction="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() + evaluator_context = LLMContext() + + # 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_context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + evaluator_context.add_message( + {"role": "user", "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/54-context-summarization-openai.py b/examples/foundational/54-context-summarization-openai.py new file mode 100644 index 000000000..060eda8f7 --- /dev/null +++ b/examples/foundational/54-context-summarization-openai.py @@ -0,0 +1,197 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Example demonstrating context summarization feature. + +This example shows how to enable and configure context summarization to automatically +compress conversation history when token limits are approached. It also demonstrates +that summarization correctly handles function calls, preserving incomplete function +call sequences. +""" + +import asyncio +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.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_context_summarizer import SummaryAppliedEvent +from pipecat.processors.aggregators.llm_response_universal import ( + LLMAssistantAggregatorParams, + 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 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 +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams +from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, + LLMContextSummaryConfig, +) + +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, + ), +} + + +# Tool functions for the LLM +async def get_current_weather(params: FunctionCallParams): + """Get the current time in a readable format.""" + logger.info("Tool called: get_current_weather") + await asyncio.sleep(1) # Simulate some processing + await params.result_callback({"conditions": "nice", "temperature": "75"}) + + +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"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You have access to tools to get the current weather - use them when relevant.", + ), + ) + + # Register tool functions + llm.register_function("get_current_weather", get_current_weather) + + 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"], + ) + tools = ToolsSchema(standard_tools=[weather_function]) + + context = LLMContext(tools=tools) + + # Create aggregators with summarization enabled + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + vad_analyzer=SileroVADAnalyzer(), + ), + assistant_params=LLMAssistantAggregatorParams( + enable_auto_context_summarization=True, + # Optional: customize context summarization behavior + # Using low limits to demonstrate the feature quickly + auto_context_summarization_config=LLMAutoContextSummarizationConfig( + max_context_tokens=1000, # Trigger summarization at 1000 tokens + max_unsummarized_messages=10, # Or when 10 new messages accumulate + summary_config=LLMContextSummaryConfig( + target_context_tokens=800, # Target context size for the summarization + min_messages_after_summary=2, # Keep last 2 messages uncompressed + ), + ), + ), + ) + + # Listen for summarization events + @assistant_aggregator.event_handler("on_summary_applied") + async def on_summary_applied(aggregator, summarizer, event: SummaryAppliedEvent): + logger.info( + f"Context summarized: {event.original_message_count} messages -> " + f"{event.new_message_count} messages " + f"({event.summarized_message_count} summarized, " + f"{event.preserved_message_count} preserved)" + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info("Client connected") + # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info("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/54a-context-summarization-google.py b/examples/foundational/54a-context-summarization-google.py new file mode 100644 index 000000000..2727d34a8 --- /dev/null +++ b/examples/foundational/54a-context-summarization-google.py @@ -0,0 +1,197 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Example demonstrating context summarization feature. + +This example shows how to enable and configure context summarization to automatically +compress conversation history when token limits are approached. It also demonstrates +that summarization correctly handles function calls, preserving incomplete function +call sequences. +""" + +import asyncio +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.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_context_summarizer import SummaryAppliedEvent +from pipecat.processors.aggregators.llm_response_universal import ( + LLMAssistantAggregatorParams, + 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 import DeepgramSTTService +from pipecat.services.google import GoogleLLMService +from pipecat.services.llm_service import FunctionCallParams +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams +from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, + LLMContextSummaryConfig, +) + +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, + ), +} + + +# Tool functions for the LLM +async def get_current_weather(params: FunctionCallParams): + """Get the current time in a readable format.""" + logger.info("Tool called: get_current_weather") + await asyncio.sleep(1) # Simulate some processing + await params.result_callback({"conditions": "nice", "temperature": "75"}) + + +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"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. You have access to tools to get the current weather - use them when relevant.", + ), + ) + + # Register tool functions + llm.register_function("get_current_weather", get_current_weather) + + 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"], + ) + tools = ToolsSchema(standard_tools=[weather_function]) + + context = LLMContext(tools=tools) + + # Create aggregators with summarization enabled + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + vad_analyzer=SileroVADAnalyzer(), + ), + assistant_params=LLMAssistantAggregatorParams( + enable_auto_context_summarization=True, + # Optional: customize context summarization behavior + # Using low limits to demonstrate the feature quickly + auto_context_summarization_config=LLMAutoContextSummarizationConfig( + max_context_tokens=1000, # Trigger summarization at 1000 tokens + max_unsummarized_messages=10, # Or when 10 new messages accumulate + summary_config=LLMContextSummaryConfig( + target_context_tokens=800, # Target context size for the summarization + min_messages_after_summary=2, # Keep last 2 messages uncompressed + ), + ), + ), + ) + + # Listen for summarization events + @assistant_aggregator.event_handler("on_summary_applied") + async def on_summary_applied(aggregator, summarizer, event: SummaryAppliedEvent): + logger.info( + f"Context summarized: {event.original_message_count} messages -> " + f"{event.new_message_count} messages " + f"({event.summarized_message_count} summarized, " + f"{event.preserved_message_count} preserved)" + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info("Client connected") + # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info("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/54b-context-summarization-manual-openai.py b/examples/foundational/54b-context-summarization-manual-openai.py new file mode 100644 index 000000000..e6bdcaf00 --- /dev/null +++ b/examples/foundational/54b-context-summarization-manual-openai.py @@ -0,0 +1,171 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Example demonstrating manual context summarization via a function call. + +This example shows how to trigger context summarization on demand rather than +automatically. The user can ask the bot to "summarize the conversation" and the +bot will call a function that pushes an LLMSummarizeContextFrame into the +pipeline, causing the LLM service to compress the conversation history. + +Unlike example 54, automatic summarization is NOT enabled here. Summarization +only happens when the user explicitly requests it through the function call. +""" + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame, LLMSummarizeContextFrame +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 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 +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams + +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 summarize_conversation(params: FunctionCallParams): + """Trigger manual context summarization via a pipeline frame.""" + logger.info("Tool called: summarize_conversation") + await params.result_callback({"status": "summarization_requested"}) + await params.llm.queue_frame(LLMSummarizeContextFrame()) + + +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"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + system_prompt = """You are a helpful LLM in a voice 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. + If the user asks you to summarize the conversation, call the + summarize_conversation function. After summarization, briefly acknowledge + that the conversation history has been compressed. + """ + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction=system_prompt, + ), + ) + + llm.register_function("summarize_conversation", summarize_conversation) + + summarize_function = FunctionSchema( + name="summarize_conversation", + description=( + "Summarize and compress the conversation history. " + "Call this when the user asks you to summarize the conversation " + "or when you want to free up context space." + ), + properties={}, + required=[], + ) + tools = ToolsSchema(standard_tools=[summarize_function]) + + context = LLMContext(tools=tools) + + # Automatic summarization is NOT enabled here (enable_auto_context_summarization + # defaults to False). The summarizer is still created internally so that + # LLMSummarizeContextFrame frames pushed via the function call are handled. + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info("Client connected") + # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info("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/54c-context-summarization-dedicated-llm.py b/examples/foundational/54c-context-summarization-dedicated-llm.py new file mode 100644 index 000000000..ed56e343e --- /dev/null +++ b/examples/foundational/54c-context-summarization-dedicated-llm.py @@ -0,0 +1,236 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Example demonstrating advanced context summarization configuration. + +This example shows how to customize context summarization with: +- A dedicated cheap/fast LLM for generating summaries (Gemini Flash) +- A custom summary message template (XML tags) +- A custom summarization prompt +- A summarization timeout +- The on_summary_applied event for observability +""" + +import asyncio +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.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_context_summarizer import SummaryAppliedEvent +from pipecat.processors.aggregators.llm_response_universal import ( + LLMAssistantAggregatorParams, + 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 import DeepgramSTTService +from pipecat.services.google.llm import GoogleLLMService +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 +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams +from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, + LLMContextSummaryConfig, +) + +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, + ), +} + +# Custom summarization prompt tailored to the application +CUSTOM_SUMMARIZATION_PROMPT = """Summarize this conversation, preserving: +- Key decisions and agreements +- Important facts and user preferences +- Any pending action items or unresolved questions + +Be concise. Use clear, factual statements grouped by topic. +Omit greetings, small talk, and resolved tangents.""" + + +# Tool functions for the LLM +async def get_current_weather(params: FunctionCallParams): + """Get the current weather.""" + logger.info("Tool called: get_current_weather") + await asyncio.sleep(1) # Simulate some processing + await params.result_callback({"conditions": "nice", "temperature": "75"}) + + +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"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + system_prompt = """You are a helpful LLM in a voice 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. + You have access to tools to get the current weather - use them when relevant. + When you see a block, it contains a compressed summary + of earlier conversation. Use it as reference but don't mention it to the user. + """ + + # Primary LLM for conversation (could be any provider) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction=system_prompt, + ), + ) + + # Dedicated cheap/fast LLM for summarization only + summarization_llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings( + model="gemini-2.5-flash", + ), + ) + + # Register tool functions + llm.register_function("get_current_weather", get_current_weather) + + 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"], + ) + tools = ToolsSchema(standard_tools=[weather_function]) + + context = LLMContext(tools=tools) + + # Create aggregators with custom summarization + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + vad_analyzer=SileroVADAnalyzer(), + ), + assistant_params=LLMAssistantAggregatorParams( + enable_auto_context_summarization=True, + auto_context_summarization_config=LLMAutoContextSummarizationConfig( + # Trigger thresholds (low values to demonstrate quickly) + max_context_tokens=1000, + max_unsummarized_messages=10, + summary_config=LLMContextSummaryConfig( + # Summary generation + target_context_tokens=800, + min_messages_after_summary=2, + summarization_prompt=CUSTOM_SUMMARIZATION_PROMPT, + # Custom summary format - wrap in XML tags so the system + # prompt can identify summaries vs. live conversation + summary_message_template="\n{summary}\n", + # Use a dedicated cheap LLM for summarization instead of + # the primary conversation model + llm=summarization_llm, + # Cancel summarization if it takes longer than 60 seconds + summarization_timeout=60.0, + ), + ), + ), + ) + + # Listen for summarization events + @assistant_aggregator.event_handler("on_summary_applied") + async def on_summary_applied(aggregator, summarizer, event: SummaryAppliedEvent): + logger.info( + f"Context summarized: {event.original_message_count} messages -> " + f"{event.new_message_count} messages " + f"({event.summarized_message_count} summarized, " + f"{event.preserved_message_count} preserved)" + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info("Client connected") + # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info("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-flux-stt.py b/examples/foundational/55a-update-settings-deepgram-flux-stt.py new file mode 100644 index 000000000..d710015bd --- /dev/null +++ b/examples/foundational/55a-update-settings-deepgram-flux-stt.py @@ -0,0 +1,141 @@ +# +# 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.flux.stt import DeepgramFluxSTTService +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 = DeepgramFluxSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + # Update configure-able fields mid-stream (sent via Configure message, + # no reconnect needed). + await asyncio.sleep(10) + logger.info("Updating Deepgram Flux STT settings: eot_threshold, keyterm") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=DeepgramFluxSTTSettings( + eot_threshold=0.8, + keyterm=["Pipecat", "Deepgram"], + ) + ) + ) + + await asyncio.sleep(10) + logger.info("Updating Deepgram Flux STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(delta=DeepgramFluxSTTService.Settings(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/55a-update-settings-deepgram-sagemaker-stt.py b/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py new file mode 100644 index 000000000..ebe3d4cce --- /dev/null +++ b/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py @@ -0,0 +1,144 @@ +# +# 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.sagemaker.stt import DeepgramSageMakerSTTService +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_STT_ENDPOINT_NAME"), + region=os.getenv("AWS_REGION"), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + # NOTE: after this change, the bot will only respond if you speak Spanish + await asyncio.sleep(10) + logger.info("Updating Deepgram SageMaker STT settings: language=es, punctuate=False") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=DeepgramSageMakerSTTService.Settings( + language=Language.ES, + punctuate=False, + ) + ) + ) + + # Old-style dict update (for backward-compat testing): + # await asyncio.sleep(10) + # logger.info("Updating Deepgram SageMaker STT settings via dict: punctuate=False, filler_words=True") + # await task.queue_frame( + # STTUpdateSettingsFrame(settings={"punctuate": False, "filler_words": True}) + # ) + + @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/07e-interruptible-playht.py b/examples/foundational/55a-update-settings-deepgram-stt.py similarity index 55% rename from examples/foundational/07e-interruptible-playht.py rename to examples/foundational/55a-update-settings-deepgram-stt.py index 516179995..46adaad6b 100644 --- a/examples/foundational/07e-interruptible-playht.py +++ b/examples/foundational/55a-update-settings-deepgram-stt.py @@ -4,16 +4,14 @@ # SPDX-License-Identifier: BSD 2-Clause License # - +import asyncio import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.audio.vad.vad_analyzer import VADParams -from pipecat.frames.frames import LLMRunFrame +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 @@ -24,36 +22,28 @@ from pipecat.processors.aggregators.llm_response_universal import ( ) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport +from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.openai.llm import OpenAILLMService -from pipecat.services.playht.tts import PlayHTTTSService 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 -from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) -# We store functions so objects (e.g. SileroVADAnalyzer) don't get -# instantiated. The function will be called when the desired transport gets -# selected. transport_params = { "daily": lambda: DailyParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), ), } @@ -63,41 +53,35 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) - tts = PlayHTTTSService( - user_id=os.getenv("PLAYHT_USER_ID"), - api_key=os.getenv("PLAYHT_API_KEY"), - voice_url="s3://voice-cloning-zero-shot/e46b4027-b38d-4d24-b292-38fbca2be0ef/original/manifest.json", - params=PlayHTTTSService.InputParams(language=Language.EN), + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="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) - context_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - user_turn_strategies=UserTurnStrategies( - stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", ), ) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + pipeline = Pipeline( [ - transport.input(), # Transport user input + transport.input(), stt, - context_aggregator.user(), # User responses - llm, # LLM - tts, # TTS - transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + user_aggregator, + llm, + tts, + transport.output(), + assistant_aggregator, ] ) @@ -113,10 +97,28 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") - # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) + # NOTE: after this change, the bot will only respond if you speak Spanish + await asyncio.sleep(10) + logger.info("Updating Deepgram STT settings: language=es, punctuate=False") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=DeepgramSTTService.Settings( + language=Language.ES, + punctuate=False, + ) + ) + ) + + # Old-style dict update (for backward-compat testing): + # await asyncio.sleep(10) + # logger.info("Updating Deepgram STT settings via dict: punctuate=False, filler_words=True") + # await task.queue_frame( + # STTUpdateSettingsFrame(settings={"punctuate": False, "filler_words": True}) + # ) + @transport.event_handler("on_client_disconnected") async def on_client_disconnected(transport, client): logger.info(f"Client disconnected") diff --git a/examples/foundational/55b-update-settings-azure-stt.py b/examples/foundational/55b-update-settings-azure-stt.py new file mode 100644 index 000000000..8e5d3bfe3 --- /dev/null +++ b/examples/foundational/55b-update-settings-azure-stt.py @@ -0,0 +1,131 @@ +# +# 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.azure.stt import AzureSTTService +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transcriptions.language import Language +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams + +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 = AzureSTTService( + api_key=os.getenv("AZURE_SPEECH_API_KEY"), + region=os.getenv("AZURE_SPEECH_REGION"), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Azure STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(delta=AzureSTTService.Settings(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/55c-update-settings-google-stt.py b/examples/foundational/55c-update-settings-google-stt.py new file mode 100644 index 000000000..be62d90ad --- /dev/null +++ b/examples/foundational/55c-update-settings-google-stt.py @@ -0,0 +1,128 @@ +# +# 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.google.stt import GoogleSTTService +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 = GoogleSTTService(credentials=os.getenv("GOOGLE_TEST_CREDENTIALS")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Google STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(delta=GoogleSTTService.Settings(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/55d-update-settings-assemblyai-stt.py b/examples/foundational/55d-update-settings-assemblyai-stt.py new file mode 100644 index 000000000..213873ab5 --- /dev/null +++ b/examples/foundational/55d-update-settings-assemblyai-stt.py @@ -0,0 +1,140 @@ +# +# 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.assemblyai.stt import AssemblyAISTTService +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams + +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 = AssemblyAISTTService( + api_key=os.getenv("ASSEMBLYAI_API_KEY"), + settings=AssemblyAISTTService.Settings( + model="u3-rt-pro", + ), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Try saying difficult names like 'Xiomara', 'Saoirse', or 'Krzystof' to test transcription accuracy.", + ), + ) + + context = LLMContext() + 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") + logger.info( + "Phase 1: No keyterms boosting - try saying 'Xiomara', 'Saoirse', or 'Krzystof'" + ) + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(15) + logger.info("🔄 Updating keyterms: Adding difficult names for boosting") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=AssemblyAISTTService.Settings( + keyterms_prompt=["Xiomara", "Saoirse", "Krzystof", "Nguyen", "Pipecat"] + ) + ) + ) + logger.info("Phase 2: Keyterms active - same names should transcribe better now!") + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + 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/55e-update-settings-gladia-stt.py b/examples/foundational/55e-update-settings-gladia-stt.py new file mode 100644 index 000000000..307ea2954 --- /dev/null +++ b/examples/foundational/55e-update-settings-gladia-stt.py @@ -0,0 +1,128 @@ +# +# 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.gladia.stt import GladiaSTTService +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 = GladiaSTTService(api_key=os.getenv("GLADIA_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Gladia STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(delta=GladiaSTTService.Settings(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/55f-update-settings-elevenlabs-realtime-stt.py b/examples/foundational/55f-update-settings-elevenlabs-realtime-stt.py new file mode 100644 index 000000000..4fc351de0 --- /dev/null +++ b/examples/foundational/55f-update-settings-elevenlabs-realtime-stt.py @@ -0,0 +1,130 @@ +# +# 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.elevenlabs.stt import ElevenLabsRealtimeSTTService +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 = ElevenLabsRealtimeSTTService(api_key=os.getenv("ELEVENLABS_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating ElevenLabs Realtime STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=ElevenLabsRealtimeSTTService.Settings(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/55g-update-settings-elevenlabs-stt.py b/examples/foundational/55g-update-settings-elevenlabs-stt.py new file mode 100644 index 000000000..d610acea6 --- /dev/null +++ b/examples/foundational/55g-update-settings-elevenlabs-stt.py @@ -0,0 +1,135 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os + +import aiohttp +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.elevenlabs.stt import ElevenLabsSTTService +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") + + async with aiohttp.ClientSession() as session: + stt = ElevenLabsSTTService( + api_key=os.getenv("ELEVENLABS_API_KEY"), + aiohttp_session=session, + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating ElevenLabs STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(delta=ElevenLabsSTTService.Settings(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/55h-update-settings-speechmatics-stt.py b/examples/foundational/55h-update-settings-speechmatics-stt.py new file mode 100644 index 000000000..eaa1874f6 --- /dev/null +++ b/examples/foundational/55h-update-settings-speechmatics-stt.py @@ -0,0 +1,153 @@ +# +# 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.openai.llm import OpenAILLMService +from pipecat.services.speechmatics.stt import SpeechmaticsSTTService +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 = SpeechmaticsSTTService( + api_key=os.getenv("SPEECHMATICS_API_KEY"), + settings=SpeechmaticsSTTService.Settings( + enable_diarization=True, + speaker_active_format="<{speaker_id}>{text}", + speaker_passive_format="<{speaker_id}>{text}", + ), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Speechmatics STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(delta=SpeechmaticsSTTService.Settings(language=Language.ES)) + ) + + await asyncio.sleep(10) + logger.info("Updating Speechmatics STT settings: focus_speakers=['S1']") + await task.queue_frame( + STTUpdateSettingsFrame(delta=SpeechmaticsSTTService.Settings(focus_speakers=["S1"])) + ) + + await asyncio.sleep(10) + logger.info( + "Updating Speechmatics STT settings: speaker_active_format={text}" + ) + await task.queue_frame( + STTUpdateSettingsFrame( + delta=SpeechmaticsSTTService.Settings( + speaker_active_format="{text}" + ) + ) + ) + + @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/55i-update-settings-whisper-api-stt.py b/examples/foundational/55i-update-settings-whisper-api-stt.py new file mode 100644 index 000000000..179a65f83 --- /dev/null +++ b/examples/foundational/55i-update-settings-whisper-api-stt.py @@ -0,0 +1,132 @@ +# +# 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.openai.llm import OpenAILLMService +from pipecat.services.openai.stt import OpenAISTTService +from pipecat.services.whisper.base_stt import BaseWhisperSTTSettings +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") + + # This file is meant to exercise Whisper API-based STT services, so we use + # OpenAI's Whisper STT as an example here. Here we could've also used: + # - SambaNova + # - Groq + stt = OpenAISTTService( + api_key=os.getenv("OPENAI_API_KEY"), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating OpenAI STT settings: language="es"') + await task.queue_frame(STTUpdateSettingsFrame(delta=BaseWhisperSTTSettings(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/55j-update-settings-sarvam-stt.py b/examples/foundational/55j-update-settings-sarvam-stt.py new file mode 100644 index 000000000..b65401dc5 --- /dev/null +++ b/examples/foundational/55j-update-settings-sarvam-stt.py @@ -0,0 +1,128 @@ +# +# 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.openai.llm import OpenAILLMService +from pipecat.services.sarvam.stt import SarvamSTTService +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 = SarvamSTTService(api_key=os.getenv("SARVAM_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Sarvam STT settings: language=en-IN") + await task.queue_frame( + STTUpdateSettingsFrame(delta=SarvamSTTService.Settings(language=Language.EN_IN)) + ) + + @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/55k-update-settings-soniox-stt.py b/examples/foundational/55k-update-settings-soniox-stt.py new file mode 100644 index 000000000..2a2548888 --- /dev/null +++ b/examples/foundational/55k-update-settings-soniox-stt.py @@ -0,0 +1,128 @@ +# +# 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.openai.llm import OpenAILLMService +from pipecat.services.soniox.stt import SonioxSTTService +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 = SonioxSTTService(api_key=os.getenv("SONIOX_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Soniox STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(delta=SonioxSTTService.Settings(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/55l-update-settings-aws-transcribe-stt.py b/examples/foundational/55l-update-settings-aws-transcribe-stt.py new file mode 100644 index 000000000..cd9cfec11 --- /dev/null +++ b/examples/foundational/55l-update-settings-aws-transcribe-stt.py @@ -0,0 +1,128 @@ +# +# 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.aws.stt import AWSTranscribeSTTService +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transcriptions.language import Language +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams + +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 = AWSTranscribeSTTService() + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating AWS Transcribe STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(delta=AWSTranscribeSTTService.Settings(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/55m-update-settings-cartesia-stt.py b/examples/foundational/55m-update-settings-cartesia-stt.py new file mode 100644 index 000000000..acd7fb972 --- /dev/null +++ b/examples/foundational/55m-update-settings-cartesia-stt.py @@ -0,0 +1,128 @@ +# +# 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.stt import CartesiaSTTService +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transcriptions.language import Language +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams + +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 = CartesiaSTTService(api_key=os.getenv("CARTESIA_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Cartesia STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(delta=CartesiaSTTService.Settings(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/55n-update-settings-cartesia-http-tts.py b/examples/foundational/55n-update-settings-cartesia-http-tts.py new file mode 100644 index 000000000..328940bb5 --- /dev/null +++ b/examples/foundational/55n-update-settings-cartesia-http-tts.py @@ -0,0 +1,129 @@ +# +# 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.cartesia.tts import CartesiaHttpTTSService, GenerationConfig +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 + +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 = CartesiaHttpTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaHttpTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Cartesia HTTP TTS settings: speed increased to 1.5") + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=CartesiaHttpTTSService.Settings(generation_config=GenerationConfig(speed=1.5)) + ) + ) + + @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/55n-update-settings-cartesia-tts.py b/examples/foundational/55n-update-settings-cartesia-tts.py new file mode 100644 index 000000000..67e0c599b --- /dev/null +++ b/examples/foundational/55n-update-settings-cartesia-tts.py @@ -0,0 +1,132 @@ +# +# 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.cartesia.tts import CartesiaTTSService, GenerationConfig +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 + +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(f"Starting bot") + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Cartesia TTS settings: speed increased to 1.5") + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=CartesiaTTSService.Settings(generation_config=GenerationConfig(speed=1.5)) + ) + ) + + @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/55o-update-settings-elevenlabs-http-tts.py b/examples/foundational/55o-update-settings-elevenlabs-http-tts.py new file mode 100644 index 000000000..d819f2bdf --- /dev/null +++ b/examples/foundational/55o-update-settings-elevenlabs-http-tts.py @@ -0,0 +1,132 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import asyncio +import os + +import aiohttp +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.elevenlabs.tts import ElevenLabsHttpTTSService +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") + + async with aiohttp.ClientSession() as session: + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = ElevenLabsHttpTTSService( + api_key=os.getenv("ELEVENLABS_API_KEY"), + settings=ElevenLabsHttpTTSService.Settings(voice=os.getenv("ELEVENLABS_VOICE_ID")), + aiohttp_session=session, + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating ElevenLabs TTS settings: speed=0.7") + await task.queue_frame( + TTSUpdateSettingsFrame(delta=ElevenLabsHttpTTSService.Settings(speed=0.7)) + ) + + @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/55o-update-settings-elevenlabs-tts.py b/examples/foundational/55o-update-settings-elevenlabs-tts.py new file mode 100644 index 000000000..9afa33252 --- /dev/null +++ b/examples/foundational/55o-update-settings-elevenlabs-tts.py @@ -0,0 +1,133 @@ +# +# 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.elevenlabs.tts import ElevenLabsTTSService +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 = ElevenLabsTTSService( + api_key=os.getenv("ELEVENLABS_API_KEY"), + settings=ElevenLabsTTSService.Settings(voice=os.getenv("ELEVENLABS_VOICE_ID")), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating ElevenLabs TTS settings: speed=0.7") + await task.queue_frame( + TTSUpdateSettingsFrame(delta=ElevenLabsTTSService.Settings(speed=0.7)) + ) + + await asyncio.sleep(10) + logger.info("Updating ElevenLabs TTS settings: switching to a different voice") + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=ElevenLabsTTSService.Settings(voice=os.getenv("ELEVENLABS_VOICE_ID_ALT")) + ) + ) + + @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/55p-update-settings-openai-tts.py b/examples/foundational/55p-update-settings-openai-tts.py new file mode 100644 index 000000000..a6f2b40c7 --- /dev/null +++ b/examples/foundational/55p-update-settings-openai-tts.py @@ -0,0 +1,121 @@ +# +# 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.openai.llm import OpenAILLMService +from pipecat.services.openai.tts import OpenAITTSService +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 = OpenAITTSService(api_key=os.getenv("OPENAI_API_KEY")) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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( + audio_out_sample_rate=24000, + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating OpenAI TTS settings: speed=2.0") + await task.queue_frame(TTSUpdateSettingsFrame(delta=OpenAITTSService.Settings(speed=2.0))) + + @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-http-tts.py b/examples/foundational/55q-update-settings-deepgram-http-tts.py new file mode 100644 index 000000000..706dcc357 --- /dev/null +++ b/examples/foundational/55q-update-settings-deepgram-http-tts.py @@ -0,0 +1,141 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import asyncio +import os + +import aiohttp +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 import DeepgramHttpTTSService +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") + + async with aiohttp.ClientSession() as session: + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = DeepgramHttpTTSService( + api_key=os.getenv("DEEPGRAM_API_KEY"), + aiohttp_session=session, + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Deepgram TTS settings: voice="aura-2-aries-en"') + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=DeepgramHttpTTSService.Settings(voice="aura-2-aries-en") + ) + ) + + await asyncio.sleep(10) + logger.info('Updating Deepgram TTS settings: voice="aura-2-luna-en"') + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=DeepgramHttpTTSService.Settings(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/55q-update-settings-deepgram-sagemaker-tts.py b/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py new file mode 100644 index 000000000..76c67095f --- /dev/null +++ b/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py @@ -0,0 +1,136 @@ +# +# 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.sagemaker.tts import DeepgramSageMakerTTSService +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 + +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_TTS_ENDPOINT_NAME"), + region=os.getenv("AWS_REGION"), + voice="aura-2-helena-en", + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "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( + delta=DeepgramSageMakerTTSService.Settings(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( + delta=DeepgramSageMakerTTSService.Settings(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/55q-update-settings-deepgram-tts.py b/examples/foundational/55q-update-settings-deepgram-tts.py new file mode 100644 index 000000000..39a6ea7b7 --- /dev/null +++ b/examples/foundational/55q-update-settings-deepgram-tts.py @@ -0,0 +1,128 @@ +# +# 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 import DeepgramTTSService +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 = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Deepgram TTS settings: voice="aura-2-aries-en"') + await task.queue_frame( + TTSUpdateSettingsFrame(delta=DeepgramTTSService.Settings(voice="aura-2-aries-en")) + ) + + await asyncio.sleep(10) + logger.info('Updating Deepgram TTS settings: voice="aura-2-luna-en"') + await task.queue_frame( + TTSUpdateSettingsFrame(delta=DeepgramTTSService.Settings(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/55r-update-settings-azure-http-tts.py b/examples/foundational/55r-update-settings-azure-http-tts.py new file mode 100644 index 000000000..c75ba5ff4 --- /dev/null +++ b/examples/foundational/55r-update-settings-azure-http-tts.py @@ -0,0 +1,125 @@ +# +# 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.azure.tts import AzureHttpTTSService +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 + +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 = AzureHttpTTSService( + api_key=os.getenv("AZURE_SPEECH_API_KEY"), + region=os.getenv("AZURE_SPEECH_REGION"), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Azure TTS settings: rate="0.7", style="sad"') + await task.queue_frame( + TTSUpdateSettingsFrame(delta=AzureHttpTTSService.Settings(rate="0.7", style="sad")) + ) + + @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/55r-update-settings-azure-tts.py b/examples/foundational/55r-update-settings-azure-tts.py new file mode 100644 index 000000000..851e16de3 --- /dev/null +++ b/examples/foundational/55r-update-settings-azure-tts.py @@ -0,0 +1,125 @@ +# +# 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.azure.tts import AzureTTSService +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 + +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 = AzureTTSService( + api_key=os.getenv("AZURE_SPEECH_API_KEY"), + region=os.getenv("AZURE_SPEECH_REGION"), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Azure TTS settings: rate="0.7", style="sad"') + await task.queue_frame( + TTSUpdateSettingsFrame(delta=AzureTTSService.Settings(rate="0.7", style="sad")) + ) + + @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/55s-update-settings-google-http-tts.py b/examples/foundational/55s-update-settings-google-http-tts.py new file mode 100644 index 000000000..bb9b1862b --- /dev/null +++ b/examples/foundational/55s-update-settings-google-http-tts.py @@ -0,0 +1,122 @@ +# +# 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.google.tts import GoogleHttpTTSService +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 = GoogleHttpTTSService(credentials=os.getenv("GOOGLE_TEST_CREDENTIALS")) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Google HTTP TTS settings: speaking_rate=1.4") + await task.queue_frame( + TTSUpdateSettingsFrame(delta=GoogleHttpTTSService.Settings(speaking_rate=1.4)) + ) + + @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/55s-update-settings-google-stream-tts.py b/examples/foundational/55s-update-settings-google-stream-tts.py new file mode 100644 index 000000000..68cfd2aaa --- /dev/null +++ b/examples/foundational/55s-update-settings-google-stream-tts.py @@ -0,0 +1,122 @@ +# +# 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.google.tts import GoogleTTSService +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 = GoogleTTSService(credentials=os.getenv("GOOGLE_TEST_CREDENTIALS")) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Google Stream TTS settings: speaking_rate=1.4") + await task.queue_frame( + TTSUpdateSettingsFrame(delta=GoogleTTSService.Settings(speaking_rate=1.4)) + ) + + @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/55t-update-settings-piper-http-tts.py b/examples/foundational/55t-update-settings-piper-http-tts.py new file mode 100644 index 000000000..57770cc91 --- /dev/null +++ b/examples/foundational/55t-update-settings-piper-http-tts.py @@ -0,0 +1,133 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os + +import aiohttp +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.openai.llm import OpenAILLMService +from pipecat.services.piper.tts import PiperHttpTTSService +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") + + async with aiohttp.ClientSession() as session: + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = PiperHttpTTSService( + base_url=os.getenv("PIPER_BASE_URL"), + aiohttp_session=session, + settings=PiperHttpTTSService.Settings(voice="en_US-ryan-high"), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Piper HTTP TTS settings: voice="en_US-lessac-medium"') + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=PiperHttpTTSService.Settings(voice="en_US-lessac-medium") + ) + ) + + @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/55t-update-settings-piper-tts.py b/examples/foundational/55t-update-settings-piper-tts.py new file mode 100644 index 000000000..5416b7e58 --- /dev/null +++ b/examples/foundational/55t-update-settings-piper-tts.py @@ -0,0 +1,129 @@ +# +# 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.openai.llm import OpenAILLMService +from pipecat.services.piper.tts import PiperTTSService +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 = PiperTTSService( + settings=PiperTTSService.Settings( + voice="en_US-ryan-high", + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + # NOTE: Local Piper loads the voice model once at init, so runtime voice + # changes are not applied. This update will log an "unhandled settings" + # warning. Use PiperHttpTTSService for dynamic voice switching. + await asyncio.sleep(10) + logger.info('Updating Piper TTS settings: voice="en_US-lessac-medium"') + await task.queue_frame( + TTSUpdateSettingsFrame(delta=PiperTTSService.Settings(voice="en_US-lessac-medium")) + ) + + @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-http-tts.py b/examples/foundational/55u-update-settings-rime-http-tts.py new file mode 100644 index 000000000..1d7154107 --- /dev/null +++ b/examples/foundational/55u-update-settings-rime-http-tts.py @@ -0,0 +1,132 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import asyncio +import os + +import aiohttp +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.openai.llm import OpenAILLMService +from pipecat.services.rime.tts import RimeHttpTTSService +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") + + async with aiohttp.ClientSession() as session: + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = RimeHttpTTSService( + api_key=os.getenv("RIME_API_KEY"), + settings=RimeHttpTTSService.Settings(voice="eva"), + aiohttp_session=session, + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Rime TTS settings: voice=rex") + await task.queue_frame( + TTSUpdateSettingsFrame(delta=RimeHttpTTSService.Settings(voice="rex")) + ) + + @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 new file mode 100644 index 000000000..33a811228 --- /dev/null +++ b/examples/foundational/55u-update-settings-rime-tts.py @@ -0,0 +1,123 @@ +# +# 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.openai.llm import OpenAILLMService +from pipecat.services.rime.tts import RimeTTSService +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 = RimeTTSService( + api_key=os.getenv("RIME_API_KEY"), + settings=RimeTTSService.Settings(voice="luna"), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Rime TTS settings: voice=bond") + await task.queue_frame(TTSUpdateSettingsFrame(delta=RimeTTSService.Settings(voice="bond"))) + + @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/55v-update-settings-lmnt-tts.py b/examples/foundational/55v-update-settings-lmnt-tts.py new file mode 100644 index 000000000..1a6c9bd07 --- /dev/null +++ b/examples/foundational/55v-update-settings-lmnt-tts.py @@ -0,0 +1,123 @@ +# +# 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.lmnt.tts import LmntTTSService +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 = LmntTTSService( + api_key=os.getenv("LMNT_API_KEY"), + settings=LmntTTSService.Settings(voice="lily"), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating LMNT TTS settings: voice="tyler"') + await task.queue_frame(TTSUpdateSettingsFrame(delta=LmntTTSService.Settings(voice="tyler"))) + + @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/55w-update-settings-fish-tts.py b/examples/foundational/55w-update-settings-fish-tts.py new file mode 100644 index 000000000..6ba018d0e --- /dev/null +++ b/examples/foundational/55w-update-settings-fish-tts.py @@ -0,0 +1,127 @@ +# +# 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.fish.tts import FishAudioTTSService +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 = FishAudioTTSService( + api_key=os.getenv("FISH_API_KEY"), + settings=FishAudioTTSService.Settings( + voice="4ce7e917cedd4bc2bb2e6ff3a46acaa1" + ), # Barack Obama + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Fish Audio TTS settings: prosody_speed=1.5") + await task.queue_frame( + TTSUpdateSettingsFrame(delta=FishAudioTTSService.Settings(prosody_speed=1.5)) + ) + + @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/55x-update-settings-minimax-tts.py b/examples/foundational/55x-update-settings-minimax-tts.py new file mode 100644 index 000000000..c91e728a4 --- /dev/null +++ b/examples/foundational/55x-update-settings-minimax-tts.py @@ -0,0 +1,132 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os + +import aiohttp +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.minimax.tts import MiniMaxHttpTTSService +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") + + async with aiohttp.ClientSession() as session: + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = MiniMaxHttpTTSService( + api_key=os.getenv("MINIMAX_API_KEY", ""), + group_id=os.getenv("MINIMAX_GROUP_ID", ""), + aiohttp_session=session, + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating MiniMax TTS settings: speed=1.5, emotion="happy"') + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=MiniMaxHttpTTSService.Settings(speed=1.5, emotion="happy") + ) + ) + + @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/55y-update-settings-groq-tts.py b/examples/foundational/55y-update-settings-groq-tts.py new file mode 100644 index 000000000..2e1a7a09d --- /dev/null +++ b/examples/foundational/55y-update-settings-groq-tts.py @@ -0,0 +1,120 @@ +# +# 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.groq.tts import GroqTTSService +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 = GroqTTSService(api_key=os.getenv("GROQ_API_KEY")) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Groq TTS settings: voice=troy") + await task.queue_frame(TTSUpdateSettingsFrame(delta=GroqTTSService.Settings(voice="troy"))) + + @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/55z-update-settings-hume-tts.py b/examples/foundational/55z-update-settings-hume-tts.py new file mode 100644 index 000000000..4a1fc061b --- /dev/null +++ b/examples/foundational/55z-update-settings-hume-tts.py @@ -0,0 +1,127 @@ +# +# 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.hume.tts import HumeTTSService +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 = HumeTTSService( + api_key=os.getenv("HUME_API_KEY"), + settings=HumeTTSService.Settings(voice="f898a92e-685f-43fa-985b-a46920f0650b"), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Hume TTS settings: speed=2.0, description="Speak with excitement"') + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=HumeTTSService.Settings(speed=2.0, description="Speak with excitement") + ) + ) + + @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/55za-update-settings-neuphonic-http-tts.py b/examples/foundational/55za-update-settings-neuphonic-http-tts.py new file mode 100644 index 000000000..12d44aa02 --- /dev/null +++ b/examples/foundational/55za-update-settings-neuphonic-http-tts.py @@ -0,0 +1,129 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os + +import aiohttp +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.neuphonic.tts import NeuphonicHttpTTSService +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")) + + async with aiohttp.ClientSession() as session: + tts = NeuphonicHttpTTSService( + api_key=os.getenv("NEUPHONIC_API_KEY"), + aiohttp_session=session, + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Neuphonic HTTP TTS settings: speed=1.4") + await task.queue_frame( + TTSUpdateSettingsFrame(delta=NeuphonicHttpTTSService.Settings(speed=1.4)) + ) + + @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/55za-update-settings-neuphonic-tts.py b/examples/foundational/55za-update-settings-neuphonic-tts.py new file mode 100644 index 000000000..f3b2970a6 --- /dev/null +++ b/examples/foundational/55za-update-settings-neuphonic-tts.py @@ -0,0 +1,122 @@ +# +# 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.neuphonic.tts import NeuphonicTTSService +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 = NeuphonicTTSService(api_key=os.getenv("NEUPHONIC_API_KEY")) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Neuphonic TTS settings: speed=1.4") + await task.queue_frame( + TTSUpdateSettingsFrame(delta=NeuphonicTTSService.Settings(speed=1.4)) + ) + + @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/55zb-update-settings-inworld-http-tts.py b/examples/foundational/55zb-update-settings-inworld-http-tts.py new file mode 100644 index 000000000..fb96a8eeb --- /dev/null +++ b/examples/foundational/55zb-update-settings-inworld-http-tts.py @@ -0,0 +1,130 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import asyncio +import os + +import aiohttp +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.inworld.tts import InworldHttpTTSService +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") + + async with aiohttp.ClientSession() as session: + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = InworldHttpTTSService(api_key=os.getenv("INWORLD_API_KEY"), aiohttp_session=session) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Inworld TTS settings: speaking_rate=1.5, temperature=0.8") + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=InworldHttpTTSService.Settings(speaking_rate=1.5, temperature=0.8) + ) + ) + + @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/55zb-update-settings-inworld-tts.py b/examples/foundational/55zb-update-settings-inworld-tts.py new file mode 100644 index 000000000..07ee4d674 --- /dev/null +++ b/examples/foundational/55zb-update-settings-inworld-tts.py @@ -0,0 +1,124 @@ +# +# 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.inworld.tts import InworldTTSService +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 = InworldTTSService(api_key=os.getenv("INWORLD_API_KEY")) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Inworld TTS settings: speaking_rate=1.5, temperature=0.8") + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=InworldTTSService.Settings(speaking_rate=1.5, temperature=0.8) + ) + ) + + @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/55zc-update-settings-gemini-tts.py b/examples/foundational/55zc-update-settings-gemini-tts.py new file mode 100644 index 000000000..fd7a8a5f7 --- /dev/null +++ b/examples/foundational/55zc-update-settings-gemini-tts.py @@ -0,0 +1,133 @@ +# +# 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.google.tts import GeminiTTSService +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 = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = GeminiTTSService( + credentials=os.getenv("GOOGLE_TEST_CREDENTIALS"), + settings=GeminiTTSService.Settings( + model="gemini-2.5-flash-tts", + voice="Charon", + language=Language.EN_US, + prompt="You are a helpful AI assistant. Speak in a natural, conversational tone.", + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Gemini TTS settings: prompt="Speak slowly and dramatically"') + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=GeminiTTSService.Settings(prompt="Speak slowly and dramatically") + ) + ) + + @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/55zd-update-settings-aws-polly-tts.py b/examples/foundational/55zd-update-settings-aws-polly-tts.py new file mode 100644 index 000000000..d293e12ed --- /dev/null +++ b/examples/foundational/55zd-update-settings-aws-polly-tts.py @@ -0,0 +1,122 @@ +# +# 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.aws.tts import AWSPollyTTSService +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 + +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 = AWSPollyTTSService() + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating AWS Polly TTS settings: rate="fast"') + await task.queue_frame( + TTSUpdateSettingsFrame(delta=AWSPollyTTSService.Settings(rate="fast")) + ) + + @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/55ze-update-settings-sarvam-http-tts.py b/examples/foundational/55ze-update-settings-sarvam-http-tts.py new file mode 100644 index 000000000..5bc5da404 --- /dev/null +++ b/examples/foundational/55ze-update-settings-sarvam-http-tts.py @@ -0,0 +1,128 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import asyncio +import os + +import aiohttp +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.openai.llm import OpenAILLMService +from pipecat.services.sarvam.tts import SarvamHttpTTSService +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") + + async with aiohttp.ClientSession() as session: + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = SarvamHttpTTSService(api_key=os.getenv("SARVAM_API_KEY"), aiohttp_session=session) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Sarvam TTS settings: pace=1.5") + await task.queue_frame( + TTSUpdateSettingsFrame(delta=SarvamHttpTTSService.Settings(pace=1.5)) + ) + + @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/55ze-update-settings-sarvam-tts.py b/examples/foundational/55ze-update-settings-sarvam-tts.py new file mode 100644 index 000000000..8df7f781b --- /dev/null +++ b/examples/foundational/55ze-update-settings-sarvam-tts.py @@ -0,0 +1,120 @@ +# +# 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.openai.llm import OpenAILLMService +from pipecat.services.sarvam.tts import SarvamTTSService +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 = SarvamTTSService(api_key=os.getenv("SARVAM_API_KEY")) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Sarvam TTS settings: pace=1.5") + await task.queue_frame(TTSUpdateSettingsFrame(delta=SarvamTTSService.Settings(pace=1.5))) + + @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/55zf-update-settings-camb-tts.py b/examples/foundational/55zf-update-settings-camb-tts.py new file mode 100644 index 000000000..66a90b6a8 --- /dev/null +++ b/examples/foundational/55zf-update-settings-camb-tts.py @@ -0,0 +1,125 @@ +# +# 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.camb.tts import CambTTSService +from pipecat.services.deepgram.stt import DeepgramSTTService +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 = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = CambTTSService(api_key=os.getenv("CAMB_API_KEY")) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Camb TTS settings: language -> Spanish, voice -> Pirate Captain") + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=CambTTSService.Settings(language=Language.ES, voice=147319) + ) + ) + + @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/55zg-update-settings-kokoro-tts.py b/examples/foundational/55zg-update-settings-kokoro-tts.py new file mode 100644 index 000000000..2ef247f66 --- /dev/null +++ b/examples/foundational/55zg-update-settings-kokoro-tts.py @@ -0,0 +1,126 @@ +# +# 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.kokoro.tts import KokoroTTSService +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 = KokoroTTSService( + settings=KokoroTTSService.Settings( + voice="af_heart", + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Kokoro TTS settings: voice="am_adam"') + await task.queue_frame( + TTSUpdateSettingsFrame(delta=KokoroTTSService.Settings(voice="am_adam")) + ) + + @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/55zh-update-settings-resembleai-tts.py b/examples/foundational/55zh-update-settings-resembleai-tts.py new file mode 100644 index 000000000..45f8785a0 --- /dev/null +++ b/examples/foundational/55zh-update-settings-resembleai-tts.py @@ -0,0 +1,127 @@ +# +# 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.openai.llm import OpenAILLMService +from pipecat.services.resembleai.tts import ResembleAITTSService +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 = ResembleAITTSService( + api_key=os.getenv("RESEMBLE_API_KEY"), + settings=ResembleAITTSService.Settings(voice=os.getenv("RESEMBLE_VOICE_UUID")), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating ResembleAI TTS settings: voice (changed)") + await task.queue_frame( + TTSUpdateSettingsFrame( + delta=ResembleAITTSService.Settings(voice=os.getenv("RESEMBLE_VOICE_UUID_ALT")) + ) + ) + + @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/55zi-update-settings-azure-llm.py b/examples/foundational/55zi-update-settings-azure-llm.py new file mode 100644 index 000000000..20bae3aac --- /dev/null +++ b/examples/foundational/55zi-update-settings-azure-llm.py @@ -0,0 +1,129 @@ +# +# 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, LLMUpdateSettingsFrame +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.azure.llm import AzureLLMService +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.deepgram.stt import DeepgramSTTService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = AzureLLMService( + api_key=os.getenv("AZURE_CHATGPT_API_KEY"), + endpoint=os.getenv("AZURE_CHATGPT_ENDPOINT"), + settings=AzureLLMService.Settings( + model=os.getenv("AZURE_CHATGPT_MODEL"), + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Azure LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=AzureLLMService.Settings(temperature=0.1)) + ) + + @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/55zi-update-settings-openai-llm.py b/examples/foundational/55zi-update-settings-openai-llm.py new file mode 100644 index 000000000..5862e3cab --- /dev/null +++ b/examples/foundational/55zi-update-settings-openai-llm.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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 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 + +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating OpenAI LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=OpenAILLMService.Settings(temperature=0.1)) + ) + + @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/55zi-update-settings-openai-responses-llm.py b/examples/foundational/55zi-update-settings-openai-responses-llm.py new file mode 100644 index 000000000..61bd8329e --- /dev/null +++ b/examples/foundational/55zi-update-settings-openai-responses-llm.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.openai.responses.llm import OpenAIResponsesLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAIResponsesLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIResponsesLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating OpenAI LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=OpenAIResponsesLLMService.Settings(temperature=0.1)) + ) + + @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/55zj-update-settings-anthropic-llm.py b/examples/foundational/55zj-update-settings-anthropic-llm.py new file mode 100644 index 000000000..437769643 --- /dev/null +++ b/examples/foundational/55zj-update-settings-anthropic-llm.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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.anthropic.llm import AnthropicLLMService +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.deepgram.stt import DeepgramSTTService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = AnthropicLLMService( + api_key=os.getenv("ANTHROPIC_API_KEY"), + settings=AnthropicLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Anthropic LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=AnthropicLLMService.Settings(temperature=0.1)) + ) + + @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/55zk-update-settings-google-llm.py b/examples/foundational/55zk-update-settings-google-llm.py new file mode 100644 index 000000000..a9fbfd093 --- /dev/null +++ b/examples/foundational/55zk-update-settings-google-llm.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.google.llm import GoogleLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = GoogleLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GoogleLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Google LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=GoogleLLMService.Settings(temperature=0.1)) + ) + + @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/55zk-update-settings-google-vertex-llm.py b/examples/foundational/55zk-update-settings-google-vertex-llm.py new file mode 100644 index 000000000..c2c8ea2bf --- /dev/null +++ b/examples/foundational/55zk-update-settings-google-vertex-llm.py @@ -0,0 +1,129 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.google.vertex.llm import GoogleVertexLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = GoogleVertexLLMService( + credentials=os.getenv("GOOGLE_VERTEX_TEST_CREDENTIALS"), + project_id=os.getenv("GOOGLE_CLOUD_PROJECT_ID"), + location=os.getenv("GOOGLE_CLOUD_LOCATION"), + settings=GoogleVertexLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Google Vertex LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=GoogleVertexLLMService.Settings(temperature=0.1)) + ) + + @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/55zl-update-settings-azure-realtime.py b/examples/foundational/55zl-update-settings-azure-realtime.py new file mode 100644 index 000000000..fde633912 --- /dev/null +++ b/examples/foundational/55zl-update-settings-azure-realtime.py @@ -0,0 +1,140 @@ +# +# 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, LLMUpdateSettingsFrame +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 ( + AssistantTurnStoppedMessage, + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.azure.realtime.llm import AzureRealtimeLLMService +from pipecat.services.openai.realtime import events +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") + + llm = AzureRealtimeLLMService( + api_key=os.getenv("AZURE_REALTIME_API_KEY"), + base_url=os.getenv("AZURE_REALTIME_BASE_URL"), + ) + + messages = [ + { + "role": "system", + "content": "You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + }, + ] + + context = LLMContext(messages) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + 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, + ) + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}assistant: {message.content}" + logger.info(f"Transcript: {line}") + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Azure Realtime LLM settings: output_modalities=['text']") + await task.queue_frame( + LLMUpdateSettingsFrame( + delta=AzureRealtimeLLMService.Settings( + session_properties=events.SessionProperties(output_modalities=["text"]) + ) + ) + ) + + await asyncio.sleep(10) + logger.info("Updating Azure Realtime LLM settings: output_modalities=['audio']") + await task.queue_frame( + LLMUpdateSettingsFrame( + delta=AzureRealtimeLLMService.Settings( + session_properties=events.SessionProperties(output_modalities=["audio"]) + ) + ) + ) + + @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/55zl-update-settings-openai-realtime.py b/examples/foundational/55zl-update-settings-openai-realtime.py new file mode 100644 index 000000000..83cdb9fa7 --- /dev/null +++ b/examples/foundational/55zl-update-settings-openai-realtime.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, LLMUpdateSettingsFrame +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 ( + AssistantTurnStoppedMessage, + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.openai.realtime import events +from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService +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") + + llm = OpenAIRealtimeLLMService(api_key=os.getenv("OPENAI_API_KEY")) + + messages = [ + { + "role": "system", + "content": "You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + }, + ] + + context = LLMContext(messages) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + 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, + ) + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}assistant: {message.content}" + logger.info(f"Transcript: {line}") + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating OpenAI Realtime LLM settings: output_modalities=['text']") + await task.queue_frame( + LLMUpdateSettingsFrame( + delta=OpenAIRealtimeLLMService.Settings( + session_properties=events.SessionProperties(output_modalities=["text"]) + ) + ) + ) + + await asyncio.sleep(10) + logger.info("Updating OpenAI Realtime LLM settings: output_modalities=['audio']") + await task.queue_frame( + LLMUpdateSettingsFrame( + delta=OpenAIRealtimeLLMService.Settings( + session_properties=events.SessionProperties(output_modalities=["audio"]) + ) + ) + ) + + @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/55zm-update-settings-gemini-live-vertex.py b/examples/foundational/55zm-update-settings-gemini-live-vertex.py new file mode 100644 index 000000000..59b9a5a41 --- /dev/null +++ b/examples/foundational/55zm-update-settings-gemini-live-vertex.py @@ -0,0 +1,116 @@ +# +# 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, LLMUpdateSettingsFrame +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.google.gemini_live.vertex.llm import GeminiLiveVertexLLMService +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") + + llm = GeminiLiveVertexLLMService( + credentials=os.getenv("GOOGLE_VERTEX_TEST_CREDENTIALS"), + project_id=os.getenv("GOOGLE_CLOUD_PROJECT_ID"), + location=os.getenv("GOOGLE_CLOUD_LOCATION"), + settings=GeminiLiveVertexLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Gemini Live Vertex LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=GeminiLiveVertexLLMService.Settings(temperature=0.1)) + ) + + @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/55zm-update-settings-gemini-live.py b/examples/foundational/55zm-update-settings-gemini-live.py new file mode 100644 index 000000000..fd5ef213a --- /dev/null +++ b/examples/foundational/55zm-update-settings-gemini-live.py @@ -0,0 +1,114 @@ +# +# 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, LLMUpdateSettingsFrame +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.google.gemini_live.llm import GeminiLiveLLMService +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") + + llm = GeminiLiveLLMService( + api_key=os.getenv("GOOGLE_API_KEY"), + settings=GeminiLiveLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Gemini Live LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=GeminiLiveLLMService.Settings(temperature=0.1)) + ) + + @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/55zn-update-settings-ultravox-realtime.py b/examples/foundational/55zn-update-settings-ultravox-realtime.py new file mode 100644 index 000000000..f77aa4252 --- /dev/null +++ b/examples/foundational/55zn-update-settings-ultravox-realtime.py @@ -0,0 +1,140 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import datetime +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame, LLMUpdateSettingsFrame +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 ( + AssistantTurnStoppedMessage, + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.ultravox.llm import OneShotInputParams, UltravoxRealtimeLLMService +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") + + system_prompt = "You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way." + + llm = UltravoxRealtimeLLMService( + params=OneShotInputParams( + api_key=os.getenv("ULTRAVOX_API_KEY"), + system_prompt=system_prompt, + temperature=0.3, + max_duration=datetime.timedelta(minutes=3), + ), + one_shot_selected_tools=ToolsSchema(standard_tools=[]), + ) + + messages = [ + { + "role": "system", + "content": system_prompt, + }, + ] + + context = LLMContext(messages) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + 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, + ) + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}assistant: {message.content}" + logger.info(f"Transcript: {line}") + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Ultravox Realtime LLM settings: output_medium=text") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=UltravoxRealtimeLLMService.Settings(output_medium="text")) + ) + + await asyncio.sleep(10) + logger.info("Updating Ultravox Realtime LLM settings: output_medium=voice") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=UltravoxRealtimeLLMService.Settings(output_medium="voice")) + ) + + @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/55zo-update-settings-grok-realtime.py b/examples/foundational/55zo-update-settings-grok-realtime.py new file mode 100644 index 000000000..0d44470e5 --- /dev/null +++ b/examples/foundational/55zo-update-settings-grok-realtime.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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 ( + AssistantTurnStoppedMessage, + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.grok.realtime import events +from pipecat.services.grok.realtime.llm import GrokRealtimeLLMService +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") + + llm = GrokRealtimeLLMService(api_key=os.getenv("GROK_API_KEY")) + + messages = [ + { + "role": "system", + "content": "You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + }, + ] + + context = LLMContext(messages) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + 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, + ) + + @assistant_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + timestamp = f"[{message.timestamp}] " if message.timestamp else "" + line = f"{timestamp}assistant: {message.content}" + logger.info(f"Transcript: {line}") + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Grok Realtime LLM settings: voice='Rex'") + await task.queue_frame( + LLMUpdateSettingsFrame( + delta=GrokRealtimeLLMService.Settings( + session_properties=events.SessionProperties(voice="Rex") + ) + ) + ) + + @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/55zp-update-settings-aws-bedrock-llm.py b/examples/foundational/55zp-update-settings-aws-bedrock-llm.py new file mode 100644 index 000000000..96f151463 --- /dev/null +++ b/examples/foundational/55zp-update-settings-aws-bedrock-llm.py @@ -0,0 +1,129 @@ +# +# 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, LLMUpdateSettingsFrame +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.aws.llm import AWSBedrockLLMService +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.deepgram.stt import DeepgramSTTService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = AWSBedrockLLMService( + aws_region="us-west-2", + settings=AWSBedrockLLMService.Settings( + model="us.anthropic.claude-sonnet-4-6", + temperature=0.8, + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating AWS Bedrock LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=AWSBedrockLLMService.Settings(temperature=0.1)) + ) + + @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/55zq-update-settings-fal-stt.py b/examples/foundational/55zq-update-settings-fal-stt.py new file mode 100644 index 000000000..8047c04e3 --- /dev/null +++ b/examples/foundational/55zq-update-settings-fal-stt.py @@ -0,0 +1,127 @@ +# +# 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.fal.stt import FalSTTService +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 = FalSTTService(api_key=os.getenv("FAL_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Fal STT settings: task="translate"') + await task.queue_frame( + STTUpdateSettingsFrame(delta=FalSTTService.Settings(task="translate")) + ) + + @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/55zr-update-settings-gradium-stt.py b/examples/foundational/55zr-update-settings-gradium-stt.py new file mode 100644 index 000000000..b906306b4 --- /dev/null +++ b/examples/foundational/55zr-update-settings-gradium-stt.py @@ -0,0 +1,130 @@ +# +# 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.gradium.stt import GradiumSTTService +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 = GradiumSTTService( + api_key=os.getenv("GRADIUM_API_KEY"), + api_endpoint_base_url="wss://us.api.gradium.ai/api/speech/asr", + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Gradium STT settings: delay_in_frames=5") + await task.queue_frame( + STTUpdateSettingsFrame(delta=GradiumSTTService.Settings(delay_in_frames=16)) + ) + + @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/55zs-update-settings-whisper-mlx-stt.py b/examples/foundational/55zs-update-settings-whisper-mlx-stt.py new file mode 100644 index 000000000..c60cd072b --- /dev/null +++ b/examples/foundational/55zs-update-settings-whisper-mlx-stt.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, 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.openai.llm import OpenAILLMService +from pipecat.services.whisper.stt import MLXModel, WhisperSTTServiceMLX +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 = WhisperSTTServiceMLX( + settings=WhisperSTTServiceMLX.Settings( + model=MLXModel.LARGE_V3_TURBO.value, + ), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + # NOTE: after this change, the bot will only respond if you speak Spanish + await asyncio.sleep(10) + logger.info("Updating Whisper MLX STT settings: model=TINY") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=WhisperSTTServiceMLX.Settings( + model=MLXModel.TINY.value, + ) + ) + ) + + @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/55zs-update-settings-whisper-stt.py b/examples/foundational/55zs-update-settings-whisper-stt.py new file mode 100644 index 000000000..5af0049ea --- /dev/null +++ b/examples/foundational/55zs-update-settings-whisper-stt.py @@ -0,0 +1,136 @@ +# +# 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.openai.llm import OpenAILLMService +from pipecat.services.whisper.stt import Model, WhisperSTTService +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 = WhisperSTTService( + settings=WhisperSTTService.Settings( + model=Model.DISTIL_MEDIUM_EN.value, + ), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Whisper STT settings: model=LARGE") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=WhisperSTTService.Settings( + model=Model.LARGE.value, + ) + ) + ) + + @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/55zt-update-settings-nvidia-segmented-stt.py b/examples/foundational/55zt-update-settings-nvidia-segmented-stt.py new file mode 100644 index 000000000..28717e035 --- /dev/null +++ b/examples/foundational/55zt-update-settings-nvidia-segmented-stt.py @@ -0,0 +1,127 @@ +# +# 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.nvidia.stt import NvidiaSegmentedSTTService +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 = NvidiaSegmentedSTTService(api_key=os.getenv("NVIDIA_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating NVIDIA Segmented STT settings: profanity_filter=True") + await task.queue_frame( + STTUpdateSettingsFrame(delta=NvidiaSegmentedSTTService.Settings(profanity_filter=True)) + ) + + @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/55zt-update-settings-nvidia-stt.py b/examples/foundational/55zt-update-settings-nvidia-stt.py new file mode 100644 index 000000000..c2ad7c813 --- /dev/null +++ b/examples/foundational/55zt-update-settings-nvidia-stt.py @@ -0,0 +1,128 @@ +# +# 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.nvidia.stt import NvidiaSTTService +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 = NvidiaSTTService(api_key=os.getenv("NVIDIA_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating NVIDIA STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(delta=NvidiaSTTService.Settings(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/55zu-update-settings-openai-realtime-stt.py b/examples/foundational/55zu-update-settings-openai-realtime-stt.py new file mode 100644 index 000000000..5ddea6181 --- /dev/null +++ b/examples/foundational/55zu-update-settings-openai-realtime-stt.py @@ -0,0 +1,128 @@ +# +# 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.openai.llm import OpenAILLMService +from pipecat.services.openai.stt import OpenAIRealtimeSTTService +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 = OpenAIRealtimeSTTService(api_key=os.getenv("OPENAI_API_KEY")) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating OpenAI Realtime STT settings: language=es") + await task.queue_frame( + STTUpdateSettingsFrame(delta=OpenAIRealtimeSTTService.Settings(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/55zv-update-settings-asyncai-http-tts.py b/examples/foundational/55zv-update-settings-asyncai-http-tts.py new file mode 100644 index 000000000..caaba5ff2 --- /dev/null +++ b/examples/foundational/55zv-update-settings-asyncai-http-tts.py @@ -0,0 +1,135 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import asyncio +import os + +import aiohttp +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.asyncai.tts import AsyncAIHttpTTSService +from pipecat.services.deepgram.stt import DeepgramSTTService +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") + + async with aiohttp.ClientSession() as session: + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = AsyncAIHttpTTSService( + api_key=os.getenv("ASYNCAI_API_KEY", ""), + settings=AsyncAIHttpTTSService.Settings( + voice=os.getenv("ASYNCAI_VOICE_ID", "e0f39dc4-f691-4e78-bba5-5c636692cc04") + ), + aiohttp_session=session, + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating AsyncAI HTTP TTS settings: language=es") + await task.queue_frame( + TTSUpdateSettingsFrame(delta=AsyncAIHttpTTSService.Settings(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/55zv-update-settings-asyncai-tts.py b/examples/foundational/55zv-update-settings-asyncai-tts.py new file mode 100644 index 000000000..6aa678df7 --- /dev/null +++ b/examples/foundational/55zv-update-settings-asyncai-tts.py @@ -0,0 +1,128 @@ +# +# 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.asyncai.tts import AsyncAITTSService +from pipecat.services.deepgram.stt import DeepgramSTTService +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 = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = AsyncAITTSService( + api_key=os.getenv("ASYNCAI_API_KEY", ""), + settings=AsyncAITTSService.Settings( + voice=os.getenv("ASYNCAI_VOICE_ID", "e0f39dc4-f691-4e78-bba5-5c636692cc04") + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating AsyncAI TTS settings: language=es") + await task.queue_frame( + TTSUpdateSettingsFrame(delta=AsyncAITTSService.Settings(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/55zw-update-settings-gradium-tts.py b/examples/foundational/55zw-update-settings-gradium-tts.py new file mode 100644 index 000000000..71f19dc98 --- /dev/null +++ b/examples/foundational/55zw-update-settings-gradium-tts.py @@ -0,0 +1,126 @@ +# +# 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.gradium.tts import GradiumTTSService +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 = GradiumTTSService( + api_key=os.getenv("GRADIUM_API_KEY"), + settings=GradiumTTSService.Settings(voice="YTpq7expH9539ERJ"), + url="wss://us.api.gradium.ai/api/speech/tts", + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Gradium TTS settings: voice="LFZvm12tW_z0xfGo"') + await task.queue_frame( + TTSUpdateSettingsFrame(delta=GradiumTTSService.Settings(voice="LFZvm12tW_z0xfGo")) + ) + + @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/55zx-update-settings-cerebras-llm.py b/examples/foundational/55zx-update-settings-cerebras-llm.py new file mode 100644 index 000000000..6e03a1d8b --- /dev/null +++ b/examples/foundational/55zx-update-settings-cerebras-llm.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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.cerebras.llm import CerebrasLLMService +from pipecat.services.deepgram.stt import DeepgramSTTService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = CerebrasLLMService( + api_key=os.getenv("CEREBRAS_API_KEY"), + settings=CerebrasLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Cerebras LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=CerebrasLLMService.Settings(temperature=0.1)) + ) + + @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/55zy-update-settings-deepseek-llm.py b/examples/foundational/55zy-update-settings-deepseek-llm.py new file mode 100644 index 000000000..4d34cf67b --- /dev/null +++ b/examples/foundational/55zy-update-settings-deepseek-llm.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.deepseek.llm import DeepSeekLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = DeepSeekLLMService( + api_key=os.getenv("DEEPSEEK_API_KEY"), + settings=DeepSeekLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating DeepSeek LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=DeepSeekLLMService.Settings(temperature=0.1)) + ) + + @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/55zz-update-settings-fireworks-llm.py b/examples/foundational/55zz-update-settings-fireworks-llm.py new file mode 100644 index 000000000..85d83097e --- /dev/null +++ b/examples/foundational/55zz-update-settings-fireworks-llm.py @@ -0,0 +1,128 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.fireworks.llm import FireworksLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = FireworksLLMService( + api_key=os.getenv("FIREWORKS_API_KEY"), + settings=FireworksLLMService.Settings( + model="accounts/fireworks/models/gpt-oss-20b", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Fireworks LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=FireworksLLMService.Settings(temperature=0.1)) + ) + + @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/55zza-update-settings-grok-llm.py b/examples/foundational/55zza-update-settings-grok-llm.py new file mode 100644 index 000000000..0a793d58e --- /dev/null +++ b/examples/foundational/55zza-update-settings-grok-llm.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.grok.llm import GrokLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = GrokLLMService( + api_key=os.getenv("GROK_API_KEY"), + settings=GrokLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Grok LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=GrokLLMService.Settings(temperature=0.1)) + ) + + @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/55zzb-update-settings-groq-llm.py b/examples/foundational/55zzb-update-settings-groq-llm.py new file mode 100644 index 000000000..896f5d6ab --- /dev/null +++ b/examples/foundational/55zzb-update-settings-groq-llm.py @@ -0,0 +1,128 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.groq.llm import GroqLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = GroqLLMService( + api_key=os.getenv("GROQ_API_KEY"), + settings=GroqLLMService.Settings( + model="meta-llama/llama-4-maverick-17b-128e-instruct", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Groq LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=GroqLLMService.Settings(temperature=0.1)) + ) + + @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/55zzc-update-settings-mistral-llm.py b/examples/foundational/55zzc-update-settings-mistral-llm.py new file mode 100644 index 000000000..6a03b9e8c --- /dev/null +++ b/examples/foundational/55zzc-update-settings-mistral-llm.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.mistral.llm import MistralLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = MistralLLMService( + api_key=os.getenv("MISTRAL_API_KEY"), + settings=MistralLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Mistral LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=MistralLLMService.Settings(temperature=0.1)) + ) + + @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/55zzd-update-settings-nvidia-llm.py b/examples/foundational/55zzd-update-settings-nvidia-llm.py new file mode 100644 index 000000000..c34d45a2a --- /dev/null +++ b/examples/foundational/55zzd-update-settings-nvidia-llm.py @@ -0,0 +1,128 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.nvidia.llm import NvidiaLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = NvidiaLLMService( + api_key=os.getenv("NVIDIA_API_KEY"), + settings=NvidiaLLMService.Settings( + model="meta/llama-3.1-405b-instruct", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating NVIDIA LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=NvidiaLLMService.Settings(temperature=0.1)) + ) + + @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/55zze-update-settings-ollama-llm.py b/examples/foundational/55zze-update-settings-ollama-llm.py new file mode 100644 index 000000000..666f96e6b --- /dev/null +++ b/examples/foundational/55zze-update-settings-ollama-llm.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.ollama.llm import OLLamaLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OLLamaLLMService( + settings=OLLamaLLMService.Settings( + model="llama3.2", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) # Update to the model you're running locally + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating OLLama LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=OLLamaLLMService.Settings(temperature=0.1)) + ) + + @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/55zzf-update-settings-openrouter-llm.py b/examples/foundational/55zzf-update-settings-openrouter-llm.py new file mode 100644 index 000000000..2647848bc --- /dev/null +++ b/examples/foundational/55zzf-update-settings-openrouter-llm.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.openrouter.llm import OpenRouterLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenRouterLLMService( + api_key=os.getenv("OPENROUTER_API_KEY"), + settings=OpenRouterLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating OpenRouter LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=OpenRouterLLMService.Settings(temperature=0.1)) + ) + + @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/55zzg-update-settings-perplexity-llm.py b/examples/foundational/55zzg-update-settings-perplexity-llm.py new file mode 100644 index 000000000..c495830dd --- /dev/null +++ b/examples/foundational/55zzg-update-settings-perplexity-llm.py @@ -0,0 +1,128 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.perplexity.llm import PerplexityLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = PerplexityLLMService(api_key=os.getenv("PERPLEXITY_API_KEY")) + + messages = [ + { + "role": "user", + "content": "You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way. Start by introducing yourself.", + }, + ] + + 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") + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Perplexity LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=PerplexityLLMService.Settings(temperature=0.1)) + ) + + @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/55zzh-update-settings-qwen-llm.py b/examples/foundational/55zzh-update-settings-qwen-llm.py new file mode 100644 index 000000000..6cfcfc97d --- /dev/null +++ b/examples/foundational/55zzh-update-settings-qwen-llm.py @@ -0,0 +1,128 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.qwen.llm import QwenLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = QwenLLMService( + api_key=os.getenv("QWEN_API_KEY"), + settings=QwenLLMService.Settings( + model="qwen2.5-72b-instruct", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Qwen LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=QwenLLMService.Settings(temperature=0.1)) + ) + + @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/55zzi-update-settings-sambanova-llm.py b/examples/foundational/55zzi-update-settings-sambanova-llm.py new file mode 100644 index 000000000..46a6561ae --- /dev/null +++ b/examples/foundational/55zzi-update-settings-sambanova-llm.py @@ -0,0 +1,127 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.sambanova.llm import SambaNovaLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = SambaNovaLLMService( + api_key=os.getenv("SAMBANOVA_API_KEY"), + settings=SambaNovaLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating SambaNova LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=SambaNovaLLMService.Settings(temperature=0.1)) + ) + + @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/55zzj-update-settings-together-llm.py b/examples/foundational/55zzj-update-settings-together-llm.py new file mode 100644 index 000000000..3ffbe9c3e --- /dev/null +++ b/examples/foundational/55zzj-update-settings-together-llm.py @@ -0,0 +1,128 @@ +# +# 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, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.together.llm import TogetherLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = TogetherLLMService( + api_key=os.getenv("TOGETHER_API_KEY"), + settings=TogetherLLMService.Settings( + model="meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating Together LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=TogetherLLMService.Settings(temperature=0.1)) + ) + + @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/55zzk-update-settings-aws-nova-sonic-llm.py b/examples/foundational/55zzk-update-settings-aws-nova-sonic-llm.py new file mode 100644 index 000000000..0d03efe58 --- /dev/null +++ b/examples/foundational/55zzk-update-settings-aws-nova-sonic-llm.py @@ -0,0 +1,116 @@ +# +# 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, LLMUpdateSettingsFrame +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.aws.nova_sonic.llm import AWSNovaSonicLLMService +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") + + llm = AWSNovaSonicLLMService( + secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), + access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), + region=os.getenv("AWS_REGION"), + settings=AWSNovaSonicLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + 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") + context.add_message({"role": "user", "content": "Tell me a fun fact."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating AWS Nova Sonic LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=AWSNovaSonicLLMService.Settings(temperature=0.1)) + ) + + @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/55zzl-update-settings-nvidia-tts.py b/examples/foundational/55zzl-update-settings-nvidia-tts.py new file mode 100644 index 000000000..3282c981d --- /dev/null +++ b/examples/foundational/55zzl-update-settings-nvidia-tts.py @@ -0,0 +1,123 @@ +# +# 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.nvidia.tts import NvidiaTTSService +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 = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = NvidiaTTSService(api_key=os.getenv("NVIDIA_API_KEY")) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating NVIDIA TTS settings: language="ES_US"') + await task.queue_frame( + TTSUpdateSettingsFrame(delta=NvidiaTTSService.Settings(language=Language.ES_US)) + ) + + @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/55zzm-update-settings-speechmatics-tts.py b/examples/foundational/55zzm-update-settings-speechmatics-tts.py new file mode 100644 index 000000000..908ff9918 --- /dev/null +++ b/examples/foundational/55zzm-update-settings-speechmatics-tts.py @@ -0,0 +1,129 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os + +import aiohttp +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.openai.llm import OpenAILLMService +from pipecat.services.speechmatics.tts import SpeechmaticsTTSService +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")) + + async with aiohttp.ClientSession() as session: + tts = SpeechmaticsTTSService( + api_key=os.getenv("SPEECHMATICS_API_KEY"), + aiohttp_session=session, + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Speechmatics TTS settings: voice="theo"') + await task.queue_frame( + TTSUpdateSettingsFrame(delta=SpeechmaticsTTSService.Settings(voice="theo")) + ) + + @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/55zzn-update-settings-groq-stt.py b/examples/foundational/55zzn-update-settings-groq-stt.py new file mode 100644 index 000000000..e5c903753 --- /dev/null +++ b/examples/foundational/55zzn-update-settings-groq-stt.py @@ -0,0 +1,127 @@ +# +# 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.groq.stt import GroqSTTService +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 = GroqSTTService( + api_key=os.getenv("GROQ_API_KEY"), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating Groq STT settings: language="es"') + await task.queue_frame(STTUpdateSettingsFrame(delta=GroqSTTService.Settings(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/55zzo-update-settings-openpipe-llm.py b/examples/foundational/55zzo-update-settings-openpipe-llm.py new file mode 100644 index 000000000..89a94d61f --- /dev/null +++ b/examples/foundational/55zzo-update-settings-openpipe-llm.py @@ -0,0 +1,131 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os +import time + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame, LLMUpdateSettingsFrame +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 import DeepgramSTTService +from pipecat.services.openpipe.llm import OpenPipeLLMService +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 = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + timestamp = int(time.time()) + llm = OpenPipeLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + openpipe_api_key=os.getenv("OPENPIPE_API_KEY"), + tags={"conversation_id": f"pipecat-{timestamp}"}, + settings=OpenPipeLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message({"role": "user", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info("Updating OpenPipe LLM settings: temperature=0.1") + await task.queue_frame( + LLMUpdateSettingsFrame(delta=OpenPipeLLMService.Settings(temperature=0.1)) + ) + + @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/55zzp-update-settings-xtts-tts.py b/examples/foundational/55zzp-update-settings-xtts-tts.py new file mode 100644 index 000000000..adb90247b --- /dev/null +++ b/examples/foundational/55zzp-update-settings-xtts-tts.py @@ -0,0 +1,133 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os + +import aiohttp +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.openai.llm import OpenAILLMService +from pipecat.services.xtts.tts import XTTSService +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") + + async with aiohttp.ClientSession() as session: + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + tts = XTTSService( + aiohttp_session=session, + settings=XTTSService.Settings( + voice="Claribel Dervla", + ), + base_url="http://localhost:8000", + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + context = LLMContext() + 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") + context.add_message( + {"role": "user", "content": "Please introduce yourself to the user."} + ) + await task.queue_frames([LLMRunFrame()]) + + await asyncio.sleep(10) + logger.info('Updating XTTS TTS settings: voice="Ana Florence"') + await task.queue_frame( + TTSUpdateSettingsFrame(delta=XTTSService.Settings(voice="Ana Florence")) + ) + + @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/56-custom-video-track.py b/examples/foundational/56-custom-video-track.py new file mode 100644 index 000000000..274ab6a5e --- /dev/null +++ b/examples/foundational/56-custom-video-track.py @@ -0,0 +1,210 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Example demonstrating custom video tracks output with Daily transport. + +This example outputs two video track simultaneously: + - The default camera track with an animated color gradient pattern. + - A custom "blue" track with the same pattern but with a blue tint applied. + +The pattern generator pushes frames to the default camera. A second processor +(BlueTintProcessor) duplicates each frame, applies a blue tint, and pushes it +to the "blue" custom video destination. + +Run with: python examples/foundational/56-custom-video-track.py -t daily +""" + +import asyncio +import math +import time + +import numpy as np +from loguru import logger + +from pipecat.frames.frames import ( + CancelFrame, + EndFrame, + Frame, + OutputImageRawFrame, + StartFrame, + SystemFrame, +) +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineTask +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.transports.base_transport import BaseTransport +from pipecat.transports.daily.transport import DailyCustomVideoTrackParams, DailyParams + +WIDTH = 320 +HEIGHT = 240 +FPS = 30 + +transport_params = { + "daily": lambda: DailyParams( + video_out_enabled=True, + video_out_width=WIDTH, + video_out_height=HEIGHT, + video_out_framerate=FPS, + video_out_destinations=["blue"], + custom_video_track_params={ + "blue": DailyCustomVideoTrackParams( + width=WIDTH, + height=HEIGHT, + send_settings={ + "maxQuality": "low", + "encodings": { + "low": { + "maxBitrate": 500_000, + "maxFramerate": FPS, + } + }, + }, + ), + }, + ), +} + + +def generate_gradient_frame(width: int, height: int, t: float) -> np.ndarray: + """Generate an animated gradient pattern. + + Creates a smooth color gradient that shifts over time using sine waves + for each RGB channel at different frequencies. + """ + x = np.linspace(0, 1, width) + y = np.linspace(0, 1, height) + xv, yv = np.meshgrid(x, y) + + r = ((np.sin(2 * math.pi * (xv + t * 0.3)) + 1) / 2 * 255).astype(np.uint8) + g = ((np.sin(2 * math.pi * (yv + t * 0.5)) + 1) / 2 * 255).astype(np.uint8) + b = ((np.sin(2 * math.pi * (xv + yv + t * 0.7)) + 1) / 2 * 255).astype(np.uint8) + + return np.stack([r, g, b], axis=-1) + + +class VideoPatternGenerator(FrameProcessor): + """Generates an animated gradient pattern and pushes it as video frames.""" + + def __init__(self, width: int, height: int, fps: int): + super().__init__() + self._width = width + self._height = height + self._fps = fps + self._generate_task = None + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + + if isinstance(frame, StartFrame): + await self.push_frame(frame, direction) + await self._start() + elif isinstance(frame, (EndFrame, CancelFrame)): + await self._stop() + await self.push_frame(frame, direction) + else: + await self.push_frame(frame, direction) + + async def _start(self): + self._generate_task = self.create_task(self._generate_loop(), "video_generate_loop") + + async def _stop(self): + if self._generate_task: + await self.cancel_task(self._generate_task) + self._generate_task = None + + async def _generate_loop(self): + interval = 1.0 / self._fps + start = time.monotonic() + + while True: + t = time.monotonic() - start + + pattern = generate_gradient_frame(self._width, self._height, t) + + frame = OutputImageRawFrame( + image=pattern.tobytes(), + size=(self._width, self._height), + format="RGB", + ) + await self.push_frame(frame) + + elapsed = time.monotonic() - start - t + await asyncio.sleep(max(0, interval - elapsed)) + + +class BlueTintProcessor(FrameProcessor): + """Duplicates OutputImageRawFrames with a blue tint for a custom video destination.""" + + def __init__(self, destination: str): + super().__init__() + self._destination = destination + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + + if isinstance(frame, OutputImageRawFrame): + # Pass through the original frame. + await self.push_frame(frame, direction) + + # Create a blue-tinted copy for the custom destination. + img = np.frombuffer(frame.image, dtype=np.uint8).reshape( + (frame.size[1], frame.size[0], 3) + ) + tinted = img.copy() + tinted[:, :, 0] = (tinted[:, :, 0] * 0.3).astype(np.uint8) # R + tinted[:, :, 1] = (tinted[:, :, 1] * 0.3).astype(np.uint8) # G + tinted[:, :, 2] = np.clip(tinted[:, :, 2].astype(np.uint16) + 80, 0, 255).astype( + np.uint8 + ) # B + + blue_frame = OutputImageRawFrame( + image=tinted.tobytes(), + size=frame.size, + format=frame.format, + ) + blue_frame.transport_destination = self._destination + await self.push_frame(blue_frame) + else: + await self.push_frame(frame, direction) + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info("Starting dual video track bot") + + generator = VideoPatternGenerator(WIDTH, HEIGHT, FPS) + blue_tint = BlueTintProcessor(destination="blue") + + task = PipelineTask( + Pipeline([generator, blue_tint, transport.output()]), + 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") + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info("Client disconnected") + await task.queue_frame(EndFrame()) + + 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/56-lemonslice-transport.py b/examples/foundational/56-lemonslice-transport.py new file mode 100644 index 000000000..667b317f8 --- /dev/null +++ b/examples/foundational/56-lemonslice-transport.py @@ -0,0 +1,123 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os +import sys + +import aiohttp +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.elevenlabs.tts import ElevenLabsTTSService +from pipecat.services.groq.llm import GroqLLMService +from pipecat.transports.lemonslice.transport import ( + LemonSliceNewSessionRequest, + LemonSliceParams, + LemonSliceTransport, +) + +load_dotenv(override=True) + +logger.remove(0) +logger.add(sys.stderr, level="DEBUG") + + +async def main(): + async with aiohttp.ClientSession() as session: + transport = LemonSliceTransport( + bot_name="Pipecat", + api_key=os.getenv("LEMONSLICE_API_KEY"), + session=session, + session_request=LemonSliceNewSessionRequest( + agent_id=os.getenv("LEMONSLICE_AGENT_ID"), + ), + params=LemonSliceParams( + audio_in_enabled=True, + audio_out_enabled=True, + microphone_out_enabled=False, + ), + ) + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + llm = GroqLLMService( + api_key=os.getenv("GROQ_API_KEY"), + settings=GroqLLMService.Settings( + system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.", + ), + ) + + tts = ElevenLabsTTSService( + api_key=os.getenv("ELEVENLABS_API_KEY", ""), + settings=ElevenLabsTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), + ) + + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + audio_in_sample_rate=16000, + audio_out_sample_rate=16000, + enable_metrics=True, + enable_usage_metrics=True, + ), + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, participant): + logger.info("Client connected") + # Kick off the conversation. + context.add_message( + { + "role": "user", + "content": "Start by greeting the user and ask how you can help.", + } + ) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, participant): + logger.info("Client disconnected") + await task.cancel() + + runner = PipelineRunner() + + await runner.run(task) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/foundational/README.md b/examples/foundational/README.md index bc25d42ca..04e88b7e7 100644 --- a/examples/foundational/README.md +++ b/examples/foundational/README.md @@ -4,7 +4,7 @@ This directory contains examples showing how to build voice and multimodal agent ## Setup -1. Follow the [README](../../README.md#%EF%B8%8F-contributing-to-the-framework) steps to get your local environment configured. +1. Follow the [README](https://github.com/pipecat-ai/pipecat/blob/main/README.md#%EF%B8%8F-contributing-to-the-framework) steps to get your local environment configured. > **Run from root directory**: Make sure you are running the steps from the root directory. @@ -37,7 +37,7 @@ Most examples support running with other transports, like Twilio or Daily. ### Daily -You need to create a Daily account at https://dashboard.daily.co/u/signup. Once signed up, you can create your own room from the dashboard and set the environment variables `DAILY_SAMPLE_ROOM_URL` and `DAILY_API_KEY`. Alternatively, you can let the example create a room for you (still needs `DAILY_API_KEY` environment variable). Then, start any example with `-t daily`: +You need to create a Daily account at https://dashboard.daily.co/u/signup. Once signed up, you can create your own room from the dashboard and set the environment variables `DAILY_ROOM_URL` and `DAILY_API_KEY`. Alternatively, you can let the example create a room for you (still needs `DAILY_API_KEY` environment variable). Then, start any example with `-t daily`: ```bash uv run 07-interruptible.py -t daily @@ -121,6 +121,7 @@ uv run 07-interruptible.py -t twilio -x NGROK_HOST_NAME - **[19-openai-realtime-beta.py](./19-openai-realtime-beta.py)**: OpenAI Speech-to-Speech (Direct S2S, Function calls) - **[21-tavus-layer-tavus-transport.py](./21-tavus-layer-tavus-transport.py)**: Tavus digital twin (Avatar integration) - **[27-simli-layer.py](./27-simli-layer.py)**: Simli avatar integration (Video synchronization) +- **[56-lemonslice-transport.py](./56-lemonslice-transport.py)**: LemonSlice avatar integration (A/V Synced Avatar integration) ### Performance & Optimization @@ -140,4 +141,4 @@ uv run python --host 0.0.0.0 --port 8080 - **Connection errors**: Verify API keys in `.env` file - **Port conflicts**: Use `--port` to change the port -For more examples, visit our the [`pipecat-examples repository](https://github.com/pipecat-ai/pipecat-examples). +For more examples, visit our the [pipecat-examples repository](https://github.com/pipecat-ai/pipecat-examples). diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md index 91a3fd888..6374d622c 100644 --- a/examples/quickstart/README.md +++ b/examples/quickstart/README.md @@ -81,23 +81,12 @@ Transform your local bot into a production-ready service. Pipecat Cloud handles > 💡 Tip: You can run the `pipecat` CLI using the `pc` alias. -3. Set up Docker for building your bot image: - - - **Install [Docker](https://www.docker.com/)** on your system - - **Create a [Docker Hub](https://hub.docker.com/) account** - - **Login to Docker Hub:** - - ```bash - docker login - ``` - ### Configure your deployment -The `pcc-deploy.toml` file tells Pipecat Cloud how to run your bot. **Update the image field** with your Docker Hub username by editing `pcc-deploy.toml`. +The `pcc-deploy.toml` file tells Pipecat Cloud how to run your bot. ```ini agent_name = "quickstart" -image = "YOUR_DOCKERHUB_USERNAME/quickstart:0.1" # 👈 Update this line secret_set = "quickstart-secrets" [scaling] @@ -107,12 +96,9 @@ secret_set = "quickstart-secrets" **Understanding the TOML file settings:** - `agent_name`: Your bot's name in Pipecat Cloud -- `image`: The Docker image to deploy (format: `username/image:version`) - `secret_set`: Where your API keys are stored securely - `min_agents`: Number of bot instances to keep ready (1 = instant start) -> 💡 Tip: [Set up `image_credentials`](https://docs.pipecat.ai/deployment/pipecat-cloud/fundamentals/secrets#image-pull-secrets) in your TOML file for authenticated image pulls - ### Log in to Pipecat Cloud To start using the CLI, authenticate to Pipecat Cloud: @@ -121,7 +107,7 @@ To start using the CLI, authenticate to Pipecat Cloud: pipecat cloud auth login ``` -You'll be presented with a link that you can click to authenticate your client. +You'll be presented with a link and six-digit code that you can click to authenticate your client. ### Configure secrets @@ -133,13 +119,7 @@ pipecat cloud secrets set quickstart-secrets --file .env This creates a secret set called `quickstart-secrets` (matching your TOML file) and uploads all your API keys from `.env`. -### Build and deploy - -Build your Docker image and push to Docker Hub: - -```bash -pipecat cloud docker build-push -``` +### Deploy Deploy to Pipecat Cloud: @@ -147,6 +127,8 @@ Deploy to Pipecat Cloud: pipecat cloud deploy ``` +This pushes your project files to Pipecat Cloud where a docker image is built and deployed into production. + ### Connect to your agent 1. Open your [Pipecat Cloud dashboard](https://pipecat.daily.co/) diff --git a/examples/quickstart/bot.py b/examples/quickstart/bot.py index d890b8b2c..c201edeb3 100644 --- a/examples/quickstart/bot.py +++ b/examples/quickstart/bot.py @@ -27,16 +27,11 @@ from loguru import logger print("🚀 Starting Pipecat bot...") print("⏳ Loading models and imports (20 seconds, first run only)\n") -logger.info("Loading Local Smart Turn Analyzer V3...") -from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 - -logger.info("✅ Local Smart Turn Analyzer V3 loaded") logger.info("Loading Silero VAD model...") from pipecat.audio.vad.silero import SileroVADAnalyzer logger.info("✅ Silero VAD model loaded") -from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import LLMRunFrame logger.info("Loading pipeline components...") @@ -44,8 +39,10 @@ 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 -from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor +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 @@ -66,33 +63,33 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + settings=CartesiaTTSService.Settings( + voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction="You are a friendly AI assistant. Respond naturally and keep your answers conversational.", + ), + ) - messages = [ - { - "role": "system", - "content": "You are a friendly AI assistant. Respond naturally and keep your answers conversational.", - }, - ] - - context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair(context) - - rtvi = RTVIProcessor(config=RTVIConfig(config=[])) + context = LLMContext() + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) pipeline = Pipeline( [ transport.input(), # Transport user input - rtvi, # RTVI processor stt, - context_aggregator.user(), # User responses + user_aggregator, # User responses llm, # LLM tts, # TTS transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses + assistant_aggregator, # Assistant spoken responses ] ) @@ -102,14 +99,15 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): enable_metrics=True, enable_usage_metrics=True, ), - observers=[RTVIObserver(rtvi)], ) @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") # Kick off the conversation. - messages.append({"role": "system", "content": "Say hello and briefly introduce yourself."}) + context.add_message( + {"role": "user", "content": "Say hello and briefly introduce yourself."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") @@ -129,14 +127,10 @@ async def bot(runner_args: RunnerArguments): "daily": lambda: DailyParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - turn_analyzer=LocalSmartTurnAnalyzerV3(), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - turn_analyzer=LocalSmartTurnAnalyzerV3(), ), } diff --git a/examples/quickstart/pcc-deploy.toml b/examples/quickstart/pcc-deploy.toml index ff77c45dc..5562f117e 100644 --- a/examples/quickstart/pcc-deploy.toml +++ b/examples/quickstart/pcc-deploy.toml @@ -1,11 +1,6 @@ agent_name = "quickstart" -image = "your_username/quickstart:0.1" secret_set = "quickstart-secrets" agent_profile = "agent-1x" -# RECOMMENDED: Set an image pull secret: -# https://docs.pipecat.ai/deployment/pipecat-cloud/fundamentals/secrets#image-pull-secrets -# image_credentials = "your_image_pull_secret" - [scaling] min_agents = 1 diff --git a/examples/quickstart/pyproject.toml b/examples/quickstart/pyproject.toml index 863e350d4..efeee9106 100644 --- a/examples/quickstart/pyproject.toml +++ b/examples/quickstart/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "Quickstart example for building voice AI bots with Pipecat" requires-python = ">=3.10" dependencies = [ - "pipecat-ai[webrtc,daily,silero,deepgram,openai,cartesia,local-smart-turn-v3,runner]", + "pipecat-ai[webrtc,daily,silero,deepgram,openai,cartesia,runner]", "pipecat-ai-cli" ] @@ -17,4 +17,4 @@ dev = [ [tool.ruff] line-length = 100 [tool.ruff.lint] -select = ["I"] \ No newline at end of file +select = ["I"] diff --git a/pyproject.toml b/pyproject.toml index f75673cb6..91afcc794 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,109 +20,119 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence" ] dependencies = [ - "aiofiles>=24.1.0,<25", + "aiofiles>=24.1.0,<27", "aiohttp>=3.11.12,<4", "audioop-lts~=0.2.1; python_version>='3.13'", - "docstring_parser~=0.16", + "docstring_parser>=0.16,<1", "loguru~=0.7.3", "Markdown>=3.7,<4", - "nltk>=3.9.1,<4", + "nltk>=3.9.3,<4", "numpy>=1.26.4,<3", - "Pillow>=11.1.0,<12", - "protobuf~=5.29.3", + "Pillow>=11.1.0,<13", + "protobuf~=5.29.6", "pydantic>=2.10.6,<3", - "pyloudnorm~=0.1.1", + "pyloudnorm~=0.2.0", "resampy~=0.4.3", - "soxr~=0.5.0", + "soxr~=1.0.0", "openai>=1.74.0,<3", # Pinning numba to resolve package dependencies - "numba==0.61.2", - "wait_for2>=0.4.1; python_version<'3.12'", + "numba>=0.61.2,<1", + "wait_for2>=0.4.1,<1; python_version<'3.12'", + # Required by LocalSmartTurnAnalyzerV3 + # Inlined here instead of using a self-referential extra for Poetry compatibility. + "transformers>=4.48.0,<6", + "onnxruntime~=1.23.2", ] [project.urls] +Homepage = "https://pipecat.ai" +Documentation = "https://docs.pipecat.ai/" Source = "https://github.com/pipecat-ai/pipecat" -Website = "https://pipecat.ai" +Issues = "https://github.com/pipecat-ai/pipecat/issues" +Changelog = "https://github.com/pipecat-ai/pipecat/blob/main/CHANGELOG.md" [project.optional-dependencies] -aic = [ "aic-sdk~=1.2.0" ] -anthropic = [ "anthropic~=0.49.0" ] +aic = [ "aic-sdk~=2.1.0" ] +anthropic = [ "anthropic>=0.49.0,<1" ] assemblyai = [ "pipecat-ai[websockets-base]" ] asyncai = [ "pipecat-ai[websockets-base]" ] -aws = [ "aioboto3~=15.5.0", "pipecat-ai[websockets-base]" ] -aws-nova-sonic = [ "aws_sdk_bedrock_runtime~=0.2.0; python_version>='3.12'" ] -azure = [ "azure-cognitiveservices-speech~=1.44.0"] -cartesia = [ "cartesia~=2.0.3", "pipecat-ai[websockets-base]" ] +aws = [ "aioboto3>=15.5.0,<16", "pipecat-ai[websockets-base]" ] +aws-nova-sonic = [ "aws_sdk_bedrock_runtime~=0.4.0; python_version>='3.12'" ] +azure = [ "azure-cognitiveservices-speech>=1.47.0,<2"] +cartesia = [ "pipecat-ai[websockets-base]" ] +camb = [ "camb-sdk>=1.5.4,<2" ] cerebras = [] -daily = [ "daily-python~=0.23.0" ] -deepgram = [ "deepgram-sdk~=4.7.0", "pipecat-ai[websockets-base]" ] +daily = [ "daily-python~=0.25.0" ] +deepgram = [ "deepgram-sdk>=6.0.1,<7", "pipecat-ai[websockets-base]" ] deepseek = [] elevenlabs = [ "pipecat-ai[websockets-base]" ] -fal = [ "fal-client~=0.5.9" ] +fal = [] fireworks = [] -fish = [ "ormsgpack~=1.7.0", "pipecat-ai[websockets-base]" ] +fish = [ "ormsgpack>=1.7.0,<2", "pipecat-ai[websockets-base]" ] gladia = [ "pipecat-ai[websockets-base]" ] -google = [ "google-cloud-speech>=2.33.0,<3", "google-cloud-texttospeech>=2.31.0,<3", "google-genai>=1.51.0,<2", "pipecat-ai[websockets-base]" ] +google = [ "google-cloud-speech>=2.33.0,<3", "google-cloud-texttospeech>=2.31.0,<3", "google-genai>=1.57.0,<2", "pipecat-ai[websockets-base]" ] gradium = [ "pipecat-ai[websockets-base]" ] grok = [] -groq = [ "groq~=0.23.0" ] +groq = [ "groq>=0.23.0,<2" ] gstreamer = [ "pygobject~=3.50.0" ] -heygen = [ "livekit>=1.0.13", "pipecat-ai[websockets-base]" ] -hume = [ "hume>=0.11.2" ] +heygen = [ "livekit>=1.0.13,<2", "pipecat-ai[websockets-base]" ] +hume = [ "hume>=0.11.2,<1" ] inworld = [] koala = [ "pvkoala~=2.0.3" ] +kokoro = [ "kokoro-onnx>=0.5.0,<1", "requests>=2.32.5,<3" ] krisp = [ "pipecat-ai-krisp~=0.4.0" ] langchain = [ "langchain~=0.3.20", "langchain-community~=0.3.20", "langchain-openai~=0.3.9" ] -livekit = [ "livekit~=1.0.13", "livekit-api~=1.0.5", "tenacity>=8.2.3,<10.0.0", "pyjwt>=2.10.1" ] +lemonslice = [ "pipecat-ai[daily]" ] +livekit = [ "livekit>=1.0.13,<2", "livekit-api>=1.0.5,<2", "tenacity>=8.2.3,<10.0.0", "pyjwt>=2.12.0,<3" ] lmnt = [ "pipecat-ai[websockets-base]" ] local = [ "pyaudio~=0.2.14" ] -local-smart-turn = [ "coremltools>=8.0", "transformers", "torch>=2.5.0,<3", "torchaudio>=2.5.0,<3" ] -local-smart-turn-v3 = [ "transformers", "onnxruntime>=1.20.1,<2" ] +local-smart-turn = [ "coremltools>=8.0", "transformers>=4.48.0,<6", "torch>=2.5.0,<3", "torchaudio>=2.5.0,<3" ] mcp = [ "mcp[cli]>=1.11.0,<2" ] mem0 = [ "mem0ai~=0.1.94" ] mistral = [] mlx-whisper = [ "mlx-whisper~=0.4.2" ] -moondream = [ "accelerate~=1.10.0", "einops~=0.8.0", "pyvips[binary]~=3.0.0", "timm~=1.0.13", "transformers>=4.48.0" ] +moondream = [ "accelerate~=1.10.0", "einops~=0.8.0", "pyvips[binary]~=3.0.0", "timm~=1.0.13", "transformers>=4.48.0,<6" ] neuphonic = [ "pipecat-ai[websockets-base]" ] noisereduce = [ "noisereduce~=3.0.3" ] -nvidia = [ "nvidia-riva-client~=2.21.1" ] +nvidia = [ "nvidia-riva-client>=2.21.1,<3" ] openai = [ "pipecat-ai[websockets-base]" ] rnnoise = [ "pyrnnoise~=0.4.1" ] openpipe = [ "openpipe>=4.50.0,<6" ] openrouter = [] perplexity = [] -playht = [ "pipecat-ai[websockets-base]" ] +piper = [ "piper-tts>=1.3.0,<2", "requests>=2.32.5,<3" ] qwen = [] 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.122.0", "pipecat-ai-small-webrtc-prebuilt>=2.0.4"] +runner = [ "python-dotenv>=1.0.0,<2.0.0", "uvicorn>=0.32.0,<1.0.0", "fastapi>=0.115.6,<1", "pipecat-ai-small-webrtc-prebuilt>=2.4.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.26", "pipecat-ai[websockets-base]" ] sentry = [ "sentry-sdk>=2.28.0,<3" ] -silero = [ "onnxruntime>=1.20.1,<2" ] -simli = [ "simli-ai~=1.0.3"] +silero = [] +simli = [ "simli-ai~=2.0.1"] soniox = [ "pipecat-ai[websockets-base]" ] soundfile = [ "soundfile~=0.13.1" ] -speechmatics = [ "speechmatics-voice[smart]>=0.2.6" ] +speechmatics = [ "speechmatics-voice[smart]~=0.2.8" ] strands = [ "strands-agents>=1.9.1,<2" ] tavus=[] together = [] -tracing = [ "opentelemetry-sdk>=1.33.0", "opentelemetry-api>=1.33.0", "opentelemetry-instrumentation>=0.54b0" ] +tracing = [ "opentelemetry-sdk>=1.33.0,<2", "opentelemetry-api>=1.33.0,<2", "opentelemetry-instrumentation>=0.54b0,<1" ] ultravox = [ "pipecat-ai[websockets-base]" ] -webrtc = [ "aiortc>=1.13.0,<2", "opencv-python>=4.11.0.86,<5" ] -websocket = [ "pipecat-ai[websockets-base]", "fastapi>=0.115.6,<0.122.0" ] +webrtc = [ "aiortc>=1.14.0,<2", "opencv-python>=4.11.0.86,<5" ] +websocket = [ "pipecat-ai[websockets-base]", "fastapi>=0.115.6,<1" ] websockets-base = [ "websockets>=13.1,<16.0" ] -whisper = [ "faster-whisper~=1.1.1" ] +whisper = [ "faster-whisper~=1.2.1" ] [dependency-groups] dev = [ - "build~=1.2.2", - "coverage~=7.9.1", + "build~=1.4.0", + "coverage~=7.13.4", "grpcio-tools~=1.67.1", - "pip-tools~=7.4.1", - "pre-commit~=4.2.0", + "pip-tools~=7.5.3", + "pre-commit~=4.5.1", "pyright>=1.1.404,<1.2", "pytest~=8.4.1", "pytest-asyncio~=1.3.0", @@ -247,6 +257,11 @@ directory = "fixed" name = "Fixed" showcontent = true +[[tool.towncrier.type]] +directory = "performance" +name = "Performance" +showcontent = true + [[tool.towncrier.type]] directory = "security" name = "Security" diff --git a/scripts/evals/eval.py b/scripts/evals/eval.py index 7c6a72604..925853981 100644 --- a/scripts/evals/eval.py +++ b/scripts/evals/eval.py @@ -16,7 +16,6 @@ from pathlib import Path from typing import Any, List, Optional, Tuple import aiofiles -from deepgram import LiveOptions from loguru import logger from PIL.ImageFile import ImageFile from utils import ( @@ -42,7 +41,10 @@ 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 +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) from pipecat.processors.audio.audio_buffer_processor import AudioBufferProcessor from pipecat.processors.frame_processor import FrameDirection from pipecat.runner.types import RunnerArguments @@ -195,7 +197,7 @@ class EvalRunner: async def run_example_pipeline(script_path: Path, eval_config: EvalConfig): - room_url = os.getenv("DAILY_SAMPLE_ROOM_URL") + room_url = os.getenv("DAILY_ROOM_URL") module = load_module_from_path(script_path) @@ -207,7 +209,6 @@ async def run_example_pipeline(script_path: Path, eval_config: EvalConfig): audio_in_enabled=True, audio_out_enabled=True, video_in_enabled=True, - vad_analyzer=SileroVADAnalyzer(), ), ) @@ -225,7 +226,7 @@ async def run_eval_pipeline( ): logger.info(f"Starting eval bot") - room_url = os.getenv("DAILY_SAMPLE_ROOM_URL") + room_url = os.getenv("DAILY_ROOM_URL") transport = DailyTransport( room_url, @@ -235,7 +236,6 @@ async def run_eval_pipeline( audio_in_enabled=True, audio_out_enabled=True, video_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=2.0)), ), ) @@ -243,7 +243,7 @@ async def run_eval_pipeline( # 5" (in audio) this can be converted to "32 is 5". stt = DeepgramSTTService( api_key=os.getenv("DEEPGRAM_API_KEY"), - live_options=LiveOptions( + settings=DeepgramSTTService.Settings( language="multi", smart_format=False, ), @@ -251,13 +251,11 @@ async def run_eval_pipeline( tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="97f4b8fb-f2fe-444b-bb9a-c109783a857a", # Nathan + settings=CartesiaTTSService.Settings( + voice="97f4b8fb-f2fe-444b-bb9a-c109783a857a", # Nathan + ), ) - llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) - - llm.register_function("eval_function", eval_runner.function_assert_eval) - eval_function = FunctionSchema( name="eval_function", description=( @@ -293,22 +291,30 @@ async def run_eval_pipeline( "You should only call the eval function if:\n" "- The user explicitly attempts to answer the question, AND\n" f"- Their answer can be cleanly evaluated using: {eval_config.eval}\n" - "Ignore greetings, comments, non-answers, or requests for clarification." + "Ignore greetings, comments, non-answers, or requests for clarification.\n" + "Numerical word answers are allowed (e.g., 'five' is the same as '5').\n" ) if eval_config.eval_speaks_first: - system_prompt = f"You are an evaluation agent, be extremly brief. Numerical word answers are allowed. You will start the conversation by saying: '{example_prompt}'. {common_system_prompt}" + system_prompt = f"You are an evaluation agent, be extremly brief. You will start the conversation by saying: '{example_prompt}'. {common_system_prompt}" else: - system_prompt = f"You are an evaluation agent, be extremly brief. Numerical word answers are allowed. First, ask one question: {example_prompt}. {common_system_prompt}" + system_prompt = f"You are an evaluation agent, be extremly brief. First, ask one question: {example_prompt}. {common_system_prompt}" - messages = [ - { - "role": "system", - "content": system_prompt, - }, - ] + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAILLMService.Settings( + system_instruction=system_prompt, + ), + ) - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair(context) + llm.register_function("eval_function", eval_runner.function_assert_eval) + + context = LLMContext(tools=tools) + context_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=2.0)), + ), + ) audio_buffer = AudioBufferProcessor() @@ -331,6 +337,7 @@ async def run_eval_pipeline( audio_in_sample_rate=16000, audio_out_sample_rate=16000, ), + enable_rtvi=False, idle_timeout_secs=PIPELINE_IDLE_TIMEOUT_SECS, ) @@ -354,7 +361,7 @@ async def run_eval_pipeline( # Default behavior is for the bot to speak first # If the eval bot speaks first, we append the prompt to the messages if eval_config.eval_speaks_first: - messages.append( + context.add_message( {"role": "user", "content": f"Start by saying this exactly: '{eval_config.prompt}'"} ) await task.queue_frames([LLMRunFrame()]) diff --git a/scripts/evals/run-release-evals.py b/scripts/evals/run-release-evals.py index 65902f41a..19492ba62 100644 --- a/scripts/evals/run-release-evals.py +++ b/scripts/evals/run-release-evals.py @@ -30,10 +30,15 @@ EVAL_SIMPLE_MATH = EvalConfig( ) EVAL_WEATHER = EvalConfig( - prompt="What's the weather in San Francisco? Temperature should be in fahrenheits.", + prompt="What's the weather in San Francisco? Temperature should be in Fahrenheit.", eval="The user talks about the weather in San Francisco, including the degrees.", ) +EVAL_WEATHER_AND_RESTAURANT = EvalConfig( + prompt="What's the weather in San Francisco, and what's a good restaurant there? Temperature should be in Fahrenheit.", + eval="The user talks about the weather in San Francisco, including the degrees, and provides a restaurant recommendation.", +) + EVAL_ONLINE_SEARCH = EvalConfig( prompt="What's the current date in UTC?", eval=f"Current date in UTC is {datetime.now(timezone.utc).strftime('%A, %B %d, %Y')}.", @@ -85,6 +90,11 @@ EVAL_ORDER = EvalConfig( eval_speaks_first=True, ) +EVAL_COMPLETE_TURN = EvalConfig( + prompt="I would go to Japan because I love the culture and want to try authentic ramen.", + eval="The user provides a relevant response about Japan or travel, showing the conversation continues normally.", +) + TESTS_07 = [ # 07 series @@ -92,15 +102,6 @@ TESTS_07 = [ ("07-interruptible-cartesia-http.py", EVAL_SIMPLE_MATH), ("07a-interruptible-speechmatics.py", EVAL_SIMPLE_MATH), ("07a-interruptible-speechmatics-vad.py", EVAL_SIMPLE_MATH), - ("07aa-interruptible-soniox.py", EVAL_SIMPLE_MATH), - ("07ab-interruptible-inworld.py", EVAL_SIMPLE_MATH), - ("07ab-interruptible-inworld-http.py", EVAL_SIMPLE_MATH), - ("07ac-interruptible-asyncai.py", EVAL_SIMPLE_MATH), - ("07ac-interruptible-asyncai-http.py", EVAL_SIMPLE_MATH), - # Need license key to run - # ("07ad-interruptible-aicoustics.py", EVAL_SIMPLE_MATH), - ("07ae-interruptible-hume.py", EVAL_SIMPLE_MATH), - ("07af-interruptible-gradium.py", EVAL_SIMPLE_MATH), ("07b-interruptible-langchain.py", EVAL_SIMPLE_MATH), ("07c-interruptible-deepgram.py", EVAL_SIMPLE_MATH), ("07c-interruptible-deepgram-flux.py", EVAL_SIMPLE_MATH), @@ -110,8 +111,10 @@ TESTS_07 = [ ("07f-interruptible-azure.py", EVAL_SIMPLE_MATH), ("07f-interruptible-azure-http.py", EVAL_SIMPLE_MATH), ("07g-interruptible-openai.py", EVAL_SIMPLE_MATH), + ("07g-interruptible-openai-http.py", EVAL_SIMPLE_MATH), ("07h-interruptible-openpipe.py", EVAL_SIMPLE_MATH), ("07j-interruptible-gladia.py", EVAL_SIMPLE_MATH), + ("07j-interruptible-gladia-vad.py", EVAL_SIMPLE_MATH), ("07k-interruptible-lmnt.py", EVAL_SIMPLE_MATH), ("07l-interruptible-groq.py", EVAL_SIMPLE_MATH), ("07m-interruptible-aws.py", EVAL_SIMPLE_MATH), @@ -120,6 +123,7 @@ TESTS_07 = [ ("07n-interruptible-google.py", EVAL_SIMPLE_MATH), ("07n-interruptible-google-http.py", EVAL_SIMPLE_MATH), ("07o-interruptible-assemblyai.py", EVAL_SIMPLE_MATH), + ("07p-interruptible-krisp-viva.py", EVAL_SIMPLE_MATH), ("07q-interruptible-rime.py", EVAL_SIMPLE_MATH), ("07q-interruptible-rime-http.py", EVAL_SIMPLE_MATH), ("07r-interruptible-nvidia.py", EVAL_SIMPLE_MATH), @@ -131,24 +135,42 @@ TESTS_07 = [ ("07y-interruptible-minimax.py", EVAL_SIMPLE_MATH), ("07z-interruptible-sarvam.py", EVAL_SIMPLE_MATH), ("07z-interruptible-sarvam-http.py", EVAL_SIMPLE_MATH), + ("07za-interruptible-soniox.py", EVAL_SIMPLE_MATH), + ("07zb-interruptible-inworld.py", EVAL_SIMPLE_MATH), + ("07zb-interruptible-inworld-http.py", EVAL_SIMPLE_MATH), + ("07zc-interruptible-asyncai.py", EVAL_SIMPLE_MATH), + ("07zc-interruptible-asyncai-http.py", EVAL_SIMPLE_MATH), + ("07zd-interruptible-aicoustics.py", EVAL_SIMPLE_MATH), + ("07ze-interruptible-hume.py", EVAL_SIMPLE_MATH), + ("07zf-interruptible-gradium.py", EVAL_SIMPLE_MATH), + ("07zg-interruptible-camb.py", EVAL_SIMPLE_MATH), + ("07zi-interruptible-piper.py", EVAL_SIMPLE_MATH), + ("07zj-interruptible-kokoro.py", EVAL_SIMPLE_MATH), + ("07zk-interruptible-resembleai.py", EVAL_SIMPLE_MATH), + ("07-interruptible-openai-responses.py", EVAL_SIMPLE_MATH), # Needs a local XTTS docker instance running. # ("07i-interruptible-xtts.py", EVAL_SIMPLE_MATH), - # Needs a Krisp license. - # ("07p-interruptible-krisp.py", EVAL_SIMPLE_MATH), ] TESTS_12 = [ ("12-describe-image-openai.py", EVAL_VISION_IMAGE(eval_speaks_first=True)), + ("12-describe-image-openai-responses.py", EVAL_VISION_IMAGE(eval_speaks_first=True)), ("12a-describe-image-anthropic.py", EVAL_VISION_IMAGE(eval_speaks_first=True)), ("12b-describe-image-aws.py", EVAL_VISION_IMAGE(eval_speaks_first=True)), ("12c-describe-image-gemini-flash.py", EVAL_VISION_IMAGE(eval_speaks_first=True)), ("12d-describe-image-moondream.py", EVAL_VISION_IMAGE()), ] +# For a few major services, we also test parallel function calling. +# (We don't bother doing this with every single service, as it's expensive and +# most rely on the same OpenAI-compatible implementation.) TESTS_14 = [ ("14-function-calling.py", EVAL_WEATHER), + ("14-function-calling.py", EVAL_WEATHER_AND_RESTAURANT), ("14a-function-calling-anthropic.py", EVAL_WEATHER), + ("14a-function-calling-anthropic.py", EVAL_WEATHER_AND_RESTAURANT), ("14e-function-calling-google.py", EVAL_WEATHER), + ("14e-function-calling-google.py", EVAL_WEATHER_AND_RESTAURANT), ("14f-function-calling-groq.py", EVAL_WEATHER), ("14g-function-calling-grok.py", EVAL_WEATHER), ("14h-function-calling-azure.py", EVAL_WEATHER), @@ -160,15 +182,19 @@ TESTS_14 = [ ("14p-function-calling-gemini-vertex-ai.py", EVAL_WEATHER), ("14q-function-calling-qwen.py", EVAL_WEATHER), ("14r-function-calling-aws.py", EVAL_WEATHER), + ("14r-function-calling-aws.py", EVAL_WEATHER_AND_RESTAURANT), ("14v-function-calling-openai.py", EVAL_WEATHER), ("14w-function-calling-mistral.py", EVAL_WEATHER), ("14x-function-calling-openpipe.py", EVAL_WEATHER), + ("14-function-calling-openai-responses.py", EVAL_WEATHER), + ("14-function-calling-openai-responses.py", EVAL_WEATHER_AND_RESTAURANT), # Video ("14d-function-calling-anthropic-video.py", EVAL_VISION_CAMERA), ("14d-function-calling-aws-video.py", EVAL_VISION_CAMERA), ("14d-function-calling-gemini-flash-video.py", EVAL_VISION_CAMERA), ("14d-function-calling-moondream-video.py", EVAL_VISION_CAMERA), ("14d-function-calling-openai-video.py", EVAL_VISION_CAMERA), + ("14d-function-calling-openai-responses-video.py", EVAL_VISION_CAMERA), # Currently not working. # ("14c-function-calling-together.py", EVAL_WEATHER), # ("14l-function-calling-deepseek.py", EVAL_WEATHER), @@ -194,6 +220,10 @@ TESTS_21 = [ ("21a-tavus-video-service.py", EVAL_SIMPLE_MATH), ] +TESTS_22 = [ + ("22-filter-incomplete-turns.py", EVAL_COMPLETE_TURN), +] + TESTS_26 = [ ("26-gemini-live.py", EVAL_SIMPLE_MATH), ("26a-gemini-live-transcription.py", EVAL_SIMPLE_MATH), @@ -250,6 +280,7 @@ TESTS = [ *TESTS_15, *TESTS_19, *TESTS_21, + *TESTS_22, *TESTS_26, *TESTS_27, *TESTS_40, diff --git a/scripts/evals/utils.py b/scripts/evals/utils.py index 590cc0946..8111d404f 100644 --- a/scripts/evals/utils.py +++ b/scripts/evals/utils.py @@ -28,7 +28,7 @@ def check_env_variables() -> bool: "CARTESIA_API_KEY", "DEEPGRAM_API_KEY", "OPENAI_API_KEY", - "DAILY_SAMPLE_ROOM_URL", + "DAILY_ROOM_URL", ] for env in required_envs: if not os.getenv(env): diff --git a/scripts/krisp/test_krisp_viva_filter_audiofile.py b/scripts/krisp/test_krisp_viva_filter_audiofile.py index c75dc19fc..65e9d7685 100644 --- a/scripts/krisp/test_krisp_viva_filter_audiofile.py +++ b/scripts/krisp/test_krisp_viva_filter_audiofile.py @@ -22,7 +22,7 @@ from pathlib import Path try: import numpy as np - import soundfile as sf + import soundfile as sf # noqa: F401 from audio_file_utils import calculate_audio_stats, read_audio_file, write_audio_file except ImportError as e: print(f"Error: Missing required dependencies: {e}") diff --git a/scripts/krisp/test_krisp_viva_turn_audiofile.py b/scripts/krisp/test_krisp_viva_turn_audiofile.py index c380ad98f..6580e8e90 100644 --- a/scripts/krisp/test_krisp_viva_turn_audiofile.py +++ b/scripts/krisp/test_krisp_viva_turn_audiofile.py @@ -23,7 +23,7 @@ from pathlib import Path try: import numpy as np - import soundfile as sf + import soundfile as sf # noqa: F401 from audio_file_utils import read_audio_file except ImportError as e: print(f"Error: Missing required dependencies: {e}") diff --git a/src/pipecat/adapters/services/bedrock_adapter.py b/src/pipecat/adapters/services/bedrock_adapter.py index 8e46b1899..d63c5cf0f 100644 --- a/src/pipecat/adapters/services/bedrock_adapter.py +++ b/src/pipecat/adapters/services/bedrock_adapter.py @@ -10,7 +10,7 @@ import base64 import copy import json from dataclasses import dataclass -from typing import Any, Dict, List, Literal, Optional, TypedDict +from typing import Any, Dict, List, Optional, TypedDict from loguru import logger @@ -209,7 +209,7 @@ class AWSBedrockLLMAdapter(BaseLLMAdapter[AWSBedrockLLMInvocationParams]): tool_result_content = [{"json": content_json}] else: tool_result_content = [{"text": message["content"]}] - except: + except (json.JSONDecodeError, ValueError, AttributeError): tool_result_content = [{"text": message["content"]}] return { diff --git a/src/pipecat/adapters/services/gemini_adapter.py b/src/pipecat/adapters/services/gemini_adapter.py index e17d9a508..4968c2719 100644 --- a/src/pipecat/adapters/services/gemini_adapter.py +++ b/src/pipecat/adapters/services/gemini_adapter.py @@ -9,7 +9,7 @@ import base64 import json from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Tuple, TypedDict +from typing import Any, Dict, List, Optional, TypedDict from loguru import logger from openai import NotGiven @@ -255,6 +255,9 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]): # Apply thought signatures to the corresponding messages self._apply_thought_signatures_to_messages(thought_signature_dicts, messages) + # When thinking is enabled, merge parallel tool calls into single messages + messages = self._merge_parallel_tool_calls_for_thinking(thought_signature_dicts, messages) + # Check if we only have function-related messages (no regular text) has_regular_messages = any( len(msg.parts) == 1 @@ -433,6 +436,103 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]): tool_call_id_to_name_mapping=tool_call_id_to_name_mapping, ) + def _merge_parallel_tool_calls_for_thinking( + self, thought_signature_dicts: List[dict], messages: List[Content] + ) -> List[Content]: + """Merge parallel tool calls into single Content objects when thinking is enabled. + + Gemini expects parallel tool calls (multiple function calls made + simultaneously) to be in a single Content with multiple function_call + Parts. This method takes a list of Content messages, where parallel + tool calls may be split across multiple messages, and merges them into + single messages. + + This only has an effect when thought_signatures are present (i.e., when + thinking is enabled). When thinking is disabled, merging doesn't matter. + When thinking is enabled, there is a guarantee that the first tool call + (and only the first) in any batch of parallel tool calls will have a + thought_signature. This allows us to distinguish: + + - Parallel tool calls: share a single thought_signature (on the first call) + - Sequential tool calls: each have their own thought_signature + + Algorithm: A tool call message with a thought_signature starts a new + parallel group. Any tool call messages after it without a + thought_signature get merged into that group, regardless of what + messages appear in between. + + Args: + thought_signature_dicts: A list of thought signature dicts, used + to determine if the work of merging is necessary. + messages: List of Content messages to process. + + Returns: + List of Content messages with parallel tool calls merged when + thought_signatures are present, otherwise unchanged. + """ + if not messages: + return messages + + # Fast-exit if no function-call-related thought signatures + # This is a shortcut for determining both: + # - whether thinking is enabled, and + # - whether there are function calls in the messages + has_function_call_signatures = any( + ts.get("bookmark", {}).get("function_call") for ts in thought_signature_dicts + ) + if not has_function_call_signatures: + return messages + + def is_tool_call_message(msg: Content) -> bool: + """Check if message contains only function_call parts.""" + return ( + msg.role == "model" + and msg.parts + and all(getattr(part, "function_call", None) for part in msg.parts) + ) + + def message_has_thought_signature(msg: Content) -> bool: + """Check if any part in the message has a thought_signature.""" + return any(getattr(part, "thought_signature", None) for part in msg.parts) + + merged_messages = [] + i = 0 + + while i < len(messages): + current = messages[i] + + # If this is a tool call message with a thought signature, start merging + if is_tool_call_message(current) and message_has_thought_signature(current): + merged_parts = list(current.parts) + other_messages = [] + j = i + 1 + + # Scan forward, merging tool calls without signatures, collecting others + while j < len(messages): + next_msg = messages[j] + if is_tool_call_message(next_msg): + if message_has_thought_signature(next_msg): + # New parallel group starts, stop here + break + else: + # Merge this call into the current group + merged_parts.extend(next_msg.parts) + j += 1 + else: + # Collect non-tool-call message, keep scanning + other_messages.append(next_msg) + j += 1 + + # Output merged calls, then collected other messages + merged_messages.append(Content(role="model", parts=merged_parts)) + merged_messages.extend(other_messages) + i = j + else: + merged_messages.append(current) + i += 1 + + return merged_messages + def _apply_thought_signatures_to_messages( self, thought_signature_dicts: List[dict], messages: List[Content] ) -> None: diff --git a/src/pipecat/adapters/services/open_ai_adapter.py b/src/pipecat/adapters/services/open_ai_adapter.py index 6c44a3404..f4b534f2c 100644 --- a/src/pipecat/adapters/services/open_ai_adapter.py +++ b/src/pipecat/adapters/services/open_ai_adapter.py @@ -7,10 +7,8 @@ """OpenAI LLM adapter for Pipecat.""" import copy -import json from typing import Any, Dict, List, TypedDict -from openai._types import NOT_GIVEN as OPEN_AI_NOT_GIVEN from openai._types import NotGiven as OpenAINotGiven from openai.types.chat import ( ChatCompletionMessageParam, diff --git a/src/pipecat/adapters/services/open_ai_responses_adapter.py b/src/pipecat/adapters/services/open_ai_responses_adapter.py new file mode 100644 index 000000000..70627fe5d --- /dev/null +++ b/src/pipecat/adapters/services/open_ai_responses_adapter.py @@ -0,0 +1,254 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""OpenAI Responses API adapter for Pipecat.""" + +import copy +from typing import Any, Dict, List, Optional, TypedDict + +from loguru import logger +from openai._types import NotGiven as OpenAINotGiven +from openai.types.responses import FunctionToolParam, ResponseInputItemParam + +from pipecat.adapters.base_llm_adapter import BaseLLMAdapter +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.processors.aggregators.llm_context import ( + LLMContext, + LLMContextMessage, + LLMSpecificMessage, + NotGiven, +) + + +class OpenAIResponsesLLMInvocationParams(TypedDict, total=False): + """Context-based parameters for invoking OpenAI Responses API.""" + + input: List[ResponseInputItemParam] + tools: List[FunctionToolParam] | OpenAINotGiven + instructions: str + + +class OpenAIResponsesLLMAdapter(BaseLLMAdapter[OpenAIResponsesLLMInvocationParams]): + """OpenAI Responses API adapter for Pipecat. + + Handles: + + - Converting LLMContext messages to Responses API input items + - Converting Pipecat's standardized tools schema to Responses API function tool format + - Extracting and sanitizing messages from the LLM context for logging + """ + + def __init__(self): + """Initialize the adapter.""" + super().__init__() + self._warned_system_instruction = False + + @property + def id_for_llm_specific_messages(self) -> str: + """Get the identifier used in LLMSpecificMessage instances.""" + return "openai_responses" + + def get_llm_invocation_params( + self, + context: LLMContext, + *, + system_instruction: Optional[str] = None, + ) -> OpenAIResponsesLLMInvocationParams: + """Get Responses API invocation parameters from a universal LLM context. + + Args: + context: The LLM context containing messages, tools, etc. + system_instruction: Optional system instruction from service settings. + + Returns: + Dictionary of parameters for the Responses API. + """ + messages = self.get_messages(context) + input_items = self._convert_messages_to_input(messages) + + params: OpenAIResponsesLLMInvocationParams = { + "input": input_items, + "tools": self.from_standard_tools(context.tools), + } + + if system_instruction: + # Compatibility: The Responses API requires at least one input + # message when instructions are provided. Contexts that worked with + # OpenAILLMService (system_instruction + empty messages) need the + # instructions converted to an initial developer message. + # + # NOTE: if/when we support `previous_response_id` and/or + # `conversation_id`, we'll need to revisit this logic, as it'll + # be legit to provide instructions without input items. Worth + # noting that OpenAI's docs suggest these parameters are primarily + # for development convenience rather than performance (the model + # still processes the full context), and come with the tradeoff + # of requiring OpenAI-side 30-day conversation storage, which may + # not be desirable for many users. But it could give folks an easy + # way to store/switch between conversations without needing to + # manage that storage themselves. + if not input_items: + params["input"] = [{"role": "developer", "content": system_instruction}] + else: + params["instructions"] = system_instruction + + return params + + def to_provider_tools_format(self, tools_schema: ToolsSchema) -> List[FunctionToolParam]: + """Convert function schemas to Responses API function tool format. + + Args: + tools_schema: The Pipecat tools schema to convert. + + Returns: + List of Responses API function tool definitions. + """ + functions_schema = tools_schema.standard_tools + result = [] + for func in functions_schema: + d = func.to_default_dict() + tool: FunctionToolParam = { + "type": "function", + "name": d["name"], + "parameters": d.get("parameters", {}), + "strict": d.get("strict", None), + } + if "description" in d: + tool["description"] = d["description"] + result.append(tool) + return result + + def get_messages_for_logging(self, context: LLMContext) -> List[Dict[str, Any]]: + """Get messages from context in a format ready for logging. + + Removes or truncates sensitive data like image content for safe logging. + + Args: + context: The LLM context containing messages. + + Returns: + List of messages in a format ready for logging. + """ + msgs = [] + for message in self.get_messages(context): + msg = copy.deepcopy(message) + if "content" in msg: + if isinstance(msg["content"], list): + for item in msg["content"]: + if item.get("type") == "image_url": + if item["image_url"]["url"].startswith("data:image/"): + item["image_url"]["url"] = "data:image/..." + if item.get("type") == "input_audio": + item["input_audio"]["data"] = "..." + msgs.append(msg) + return msgs + + def _convert_messages_to_input( + self, messages: List[LLMContextMessage] + ) -> List[ResponseInputItemParam]: + """Convert LLMContext messages to Responses API input items. + + Args: + messages: Messages from the LLMContext. + + Returns: + List of Responses API input items. + """ + result: List[ResponseInputItemParam] = [] + is_first = True + + for message in messages: + if isinstance(message, LLMSpecificMessage): + result.append(message.message) + is_first = False + continue + + role = message.get("role") + + if role == "system": + if is_first and not self._warned_system_instruction: + logger.warning( + "System messages in LLMContext are converted to 'developer' role for the " + "Responses API. Consider using settings.system_instruction instead, which " + "maps to the 'instructions' parameter." + ) + self._warned_system_instruction = True + content = message.get("content", "") + if isinstance(content, list): + content = self._convert_multimodal_content(content) + result.append({"role": "developer", "content": content}) + + elif role == "user": + content = message.get("content", "") + if isinstance(content, list): + content = self._convert_multimodal_content(content) + result.append({"role": "user", "content": content}) + + elif role == "assistant": + tool_calls = message.get("tool_calls") + if tool_calls: + for tc in tool_calls: + func = tc.get("function", {}) + result.append( + { + "type": "function_call", + "call_id": tc.get("id", ""), + "name": func.get("name", ""), + "arguments": func.get("arguments", ""), + } + ) + else: + content = message.get("content", "") + if isinstance(content, list): + content = self._convert_multimodal_content(content) + result.append({"role": "assistant", "content": content}) + + elif role == "tool": + content = message.get("content", "") + if not isinstance(content, str): + content = str(content) + result.append( + { + "type": "function_call_output", + "call_id": message.get("tool_call_id", ""), + "output": content, + } + ) + + is_first = False + + return result + + def _convert_multimodal_content(self, content: list) -> list: + """Convert multimodal content parts to Responses API format. + + Args: + content: List of content parts from the LLMContext message. + + Returns: + List of content parts in Responses API format. + """ + result = [] + for part in content: + part_type = part.get("type") + if part_type == "text": + result.append({"type": "input_text", "text": part.get("text", "")}) + elif part_type == "image_url": + image_url_obj = part.get("image_url", {}) + result.append( + { + "type": "input_image", + "image_url": image_url_obj.get("url", ""), + "detail": image_url_obj.get("detail", "auto"), + } + ) + else: + # Pass through other types as-is. Note: "input_audio" is not + # yet supported by the Responses API (coming soon per OpenAI + # docs) but the LLMContext format already matches the expected + # shape, so it should work once support is enabled. + result.append(part) + return result diff --git a/src/pipecat/adapters/services/perplexity_adapter.py b/src/pipecat/adapters/services/perplexity_adapter.py new file mode 100644 index 000000000..a8fbe3c18 --- /dev/null +++ b/src/pipecat/adapters/services/perplexity_adapter.py @@ -0,0 +1,152 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Perplexity LLM adapter for Pipecat. + +Perplexity's API uses an OpenAI-compatible interface but enforces stricter +constraints on conversation history structure: + +1. **Strict role alternation** — Messages must alternate between "user"/"tool" + and "assistant" roles. Consecutive messages with the same role (e.g. two + "user" messages in a row) are rejected with: + ``"messages must be an alternating sequence of user/tool and assistant messages"`` + +2. **No non-initial system messages** — "system" messages are only allowed at + the start of the conversation. A system message after a non-system message + causes: + ``"only the initial message can have the system role"`` + +3. **Last message must be user/tool** — The final message in the conversation + must have role "user" or "tool". A trailing "assistant" message causes: + ``"the last message must have the user or tool role"`` + +This adapter transforms the message list to satisfy all three constraints before +the messages are sent to Perplexity's API. +""" + +import copy +from typing import List + +from openai.types.chat import ChatCompletionMessageParam + +from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter, OpenAILLMInvocationParams +from pipecat.processors.aggregators.llm_context import LLMContext + + +class PerplexityLLMAdapter(OpenAILLMAdapter): + """Adapter that transforms messages to satisfy Perplexity's API constraints. + + Perplexity's API is stricter than OpenAI about message structure. This + adapter extends ``OpenAILLMAdapter`` and applies message transformations + to ensure compliance with Perplexity's constraints (role alternation, + no non-initial system messages, last message must be user/tool). + + The transformations are applied in ``get_llm_invocation_params`` after the + parent adapter extracts messages from the LLM context, and before + ``build_chat_completion_params`` prepends ``system_instruction``. + """ + + def get_llm_invocation_params(self, context: LLMContext) -> OpenAILLMInvocationParams: + """Get OpenAI-compatible invocation parameters with Perplexity message fixes applied. + + Args: + context: The LLM context containing messages, tools, etc. + + Returns: + Dictionary of parameters for Perplexity's ChatCompletion API, with + messages transformed to satisfy Perplexity's constraints. + """ + params = super().get_llm_invocation_params(context) + params["messages"] = self._transform_messages(list(params["messages"])) + return params + + def _transform_messages( + self, messages: List[ChatCompletionMessageParam] + ) -> List[ChatCompletionMessageParam]: + """Transform messages to satisfy Perplexity's API constraints. + + Applies three transformation steps in order: + + 1. **Convert non-initial system messages to user** — Any system message + after the initial system message block is converted to role "user", + since Perplexity rejects system messages after a non-system message. + + 2. **Merge consecutive same-role messages** — After the above + conversions, adjacent messages with the same role are merged using + list-of-dicts content format. This ensures strict role alternation + (e.g. a converted system→user message adjacent to an existing user + message gets merged). + + 3. **Remove trailing assistant messages** — If the last message is + "assistant", remove it. OpenAI appears to silently ignore trailing + assistant messages server-side, so removing them preserves equivalent + behavior while satisfying Perplexity's "last message must be + user/tool" constraint. + + Note: we intentionally do *not* convert a trailing system message to + "user". That would make the transformation unstable across calls — + Perplexity appears to have statefulness/caching within a conversation, + so a message that was sent as "user" in one call but becomes "system" + in the next (once more messages are appended) causes errors. If the + context consists entirely of system messages, the Perplexity API call + will fail, but that mistake will be caught right away. + + Args: + messages: List of message dicts with "role" and "content" keys. + + Returns: + Transformed list of message dicts satisfying Perplexity's constraints. + """ + if not messages: + return messages + + messages = copy.deepcopy(messages) + + # Step 1: Convert non-initial system messages to "user". + # Perplexity allows system messages at the start, but rejects them + # after any non-system message. + in_initial_system_block = True + for i in range(len(messages)): + if messages[i].get("role") == "system": + if not in_initial_system_block: + messages[i]["role"] = "user" + else: + in_initial_system_block = False + + # Step 2: Merge consecutive same-role messages. + # After system→user conversions above, we may have adjacent same-role + # messages that violate Perplexity's strict alternation requirement. + # Skip consecutive system messages at the start — Perplexity allows those. + i = 0 + while i < len(messages) - 1: + current = messages[i] + next_msg = messages[i + 1] + if current["role"] == next_msg["role"] == "system": + # Perplexity allows multiple initial system messages, don't merge + i += 1 + elif current["role"] == next_msg["role"]: + # Convert string content to list-of-dicts format for merging + if isinstance(current.get("content"), str): + current["content"] = [{"type": "text", "text": current["content"]}] + if isinstance(next_msg.get("content"), str): + next_msg["content"] = [{"type": "text", "text": next_msg["content"]}] + # Merge content from next message into current + if isinstance(current.get("content"), list) and isinstance( + next_msg.get("content"), list + ): + current["content"].extend(next_msg["content"]) + messages.pop(i + 1) + else: + i += 1 + + # Step 3: Remove trailing assistant messages. + # Perplexity requires the last message to be "user" or "tool". + # OpenAI appears to silently ignore trailing assistant messages + # server-side, so removing them preserves equivalent behavior. + while messages and messages[-1].get("role") == "assistant": + messages.pop() + + return messages diff --git a/src/pipecat/audio/filters/aic_filter.py b/src/pipecat/audio/filters/aic_filter.py index e4242521b..752f6f3fa 100644 --- a/src/pipecat/audio/filters/aic_filter.py +++ b/src/pipecat/audio/filters/aic_filter.py @@ -9,132 +9,351 @@ This module provides an audio filter implementation using ai-coustics' AIC SDK to enhance audio streams in real time. It mirrors the structure of other filters like 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. """ -from typing import List, Optional +import asyncio +from pathlib import Path +from threading import Lock +from typing import List, Optional, Tuple import numpy as np +from aic_sdk import ( + Model, + ParameterOutOfRangeError, + ProcessorAsync, + ProcessorConfig, + ProcessorParameter, + set_sdk_id, +) from loguru import logger from pipecat.audio.filters.base_audio_filter import BaseAudioFilter +from pipecat.audio.vad.aic_vad import AICVADAnalyzer from pipecat.frames.frames import FilterControlFrame, FilterEnableFrame -try: - # AIC SDK (https://ai-coustics.github.io/aic-sdk-py/api/) - from aic import AICModelType, AICParameter, Model -except ModuleNotFoundError as e: - logger.error(f"Exception: {e}") - logger.error("In order to use the AIC filter, you need to `pip install pipecat-ai[aic]`.") - raise Exception(f"Missing module: {e}") + +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. Buffers incoming audio to the model's preferred block size and processes - planar frames in-place using float32 samples in the linear -1..+1 range. + frames using float32 samples normalized to the range -1 to +1. """ def __init__( self, *, - license_key: str = "", - model_type: AICModelType = AICModelType.QUAIL_STT, - enhancement_level: Optional[float] = 1.0, - voice_gain: Optional[float] = 1.0, - noise_gate_enable: Optional[bool] = True, + license_key: str, + model_id: Optional[str] = None, + model_path: Optional[Path] = None, + model_download_dir: Optional[Path] = None, + enhancement_level: Optional[float] = None, ) -> None: """Initialize the AIC filter. Args: license_key: ai-coustics license key for authentication. - model_type: Model variant to load. + model_id: Model identifier to download from CDN. Required if model_path + is not provided. See https://artifacts.ai-coustics.io/ for available models. + model_path: Optional path to a local .aicmodel file. If provided, + model_id is ignored and no download occurs. + model_download_dir: Directory for downloading models as a Path object. + Defaults to a cache directory in user's home folder. enhancement_level: Optional overall enhancement strength (0.0..1.0). - voice_gain: Optional linear gain applied to detected speech (0.0..4.0). - noise_gate_enable: Optional enable/disable noise gate (default: True). + If None, the model default is used. - .. deprecated:: 1.3.0 - The `noise_gate_enable` parameter is deprecated and no longer has any effect. - It will be removed in a future version. + Raises: + ValueError: If neither model_id nor model_path is provided, or if + enhancement_level is out of range. """ + # Set SDK ID for telemetry identification (6 = pipecat) + set_sdk_id(6) + + if model_id is None and model_path is None: + raise ValueError( + "Either 'model_id' or 'model_path' must be provided. " + "See https://artifacts.ai-coustics.io/ for available models." + ) + + if enhancement_level is not None and not 0.0 <= enhancement_level <= 1.0: + raise ValueError("'enhancement_level' must be between 0.0 and 1.0.") + self._license_key = license_key - self._model_type = model_type - + self._model_id = model_id + self._model_path = model_path + self._model_download_dir = model_download_dir or ( + Path.home() / ".cache" / "pipecat" / "aic-models" + ) self._enhancement_level = enhancement_level - self._voice_gain = voice_gain - if noise_gate_enable is not None: - import warnings + self._bypass = False - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "Parameter `noise_gate_enable` is deprecated and no longer has any effect. " - "It will be removed in a future version. Use AIC VAD instead (create_vad_analyzer()).", - DeprecationWarning, - ) - - self._noise_gate_enable = noise_gate_enable - - self._enabled = True self._sample_rate = 0 self._aic_ready = False self._frames_per_block = 0 self._audio_buffer = bytearray() - # Model will be created in start() since the API now requires sample_rate - self._aic = None - def get_vad_factory(self): - """Return a zero-arg factory that will create the VAD once the model exists. + # Audio format constants + self._bytes_per_sample = 2 # int16 = 2 bytes + self._dtype = np.int16 + self._scale = ( + 32768.0 # 2^15, for normalizing int16 (-32768 to 32767) to float32 (-1.0 to 1.0) + ) + + # 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 + self._vad_ctx = None + + # Pre-allocated buffers (resized in start() once frames_per_block is known) + self._in_f32 = None + self._out_i16 = None + + def get_vad_context(self): + """Return the VAD context once the processor exists. Returns: - A zero-argument callable that, when invoked, returns an initialized - VoiceActivityDetector bound to the underlying AIC model. Raises a - RuntimeError if the model has not been initialized (i.e. start() - has not been called successfully). + The VadContext instance bound to the underlying processor. + Raises RuntimeError if the processor has not been initialized. """ - - def _factory(): - if self._aic is None: - raise RuntimeError("AIC model not initialized yet. Call start(sample_rate) first.") - return self._aic.create_vad() - - return _factory + if self._vad_ctx is None: + raise RuntimeError("AIC processor not initialized yet. Call start(sample_rate) first.") + return self._vad_ctx def create_vad_analyzer( self, *, - lookback_buffer_size: Optional[float] = None, + speech_hold_duration: Optional[float] = None, + minimum_speech_duration: Optional[float] = None, sensitivity: Optional[float] = None, ): """Return an analyzer that will lazily instantiate the AIC VAD when ready. AIC VAD parameters: - - lookback_buffer_size: - Number of window-length audio buffers used as a lookback buffer. - Higher values increase prediction stability but add latency. - Range: 1.0 .. 20.0, Default (SDK): 6.0 + - speech_hold_duration: + How long VAD continues detecting after speech ends (in seconds). + Range: 0.0 to 100x model window length, Default (SDK): 0.05s + - minimum_speech_duration: + Minimum duration of speech required before VAD reports speech detected + (in seconds). Range: 0.0 to 1.0, Default (SDK): 0.0s - sensitivity: Energy threshold sensitivity. Energy threshold = 10 ** (-sensitivity). - Range: 1.0 .. 15.0, Default (SDK): 6.0 + Range: 1.0 to 15.0, Default (SDK): 6.0 Args: - lookback_buffer_size: Optional lookback buffer size to configure on the VAD. - Range: 1.0 .. 20.0. If None, SDK default is used. + speech_hold_duration: Optional speech hold duration to configure on the VAD. + If None, SDK default (0.05s) is used. + minimum_speech_duration: Optional minimum speech duration before VAD reports + speech detected. If None, SDK default (0.0s) is used. sensitivity: Optional sensitivity (energy threshold) to configure on the VAD. - Range: 1.0 .. 15.0. If None, SDK default is used. + Range: 1.0 to 15.0. If None, SDK default (6.0) is used. Returns: - A lazily-initialized AICVADAnalyzer that will bind to the VAD backend - once the filter's model has been created (after start(sample_rate)). + A lazily-initialized AICVADAnalyzer that will bind to the VAD context + once the filter's processor has been created (after start(sample_rate)). """ - from pipecat.audio.vad.aic_vad import AICVADAnalyzer - return AICVADAnalyzer( - vad_factory=self.get_vad_factory(), - lookback_buffer_size=lookback_buffer_size, + vad_context_factory=lambda: self.get_vad_context(), + speech_hold_duration=speech_hold_duration, + minimum_speech_duration=minimum_speech_duration, sensitivity=sensitivity, ) + def _apply_enhancement_level(self): + """Apply enhancement_level if configured and supported by the active model.""" + if self._processor_ctx is None or self._enhancement_level is None: + return + + try: + self._processor_ctx.set_parameter( + ProcessorParameter.EnhancementLevel, self._enhancement_level + ) + except ParameterOutOfRangeError as e: + logger.warning(f"AIC EnhancementLevel set_parameter out-of-range: {e}") + self._enhancement_level = None + + def _apply_bypass(self): + """Apply bypass parameter to the active processor.""" + if self._processor_ctx is None: + return + + self._processor_ctx.set_parameter(ProcessorParameter.Bypass, 1.0 if self._bypass else 0.0) + async def start(self, sample_rate: int): """Initialize the filter with the transport's sample rate. @@ -146,55 +365,87 @@ class AICFilter(BaseAudioFilter): """ self._sample_rate = sample_rate + # 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) + + # Allocate processing buffers now that we know the block size + self._in_f32 = np.zeros((1, self._frames_per_block), dtype=np.float32) + self._out_i16 = np.zeros(self._frames_per_block, dtype=np.int16) + + # Create configuration + config = ProcessorConfig.optimal( + self._model, + sample_rate=self._sample_rate, + ) + + # Create async processor try: - # Create model with required runtime parameters - self._aic = Model( - model_type=self._model_type, - license_key=self._license_key or None, - sample_rate=self._sample_rate, - channels=1, - ) - self._frames_per_block = self._aic.optimal_num_frames() - - # Optional parameter configuration - if self._enhancement_level is not None: - self._aic.set_parameter( - AICParameter.ENHANCEMENT_LEVEL, - float(self._enhancement_level if self._enabled else 0.0), - ) - if self._voice_gain is not None: - self._aic.set_parameter(AICParameter.VOICE_GAIN, float(self._voice_gain)) - - self._aic_ready = True - - # Log processor information - logger.debug(f"ai-coustics filter started:") - logger.debug(f" Sample rate: {self._sample_rate} Hz") - logger.debug(f" Frames per chunk: {self._frames_per_block}") - logger.debug(f" Enhancement strength: {int(self._enhancement_level * 100)}%") - logger.debug(f" Optimal input buffer size: {self._aic.optimal_num_frames()} samples") - logger.debug(f" Optimal sample rate: {self._aic.optimal_sample_rate()} Hz") - logger.debug( - f" Current algorithmic latency: {self._aic.processing_latency() / self._sample_rate * 1000:.2f}ms" - ) + self._processor = ProcessorAsync(self._model, self._license_key, config) except Exception as e: # noqa: BLE001 - surfacing SDK initialization errors logger.error(f"AIC model initialization failed: {e}") - self._aic_ready = False + self._processor = None + + self._aic_ready = self._processor is not None + + if not self._aic_ready: + logger.debug(f"ai-coustics filter is not ready.") + return + + # Get contexts for parameter control and VAD + self._processor_ctx = self._processor.get_processor_context() + self._vad_ctx = self._processor.get_vad_context() + + # Apply initial control parameters + self._apply_bypass() + self._apply_enhancement_level() + + # Log processor information + logger.debug(f"ai-coustics filter started:") + logger.debug(f" Model ID: {self._model.get_id()}") + logger.debug(f" Sample rate: {self._sample_rate} Hz") + logger.debug(f" Frames per chunk: {self._frames_per_block}") + if self._enhancement_level is not None: + logger.debug(f" Enhancement level: {self._enhancement_level}") + else: + logger.debug(" Enhancement level not configured; using the model's default behavior.") + logger.debug(f" Optimal sample rate: {self._model.get_optimal_sample_rate()} Hz") + logger.debug( + f" Optimal number of frames for {self._sample_rate} Hz: " + f"{self._model.get_optimal_num_frames(self._sample_rate)}" + ) + logger.debug( + f" Output delay: {self._processor_ctx.get_output_delay()} samples " + f"({self._processor_ctx.get_output_delay() / self._sample_rate * 1000:.2f}ms)" + ) async def stop(self): - """Clean up the AIC model when stopping. + """Clean up the AIC processor when stopping. Returns: None """ try: - if self._aic is not None: - self._aic.close() + if self._processor_ctx is not None: + self._processor_ctx.reset() finally: - self._aic = None + self._processor = None + self._processor_ctx = None + self._vad_ctx = None + self._model = None 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. @@ -205,11 +456,11 @@ class AICFilter(BaseAudioFilter): None """ if isinstance(frame, FilterEnableFrame): - self._enabled = frame.enable - if self._aic is not None: + self._bypass = not frame.enable + if self._processor_ctx is not None: try: - level = float(self._enhancement_level if self._enabled else 0.0) - self._aic.set_parameter(AICParameter.ENHANCEMENT_LEVEL, level) + self._apply_bypass() + self._apply_enhancement_level() except Exception as e: # noqa: BLE001 logger.error(f"AIC set_parameter failed: {e}") @@ -220,43 +471,43 @@ class AICFilter(BaseAudioFilter): model's required block length. Returns enhanced audio data. Args: - audio: Raw audio data as bytes to be filtered (int16 PCM, planar). + audio: Raw audio data as bytes (int16 PCM). Returns: - Enhanced audio data as bytes (int16 PCM, planar). + Enhanced audio data as bytes (int16 PCM). """ - if not self._aic_ready or self._aic is None: + if not self._aic_ready or self._processor is None: return audio self._audio_buffer.extend(audio) + available_frames = len(self._audio_buffer) // self._bytes_per_sample + num_blocks = available_frames // self._frames_per_block + + if num_blocks == 0: + return b"" + + block_size = self._frames_per_block * self._bytes_per_sample + total_size = num_blocks * block_size + blocks_data = bytes(self._audio_buffer[:total_size]) + self._audio_buffer = self._audio_buffer[total_size:] filtered_chunks: List[bytes] = [] - # Number of int16 samples currently buffered - available_frames = len(self._audio_buffer) // 2 + for i in range(num_blocks): + start = i * block_size + block_i16 = np.frombuffer(blocks_data[start : start + block_size], dtype=self._dtype) - while available_frames >= self._frames_per_block: - # Consume exactly one block worth of frames - samples_to_consume = self._frames_per_block * 1 - bytes_to_consume = samples_to_consume * 2 - block_bytes = bytes(self._audio_buffer[:bytes_to_consume]) + # Reuse input buffer, in-place divide + np.copyto(self._in_f32[0], block_i16) + self._in_f32 /= self._scale - # Convert to float32 in -1..+1 range and reshape to planar (channels, frames) - block_i16 = np.frombuffer(block_bytes, dtype=np.int16) - block_f32 = (block_i16.astype(np.float32) / 32768.0).reshape( - (1, self._frames_per_block) - ) + out_f32 = await self._processor.process_async(self._in_f32) - # Process planar in-place; returns ndarray (same shape) - out_f32 = await self._aic.process_async(block_f32) + # Convert float32 output back to int16 + np.multiply(out_f32, self._scale, out=self._in_f32) # reuse in_f32 as temp + np.clip(self._in_f32, -self._scale, self._scale - 1, out=self._in_f32) + np.copyto(self._out_i16, self._in_f32[0].astype(self._dtype)) - # Convert back to int16 bytes, planar layout - out_i16 = np.clip(out_f32 * 32768.0, -32768, 32767).astype(np.int16) - filtered_chunks.append(out_i16.reshape(-1).tobytes()) + filtered_chunks.append(self._out_i16.tobytes()) - # Slide buffer - self._audio_buffer = self._audio_buffer[bytes_to_consume:] - available_frames = len(self._audio_buffer) // 2 - - # Do not flush incomplete frames; keep them buffered for the next call return b"".join(filtered_chunks) diff --git a/src/pipecat/audio/filters/krisp_filter.py b/src/pipecat/audio/filters/krisp_filter.py index 7fbc00b88..58c4a2835 100644 --- a/src/pipecat/audio/filters/krisp_filter.py +++ b/src/pipecat/audio/filters/krisp_filter.py @@ -61,6 +61,7 @@ class KrispFilter(BaseAudioFilter): Provides real-time noise reduction for audio streams using Krisp's proprietary noise suppression algorithms. Requires a Krisp model file for operation. + .. deprecated:: 0.0.94 The KrispFilter is deprecated and will be removed in a future version. Use KrispVivaFilter instead. diff --git a/src/pipecat/audio/filters/krisp_viva_filter.py b/src/pipecat/audio/filters/krisp_viva_filter.py index 2f9dda10f..1e2f6c81b 100644 --- a/src/pipecat/audio/filters/krisp_viva_filter.py +++ b/src/pipecat/audio/filters/krisp_viva_filter.py @@ -9,7 +9,6 @@ This module provides an audio filter implementation using Krisp VIVA SDK. """ -import asyncio import os import numpy as np @@ -40,7 +39,11 @@ class KrispVivaFilter(BaseAudioFilter): """ def __init__( - self, model_path: str = None, frame_duration: int = 10, noise_suppression_level: int = 100 + self, + model_path: str = None, + frame_duration: int = 10, + noise_suppression_level: int = 100, + api_key: str = "", ) -> None: """Initialize the Krisp noise reduction filter. @@ -49,6 +52,8 @@ class KrispVivaFilter(BaseAudioFilter): If None, uses KRISP_VIVA_FILTER_MODEL_PATH environment variable. frame_duration: Frame duration in milliseconds. noise_suppression_level: Noise suppression level. + api_key: Krisp SDK API key. If empty, falls back to + the KRISP_VIVA_API_KEY environment variable. Raises: ValueError: If model_path is not provided and KRISP_VIVA_FILTER_MODEL_PATH is not set. @@ -58,6 +63,8 @@ class KrispVivaFilter(BaseAudioFilter): """ super().__init__() + self._api_key = api_key + try: # Set model path, checking environment if not specified if model_path: @@ -133,7 +140,7 @@ class KrispVivaFilter(BaseAudioFilter): """ try: # Acquire SDK reference (will initialize on first call) - KrispVivaSDKManager.acquire() + KrispVivaSDKManager.acquire(api_key=self._api_key) self._session = self._create_session(sample_rate, self._frame_duration_ms) except Exception as e: logger.error(f"Failed to start Krisp session: {e}", exc_info=True) diff --git a/src/pipecat/audio/krisp_instance.py b/src/pipecat/audio/krisp_instance.py index fae2c691e..5ebfd24cc 100644 --- a/src/pipecat/audio/krisp_instance.py +++ b/src/pipecat/audio/krisp_instance.py @@ -7,6 +7,7 @@ """Krisp Instance manager for pipecat audio.""" import atexit +import os from threading import Lock from loguru import logger @@ -88,17 +89,26 @@ class KrispVivaSDKManager: _lock = Lock() _reference_count = 0 + @staticmethod + def _license_callback(error, error_message): + """Callback for Krisp SDK licensing errors.""" + logger.error(f"Krisp licensing error: {error} - {error_message}") + @staticmethod def _log_callback(log_message, log_level): """Thread-safe callback for Krisp SDK logging.""" logger.info(f"[{log_level}] {log_message}") @classmethod - def acquire(cls): + def acquire(cls, api_key: str = ""): """Acquire a reference to the SDK (initializes if needed). Call this when creating a filter instance. + Args: + api_key: Krisp SDK API key. If empty, falls back to the + KRISP_VIVA_API_KEY environment variable. + Raises: Exception: If SDK initialization fails (propagated from krisp_audio) """ @@ -106,7 +116,19 @@ class KrispVivaSDKManager: # Initialize SDK on first acquire if cls._reference_count == 0: try: - krisp_audio.globalInit("", cls._log_callback, krisp_audio.LogLevel.Off) + key = api_key or os.environ.get("KRISP_VIVA_API_KEY", "") + try: + # New SDK signature (requires license key) + krisp_audio.globalInit( + "", + key, + cls._license_callback, + cls._log_callback, + krisp_audio.LogLevel.Off, + ) + except TypeError: + # Old SDK signature (no license key) + krisp_audio.globalInit("", cls._log_callback, krisp_audio.LogLevel.Off) cls._initialized = True diff --git a/src/pipecat/audio/turn/krisp_viva_turn.py b/src/pipecat/audio/turn/krisp_viva_turn.py index 04e59421f..3aa540491 100644 --- a/src/pipecat/audio/turn/krisp_viva_turn.py +++ b/src/pipecat/audio/turn/krisp_viva_turn.py @@ -15,6 +15,7 @@ passed directly to the constructor. """ import os +import time from typing import Optional, Tuple import numpy as np @@ -26,7 +27,7 @@ from pipecat.audio.krisp_instance import ( int_to_krisp_sample_rate, ) from pipecat.audio.turn.base_turn_analyzer import BaseTurnAnalyzer, BaseTurnParams, EndOfTurnState -from pipecat.metrics.metrics import MetricsData +from pipecat.metrics.metrics import MetricsData, TurnMetricsData try: import krisp_audio @@ -63,6 +64,7 @@ class KrispVivaTurn(BaseTurnAnalyzer): model_path: Optional[str] = None, sample_rate: Optional[int] = None, params: Optional[KrispTurnParams] = None, + api_key: str = "", ) -> None: """Initialize the Krisp turn analyzer. @@ -72,6 +74,8 @@ class KrispVivaTurn(BaseTurnAnalyzer): sample_rate: Optional initial sample rate for audio processing. If provided, this will be used as the fixed sample rate. params: Configuration parameters for turn analysis behavior. + api_key: Krisp SDK API key. If empty, falls back to + the KRISP_VIVA_API_KEY environment variable. Raises: ValueError: If model_path is not provided and KRISP_VIVA_TURN_MODEL_PATH is not set. @@ -83,7 +87,7 @@ class KrispVivaTurn(BaseTurnAnalyzer): # Acquire SDK reference (will initialize on first call) try: - KrispVivaSDKManager.acquire() + KrispVivaSDKManager.acquire(api_key=api_key) self._sdk_acquired = True except Exception as e: self._sdk_acquired = False @@ -115,6 +119,9 @@ class KrispVivaTurn(BaseTurnAnalyzer): self._last_probability = None self._frame_probabilities = [] self._last_state = EndOfTurnState.INCOMPLETE + self._speech_stopped_time: Optional[float] = None + self._e2e_processing_time_ms: Optional[float] = None + self._last_metrics: Optional[TurnMetricsData] = None # Create session with provided sample rate or default to 16000 Hz # This preloads the model to improve latency when set_sample_rate is called later @@ -288,7 +295,14 @@ class KrispVivaTurn(BaseTurnAnalyzer): # Track speech start time if not self._speech_triggered: logger.trace("Speech detected, turn analysis started") + self._e2e_processing_time_ms = None self._speech_triggered = True + # Reset speech stopped time when speech resumes + self._speech_stopped_time = None + else: + # Record the moment speech transitions to non-speech + if self._speech_triggered and self._speech_stopped_time is None: + self._speech_stopped_time = time.perf_counter() # Note: We don't immediately mark as complete on silence detection. # Instead, we wait for the model's probability check below to confirm # end-of-turn based on the threshold. @@ -308,6 +322,18 @@ class KrispVivaTurn(BaseTurnAnalyzer): # Only mark as complete if we've detected speech and the model # confirms with sufficient confidence if self._speech_triggered and prob >= self._params.threshold: + # Calculate e2e processing time: time from speech stop to threshold crossing + if self._speech_stopped_time is not None: + self._e2e_processing_time_ms = ( + time.perf_counter() - self._speech_stopped_time + ) * 1000 + self._last_metrics = TurnMetricsData( + processor="KrispVivaTurn", + is_complete=True, + probability=prob, + e2e_processing_time_ms=self._e2e_processing_time_ms, + ) + logger.debug(f"Krisp turn complete") state = EndOfTurnState.COMPLETE self.clear() break @@ -329,12 +355,15 @@ class KrispVivaTurn(BaseTurnAnalyzer): Tuple containing the end-of-turn state and optional metrics data. Returns the last state determined by append_audio(). """ - # For real-time processing, the state is determined in append_audio - # Return the last state that was computed - return self._last_state, None + # For real-time processing, the state is determined in append_audio. + # Consume metrics so they aren't pushed twice. + metrics = self._last_metrics + self._last_metrics = None + return self._last_state, metrics def clear(self): """Reset the turn analyzer to its initial state.""" self._speech_triggered = False self._audio_buffer.clear() self._last_state = EndOfTurnState.INCOMPLETE + self._speech_stopped_time = None diff --git a/src/pipecat/audio/turn/smart_turn/base_smart_turn.py b/src/pipecat/audio/turn/smart_turn/base_smart_turn.py index 66b45a8f6..fa652d884 100644 --- a/src/pipecat/audio/turn/smart_turn/base_smart_turn.py +++ b/src/pipecat/audio/turn/smart_turn/base_smart_turn.py @@ -21,7 +21,7 @@ import numpy as np from loguru import logger from pipecat.audio.turn.base_turn_analyzer import BaseTurnAnalyzer, BaseTurnParams, EndOfTurnState -from pipecat.metrics.metrics import MetricsData, SmartTurnMetricsData +from pipecat.metrics.metrics import MetricsData, TurnMetricsData # Default timing parameters STOP_SECS = 3 @@ -222,18 +222,11 @@ class BaseSmartTurn(BaseTurnAnalyzer): # Calculate processing time e2e_processing_time_ms = (end_time - start_time) * 1000 - # Extract metrics from the nested structure - metrics = result.get("metrics", {}) - inference_time = metrics.get("inference_time", 0) - total_time = metrics.get("total_time", 0) - # Prepare the result data - result_data = SmartTurnMetricsData( + result_data = TurnMetricsData( processor="BaseSmartTurn", is_complete=result["prediction"] == 1, probability=result["probability"], - inference_time_ms=inference_time * 1000, - server_total_time_ms=total_time * 1000, e2e_processing_time_ms=e2e_processing_time_ms, ) @@ -241,8 +234,6 @@ class BaseSmartTurn(BaseTurnAnalyzer): f"Prediction: {'Complete' if result_data.is_complete else 'Incomplete'}" ) logger.trace(f"Probability of complete: {result_data.probability:.4f}") - logger.trace(f"Inference time: {result_data.inference_time_ms:.2f}ms") - logger.trace(f"Server total time: {result_data.server_total_time_ms:.2f}ms") logger.trace(f"E2E processing time: {result_data.e2e_processing_time_ms:.2f}ms") except SmartTurnTimeoutException: logger.debug( diff --git a/src/pipecat/audio/turn/smart_turn/local_coreml_smart_turn.py b/src/pipecat/audio/turn/smart_turn/local_coreml_smart_turn.py index be4744c27..18310c386 100644 --- a/src/pipecat/audio/turn/smart_turn/local_coreml_smart_turn.py +++ b/src/pipecat/audio/turn/smart_turn/local_coreml_smart_turn.py @@ -10,6 +10,7 @@ This module provides a smart turn analyzer that uses CoreML models for local end-of-turn detection without requiring network connectivity. """ +import warnings from typing import Any, Dict import numpy as np @@ -35,6 +36,10 @@ class LocalCoreMLSmartTurnAnalyzer(BaseSmartTurn): Provides end-of-turn detection using locally-stored CoreML models, enabling offline operation without network dependencies. Optimized for Apple Silicon and other CoreML-compatible hardware. + + .. deprecated:: 0.0.106 + LocalCoreMLSmartTurnAnalyzer is deprecated and will be removed in a future version. + Use LocalSmartTurnAnalyzerV3 instead. """ def __init__(self, *, smart_turn_model_path: str, **kwargs): @@ -50,6 +55,15 @@ class LocalCoreMLSmartTurnAnalyzer(BaseSmartTurn): """ super().__init__(**kwargs) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "LocalCoreMLSmartTurnAnalyzer is deprecated and will be removed in a future " + "version. Use LocalSmartTurnAnalyzerV3 instead.", + DeprecationWarning, + stacklevel=2, + ) + if not smart_turn_model_path: logger.error("smart_turn_model_path is not set.") raise Exception("smart_turn_model_path must be provided.") diff --git a/src/pipecat/audio/turn/smart_turn/local_smart_turn.py b/src/pipecat/audio/turn/smart_turn/local_smart_turn.py index e98c345a1..791b63af1 100644 --- a/src/pipecat/audio/turn/smart_turn/local_smart_turn.py +++ b/src/pipecat/audio/turn/smart_turn/local_smart_turn.py @@ -36,7 +36,7 @@ class LocalSmartTurnAnalyzer(BaseSmartTurn): enabling offline operation without network dependencies. Uses Wav2Vec2-BERT architecture for audio sequence classification. - .. deprecated:: 0.98.0 + .. deprecated:: 0.0.98 LocalSmartTurnAnalyzer is deprecated and will be removed in a future version. Use LocalSmartTurnAnalyzerV3 instead. """ diff --git a/src/pipecat/audio/turn/smart_turn/local_smart_turn_v2.py b/src/pipecat/audio/turn/smart_turn/local_smart_turn_v2.py index 0b2f21cba..8d584ecd2 100644 --- a/src/pipecat/audio/turn/smart_turn/local_smart_turn_v2.py +++ b/src/pipecat/audio/turn/smart_turn/local_smart_turn_v2.py @@ -10,6 +10,7 @@ This module provides a smart turn analyzer that uses PyTorch models for local end-of-turn detection without requiring network connectivity. """ +import warnings from typing import Any, Dict import numpy as np @@ -41,6 +42,10 @@ class LocalSmartTurnAnalyzerV2(BaseSmartTurn): Provides end-of-turn detection using locally-stored PyTorch models, enabling offline operation without network dependencies. Uses Wav2Vec2 architecture for audio sequence classification. + + .. deprecated:: 0.0.106 + LocalSmartTurnAnalyzerV2 is deprecated and will be removed in a future version. + Use LocalSmartTurnAnalyzerV3 instead. """ def __init__(self, *, smart_turn_model_path: str, **kwargs): @@ -53,6 +58,15 @@ class LocalSmartTurnAnalyzerV2(BaseSmartTurn): """ super().__init__(**kwargs) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "LocalSmartTurnAnalyzerV2 is deprecated and will be removed in a future version. " + "Use LocalSmartTurnAnalyzerV3 instead.", + DeprecationWarning, + stacklevel=2, + ) + if not smart_turn_model_path: # Define the path to the pretrained model on Hugging Face smart_turn_model_path = "pipecat-ai/smart-turn-v2" diff --git a/src/pipecat/audio/turn/smart_turn/local_smart_turn_v3.py b/src/pipecat/audio/turn/smart_turn/local_smart_turn_v3.py index 0907ab28f..a8cc249fd 100644 --- a/src/pipecat/audio/turn/smart_turn/local_smart_turn_v3.py +++ b/src/pipecat/audio/turn/smart_turn/local_smart_turn_v3.py @@ -13,19 +13,16 @@ local end-of-turn detection without requiring network connectivity. from typing import Any, Dict, Optional import numpy as np +import onnxruntime as ort +import soxr from loguru import logger +from transformers import WhisperFeatureExtractor from pipecat.audio.turn.smart_turn.base_smart_turn import BaseSmartTurn +from pipecat.utils.env import env_truthy -try: - import onnxruntime as ort - from transformers import WhisperFeatureExtractor -except ModuleNotFoundError as e: - logger.error(f"Exception: {e}") - logger.error( - "In order to use LocalSmartTurnAnalyzerV3, you need to `pip install pipecat-ai[local-smart-turn-v3]`." - ) - raise Exception(f"Missing module: {e}") +# The Whisper-based ONNX model expects 16 kHz audio input. +_MODEL_SAMPLE_RATE = 16000 class LocalSmartTurnAnalyzerV3(BaseSmartTurn): @@ -48,6 +45,8 @@ class LocalSmartTurnAnalyzerV3(BaseSmartTurn): """ super().__init__(**kwargs) + self._log_data = env_truthy("PIPECAT_SMART_TURN_LOG_DATA", default=False) + if not smart_turn_model_path: # Load bundled model model_name = "smart-turn-v3.2-cpu.onnx" @@ -81,10 +80,70 @@ class LocalSmartTurnAnalyzerV3(BaseSmartTurn): logger.debug("Loaded Local Smart Turn v3.x") + def _write_audio_to_wav( + self, audio_array: np.ndarray, sample_rate: int = _MODEL_SAMPLE_RATE, suffix: str = "" + ) -> None: + """Write audio data to a WAV file in a background thread. + + Args: + audio_array: The audio data as a numpy array (float32, normalized to [-1, 1]). + sample_rate: The sample rate of the audio data. + suffix: Optional suffix to append to the filename (e.g., "_raw", "_padded"). + """ + import os + import threading + import wave + from datetime import datetime + + # Generate filename with current timestamp (millisecond precision) + timestamp = datetime.now().strftime("%Y-%m-%d__%H:%M:%S.%f")[:-3] + log_dir = "./smart_turn_audio_log" + os.makedirs(log_dir, exist_ok=True) + filename = os.path.join(log_dir, f"{timestamp}{suffix}.wav") + + # Make a copy of the audio data to avoid issues with the array being modified + audio_copy = audio_array.copy() + + def write_wav(): + try: + # Convert float32 audio to int16 for WAV file + audio_int16 = (audio_copy * 32767).astype(np.int16) + + with wave.open(filename, "wb") as wav_file: + wav_file.setnchannels(1) # Mono + wav_file.setsampwidth(2) # 2 bytes for int16 + wav_file.setframerate(sample_rate) + wav_file.writeframes(audio_int16.tobytes()) + + logger.debug(f"Wrote audio to {filename}") + except Exception as e: + logger.error(f"Failed to write audio to {filename}: {e}") + + # Start background thread to write the WAV file + thread = threading.Thread(target=write_wav, daemon=True) + thread.start() + + def _resample_to_model_rate(self, audio_array: np.ndarray) -> np.ndarray: + """Resample audio to the model's expected sample rate (16 kHz). + + Args: + audio_array: Audio data as a float32 numpy array. + + Returns: + Resampled audio array at 16 kHz. + """ + actual_rate = self._sample_rate or _MODEL_SAMPLE_RATE + if actual_rate == _MODEL_SAMPLE_RATE: + return audio_array + + return soxr.resample(audio_array, actual_rate, _MODEL_SAMPLE_RATE, quality="VHQ") + def _predict_endpoint(self, audio_array: np.ndarray) -> Dict[str, Any]: """Predict end-of-turn using local ONNX model.""" - def truncate_audio_to_last_n_seconds(audio_array, n_seconds=8, sample_rate=16000): + def truncate_audio_to_last_n_seconds( + audio_array, n_seconds=8, sample_rate=_MODEL_SAMPLE_RATE + ): """Truncate audio to last n seconds or pad with zeros to meet n seconds.""" max_samples = n_seconds * sample_rate if len(audio_array) > max_samples: @@ -95,16 +154,22 @@ class LocalSmartTurnAnalyzerV3(BaseSmartTurn): return np.pad(audio_array, (padding, 0), mode="constant", constant_values=0) return audio_array + audio_for_logging = audio_array + actual_rate = self._sample_rate or _MODEL_SAMPLE_RATE + + # Resample to 16 kHz if the pipeline uses a different sample rate + audio_array = self._resample_to_model_rate(audio_array) + # Truncate to 8 seconds (keeping the end) or pad to 8 seconds audio_array = truncate_audio_to_last_n_seconds(audio_array, n_seconds=8) # Process audio using Whisper's feature extractor inputs = self._feature_extractor( audio_array, - sampling_rate=16000, + sampling_rate=_MODEL_SAMPLE_RATE, return_tensors="np", padding="max_length", - max_length=8 * 16000, + max_length=8 * _MODEL_SAMPLE_RATE, truncation=True, do_normalize=True, ) @@ -122,6 +187,10 @@ class LocalSmartTurnAnalyzerV3(BaseSmartTurn): # Make prediction (1 for Complete, 0 for Incomplete) prediction = 1 if probability > 0.5 else 0 + if self._log_data: + suffix = "_complete" if prediction == 1 else "_incomplete" + self._write_audio_to_wav(audio_for_logging, sample_rate=actual_rate, suffix=suffix) + return { "prediction": prediction, "probability": probability, diff --git a/src/pipecat/audio/vad/aic_vad.py b/src/pipecat/audio/vad/aic_vad.py index 4907e4f55..813029e2b 100644 --- a/src/pipecat/audio/vad/aic_vad.py +++ b/src/pipecat/audio/vad/aic_vad.py @@ -1,44 +1,44 @@ """AIC-integrated VAD analyzer that lazily binds to the AIC SDK backend. -This analyzer queries the backend's is_speech_detected() and maps it to a float -confidence (1.0/0.0). It uses 10 ms windows based on the sample rate and applies -optional AIC VAD parameters (lookback_buffer_size, sensitivity) when available. +This module provides VAD analyzer implementations that query the AIC SDK's +is_speech_detected() and map it to a float confidence (1.0/0.0). + +Classes: + AICVADAnalyzer: For aic-sdk (uses 'aic_sdk' module) """ from typing import Any, Callable, Optional +from aic_sdk import VadParameter from loguru import logger from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams -try: - from aic import AICVadParameter -except ModuleNotFoundError as e: - logger.error(f"Exception: {e}") - logger.error("In order to use the AIC filter, you need to `pip install pipecat-ai[aic]`.") - raise Exception(f"Missing module: {e}") - class AICVADAnalyzer(VADAnalyzer): - """VAD analyzer that lazily instantiates the AIC VoiceActivityDetector via a factory. + """VAD analyzer that lazily binds to the AIC VadContext via a factory. - The analyzer can be constructed before the AIC Model exists. Once the filter has - started and the Model is available, the provided factory will succeed and the - backend VAD will be created. We then switch to single-sample updates where - num_frames_required() returns 1 and confidence is derived from the backend's - boolean is_speech_detected() state. + The analyzer can be constructed before the AIC Processor exists. Once the filter has + started and the Processor is available, the provided factory will succeed and the + VadContext will be obtained. The context's is_speech_detected() boolean state is + then mapped to 1.0 (speech) or 0.0 (no speech) to satisfy the VADAnalyzer interface. AIC VAD runtime parameters: - - lookback_buffer_size: - Controls the lookback buffer size used by the VAD, i.e. the number of - window-length audio buffers used as a lookback buffer. Larger values improve - stability but increase latency. - Range: 1.0 .. 20.0 - Default (SDK): 6.0 + - speech_hold_duration: + Controls for how long the VAD continues to detect speech after the audio signal + no longer contains speech (in seconds). + Range: 0.0 to 100x model window length + Default (SDK): 0.05s (50ms) + - minimum_speech_duration: + Controls for how long speech needs to be present in the audio signal before the + VAD considers it speech (in seconds). + Range: 0.0 to 1.0 + Default (SDK): 0.0s - sensitivity: - Controls the energy threshold sensitivity. Higher values make the detector - less sensitive (require more energy to count as speech). - Range: 1.0 .. 15.0 + Controls the sensitivity (energy threshold) of the VAD. This value is used by + the VAD as the threshold a speech audio signal's energy has to exceed in order + to be considered speech. + Range: 1.0 to 15.0 Formula: Energy threshold = 10 ** (-sensitivity) Default (SDK): 6.0 """ @@ -46,69 +46,80 @@ class AICVADAnalyzer(VADAnalyzer): def __init__( self, *, - vad_factory: Optional[Callable[[], Any]] = None, - lookback_buffer_size: Optional[float] = None, + vad_context_factory: Optional[Callable[[], Any]] = None, + speech_hold_duration: Optional[float] = None, + minimum_speech_duration: Optional[float] = None, sensitivity: Optional[float] = None, ): """Create an AIC VAD analyzer. Args: - vad_factory: - Zero-arg callable that returns an initialized AIC VoiceActivityDetector. - This may raise until the filter's Model has been created; the analyzer + vad_context_factory: + Zero-arg callable that returns the AIC VadContext. + This may raise until the filter's Processor has been created; the analyzer will retry on set_sample_rate/first use. - lookback_buffer_size: - Optional override for AIC VAD lookback buffer size. - Range: 1.0 .. 20.0. Larger values increase stability at the cost of latency. - If None, the SDK default (6.0) is used. + speech_hold_duration: + Optional override for AIC VAD speech hold duration (in seconds). + Range: 0.0 to 100x model window length. + If None, the SDK default (0.05s) is used. + minimum_speech_duration: + Optional override for minimum speech duration before VAD reports + speech detected (in seconds). + Range: 0.0 to 1.0. + If None, the SDK default (0.0s) is used. sensitivity: Optional override for AIC VAD sensitivity (energy threshold). - Range: 1.0 .. 15.0. Energy threshold = 10 ** (-sensitivity). + Range: 1.0 to 15.0. Energy threshold = 10 ** (-sensitivity). If None, the SDK default (6.0) is used. """ # Use fixed VAD parameters for AIC: no user override fixed_params = VADParams(confidence=0.5, start_secs=0.0, stop_secs=0.0, min_volume=0.0) super().__init__(sample_rate=None, params=fixed_params) - self._vad_factory = vad_factory - self._backend_vad: Optional[Any] = None - self._pending_lookback: Optional[float] = lookback_buffer_size + + self._vad_context_factory = vad_context_factory + self._vad_ctx: Optional[Any] = None + self._pending_speech_hold_duration: Optional[float] = speech_hold_duration + self._pending_minimum_speech_duration: Optional[float] = minimum_speech_duration self._pending_sensitivity: Optional[float] = sensitivity - def bind_vad_factory(self, vad_factory: Callable[[], Any]): + def bind_vad_context_factory(self, vad_context_factory: Callable[[], Any]): """Attach or replace the factory post-construction.""" - self._vad_factory = vad_factory - self._ensure_backend_initialized() + self._vad_context_factory = vad_context_factory + self._ensure_vad_context_initialized() - def _apply_backend_params(self): + def _apply_vad_params(self): """Apply optional AIC VAD parameters if available.""" - if self._backend_vad is None or AICVadParameter is None: + if self._vad_ctx is None or VadParameter is None: return + try: - if self._pending_lookback is not None: - self._backend_vad.set_parameter( - AICVadParameter.LOOKBACK_BUFFER_SIZE, float(self._pending_lookback) + if self._pending_speech_hold_duration is not None: + self._vad_ctx.set_parameter( + VadParameter.SpeechHoldDuration, self._pending_speech_hold_duration + ) + if self._pending_minimum_speech_duration is not None: + self._vad_ctx.set_parameter( + VadParameter.MinimumSpeechDuration, self._pending_minimum_speech_duration ) if self._pending_sensitivity is not None: - self._backend_vad.set_parameter( - AICVadParameter.SENSITIVITY, float(self._pending_sensitivity) - ) + self._vad_ctx.set_parameter(VadParameter.Sensitivity, self._pending_sensitivity) except Exception as e: # noqa: BLE001 logger.debug(f"AIC VAD parameter application deferred/failed: {e}") - def _ensure_backend_initialized(self): - if self._backend_vad is not None: + def _ensure_vad_context_initialized(self): + if self._vad_ctx is not None: return - if not self._vad_factory: + if not self._vad_context_factory: return try: - self._backend_vad = self._vad_factory() - self._apply_backend_params() - # With backend ready, recompute internal frame sizing + self._vad_ctx = self._vad_context_factory() + self._apply_vad_params() + # With VAD context ready, recompute internal frame sizing super().set_params(self._params) - logger.debug("AIC VAD backend initialized in analyzer.") + logger.debug("AIC VAD context initialized in analyzer.") except Exception as e: # noqa: BLE001 # Filter may not be started yet; try again later - logger.debug(f"Deferring AIC VAD backend initialization: {e}") + logger.debug(f"Deferring AIC VAD context initialization: {e}") def set_sample_rate(self, sample_rate: int): """Set the sample rate for audio processing. @@ -116,10 +127,10 @@ class AICVADAnalyzer(VADAnalyzer): Args: sample_rate: Audio sample rate in Hz. """ - # Set rate and attempt backend initialization once we know SR + # Set rate and attempt VAD context initialization once we know SR self._sample_rate = self._init_sample_rate or sample_rate - self._ensure_backend_initialized() - # Ensure params are initialized even if backend not ready yet + self._ensure_vad_context_initialized() + # Ensure params are initialized even if VAD context not ready yet try: super().set_params(self._params) except Exception: @@ -135,23 +146,29 @@ class AICVADAnalyzer(VADAnalyzer): return int(self.sample_rate * 0.01) if self.sample_rate > 0 else 160 def voice_confidence(self, buffer: bytes) -> float: - """Calculate voice activity confidence for the given audio buffer. + """Return voice activity detection result for the given audio buffer. + + Note: + The AIC SDK provides binary speech detection (not a probability score). + This method returns 1.0 when speech is detected and 0.0 otherwise, + rather than a true confidence value. Args: - buffer: Audio buffer to analyze. + buffer: Audio buffer (unused - AIC VAD state is updated internally + by the enhancement pipeline). Returns: - Voice confidence score is 0.0 or 1.0. + 1.0 if speech is detected, 0.0 otherwise. """ - # Ensure backend exists (filter might have started since last call) - self._ensure_backend_initialized() - if self._backend_vad is None: + # Ensure VAD context exists (filter might have started since last call) + self._ensure_vad_context_initialized() + if self._vad_ctx is None: return 0.0 - # We do not need to analyze 'buffer' here since the model's VAD is updated + # We do not need to analyze 'buffer' here since the processor's VAD is updated # as part of the enhancement pipeline. Simply query the boolean and map it. try: - is_speech = self._backend_vad.is_speech_detected() + is_speech = self._vad_ctx.is_speech_detected() return 1.0 if is_speech else 0.0 except Exception as e: # noqa: BLE001 logger.error(f"AIC VAD inference error: {e}") diff --git a/src/pipecat/audio/vad/silero.py b/src/pipecat/audio/vad/silero.py index 5b4ad9c0a..c15ba5b90 100644 --- a/src/pipecat/audio/vad/silero.py +++ b/src/pipecat/audio/vad/silero.py @@ -27,7 +27,7 @@ try: except ModuleNotFoundError as e: logger.error(f"Exception: {e}") - logger.error("In order to use Silero VAD, you need to `pip install pipecat-ai[silero]`.") + logger.error("In order to use Silero VAD, you need to `pip install pipecat-ai`.") raise Exception(f"Missing module(s): {e}") diff --git a/src/pipecat/audio/vad/vad_analyzer.py b/src/pipecat/audio/vad/vad_analyzer.py index a96b025dd..2c3ef5531 100644 --- a/src/pipecat/audio/vad/vad_analyzer.py +++ b/src/pipecat/audio/vad/vad_analyzer.py @@ -24,7 +24,7 @@ from pipecat.audio.utils import calculate_audio_volume, exp_smoothing VAD_CONFIDENCE = 0.7 VAD_START_SECS = 0.2 -VAD_STOP_SECS = 0.8 +VAD_STOP_SECS = 0.2 VAD_MIN_VOLUME = 0.6 @@ -127,7 +127,7 @@ class VADAnalyzer(ABC): pass @abstractmethod - def voice_confidence(self, buffer) -> float: + def voice_confidence(self, buffer: bytes) -> float: """Calculate voice activity confidence for the given audio buffer. Args: diff --git a/src/pipecat/audio/vad/vad_controller.py b/src/pipecat/audio/vad/vad_controller.py new file mode 100644 index 000000000..1db590e10 --- /dev/null +++ b/src/pipecat/audio/vad/vad_controller.py @@ -0,0 +1,171 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Voice Activity Detection controller for managing speech state transitions. + +This module provides a controller that wraps a VADAnalyzer to track speech state +and emit events when speech starts, stops, or is actively detected. +""" + +import time +from typing import Type + +from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADState +from pipecat.frames.frames import ( + Frame, + InputAudioRawFrame, + SpeechControlParamsFrame, + StartFrame, + VADParamsUpdateFrame, +) +from pipecat.processors.frame_processor import FrameDirection +from pipecat.utils.base_object import BaseObject + + +class VADController(BaseObject): + """Manages voice activity detection state and emits speech events. + + Wraps a `VADAnalyzer` to process audio and trigger events based on speech + state transitions. Tracks whether the user is speaking, quiet, or + transitioning between states. + + Event handlers available: + + - on_speech_started: Called when speech begins. + - on_speech_stopped: Called when speech ends. + - on_speech_activity: Called periodically while speech is detected. + - on_push_frame: Called when the controller wants to push a frame. + - on_broadcast_frame: Called when the controller wants to broadcast a frame. + + Example:: + + @vad_controller.event_handler("on_speech_started") + async def on_speech_started(controller): + ... + + @vad_controller.event_handler("on_speech_stopped") + async def on_speech_stopped(controller): + ... + + @vad_controller.event_handler("on_speech_activity") + async def on_speech_activity(controller): + ... + + @vad_controller.event_handler("on_push_frame") + async def on_push_frame(controller, frame: Frame, direction: FrameDirection): + ... + + @vad_controller.event_handler("on_broadcast_frame") + async def on_broadcast_frame(controller, frame_cls: Type[Frame], **kwargs): + ... + """ + + def __init__(self, vad_analyzer: VADAnalyzer, *, speech_activity_period: float = 0.2): + """Initialize the VAD controller. + + Args: + vad_analyzer: The `VADAnalyzer` instance for processing audio. + speech_activity_period: Minimum interval in seconds between + `on_speech_activity` events. Defaults to 0.2. + """ + super().__init__() + self._vad_analyzer = vad_analyzer + self._vad_state: VADState = VADState.QUIET + + # Last time a on_speech_activity was triggered. + self._speech_activity_time = 0 + # How often a on_speech_activity event should be triggered (value should + # be greater than the audio chunks to have any effect). + self._speech_activity_period = speech_activity_period + + self._register_event_handler("on_speech_started", sync=True) + self._register_event_handler("on_speech_stopped", sync=True) + self._register_event_handler("on_speech_activity", sync=True) + self._register_event_handler("on_push_frame", sync=True) + self._register_event_handler("on_broadcast_frame", sync=True) + + async def process_frame(self, frame: Frame): + """Process a frame and handle VAD-related events. + + Handles `StartFrame` to initialize the sample rate and `InputAudioRawFrame` + to analyze audio for voice activity. + + Args: + frame: The frame to process. + """ + if isinstance(frame, StartFrame): + await self._start(frame) + elif isinstance(frame, InputAudioRawFrame): + await self._handle_audio(frame) + elif isinstance(frame, VADParamsUpdateFrame): + self._vad_analyzer.set_params(frame.params) + await self.broadcast_frame(SpeechControlParamsFrame, vad_params=frame.params) + + async def _start(self, frame: StartFrame): + self._vad_analyzer.set_sample_rate(frame.audio_in_sample_rate) + # Broadcast initial VAD params so other services (e.g. STT) can use them + await self.broadcast_frame(SpeechControlParamsFrame, vad_params=self._vad_analyzer.params) + + async def _handle_audio(self, frame: InputAudioRawFrame): + """Process an audio chunk and emit speech events as needed. + + Analyzes the audio for voice activity and triggers `on_speech_started`, + `on_speech_stopped`, or `on_speech_activity` events based on state changes. + + Args: + frame: Audio frame to process. + """ + self._vad_state = await self._handle_vad(frame.audio, self._vad_state) + + if self._vad_state == VADState.SPEAKING: + await self._call_event_handler("on_speech_activity") + + async def _handle_vad(self, audio: bytes, vad_state: VADState) -> VADState: + """Handle Voice Activity Detection results and trigger appropriate events.""" + new_vad_state = await self._vad_analyzer.analyze_audio(audio) + if ( + new_vad_state != vad_state + and new_vad_state != VADState.STARTING + and new_vad_state != VADState.STOPPING + ): + if new_vad_state == VADState.SPEAKING: + await self._call_event_handler("on_speech_started") + elif new_vad_state == VADState.QUIET: + await self._call_event_handler("on_speech_stopped") + + vad_state = new_vad_state + return vad_state + + async def _maybe_speech_activity(self): + """Handle user speaking frame.""" + diff_time = time.time() - self._speech_activity_time + if diff_time >= self._speech_activity_period: + self._speech_activity_time = time.time() + await self._call_event_handler("on_speech_activity") + + async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): + """Request a frame to be pushed through the pipeline. + + This emits an on_push_frame event that must be handled by a processor + to actually push the frame into the pipeline. + + Args: + frame: The frame to push. + direction: The direction to push the frame. + """ + await self._call_event_handler("on_push_frame", frame, direction) + + async def broadcast_frame(self, frame_cls: Type[Frame], **kwargs): + """Request a frame to be broadcast upstream and downstream. + + This emits an on_broadcast_frame event that must be handled by a processor + to actually broadcast the frame in the pipeline. + + Args: + frame_cls: The class of the frame to broadcast. + **kwargs: Arguments to pass to the frame constructor. + """ + await self._call_event_handler("on_broadcast_frame", frame_cls, **kwargs) diff --git a/src/pipecat/extensions/ivr/ivr_navigator.py b/src/pipecat/extensions/ivr/ivr_navigator.py index 0c299d634..64a6d0942 100644 --- a/src/pipecat/extensions/ivr/ivr_navigator.py +++ b/src/pipecat/extensions/ivr/ivr_navigator.py @@ -18,6 +18,7 @@ from loguru import logger from pipecat.audio.dtmf.types import KeypadEntry from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import ( + AggregatedTextFrame, EndFrame, Frame, LLMContextFrame, @@ -153,13 +154,19 @@ class IVRProcessor(FrameProcessor): # Process text through the pattern aggregator async for result in self._aggregator.aggregate(frame.text): # Push aggregated text that doesn't contain XML patterns - await self.push_frame(LLMTextFrame(result.text), direction) + await self.push_frame( + AggregatedTextFrame(text=result.text, aggregated_by=result.type), + direction, + ) elif isinstance(frame, (LLMFullResponseEndFrame, EndFrame)): # Flush any remaining text from the aggregator remaining = await self._aggregator.flush() if remaining: - await self.push_frame(LLMTextFrame(remaining.text), direction) + await self.push_frame( + AggregatedTextFrame(text=remaining.text, aggregated_by=remaining.type), + direction, + ) # Push the end frame await self.push_frame(frame, direction) diff --git a/src/pipecat/extensions/voicemail/voicemail_detector.py b/src/pipecat/extensions/voicemail/voicemail_detector.py index 7e22e535a..470f5dd54 100644 --- a/src/pipecat/extensions/voicemail/voicemail_detector.py +++ b/src/pipecat/extensions/voicemail/voicemail_detector.py @@ -368,7 +368,7 @@ class ClassificationProcessor(FrameProcessor): await self._voicemail_notifier.notify() # Clear buffered TTS frames # Interrupt the current pipeline to stop any ongoing processing - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() # Set the voicemail event to trigger the voicemail handler self._voicemail_event.clear() diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index fb0f8243b..7107cfd97 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -11,8 +11,8 @@ including data frames, system frames, and control frames for audio, video, text, and LLM processing. """ +import time from dataclasses import dataclass, field -from enum import Enum from typing import ( TYPE_CHECKING, Any, @@ -34,12 +34,16 @@ from pipecat.audio.turn.base_turn_analyzer import BaseTurnParams from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.metrics.metrics import MetricsData from pipecat.transcriptions.language import Language +from pipecat.utils.text.base_text_aggregator import AggregationType from pipecat.utils.time import nanoseconds_to_str from pipecat.utils.utils import obj_count, obj_id if TYPE_CHECKING: from pipecat.processors.aggregators.llm_context import LLMContext, NotGiven from pipecat.processors.frame_processor import FrameProcessor + from pipecat.services.settings import ServiceSettings + from pipecat.utils.context.llm_context_summarization import LLMContextSummaryConfig + from pipecat.utils.tracing.tracing_context import TracingContext class DeprecatedKeypadEntry: @@ -120,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. @@ -128,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) @@ -136,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 @@ -265,8 +274,16 @@ class OutputImageRawFrame(DataFrame, ImageRawFrame): An image that will be shown by the transport. If the transport supports multiple video destinations (e.g. multiple video tracks) the destination name can be specified in transport_destination. + + Parameters: + sync_with_audio: If True, the image is queued with audio frames so + it is only displayed after all preceding audio has been sent. + Defaults to False (image is displayed immediately when the output + transport receives it). """ + sync_with_audio: bool = field(default=False, init=False) + def __str__(self): pts = format_pts(self.pts) return f"{self.name}(pts: {pts}, destination: {self.transport_destination}, size: {self.size}, format: {self.format})" @@ -277,9 +294,12 @@ class TTSAudioRawFrame(OutputAudioRawFrame): """Audio data frame generated by Text-to-Speech services. A chunk of output audio generated by a TTS service, ready for playback. + + Parameters: + context_id: Unique identifier for the TTS context that generated this audio. """ - pass + context_id: Optional[str] = None @dataclass @@ -341,6 +361,11 @@ class TextFrame(DataFrame): Parameters: text: The text content. + skip_tts: Whether this text should be skipped by the TTS service. + includes_inter_frame_spaces: Whether any necessary inter-frame (leading/trailing) spaces are already + included in the text. + append_to_context: Whether this text should be appended to the LLM context. + Defaults to True. """ text: str @@ -376,16 +401,6 @@ class LLMTextFrame(TextFrame): self.includes_inter_frame_spaces = True -class AggregationType(str, Enum): - """Built-in aggregation strings.""" - - SENTENCE = "sentence" - WORD = "word" - - def __str__(self): - return self.value - - @dataclass class AggregatedTextFrame(TextFrame): """Text frame representing an aggregation of TextFrames. @@ -395,9 +410,11 @@ class AggregatedTextFrame(TextFrame): Parameters: aggregated_by: Method used to aggregate the text frames. + context_id: Unique identifier for the TTS context that generated this text. """ aggregated_by: AggregationType | str + context_id: Optional[str] = None @dataclass @@ -409,9 +426,13 @@ class VisionTextFrame(LLMTextFrame): @dataclass class TTSTextFrame(AggregatedTextFrame): - """Text frame generated by Text-to-Speech services.""" + """Text frame generated by Text-to-Speech services. - pass + Parameters: + context_id: Unique identifier for the TTS context that generated this text. + """ + + context_id: Optional[str] = None @dataclass @@ -426,12 +447,15 @@ class TranscriptionFrame(TextFrame): timestamp: When the transcription occurred. language: Detected or specified language of the speech. result: Raw result from the STT service. + finalized: Whether this is the final transcription for an utterance. + Set by STT services that support commit/finalize signals. """ user_id: str timestamp: str language: Optional[Language] = None result: Optional[Any] = None + finalized: bool = False def __str__(self): return f"{self.name}(user: {self.user_id}, text: [{self.text}], language: {self.language}, timestamp: {self.timestamp})" @@ -918,9 +942,11 @@ class TTSSpeakFrame(DataFrame): Parameters: text: The text to be spoken. + append_to_context: Whether to append the text to the context. """ text: str + append_to_context: Optional[bool] = None @dataclass @@ -983,7 +1009,8 @@ class OutputDTMFFrame(DTMFFrame, DataFrame): specify where the DTMF keypress should be sent. """ - pass + def __str__(self): + return f"{self.name}(tone: {self.button})" # @@ -1015,6 +1042,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 @@ -1025,6 +1053,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 @@ -1115,12 +1144,11 @@ class FrameProcessorResumeUrgentFrame(SystemFrame): @dataclass class InterruptionFrame(SystemFrame): - """Frame indicating user started speaking (interruption detected). + """Frame pushed to interrupt the pipeline. - Emitted by the BaseInputTransport to indicate that a user has started - speaking (i.e. is interrupting). This is similar to - UserStartedSpeakingFrame except that it should be pushed concurrently - with other frames (so the order is not guaranteed). + This frame is used to interrupt the pipeline. For example, when a user + starts speaking to cancel any in-progress bot output. It can also be pushed + by any processor. """ pass @@ -1190,6 +1218,28 @@ class UserStoppedSpeakingFrame(SystemFrame): emulated: bool = False +@dataclass +class UserMuteStartedFrame(SystemFrame): + """Frame indicating that the user has been muted. + + Emitted when a mute strategy activates, suppressing user frames (audio, + transcription, interruption) from propagating through the pipeline. + """ + + pass + + +@dataclass +class UserMuteStoppedFrame(SystemFrame): + """Frame indicating that the user has been unmuted. + + Emitted when a mute strategy deactivates, allowing user frames to + propagate through the pipeline again. + """ + + pass + + @dataclass class UserSpeakingFrame(SystemFrame): """Frame indicating the user is speaking. @@ -1252,16 +1302,32 @@ class EmulateUserStoppedSpeakingFrame(SystemFrame): @dataclass class VADUserStartedSpeakingFrame(SystemFrame): - """Frame emitted when VAD definitively detects user started speaking.""" + """Frame emitted when VAD definitively detects user started speaking. - pass + Parameters: + start_secs: The VAD start_secs duration that was used to confirm the user + started speaking. This represents the speech duration that had to + elapse before the VAD determined speech began. + timestamp: Wall-clock time when the VAD made its determination. + """ + + start_secs: float = 0.0 + timestamp: float = field(default_factory=time.time) @dataclass class VADUserStoppedSpeakingFrame(SystemFrame): - """Frame emitted when VAD definitively detects user stopped speaking.""" + """Frame emitted when VAD definitively detects user stopped speaking. - pass + Parameters: + stop_secs: The VAD stop_secs duration that was used to confirm the user + stopped speaking. This represents the silence duration that had to + elapse before the VAD determined speech ended. + timestamp: Wall-clock time when the VAD made its determination. + """ + + stop_secs: float = 0.0 + timestamp: float = field(default_factory=time.time) @dataclass @@ -1461,29 +1527,31 @@ class UserImageRequestFrame(SystemFrame): text: An optional text associated to the image request. append_to_context: Whether the requested image should be appended to the LLM context. video_source: Specific video source to capture from. + function_name: Name of function that generated this request (if any). + tool_call_id: Tool call ID if generated by function call (if any). + result_callback: Optional callback to invoke when the image is retrieved. context: [DEPRECATED] Optional context for the image request. - function_name: [DEPRECATED] Name of function that generated this request (if any). - tool_call_id: [DEPRECATED] Tool call ID if generated by function call. """ user_id: str text: Optional[str] = None append_to_context: Optional[bool] = None video_source: Optional[str] = None - context: Optional[Any] = None function_name: Optional[str] = None tool_call_id: Optional[str] = None + result_callback: Optional[Any] = None + context: Optional[Any] = None def __post_init__(self): super().__post_init__() - if self.context or self.function_name or self.tool_call_id: + if self.context: import warnings with warnings.catch_warnings(): warnings.simplefilter("always") warnings.warn( - "`UserImageRequestFrame` fields `context`, `function_name` and `tool_call_id` are deprecated.", + "`UserImageRequestFrame` field `context` is deprecated.", DeprecationWarning, stacklevel=2, ) @@ -1565,7 +1633,7 @@ class UserImageRawFrame(InputImageRawFrame): user_id: Identifier of the user who provided this image. text: An optional text associated to this image. append_to_context: Whether the requested image should be appended to the LLM context. - request: [DEPRECATED] The original image request frame if this is a response. + request: The original image request frame if this is a response. """ user_id: str = "" @@ -1573,20 +1641,6 @@ class UserImageRawFrame(InputImageRawFrame): append_to_context: Optional[bool] = None request: Optional[UserImageRequestFrame] = None - def __post_init__(self): - super().__post_init__() - - if self.request: - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "`UserImageRawFrame` field `request` is deprecated.", - DeprecationWarning, - stacklevel=2, - ) - def __str__(self): pts = format_pts(self.pts) return f"{self.name}(pts: {pts}, user: {self.user_id}, source: {self.transport_source}, size: {self.size}, format: {self.format}, text: {self.text}, append_to_context: {self.append_to_context})" @@ -1613,7 +1667,8 @@ class AssistantImageRawFrame(OutputImageRawFrame): class InputDTMFFrame(DTMFFrame, SystemFrame): """DTMF keypress input frame from transport.""" - pass + def __str__(self): + return f"{self.name}(tone: {self.button.value})" @dataclass @@ -1645,13 +1700,59 @@ class SpeechControlParamsFrame(SystemFrame): turn_params: Optional[BaseTurnParams] = None +@dataclass +class ServiceMetadataFrame(SystemFrame): + """Base metadata frame for services. + + Broadcast by services at pipeline start to share service-specific + configuration and performance characteristics with downstream processors. + + Parameters: + service_name: The name of the service broadcasting this metadata. + """ + + service_name: str + + +@dataclass +class STTMetadataFrame(ServiceMetadataFrame): + """Metadata from STT service. + + Broadcast by STT services to inform downstream processors (like turn + strategies) about STT latency characteristics. + + Parameters: + ttfs_p99_latency: Time to final segment P99 latency in seconds. + This is the expected time from when speech ends to when the + final transcript is received, at the 99th percentile. + """ + + ttfs_p99_latency: float + + +@dataclass +class ServiceSwitcherRequestMetadataFrame(ControlFrame): + """Request a service to re-emit its metadata frames. + + Used by ServiceSwitcher when switching active services to ensure + downstream processors receive updated metadata from the newly active service. + Services that receive this frame should re-push their metadata frame + (e.g., STTMetadataFrame for STT services). + + Parameters: + service: The target service that should re-emit its metadata. + """ + + service: "FrameProcessor" + + # # Task frames # @dataclass -class TaskFrame(SystemFrame): +class TaskFrame(ControlFrame): """Base frame for task frames. This is a base class for frames that are meant to be sent and handled @@ -1665,7 +1766,21 @@ class TaskFrame(SystemFrame): @dataclass -class EndTaskFrame(TaskFrame): +class TaskSystemFrame(SystemFrame): + """Base frame for task system frames. + + This is a base class for frames that are meant to be sent and handled + upstream by the pipeline task. This might result in a corresponding frame + sent downstream (e.g. `InterruptionTaskFrame` / `InterruptionFrame` or + `EndTaskFrame` / `EndFrame`). + + """ + + pass + + +@dataclass +class EndTaskFrame(TaskFrame, UninterruptibleFrame): """Frame to request graceful pipeline task closure. This is used to notify the pipeline task that the pipeline should be @@ -1683,7 +1798,20 @@ class EndTaskFrame(TaskFrame): @dataclass -class CancelTaskFrame(TaskFrame): +class StopTaskFrame(TaskFrame, UninterruptibleFrame): + """Frame to request pipeline task stop while keeping processors running. + + This is used to notify the pipeline task that it should be stopped as + soon as possible (flushing all the queued frames) but that the pipeline + processors should be kept in a running state. This frame should be pushed + upstream. + """ + + pass + + +@dataclass +class CancelTaskFrame(TaskSystemFrame): """Frame to request immediate pipeline task cancellation. This is used to notify the pipeline task that the pipeline should be @@ -1701,26 +1829,12 @@ class CancelTaskFrame(TaskFrame): @dataclass -class StopTaskFrame(TaskFrame): - """Frame to request pipeline task stop while keeping processors running. +class InterruptionTaskFrame(TaskSystemFrame): + """Frame indicating the pipeline should be interrupted. - This is used to notify the pipeline task that it should be stopped as - soon as possible (flushing all the queued frames) but that the pipeline - processors should be kept in a running state. This frame should be pushed - upstream. - """ - - pass - - -@dataclass -class InterruptionTaskFrame(TaskFrame): - """Frame indicating the bot should be interrupted. - - Emitted when the bot should be interrupted. This will mainly cause the - same actions as if the user interrupted except that the - UserStartedSpeakingFrame and UserStoppedSpeakingFrame won't be generated. - This frame should be pushed upstream. + This frame should be pushed upstream to indicate the pipeline should be + interrupted. The pipeline task converts this into an `InterruptionFrame` + and sends it downstream. """ pass @@ -1760,7 +1874,7 @@ class BotInterruptionFrame(InterruptionTaskFrame): @dataclass -class EndFrame(ControlFrame): +class EndFrame(ControlFrame, UninterruptibleFrame): """Frame indicating pipeline has ended and should shut down. Indicates that a pipeline has ended and frame processors and pipelines @@ -1769,6 +1883,10 @@ class EndFrame(ControlFrame): that this is a control frame, which means it will be received in the order it was sent. + This frame is marked as UninterruptibleFrame to ensure it is not lost when + an InterruptionFrame is processed. Terminal frames must survive interruption + to guarantee proper pipeline shutdown. + Parameters: reason: Optional reason for pushing an end frame. """ @@ -1780,12 +1898,39 @@ class EndFrame(ControlFrame): @dataclass -class StopFrame(ControlFrame): +class StopFrame(ControlFrame, UninterruptibleFrame): """Frame indicating pipeline should stop but keep processors running. Indicates that a pipeline should be stopped but that the pipeline processors should be kept in a running state. This is normally queued from the pipeline task. + + This frame is marked as UninterruptibleFrame to ensure it is not lost when + an InterruptionFrame is processed. Terminal frames must survive interruption + to guarantee proper pipeline control. + """ + + pass + + +@dataclass +class BotConnectedFrame(SystemFrame): + """Frame indicating the bot has connected to the transport service. + + Pushed downstream by SFU transports (Daily, LiveKit, HeyGen, Tavus) + when the bot successfully joins the room. Non-SFU transports do not + emit this frame. + """ + + pass + + +@dataclass +class ClientConnectedFrame(SystemFrame): + """Frame indicating that a client has connected to the transport. + + Pushed downstream by the input transport when a client (participant) + connects. Used by observers to measure transport readiness timing. """ pass @@ -1872,6 +2017,85 @@ class LLMFullResponseEndFrame(ControlFrame): self.skip_tts = None +@dataclass +class LLMAssistantPushAggregationFrame(ControlFrame): + """Frame that forces the LLM assistant aggregator to push its current aggregation to context. + + When received by ``LLMAssistantAggregator``, any text that has been accumulated + in the aggregation buffer is immediately committed to the conversation context as + an assistant message, without waiting for an ``LLMFullResponseEndFrame``. + """ + + +@dataclass +class LLMSummarizeContextFrame(ControlFrame): + """Frame requesting on-demand context summarization. + + Push this frame into the pipeline to trigger a manual context summarization. + + Parameters: + config: Optional per-request override for summary generation settings + (prompt, token budget, messages to keep). If ``None``, the + summarizer's default :class:`~pipecat.utils.context.llm_context_summarization.LLMContextSummaryConfig` + is used. + """ + + config: Optional["LLMContextSummaryConfig"] = None + + +@dataclass +class LLMContextSummaryRequestFrame(ControlFrame): + """Frame requesting context summarization from an LLM service. + + Sent by aggregators to LLM services when conversation context needs to be + compressed. The LLM service generates a summary of older messages while + preserving recent conversation history. + + Parameters: + request_id: Unique identifier to match this request with its response. + Used to handle async responses and avoid race conditions. + context: The full LLM context containing all messages to analyze and summarize. + min_messages_to_keep: Number of recent messages to preserve uncompressed. + These messages will not be included in the summary. + target_context_tokens: Maximum token size for the generated summary. This value + is passed directly to the LLM as the max_tokens parameter when generating + the summary text. + summarization_prompt: System prompt instructing the LLM how to generate + the summary. + summarization_timeout: Maximum time in seconds for the LLM to generate a + summary. When None, a default timeout of 120s is applied. + """ + + request_id: str + context: "LLMContext" + min_messages_to_keep: int + target_context_tokens: int + summarization_prompt: str + summarization_timeout: Optional[float] = None + + +@dataclass +class LLMContextSummaryResultFrame(ControlFrame, UninterruptibleFrame): + """Frame containing the result of context summarization. + + Sent by LLM services back to aggregators after generating a summary. + Contains the formatted summary message and metadata about what was summarized. + + Parameters: + request_id: Identifier matching the original request. Used to correlate + async responses. + summary: The formatted summary message ready to be inserted into context. + last_summarized_index: Index (0-based) of the last message that was + included in the summary. Messages after this index are preserved. + error: Error message if summarization failed, None on success. + """ + + request_id: str + summary: str + last_summarized_index: int + error: Optional[str] = None + + @dataclass class FunctionCallInProgressFrame(ControlFrame, UninterruptibleFrame): """Frame signaling that a function call is currently executing. @@ -1920,29 +2144,49 @@ class TTSStartedFrame(ControlFrame): TTSStoppedFrame. These frames can be used for aggregating audio frames in a transport to optimize the size of frames sent to the session, without needing to control this in the TTS service. + + Parameters: + context_id: Unique identifier for this TTS context. """ - pass + context_id: Optional[str] = None @dataclass class TTSStoppedFrame(ControlFrame): - """Frame indicating the end of a TTS response.""" + """Frame indicating the end of a TTS response. - pass + Parameters: + context_id: Unique identifier for this TTS context. + """ + + context_id: Optional[str] = None @dataclass -class ServiceUpdateSettingsFrame(ControlFrame): +class ServiceUpdateSettingsFrame(ControlFrame, UninterruptibleFrame): """Base frame for updating service settings. - A control frame containing a request to update service settings. + Supports both a ``settings`` dict (for backward compatibility) and a + ``delta`` object. When both are provided, ``delta`` takes precedence. Parameters: settings: Dictionary of setting name to value mappings. + + .. deprecated:: 0.0.104 + Use ``delta`` with a typed settings object instead. + + delta: :class:`~pipecat.services.settings.ServiceSettings` delta-mode + object describing the fields to change. + + service: Optional target service instance. When provided, only that + service will apply the settings; other services will forward the + frame unchanged. """ - settings: Mapping[str, Any] + settings: Mapping[str, Any] = field(default_factory=dict) + delta: Optional["ServiceSettings"] = None + service: Optional["FrameProcessor"] = None @dataclass @@ -1966,6 +2210,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/metrics/metrics.py b/src/pipecat/metrics/metrics.py index 98903483a..2030306e5 100644 --- a/src/pipecat/metrics/metrics.py +++ b/src/pipecat/metrics/metrics.py @@ -87,19 +87,44 @@ class TTSUsageMetricsData(MetricsData): value: int -class SmartTurnMetricsData(MetricsData): - """Metrics data for smart turn predictions. +class TextAggregationMetricsData(MetricsData): + """Text aggregation time metrics data. + + Measures the time from the first LLM token to the first complete sentence, + representing the latency cost of sentence aggregation in the TTS pipeline. + + Parameters: + value: Aggregation time in seconds. + """ + + value: float + + +class TurnMetricsData(MetricsData): + """Metrics data for turn detection predictions. Parameters: is_complete: Whether the turn is predicted to be complete. probability: Confidence probability of the turn completion prediction. - inference_time_ms: Time taken for inference in milliseconds. - server_total_time_ms: Total server processing time in milliseconds. - e2e_processing_time_ms: End-to-end processing time in milliseconds. + e2e_processing_time_ms: End-to-end processing time in milliseconds, + measured from VAD speech-to-silence transition to turn completion. """ is_complete: bool probability: float - inference_time_ms: float - server_total_time_ms: float e2e_processing_time_ms: float + + +class SmartTurnMetricsData(TurnMetricsData): + """Metrics data for smart turn predictions. + + .. deprecated:: 0.0.104 + Use :class:`TurnMetricsData` instead. This class will be removed in a future version. + + Parameters: + inference_time_ms: Time taken for inference in milliseconds. + server_total_time_ms: Total server processing time in milliseconds. + """ + + inference_time_ms: float = 0.0 + server_total_time_ms: float = 0.0 diff --git a/src/pipecat/observers/base_observer.py b/src/pipecat/observers/base_observer.py index 78e36fec8..70c79224a 100644 --- a/src/pipecat/observers/base_observer.py +++ b/src/pipecat/observers/base_observer.py @@ -100,3 +100,11 @@ class BaseObserver(BaseObject): data: The event data containing details about the frame transfer. """ pass + + async def on_pipeline_started(self): + """Called when the pipeline has fully started. + + Fired after the ``StartFrame`` has been processed by all processors + in the pipeline, including nested ``ParallelPipeline`` branches. + """ + pass diff --git a/src/pipecat/observers/loggers/metrics_log_observer.py b/src/pipecat/observers/loggers/metrics_log_observer.py index a36ab510e..7f4c1635c 100644 --- a/src/pipecat/observers/loggers/metrics_log_observer.py +++ b/src/pipecat/observers/loggers/metrics_log_observer.py @@ -24,6 +24,7 @@ from pipecat.metrics.metrics import ( SmartTurnMetricsData, TTFBMetricsData, TTSUsageMetricsData, + TurnMetricsData, ) from pipecat.observers.base_observer import BaseObserver, FramePushed @@ -37,7 +38,7 @@ class MetricsLogObserver(BaseObserver): - ProcessingMetricsData (General processing time) - LLMUsageMetricsData (Token usage statistics) - TTSUsageMetricsData (Text-to-Speech character counts) - - SmartTurnMetricsData (Turn prediction metrics) + - TurnMetricsData (Turn prediction metrics) This allows developers to track performance metrics, token usage, and other statistics throughout the pipeline. @@ -70,6 +71,17 @@ class MetricsLogObserver(BaseObserver): **kwargs: Additional arguments passed to parent class. """ super().__init__(**kwargs) + # Normalize deprecated types in include_metrics + if include_metrics and SmartTurnMetricsData in include_metrics: + import warnings + + warnings.warn( + "SmartTurnMetricsData is deprecated in include_metrics, " + "use TurnMetricsData instead.", + DeprecationWarning, + stacklevel=2, + ) + include_metrics = (include_metrics - {SmartTurnMetricsData}) | {TurnMetricsData} self._include_metrics = include_metrics self._frames_seen = set() @@ -144,8 +156,8 @@ class MetricsLogObserver(BaseObserver): logger.debug( f"📊 {processor_info} TTS USAGE{model_info}: {metrics_data.value} characters at {time_sec:.3f}s" ) - elif isinstance(metrics_data, SmartTurnMetricsData): - self._log_smart_turn(metrics_data, processor_info, model_info, time_sec) + elif isinstance(metrics_data, TurnMetricsData): + self._log_turn(metrics_data, processor_info, model_info, time_sec) else: # Generic fallback for unknown metrics types logger.debug( @@ -191,28 +203,27 @@ class MetricsLogObserver(BaseObserver): f"📊 {processor_info} LLM TOKEN USAGE{model_info}: {usage_str} at {time_sec:.2f}s" ) - def _log_smart_turn( + def _log_turn( self, - metrics_data: SmartTurnMetricsData, + metrics_data: TurnMetricsData, processor_info: str, model_info: str, time_sec: float, ): - """Log smart turn prediction metrics. + """Log turn prediction metrics. Args: - metrics_data: The smart turn metrics data. + metrics_data: The turn metrics data. processor_info: Formatted processor name string. model_info: Formatted model name string. time_sec: Timestamp in seconds. """ complete_str = "COMPLETE" if metrics_data.is_complete else "INCOMPLETE" + e2e_str = f"{metrics_data.e2e_processing_time_ms:.1f}ms" logger.debug( - f"📊 {processor_info} SMART TURN{model_info}: {complete_str} " + f"📊 {processor_info} TURN{model_info}: {complete_str} " f"(probability: {metrics_data.probability:.2%}, " - f"inference: {metrics_data.inference_time_ms:.1f}ms, " - f"server: {metrics_data.server_total_time_ms:.1f}ms, " - f"e2e: {metrics_data.e2e_processing_time_ms:.1f}ms) " + f"e2e: {e2e_str}) " f"at {time_sec:.2f}s" ) diff --git a/src/pipecat/observers/loggers/user_bot_latency_log_observer.py b/src/pipecat/observers/loggers/user_bot_latency_log_observer.py index b8dff734e..2323a36ee 100644 --- a/src/pipecat/observers/loggers/user_bot_latency_log_observer.py +++ b/src/pipecat/observers/loggers/user_bot_latency_log_observer.py @@ -4,9 +4,15 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Observer for measuring user-to-bot response latency.""" +"""Observer for measuring user-to-bot response latency. + +.. deprecated:: 0.0.102 + This module is deprecated. Use :class:`UserBotLatencyObserver` directly + with its ``on_latency_measured`` event handler instead. +""" import time +import warnings from statistics import mean from loguru import logger @@ -27,6 +33,10 @@ class UserBotLatencyLogObserver(BaseObserver): This helps measure how quickly the AI services respond by tracking conversation turn timing and logging latency metrics. + + .. deprecated:: 0.0.102 + This class is deprecated. Use :class:`UserBotLatencyObserver` directly + with its ``on_latency_measured`` event handler for custom logging. """ def __init__(self): @@ -34,7 +44,17 @@ class UserBotLatencyLogObserver(BaseObserver): Sets up tracking for processed frames and user speech timing to calculate response latencies. + + .. deprecated:: 0.0.102 + This class is deprecated. Use :class:`UserBotLatencyObserver` + directly with its ``on_latency_measured`` event handler. """ + warnings.warn( + "UserBotLatencyLogObserver is deprecated and will be removed in a future version. " + "Use UserBotLatencyObserver directly with its on_latency_measured event handler instead.", + DeprecationWarning, + stacklevel=2, + ) super().__init__() self._user_bot_latency_processed_frames = set() self._user_stopped_time = 0 @@ -59,7 +79,7 @@ class UserBotLatencyLogObserver(BaseObserver): if isinstance(data.frame, VADUserStartedSpeakingFrame): self._user_stopped_time = 0 elif isinstance(data.frame, VADUserStoppedSpeakingFrame): - self._user_stopped_time = time.time() + self._user_stopped_time = data.frame.timestamp - data.frame.stop_secs elif isinstance(data.frame, (EndFrame, CancelFrame)): self._log_summary() elif isinstance(data.frame, BotStartedSpeakingFrame) and self._user_stopped_time: diff --git a/src/pipecat/observers/startup_timing_observer.py b/src/pipecat/observers/startup_timing_observer.py new file mode 100644 index 000000000..a1ea04d47 --- /dev/null +++ b/src/pipecat/observers/startup_timing_observer.py @@ -0,0 +1,328 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Observer for tracking pipeline startup timing. + +This module provides an observer that measures how long each processor's +``start()`` method takes during pipeline startup. It works by tracking +when a ``StartFrame`` arrives at a processor (``on_process_frame``) versus +when it leaves (``on_push_frame``), giving the exact ``start()`` duration +for each processor in the pipeline. + +It also measures transport timing — the time from ``StartFrame`` to the +first ``BotConnectedFrame`` (SFU transports only) and ``ClientConnectedFrame`` +— via a separate ``on_transport_timing_report`` event. + +Example:: + + observer = StartupTimingObserver() + + @observer.event_handler("on_startup_timing_report") + async def on_report(observer, report): + for t in report.processor_timings: + print(f"{t.processor_name}: {t.duration_secs:.3f}s") + + @observer.event_handler("on_transport_timing_report") + async def on_transport(observer, report): + if report.bot_connected_secs is not None: + print(f"Bot connected in {report.bot_connected_secs:.3f}s") + print(f"Client connected in {report.client_connected_secs:.3f}s") + + task = PipelineTask(pipeline, observers=[observer]) +""" + +import time +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple, Type + +from pydantic import BaseModel, Field + +from pipecat.frames.frames import BotConnectedFrame, ClientConnectedFrame, StartFrame +from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed +from pipecat.pipeline.base_pipeline import BasePipeline +from pipecat.pipeline.pipeline import PipelineSource +from pipecat.processors.frame_processor import FrameProcessor + +# Internal pipeline types excluded from tracking by default. +_INTERNAL_TYPES = (PipelineSource, BasePipeline) + + +@dataclass +class _ArrivalInfo: + """Internal record of when a StartFrame arrived at a processor.""" + + processor: FrameProcessor + arrival_ts_ns: int + + +class ProcessorStartupTiming(BaseModel): + """Startup timing for a single processor. + + Parameters: + processor_name: The name of the processor. + start_offset_secs: Offset in seconds from the StartFrame to when this + processor's start() began. + duration_secs: How long the processor's start() took, in seconds. + """ + + processor_name: str + start_offset_secs: float + duration_secs: float + + +class StartupTimingReport(BaseModel): + """Report of startup timings for all measured processors. + + Parameters: + start_time: Unix timestamp when the first processor began starting. + total_duration_secs: Total wall-clock time from first to last processor start. + processor_timings: Per-processor timing data, in pipeline order. + """ + + start_time: float + total_duration_secs: float + processor_timings: List[ProcessorStartupTiming] = Field(default_factory=list) + + +class TransportTimingReport(BaseModel): + """Time from pipeline start to transport connection milestones. + + Parameters: + start_time: Unix timestamp of the StartFrame (pipeline start). + bot_connected_secs: Seconds from StartFrame to first BotConnectedFrame + (only set for SFU transports). + client_connected_secs: Seconds from StartFrame to first ClientConnectedFrame. + """ + + start_time: float + bot_connected_secs: Optional[float] = None + client_connected_secs: Optional[float] = None + + +class StartupTimingObserver(BaseObserver): + """Observer that measures processor startup times during pipeline initialization. + + Tracks how long each processor's ``start()`` method takes by measuring the + time between when a ``StartFrame`` arrives at a processor and when it is + pushed downstream. This captures WebSocket connections, API authentication, + model loading, and other initialization work. + + Also measures transport timing, the time from ``StartFrame`` to connection + milestones: + + - ``bot_connected_secs``: When the bot joins the transport room + (SFU transports only, triggered by ``BotConnectedFrame``). + - ``client_connected_secs``: When a remote participant connects + (triggered by ``ClientConnectedFrame``). + + By default, internal pipeline processors (``PipelineSource``, ``Pipeline``) + are excluded from the report. Pass ``processor_types`` to measure only + specific types. + + Event handlers available: + + - on_startup_timing_report: Called once after startup completes with the full + timing report. + - on_transport_timing_report: Called once when the first client connects with a + TransportTimingReport containing client_connected_secs and bot_connected_secs + (if available). + + Example:: + + observer = StartupTimingObserver( + processor_types=(STTService, TTSService) + ) + + @observer.event_handler("on_startup_timing_report") + async def on_report(observer, report): + for t in report.processor_timings: + logger.info(f"{t.processor_name}: {t.duration_secs:.3f}s") + + @observer.event_handler("on_transport_timing_report") + async def on_transport(observer, report): + if report.bot_connected_secs is not None: + logger.info(f"Bot connected in {report.bot_connected_secs:.3f}s") + logger.info(f"Client connected in {report.client_connected_secs:.3f}s") + + task = PipelineTask(pipeline, observers=[observer]) + + Args: + processor_types: Optional tuple of processor types to measure. If None, + all non-internal processors are measured. + """ + + def __init__( + self, + *, + processor_types: Optional[Tuple[Type[FrameProcessor], ...]] = None, + **kwargs, + ): + """Initialize the startup timing observer. + + Args: + processor_types: Optional tuple of processor types to measure. + If None, all non-internal processors are measured. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(**kwargs) + self._processor_types = processor_types + + # Map processor ID -> arrival info. + self._arrivals: Dict[int, _ArrivalInfo] = {} + + # Collected timings in pipeline order. + self._timings: List[ProcessorStartupTiming] = [] + + # Lock onto the first StartFrame we see (by frame ID). + self._start_frame_id: Optional[str] = None + + # Whether we've already emitted the startup timing report. + self._startup_timing_reported = False + + # Whether we've already measured transport timing. + self._transport_timing_reported = False + + # Timestamp (ns) when we first see a StartFrame arrive at a processor. + self._start_frame_arrival_ns: Optional[int] = None + + # Bot connected timing (stored for inclusion in the transport report). + self._bot_connected_secs: Optional[float] = None + + # Wall clock time when the StartFrame was first seen. + self._start_wall_clock: Optional[float] = None + + self._register_event_handler("on_startup_timing_report") + self._register_event_handler("on_transport_timing_report") + + def _should_track(self, processor: FrameProcessor) -> bool: + """Check if a processor should be tracked for timing. + + Args: + processor: The processor to check. + + Returns: + True if the processor matches the filter or no filter is set. + """ + if self._processor_types is not None: + return isinstance(processor, self._processor_types) + # Default: exclude internal pipeline plumbing. + return not isinstance(processor, _INTERNAL_TYPES) + + async def on_pipeline_started(self): + """Emit the startup timing report when the pipeline has fully started. + + Called by the ``PipelineTask`` after the ``StartFrame`` has been + processed by all processors, including nested ``ParallelPipeline`` + branches. + """ + if self._timings: + await self._emit_report() + + async def on_process_frame(self, data: FrameProcessed): + """Record when a StartFrame arrives at a processor. + + Args: + data: The frame processing event data. + """ + if self._startup_timing_reported: + return + + if not isinstance(data.frame, StartFrame): + return + + # Lock onto the first StartFrame. + if self._start_frame_id is None: + self._start_frame_id = data.frame.id + self._start_frame_arrival_ns = data.timestamp + self._start_wall_clock = time.time() + elif data.frame.id != self._start_frame_id: + return + + if self._should_track(data.processor): + self._arrivals[data.processor.id] = _ArrivalInfo( + processor=data.processor, arrival_ts_ns=data.timestamp + ) + + async def on_push_frame(self, data: FramePushed): + """Record when a StartFrame leaves a processor and compute the delta. + + Also handles ``BotConnectedFrame`` and ``ClientConnectedFrame`` to + measure transport timing. + + Args: + data: The frame push event data. + """ + if isinstance(data.frame, BotConnectedFrame): + self._handle_bot_connected(data) + return + + if isinstance(data.frame, ClientConnectedFrame): + await self._handle_client_connected(data) + return + + if self._startup_timing_reported: + return + + if not isinstance(data.frame, StartFrame): + return + + if self._start_frame_id is not None and data.frame.id != self._start_frame_id: + return + + arrival = self._arrivals.pop(data.source.id, None) + if arrival is None: + return + + duration_ns = data.timestamp - arrival.arrival_ts_ns + duration_secs = duration_ns / 1e9 + start_offset_secs = (arrival.arrival_ts_ns - self._start_frame_arrival_ns) / 1e9 + + self._timings.append( + ProcessorStartupTiming( + processor_name=arrival.processor.name, + start_offset_secs=start_offset_secs, + duration_secs=duration_secs, + ) + ) + + def _handle_bot_connected(self, data: FramePushed): + """Record bot connected timing on first BotConnectedFrame.""" + if self._bot_connected_secs is not None or self._start_frame_arrival_ns is None: + return + + delta_ns = data.timestamp - self._start_frame_arrival_ns + self._bot_connected_secs = delta_ns / 1e9 + + async def _handle_client_connected(self, data: FramePushed): + """Emit transport timing report on first ClientConnectedFrame.""" + if self._transport_timing_reported or self._start_frame_arrival_ns is None: + return + + self._transport_timing_reported = True + delta_ns = data.timestamp - self._start_frame_arrival_ns + client_connected_secs = delta_ns / 1e9 + report = TransportTimingReport( + start_time=self._start_wall_clock or 0.0, + bot_connected_secs=self._bot_connected_secs, + client_connected_secs=client_connected_secs, + ) + await self._call_event_handler("on_transport_timing_report", report) + + async def _emit_report(self): + """Build and emit the startup timing report.""" + if self._startup_timing_reported: + return + self._startup_timing_reported = True + + total = sum(t.duration_secs for t in self._timings) + + report = StartupTimingReport( + start_time=self._start_wall_clock or 0.0, + total_duration_secs=total, + processor_timings=self._timings, + ) + + await self._call_event_handler("on_startup_timing_report", report) diff --git a/src/pipecat/observers/user_bot_latency_observer.py b/src/pipecat/observers/user_bot_latency_observer.py new file mode 100644 index 000000000..0672b689c --- /dev/null +++ b/src/pipecat/observers/user_bot_latency_observer.py @@ -0,0 +1,351 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Observer for tracking user-to-bot response latency. + +This module provides an observer that monitors the time between when a user +stops speaking and when the bot starts speaking, emitting events when latency +is measured. Optionally collects per-service latency breakdown metrics +(TTFB, text aggregation) when ``enable_metrics=True``. +""" + +import time +from collections import deque +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + ClientConnectedFrame, + FunctionCallInProgressFrame, + FunctionCallResultFrame, + InterruptionFrame, + MetricsFrame, + UserStoppedSpeakingFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, +) +from pipecat.metrics.metrics import ( + TextAggregationMetricsData, + TTFBMetricsData, +) +from pipecat.observers.base_observer import BaseObserver, FramePushed +from pipecat.processors.frame_processor import FrameDirection + + +class TTFBBreakdownMetrics(BaseModel): + """TTFB measurement with timestamp for timeline placement. + + Parameters: + processor: Name of the processor that reported the TTFB. + model: Optional model name associated with the metric. + start_time: Unix timestamp when the TTFB measurement started. + duration_secs: TTFB duration in seconds. + """ + + processor: str + model: Optional[str] = None + start_time: float + duration_secs: float + + +class TextAggregationBreakdownMetrics(BaseModel): + """Text aggregation measurement with timestamp for timeline placement. + + Parameters: + processor: Name of the processor that reported the metric. + start_time: Unix timestamp when text aggregation started. + duration_secs: Aggregation duration in seconds. + """ + + processor: str + start_time: float + duration_secs: float + + +class FunctionCallMetrics(BaseModel): + """Latency for a single function call execution. + + Parameters: + function_name: Name of the function that was called. + start_time: Unix timestamp when execution started. + duration_secs: Time in seconds from execution start to result. + """ + + function_name: str + start_time: float + duration_secs: float + + +class LatencyBreakdown(BaseModel): + """Per-service latency breakdown for a single user-to-bot cycle. + + Collected between ``VADUserStoppedSpeakingFrame`` and + ``BotStartedSpeakingFrame`` when ``enable_metrics=True`` in + :class:`~pipecat.pipeline.task.PipelineParams`. + + Parameters: + ttfb: Time-to-first-byte metrics from each service in the pipeline. + text_aggregation: First text aggregation measurement, representing + the latency cost of sentence aggregation in the TTS pipeline. + user_turn_start_time: Unix timestamp when the user turn started + (actual user silence, adjusted for VAD stop_secs). ``None`` if + no ``VADUserStoppedSpeakingFrame`` was observed. + user_turn_secs: Duration in seconds of the user's turn, measured + from when the user actually stopped speaking to when the turn + was released (``UserStoppedSpeakingFrame``). This includes + VAD silence detection, STT finalization, and any turn analyzer + wait. ``None`` if no ``UserStoppedSpeakingFrame`` was observed + (e.g. no turn analyzer configured). + function_calls: Latency for each function call executed during + this cycle. Empty if no function calls occurred. + """ + + ttfb: List[TTFBBreakdownMetrics] = Field(default_factory=list) + text_aggregation: Optional[TextAggregationBreakdownMetrics] = None + user_turn_start_time: Optional[float] = None + user_turn_secs: Optional[float] = None + function_calls: List[FunctionCallMetrics] = Field(default_factory=list) + + def chronological_events(self) -> List[str]: + """Return human-readable event labels sorted by start time. + + Collects all sub-metrics into a flat list, sorts by ``start_time``, + and returns formatted strings suitable for logging. + + Returns: + List of formatted strings, one per event, in chronological order. + """ + events: List[tuple] = [] + + if self.user_turn_start_time is not None and self.user_turn_secs is not None: + events.append((self.user_turn_start_time, f"User turn: {self.user_turn_secs:.3f}s")) + + for t in self.ttfb: + events.append((t.start_time, f"{t.processor}: TTFB {t.duration_secs:.3f}s")) + + for fc in self.function_calls: + events.append((fc.start_time, f"{fc.function_name}: {fc.duration_secs:.3f}s")) + + if self.text_aggregation: + ta = self.text_aggregation + events.append( + (ta.start_time, f"{ta.processor}: text aggregation {ta.duration_secs:.3f}s") + ) + + events.sort(key=lambda e: e[0]) + return [label for _, label in events] + + +class UserBotLatencyObserver(BaseObserver): + """Observer that tracks user-to-bot response latency. + + Measures the time between when a user stops speaking (VADUserStoppedSpeakingFrame) + and when the bot starts speaking (BotStartedSpeakingFrame). Emits events when + latency is measured, allowing consumers to log, trace, or otherwise process + the latency data. + + When ``enable_metrics=True`` in pipeline params, also collects per-service + latency breakdown (TTFB, text aggregation) and emits an + ``on_latency_breakdown`` event alongside the existing latency measurement. + + This observer follows the composition pattern used by TurnTrackingObserver, + acting as a reusable component for latency measurement. + + Events: + on_latency_measured(observer, latency_seconds): Emitted when + time-to-first-bot-speech is calculated. Measures the time from + when the user stopped speaking to when the bot starts speaking. + on_latency_breakdown(observer, breakdown): Emitted at each + ``BotStartedSpeakingFrame`` with a :class:`LatencyBreakdown` + containing per-service metrics collected during the user→bot cycle. + on_first_bot_speech_latency(observer, latency_seconds): Emitted once, + the first time ``BotStartedSpeakingFrame`` arrives after + ``ClientConnectedFrame``. Measures the time from client connection + to the first bot speech. + """ + + def __init__(self, *, max_frames=100, **kwargs): + """Initialize the user-bot latency observer. + + Sets up tracking for processed frames and user speech timing + to calculate response latencies. + + Args: + max_frames: Maximum number of frame IDs to keep in history for + duplicate detection. Defaults to 100. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(**kwargs) + self._user_stopped_time: Optional[float] = None + self._user_turn_start_time: Optional[float] = None + self._user_turn: Optional[float] = None + + # First bot speech tracking + self._client_connected_time: Optional[float] = None + self._first_bot_speech_measured: bool = False + + # Frame deduplication (bounded deque + set pattern) + self._processed_frames: set = set() + self._frame_history: deque = deque(maxlen=max_frames) + + # Per-cycle metric accumulators + self._ttfb: List[TTFBBreakdownMetrics] = [] + self._text_aggregation: Optional[TextAggregationBreakdownMetrics] = None + self._function_call_starts: Dict[str, tuple[str, float]] = {} + self._function_call_metrics: List[FunctionCallMetrics] = [] + + self._register_event_handler("on_latency_measured") + self._register_event_handler("on_latency_breakdown") + self._register_event_handler("on_first_bot_speech_latency") + + async def on_push_frame(self, data: FramePushed): + """Process frames to track speech timing and calculate latency. + + Tracks VAD events and bot speaking events to measure the time between + user stopping speech and bot starting speech. Also accumulates metrics + from MetricsFrame for the latency breakdown. + + Args: + data: Frame push event containing the frame and direction information. + """ + # Only process downstream frames + if data.direction != FrameDirection.DOWNSTREAM: + return + + # Skip already processed frames (bounded deque + set) + if data.frame.id in self._processed_frames: + return + + self._processed_frames.add(data.frame.id) + self._frame_history.append(data.frame.id) + + if len(self._processed_frames) > len(self._frame_history): + self._processed_frames = set(self._frame_history) + + # Track client connection (first occurrence only) + if isinstance(data.frame, ClientConnectedFrame): + if self._client_connected_time is None: + self._client_connected_time = time.time() + return + + # Track speech and pipeline events for latency + if isinstance(data.frame, VADUserStartedSpeakingFrame): + # Reset when user starts speaking + self._user_stopped_time = None + self._user_turn_start_time = None + self._user_turn = None + self._reset_accumulators() + # If user speaks before the bot's first speech, abandon the + # first-bot-speech measurement — it's only meaningful for greetings. + self._first_bot_speech_measured = True + elif isinstance(data.frame, VADUserStoppedSpeakingFrame): + # Record the actual time the user stopped speaking, which is + # the VAD determination time minus the stop_secs silence duration + # that had to elapse before the VAD confirmed speech ended. + self._user_stopped_time = data.frame.timestamp - data.frame.stop_secs + self._user_turn_start_time = self._user_stopped_time + elif isinstance(data.frame, UserStoppedSpeakingFrame): + # Measure the user turn duration: from actual user silence to + # turn release. Includes VAD silence detection, STT finalization, + # and any turn analyzer wait. + if self._user_stopped_time is not None: + self._user_turn = time.time() - self._user_stopped_time + elif isinstance(data.frame, InterruptionFrame): + # Discard stale metrics from cancelled LLM/TTS cycles + self._reset_accumulators() + elif isinstance(data.frame, FunctionCallInProgressFrame): + self._function_call_starts[data.frame.tool_call_id] = ( + data.frame.function_name, + time.time(), + ) + elif isinstance(data.frame, FunctionCallResultFrame): + start = self._function_call_starts.pop(data.frame.tool_call_id, None) + if start is not None: + function_name, start_time = start + self._function_call_metrics.append( + FunctionCallMetrics( + function_name=function_name, + start_time=start_time, + duration_secs=time.time() - start_time, + ) + ) + elif isinstance(data.frame, MetricsFrame): + self._handle_metrics_frame(data.frame) + elif isinstance(data.frame, BotStartedSpeakingFrame): + await self._handle_bot_started_speaking() + + async def _handle_bot_started_speaking(self): + """Handle BotStartedSpeakingFrame to emit latency and breakdown.""" + emit_breakdown = False + + # One-time first bot speech measurement (client connect → first speech) + if self._client_connected_time is not None and not self._first_bot_speech_measured: + self._first_bot_speech_measured = True + latency = time.time() - self._client_connected_time + await self._call_event_handler("on_first_bot_speech_latency", latency) + emit_breakdown = True + + if self._user_stopped_time is not None: + latency = time.time() - self._user_stopped_time + self._user_stopped_time = None + await self._call_event_handler("on_latency_measured", latency) + emit_breakdown = True + + if emit_breakdown: + breakdown = LatencyBreakdown( + ttfb=list(self._ttfb), + text_aggregation=self._text_aggregation, + user_turn_start_time=self._user_turn_start_time, + user_turn_secs=self._user_turn, + function_calls=list(self._function_call_metrics), + ) + await self._call_event_handler("on_latency_breakdown", breakdown) + self._reset_accumulators() + + def _handle_metrics_frame(self, frame: MetricsFrame): + """Extract latency metrics from a MetricsFrame. + + Accumulates metrics when a measurement is in progress: either a + user→bot cycle (after ``VADUserStoppedSpeakingFrame``) or the + first-bot-speech window (after ``ClientConnectedFrame``). + """ + waiting_for_first_speech = ( + self._client_connected_time is not None and not self._first_bot_speech_measured + ) + if self._user_stopped_time is None and not waiting_for_first_speech: + return + + now = time.time() + for metrics_data in frame.data: + if isinstance(metrics_data, TTFBMetricsData) and metrics_data.value > 0: + self._ttfb.append( + TTFBBreakdownMetrics( + processor=metrics_data.processor, + model=metrics_data.model, + start_time=now - metrics_data.value, + duration_secs=metrics_data.value, + ) + ) + elif isinstance(metrics_data, TextAggregationMetricsData): + # Only keep the first measurement — it's the one that + # impacts the initial speaking latency. + if self._text_aggregation is None: + self._text_aggregation = TextAggregationBreakdownMetrics( + processor=metrics_data.processor, + start_time=now - metrics_data.value, + duration_secs=metrics_data.value, + ) + + def _reset_accumulators(self): + """Clear per-cycle metric accumulators.""" + self._ttfb = [] + self._text_aggregation = None + self._user_turn_start_time = None + self._user_turn = None + self._function_call_starts = {} + self._function_call_metrics = [] diff --git a/src/pipecat/pipeline/llm_switcher.py b/src/pipecat/pipeline/llm_switcher.py index 616a65b66..cfee6b2b0 100644 --- a/src/pipecat/pipeline/llm_switcher.py +++ b/src/pipecat/pipeline/llm_switcher.py @@ -9,7 +9,11 @@ from typing import Any, List, Optional, Type from pipecat.adapters.schemas.direct_function import DirectFunction -from pipecat.pipeline.service_switcher import ServiceSwitcher, StrategyType +from pipecat.pipeline.service_switcher import ( + ServiceSwitcher, + ServiceSwitcherStrategyManual, + StrategyType, +) from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.services.llm_service import LLMService @@ -19,18 +23,20 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]): Example:: - llm_switcher = LLMSwitcher( - llms=[openai_llm, anthropic_llm], - strategy_type=ServiceSwitcherStrategyManual - ) + llm_switcher = LLMSwitcher(llms=[openai_llm, anthropic_llm]) """ - def __init__(self, llms: List[LLMService], strategy_type: Type[StrategyType]): + def __init__( + self, + llms: List[LLMService], + strategy_type: Type[StrategyType] = ServiceSwitcherStrategyManual, + ): """Initialize the service switcher with a list of LLMs and a switching strategy. Args: llms: List of LLM services to switch between. strategy_type: The strategy class to use for switching between LLMs. + Defaults to ``ServiceSwitcherStrategyManual``. """ super().__init__(llms, strategy_type) @@ -44,7 +50,7 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]): return self.services @property - def active_llm(self) -> Optional[LLMService]: + def active_llm(self) -> LLMService: """Get the currently active LLM. Returns: @@ -52,17 +58,19 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]): """ return self.strategy.active_service - async def run_inference(self, context: LLMContext) -> Optional[str]: + async def run_inference(self, context: LLMContext, **kwargs) -> Optional[str]: """Run a one-shot, out-of-band (i.e. out-of-pipeline) inference with the given LLM context, using the currently active LLM. Args: context: The LLM context containing conversation history. + **kwargs: Additional arguments forwarded to the active LLM's run_inference + (e.g. max_tokens, system_instruction). Returns: The LLM's response as a string, or None if no response is generated. """ if self.active_llm: - return await self.active_llm.run_inference(context=context) + return await self.active_llm.run_inference(context=context, **kwargs) return None def register_function( @@ -72,6 +80,7 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]): start_callback=None, *, cancel_on_interruption: bool = True, + timeout_secs: Optional[float] = None, ): """Register a function handler for LLM function calls, on all LLMs, active or not. @@ -88,6 +97,7 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]): cancel_on_interruption: Whether to cancel this function call when an interruption occurs. Defaults to True. + timeout_secs: Optional timeout in seconds for the function call. """ for llm in self.llms: llm.register_function( @@ -95,6 +105,7 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]): handler=handler, start_callback=start_callback, cancel_on_interruption=cancel_on_interruption, + timeout_secs=timeout_secs, ) def register_direct_function( @@ -102,6 +113,7 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]): handler: DirectFunction, *, cancel_on_interruption: bool = True, + timeout_secs: Optional[float] = None, ): """Register a direct function handler for LLM function calls, on all LLMs, active or not. @@ -109,9 +121,11 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]): handler: The direct function to register. Must follow DirectFunction protocol. cancel_on_interruption: Whether to cancel this function call when an interruption occurs. Defaults to True. + timeout_secs: Optional timeout in seconds for the function call. """ for llm in self.llms: llm.register_direct_function( handler=handler, cancel_on_interruption=cancel_on_interruption, + timeout_secs=timeout_secs, ) diff --git a/src/pipecat/pipeline/parallel_pipeline.py b/src/pipecat/pipeline/parallel_pipeline.py index 81beeead8..1e2e03a8f 100644 --- a/src/pipecat/pipeline/parallel_pipeline.py +++ b/src/pipecat/pipeline/parallel_pipeline.py @@ -52,6 +52,8 @@ class ParallelPipeline(BasePipeline): self._seen_ids = set() self._frame_counter: Dict[int, int] = {} + self._synchronizing: bool = False + self._buffered_frames: list[tuple[Frame, FrameDirection]] = [] logger.debug(f"Creating {self} pipelines") @@ -141,8 +143,22 @@ class ParallelPipeline(BasePipeline): await super().process_frame(frame, direction) # Parallel pipeline synchronized frames. + # + # - StartFrame: If a fast branch completes first, processors in + # other branches that haven't received StartFrame yet could + # receive other frames before it, causing errors. + # + # - EndFrame: If EndFrame escapes from a fast branch, downstream + # processors (e.g. output transport) begin shutting down while + # other branches still have frames to flush, causing lost output. + # + # - CancelFrame: PipelineTask waits for CancelFrame to reach the + # pipeline sink. If it escapes from a fast branch while slower + # branches are still running, the task considers cancellation + # complete prematurely. if isinstance(frame, (StartFrame, EndFrame, CancelFrame)): self._frame_counter[frame.id] = len(self._pipelines) + self._synchronizing = True await self.pause_processing_system_frames() await self.pause_processing_frames() @@ -151,10 +167,18 @@ class ParallelPipeline(BasePipeline): await p.queue_frame(frame, direction) async def _parallel_push_frame(self, frame: Frame, direction: FrameDirection): - """Push frames while avoiding duplicates using frame ID tracking.""" + """Push frames while avoiding duplicates using frame ID tracking. + + During lifecycle frame synchronization, non-lifecycle frames are buffered + to prevent them from escaping the parallel pipeline before all branches + have finished processing the lifecycle frame. + """ if frame.id not in self._seen_ids: self._seen_ids.add(frame.id) - await self.push_frame(frame, direction) + if self._synchronizing: + self._buffered_frames.append((frame, direction)) + else: + await self.push_frame(frame, direction) async def _pipeline_sink_push_frame(self, frame: Frame, direction: FrameDirection): # Parallel pipeline synchronized frames. @@ -167,8 +191,21 @@ class ParallelPipeline(BasePipeline): # Only push the frame when all pipelines have processed it. if frame_counter == 0: - await self._parallel_push_frame(frame, direction) + self._synchronizing = False + # StartFrame should always go before any other frame. + if isinstance(frame, StartFrame): + await self._parallel_push_frame(frame, direction) + await self._flush_buffered_frames() + else: + await self._flush_buffered_frames() + await self._parallel_push_frame(frame, direction) await self.resume_processing_system_frames() await self.resume_processing_frames() else: await self._parallel_push_frame(frame, direction) + + async def _flush_buffered_frames(self): + """Flush frames that were buffered during lifecycle frame synchronization.""" + while len(self._buffered_frames) > 0: + frame, direction = self._buffered_frames.pop(0) + await self.push_frame(frame, direction) diff --git a/src/pipecat/pipeline/service_switcher.py b/src/pipecat/pipeline/service_switcher.py index dc73496a3..76b703681 100644 --- a/src/pipecat/pipeline/service_switcher.py +++ b/src/pipecat/pipeline/service_switcher.py @@ -6,26 +6,40 @@ """Service switcher for switching between different services at runtime, with different switching strategies.""" -from dataclasses import dataclass from typing import Any, Generic, List, Optional, Type, TypeVar +from loguru import logger + from pipecat.frames.frames import ( - ControlFrame, + ErrorFrame, Frame, ManuallySwitchServiceFrame, + ServiceMetadataFrame, ServiceSwitcherFrame, + ServiceSwitcherRequestMetadataFrame, ) from pipecat.pipeline.parallel_pipeline import ParallelPipeline from pipecat.processors.filters.function_filter import FunctionFilter from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.utils.base_object import BaseObject -class ServiceSwitcherStrategy: +class ServiceSwitcherStrategy(BaseObject): """Base class for service switching strategies. Note: Strategy classes are instantiated internally by ServiceSwitcher. Developers should pass the strategy class (not an instance) to ServiceSwitcher. + + Event handlers available: + + - on_service_switched: Called when the active service changes. + + Example:: + + @strategy.event_handler("on_service_switched") + async def on_service_switched(strategy, service): + ... """ def __init__(self, services: List[FrameProcessor]): @@ -37,20 +51,76 @@ class ServiceSwitcherStrategy: Args: services: List of frame processors to switch between. """ - self.services = services - self.active_service: Optional[FrameProcessor] = None + super().__init__() - def handle_frame(self, frame: ServiceSwitcherFrame, direction: FrameDirection): + if len(services) == 0: + raise Exception(f"ServiceSwitcherStrategy needs at least one service") + + self._services = services + self._active_service = services[0] + + self._register_event_handler("on_service_switched") + + @property + def services(self) -> List[FrameProcessor]: + """Return the list of available services.""" + return self._services + + @property + def active_service(self) -> FrameProcessor: + """Return the currently active service.""" + return self._active_service + + async def handle_frame( + self, frame: ServiceSwitcherFrame, direction: FrameDirection + ) -> Optional[FrameProcessor]: """Handle a frame that controls service switching. - This method can be overridden by subclasses to implement specific logic - for handling frames that control service switching. + The base implementation returns ``None`` for all frames. Subclasses + override this to implement specific switching behaviors. Args: frame: The frame to handle. direction: The direction of the frame (upstream or downstream). + + Returns: + The newly active service if a switch occurred, or None otherwise. """ - raise NotImplementedError("Subclasses must implement this method.") + return None + + async def handle_error(self, error: ErrorFrame) -> Optional[FrameProcessor]: + """Handle an error from the active service. + + Called by ``ServiceSwitcher`` when a non-fatal ``ErrorFrame`` is pushed + upstream by the currently active service. Subclasses can override this + to implement automatic failover. + + Args: + error: The error frame pushed by the active service. + + Returns: + The newly active service if a switch occurred, or None otherwise. + """ + return None + + async def _set_active_if_available(self, service: FrameProcessor) -> Optional[FrameProcessor]: + """Set the active service to the given one, if it is in the list of available services. + + If it's not in the list, the request is ignored, as it may have been + intended for another ServiceSwitcher in the pipeline. + + Args: + service: The service to set as active. + + Returns: + The newly active service, or None if the service was not found. + """ + if service in self.services: + self._active_service = service + await service.queue_frame(ServiceSwitcherRequestMetadataFrame(service=service)) + await self._call_event_handler("on_service_switched", service) + return service + return None class ServiceSwitcherStrategyManual(ServiceSwitcherStrategy): @@ -67,103 +137,115 @@ class ServiceSwitcherStrategyManual(ServiceSwitcherStrategy): ) """ - def __init__(self, services: List[FrameProcessor]): - """Initialize the manual service switcher strategy with a list of services. - - Note: - This is called internally by ServiceSwitcher. Do not instantiate directly. - - Args: - services: List of frame processors to switch between. - """ - super().__init__(services) - self.active_service = services[0] if services else None - - def handle_frame(self, frame: ServiceSwitcherFrame, direction: FrameDirection): + async def handle_frame( + self, frame: ServiceSwitcherFrame, direction: FrameDirection + ) -> Optional[FrameProcessor]: """Handle a frame that controls service switching. Args: frame: The frame to handle. direction: The direction of the frame (upstream or downstream). + + Returns: + The newly active service if a switch occurred, or None otherwise. """ if isinstance(frame, ManuallySwitchServiceFrame): - self._set_active_if_available(frame.service) - else: - raise ValueError(f"Unsupported frame type: {type(frame)}") + return await self._set_active_if_available(frame.service) - def _set_active_if_available(self, service: FrameProcessor): - """Set the active service to the given one, if it is in the list of available services. + return None - If it's not in the list, the request is ignored, as it may have been - intended for another ServiceSwitcher in the pipeline. + +class ServiceSwitcherStrategyFailover(ServiceSwitcherStrategyManual): + """A strategy that automatically switches to a backup service on failure. + + When the active service produces a non-fatal error, this strategy switches + to the next available service in the list. Recovery and fallback policies + are left to application code via the ``on_service_switched`` event. + + Event handlers available: + + - on_service_switched: Called when the active service changes. + + Example:: + + switcher = ServiceSwitcher( + services=[primary_stt, backup_stt], + strategy_type=ServiceSwitcherStrategyFailover, + ) + + @switcher.strategy.event_handler("on_service_switched") + async def on_switched(strategy, service): + # App decides when/how to recover the failed service + ... + """ + + async def handle_error(self, error: ErrorFrame) -> Optional[FrameProcessor]: + """Handle an error from the active service by failing over. + + Switches to the next service in the list. The failed service remains + in the list and can be switched back to manually or via application + logic in the ``on_service_switched`` event handler. Args: - service: The service to set as active. + error: The error frame pushed by the active service. + + Returns: + The newly active service if a switch occurred, or None if no + other service is available. """ - if service in self.services: - self.active_service = service + logger.warning(f"Service {self._active_service.name} reported an error: {error.error}") + + if len(self._services) <= 1: + logger.error("No other service available to switch to") + return None + + current_idx = self._services.index(self._active_service) + next_idx = (current_idx + 1) % len(self._services) + return await self._set_active_if_available(self._services[next_idx]) StrategyType = TypeVar("StrategyType", bound=ServiceSwitcherStrategy) class ServiceSwitcher(ParallelPipeline, Generic[StrategyType]): - """A pipeline that switches between different services at runtime.""" + """Parallel pipeline that routes frames to one active service at a time. - def __init__(self, services: List[FrameProcessor], strategy_type: Type[StrategyType]): + Wraps each service in a pair of filters that gate frame flow based on + which service is currently active. Switching is controlled by + `ServiceSwitcherFrame` frames and delegated to a pluggable + `ServiceSwitcherStrategy`. + + Example:: + + switcher = ServiceSwitcher(services=[stt_1, stt_2]) + """ + + def __init__( + self, + services: List[FrameProcessor], + strategy_type: Type[StrategyType] = ServiceSwitcherStrategyManual, + ): """Initialize the service switcher with a list of services and a switching strategy. Args: services: List of frame processors to switch between. strategy_type: The strategy class to use for switching between services. + Defaults to ``ServiceSwitcherStrategyManual``. """ - strategy = strategy_type(services) - super().__init__(*self._make_pipeline_definitions(services, strategy)) - self.services = services - self.strategy = strategy + _strategy = strategy_type(services) + super().__init__(*self._make_pipeline_definitions(services, _strategy)) + self._services = services + self._strategy = _strategy - class ServiceSwitcherFilter(FunctionFilter): - """An internal filter that allows frames to pass through to the wrapped service only if it's the active service.""" + @property + def strategy(self) -> StrategyType: + """Return the active switching strategy.""" + return self._strategy - def __init__( - self, - wrapped_service: FrameProcessor, - active_service: FrameProcessor, - direction: FrameDirection, - ): - """Initialize the service switcher filter with a strategy and direction. - - Args: - wrapped_service: The service that this filter wraps. - active_service: The currently active service. - direction: The direction of frame flow to filter. - """ - self._wrapped_service = wrapped_service - self._active_service = active_service - - async def filter(_: Frame) -> bool: - return self._wrapped_service == self._active_service - - super().__init__(filter, direction, filter_system_frames=True) - - async def process_frame(self, frame, direction): - """Process a frame through the filter, handling special internal filter-updating frames.""" - if isinstance(frame, ServiceSwitcher.ServiceSwitcherFilterFrame): - self._active_service = frame.active_service - # Two ServiceSwitcherFilters "sandwich" a service. Push the - # frame only to update the other side of the sandwich, but - # otherwise don't let it leave the sandwich. - if direction == self._direction: - await self.push_frame(frame, direction) - return - - await super().process_frame(frame, direction) - - @dataclass - class ServiceSwitcherFilterFrame(ControlFrame): - """An internal frame used by ServiceSwitcher to filter frames based on active service.""" - - active_service: FrameProcessor + @property + def services(self) -> List[FrameProcessor]: + """Return the list of available services.""" + return self._services @staticmethod def _make_pipeline_definitions( @@ -178,20 +260,64 @@ class ServiceSwitcher(ParallelPipeline, Generic[StrategyType]): def _make_pipeline_definition( service: FrameProcessor, strategy: ServiceSwitcherStrategy ) -> Any: + async def filter(_: Frame) -> bool: + return service == strategy.active_service + + # Layout: Filter → Service → Filter + # + # filter_system_frames: we want to run filter functions also on system + # frames. + # + # enable_direct_mode: filter functions are quick so we don't need + # additional tasks. return [ - ServiceSwitcher.ServiceSwitcherFilter( - wrapped_service=service, - active_service=strategy.active_service, + FunctionFilter( + filter=filter, direction=FrameDirection.DOWNSTREAM, + filter_system_frames=True, + enable_direct_mode=True, ), service, - ServiceSwitcher.ServiceSwitcherFilter( - wrapped_service=service, - active_service=strategy.active_service, + FunctionFilter( + filter=filter, direction=FrameDirection.UPSTREAM, + filter_system_frames=True, + enable_direct_mode=True, ), ] + async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): + """Push a frame out of the service switcher. + + Suppresses `ServiceSwitcherRequestMetadataFrame` targeting the active + service (since it has already been handled) and `ServiceMetadataFrame` + from inactive services so only the active service's metadata reaches + downstream processors. One case this happens is with `StartFrame` since + all the filters let it pass, and `StartFrame` causes the service to + generate `ServiceMetadataFrame`. + + Non-fatal ``ErrorFrame`` instances are forwarded to the strategy via + ``handle_error`` so strategies like ``ServiceSwitcherStrategyFailover`` + can perform failover. The error frame is still propagated upstream so + that application-level error handlers can observe it. + """ + # Consume ServiceSwitcherRequestMetadataFrame once the targeted service + # has handled it (i.e. the active service). + if isinstance(frame, ServiceSwitcherRequestMetadataFrame): + if frame.service == self.strategy.active_service: + return + + # Only let metadata from the active service escape. + if isinstance(frame, ServiceMetadataFrame): + if frame.service_name != self.strategy.active_service.name: + return + + # Let the strategy react to non-fatal errors from the active service. + if isinstance(frame, ErrorFrame) and not frame.fatal: + await self.strategy.handle_error(frame) + + await super().push_frame(frame, direction) + async def process_frame(self, frame: Frame, direction: FrameDirection): """Process a frame, handling frames which affect service switching. @@ -199,11 +325,12 @@ class ServiceSwitcher(ParallelPipeline, Generic[StrategyType]): frame: The frame to process. direction: The direction of the frame (upstream or downstream). """ - await super().process_frame(frame, direction) - if isinstance(frame, ServiceSwitcherFrame): - self.strategy.handle_frame(frame, direction) - service_switcher_filter_frame = ServiceSwitcher.ServiceSwitcherFilterFrame( - active_service=self.strategy.active_service - ) - await super().process_frame(service_switcher_filter_frame, direction) + service = await self.strategy.handle_frame(frame, direction) + + # If we don't switch to a new service we need to keep processing the + # frame. If we switched, we just swallow the frame. + if not service: + await super().process_frame(frame, direction) + else: + await super().process_frame(frame, direction) diff --git a/src/pipecat/pipeline/sync_parallel_pipeline.py b/src/pipecat/pipeline/sync_parallel_pipeline.py index cb3f1bbe0..148d29b25 100644 --- a/src/pipecat/pipeline/sync_parallel_pipeline.py +++ b/src/pipecat/pipeline/sync_parallel_pipeline.py @@ -4,15 +4,21 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Synchronous parallel pipeline implementation for concurrent frame processing. +"""Synchronized parallel pipeline that holds output until all branches finish. -This module provides a pipeline that processes frames through multiple parallel -pipelines simultaneously, synchronizing their output to maintain frame ordering -and prevent duplicate processing. +A SyncParallelPipeline fans each inbound frame out to multiple parallel pipelines +and waits for every pipeline to finish processing before releasing any of the +resulting output frames. This ensures that all frames produced in response to a +single input frame are emitted together. + +System frames (except EndFrame) are exempt from this synchronization — they pass +straight through without waiting, since they are expected to race ahead of +regular data frames. """ import asyncio from dataclasses import dataclass +from enum import Enum from itertools import chain from typing import List @@ -24,22 +30,42 @@ from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup +class FrameOrder(Enum): + """Controls the order in which synchronized frames are pushed downstream. + + When multiple parallel pipelines produce output for the same input frame, + this setting determines the order in which those output frames are pushed. + + Attributes: + ARRIVAL: Frames are pushed in the order they arrive from any pipeline. + This is the default and matches the behavior of prior versions. + PIPELINE: Frames are pushed in pipeline definition order — all frames + from the first pipeline are pushed, then all frames from the second + pipeline, and so on. Useful when the relative ordering between + pipelines matters (e.g. ensuring image frames precede audio frames). + """ + + ARRIVAL = "arrival" + PIPELINE = "pipeline" + + @dataclass class SyncFrame(ControlFrame): - """Control frame used to synchronize parallel pipeline processing. + """Sentinel frame used to detect when a parallel pipeline has finished processing. - This frame is sent through parallel pipelines to determine when the - internal pipelines have finished processing a batch of frames. + After sending a real frame into a parallel pipeline, a SyncFrame is sent + behind it. When the SyncFrame emerges from the pipeline's output, we know + all output frames for the preceding input have been produced. """ pass class SyncParallelPipelineSource(FrameProcessor): - """Source processor for synchronous parallel pipeline processing. + """Bookend processor placed at the start of each parallel pipeline. - Routes frames to parallel pipelines and collects upstream responses - for synchronization purposes. + Forwards downstream frames into the pipeline and captures upstream frames + into a queue so the parent SyncParallelPipeline can release them later. """ def __init__(self, upstream_queue: asyncio.Queue): @@ -68,10 +94,11 @@ class SyncParallelPipelineSource(FrameProcessor): class SyncParallelPipelineSink(FrameProcessor): - """Sink processor for synchronous parallel pipeline processing. + """Bookend processor placed at the end of each parallel pipeline. - Collects downstream frames from parallel pipelines and routes - upstream frames back through the pipeline. + Captures downstream output frames into a queue so the parent + SyncParallelPipeline can release them later, and forwards upstream + frames back through the pipeline. """ def __init__(self, downstream_queue: asyncio.Queue): @@ -100,29 +127,44 @@ class SyncParallelPipelineSink(FrameProcessor): class SyncParallelPipeline(BasePipeline): - """Pipeline that processes frames through multiple parallel pipelines synchronously. + """Fans each input frame to parallel pipelines then holds output until every pipeline finishes. - Creates multiple parallel processing paths that all receive the same input frames - and produces synchronized output. Each parallel path is a separate pipeline that - processes frames independently, with synchronization points to ensure consistent - ordering and prevent duplicate frame processing. + For each inbound frame the pipeline: - The pipeline uses SyncFrame control frames to coordinate between parallel paths - and ensure all paths have completed processing before moving to the next frame. + 1. Sends the frame into every parallel pipeline. + 2. Sends a ``SyncFrame`` sentinel behind it in each pipeline. + 3. Waits until every pipeline has produced its ``SyncFrame``, meaning all + output for that input is ready. + 4. Releases the collected output frames (deduplicating by frame id, since + the same frame may emerge from more than one branch). + + System frames (except ``EndFrame``) bypass this mechanism entirely — they are + forwarded through each pipeline and pushed immediately, since system frames + are expected to race ahead of regular data frames. + + By default, output frames are pushed in the order they arrive from any pipeline + (``FrameOrder.ARRIVAL``). Set ``frame_order=FrameOrder.PIPELINE`` to push frames + in pipeline definition order instead — all output from the first pipeline, then + the second, and so on. """ - def __init__(self, *args): + def __init__(self, *args, frame_order: FrameOrder = FrameOrder.ARRIVAL): """Initialize the synchronous parallel pipeline. Args: - *args: Variable number of processor lists, each representing a parallel pipeline path. - Each argument should be a list of FrameProcessor instances. + *args: Variable number of processor lists, each representing a parallel + pipeline path. Each argument should be a list of FrameProcessor instances. + frame_order: Controls the order in which synchronized output frames are + pushed. ``FrameOrder.ARRIVAL`` (default) pushes frames in the order they arrive. + ``FrameOrder.PIPELINE`` pushes all frames from the first pipeline + before the second, and so on. Raises: Exception: If no arguments are provided. TypeError: If any argument is not a list of processors. """ super().__init__() + self._frame_order = frame_order if len(args) == 0: raise Exception(f"SyncParallelPipeline needs at least one argument") @@ -184,7 +226,7 @@ class SyncParallelPipeline(BasePipeline): Returns: The list of entry processors. """ - return self._sources + return [s["processor"] for s in self._sources] def processors_with_metrics(self) -> List[FrameProcessor]: """Collect processors that can generate metrics from all parallel pipelines. @@ -209,11 +251,11 @@ class SyncParallelPipeline(BasePipeline): await asyncio.gather(*[p.cleanup() for p in self._pipelines]) async def process_frame(self, frame: Frame, direction: FrameDirection): - """Process frames through all parallel pipelines with synchronization. + """Send a frame through all parallel pipelines and release output once all finish. - Distributes frames to all parallel pipelines and synchronizes their output - to maintain proper ordering and prevent duplicate processing. Uses SyncFrame - control frames to coordinate between parallel paths. + System frames (except EndFrame) skip synchronization and pass straight + through. All other frames are fanned out to every pipeline, and output is + held until every pipeline signals completion (via SyncFrame). Args: frame: The frame to process. @@ -221,60 +263,102 @@ class SyncParallelPipeline(BasePipeline): """ await super().process_frame(frame, direction) + # SystemFrames (but not EndFrame) are simply passed through all + # internal pipelines without draining queued output. This avoids + # the race condition where a SystemFrame's wait_for_sync steals + # frames from a concurrent non-SystemFrame's wait_for_sync. + if isinstance(frame, SystemFrame) and not isinstance(frame, EndFrame): + if direction == FrameDirection.UPSTREAM: + for s in self._sinks: + await s["processor"].process_frame(frame, direction) + elif direction == FrameDirection.DOWNSTREAM: + for s in self._sources: + await s["processor"].process_frame(frame, direction) + await self.push_frame(frame, direction) + return + + use_pipeline_order = self._frame_order == FrameOrder.PIPELINE + # The last processor of each pipeline needs to be synchronous otherwise - # this element won't work. Since, we know it should be synchronous we + # this element won't work. Since we know it should be synchronous we # push a SyncFrame. Since frames are ordered we know this frame will be # pushed after the synchronous processor has pushed its data allowing us - # to synchrnonize all the internal pipelines by waiting for the + # to synchronize all the internal pipelines by waiting for the # SyncFrame in all of them. + # + # In ARRIVAL mode, output frames are put onto a shared main_queue as + # they arrive. In PIPELINE mode, they are accumulated in a per-pipeline + # list and returned so the caller can drain them in definition order. async def wait_for_sync( obj, main_queue: asyncio.Queue, frame: Frame, direction: FrameDirection - ): + ) -> list[Frame]: processor = obj["processor"] queue = obj["queue"] + output_frames: list[Frame] = [] await processor.process_frame(frame, direction) - if isinstance(frame, (SystemFrame, EndFrame)): + if isinstance(frame, EndFrame): new_frame = await queue.get() - if isinstance(new_frame, (SystemFrame, EndFrame)): - await main_queue.put(new_frame) - else: - while not isinstance(new_frame, (SystemFrame, EndFrame)): + if isinstance(new_frame, EndFrame): + if use_pipeline_order: + output_frames.append(new_frame) + else: await main_queue.put(new_frame) + else: + while not isinstance(new_frame, EndFrame): + if use_pipeline_order: + output_frames.append(new_frame) + else: + await main_queue.put(new_frame) queue.task_done() new_frame = await queue.get() else: await processor.process_frame(SyncFrame(), direction) new_frame = await queue.get() while not isinstance(new_frame, SyncFrame): - await main_queue.put(new_frame) + if use_pipeline_order: + output_frames.append(new_frame) + else: + await main_queue.put(new_frame) queue.task_done() new_frame = await queue.get() + return output_frames + if direction == FrameDirection.UPSTREAM: # If we get an upstream frame we process it in each sink. - await asyncio.gather( + frames_per_pipeline = await asyncio.gather( *[wait_for_sync(s, self._up_queue, frame, direction) for s in self._sinks] ) elif direction == FrameDirection.DOWNSTREAM: # If we get a downstream frame we process it in each source. - await asyncio.gather( + frames_per_pipeline = await asyncio.gather( *[wait_for_sync(s, self._down_queue, frame, direction) for s in self._sources] ) - seen_ids = set() - while not self._up_queue.empty(): - frame = await self._up_queue.get() - if frame.id not in seen_ids: - await self.push_frame(frame, FrameDirection.UPSTREAM) - seen_ids.add(frame.id) - self._up_queue.task_done() + if use_pipeline_order: + # Push frames in pipeline definition order, deduplicating by id. + seen_ids = set() + for pipeline_frames in frames_per_pipeline: + for f in pipeline_frames: + if f.id not in seen_ids: + await self.push_frame(f, direction) + seen_ids.add(f.id) + else: + # ARRIVAL mode: drain the shared queues in the order frames arrived. + seen_ids = set() + while not self._up_queue.empty(): + frame = await self._up_queue.get() + if frame.id not in seen_ids: + await self.push_frame(frame, FrameDirection.UPSTREAM) + seen_ids.add(frame.id) + self._up_queue.task_done() - seen_ids = set() - while not self._down_queue.empty(): - frame = await self._down_queue.get() - if frame.id not in seen_ids: - await self.push_frame(frame, FrameDirection.DOWNSTREAM) - seen_ids.add(frame.id) - self._down_queue.task_done() + seen_ids = set() + while not self._down_queue.empty(): + frame = await self._down_queue.get() + if frame.id not in seen_ids: + await self.push_frame(frame, FrameDirection.DOWNSTREAM) + seen_ids.add(frame.id) + self._down_queue.task_done() diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index 6d4b4c039..56df719d5 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -15,7 +15,7 @@ import asyncio import importlib.util import os from pathlib import Path -from typing import Any, AsyncIterable, Dict, Iterable, List, Optional, Tuple, Type +from typing import Any, AsyncIterable, Dict, Iterable, List, Optional, Set, Tuple, Type, TypeVar from loguru import logger from pydantic import BaseModel, ConfigDict, Field @@ -43,14 +43,17 @@ from pipecat.frames.frames import ( from pipecat.metrics.metrics import ProcessingMetricsData, TTFBMetricsData 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.pipeline.base_pipeline import BasePipeline from pipecat.pipeline.base_task import BasePipelineTask, PipelineTaskParams from pipecat.pipeline.pipeline import Pipeline, PipelineSink, PipelineSource from pipecat.pipeline.task_observer import TaskObserver from pipecat.processors.aggregators.llm_response import LLMUserContextAggregator from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup +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 @@ -61,6 +64,9 @@ IDLE_TIMEOUT_SECS = 300 CANCEL_TIMEOUT_SECS = 20.0 +T = TypeVar("T") + + class IdleFrameObserver(BaseObserver): """Idle timeout observer. @@ -225,9 +231,12 @@ class PipelineTask(BasePipelineTask): conversation_id: Optional[str] = None, enable_tracing: bool = False, enable_turn_tracking: bool = True, + enable_rtvi: bool = True, idle_timeout_frames: Tuple[Type[Frame], ...] = (BotSpeakingFrame, UserSpeakingFrame), idle_timeout_secs: Optional[float] = IDLE_TIMEOUT_SECS, observers: Optional[List[BaseObserver]] = None, + rtvi_processor: Optional[RTVIProcessor] = None, + rtvi_observer_params: Optional[RTVIObserverParams] = None, task_manager: Optional[BaseTaskManager] = None, ): """Initialize the PipelineTask. @@ -244,6 +253,7 @@ class PipelineTask(BasePipelineTask): check_dangling_tasks: Whether to check for processors' tasks finishing properly. clock: Clock implementation for timing operations. conversation_id: Optional custom ID for the conversation. + enable_rtvi: Whether to automatically add RTVI support to the pipeline. enable_tracing: Whether to enable tracing. enable_turn_tracking: Whether to enable turn tracking. idle_timeout_frames: A tuple with the frames that should trigger an idle @@ -252,6 +262,8 @@ class PipelineTask(BasePipelineTask): None. If a pipeline is idle the pipeline task will be cancelled automatically. observers: List of observers for monitoring pipeline execution. + rtvi_observer_params: The RTVI observer parameter to use if RTVI is enabled. + rtvi_processor: The RTVI processor to add if RTVI is enabled. task_manager: Optional task manager for handling asyncio tasks. """ super().__init__() @@ -277,15 +289,25 @@ class PipelineTask(BasePipelineTask): observers = self._params.observers observers = observers or [] 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) + # Create turn trace observer with latency tracking self._turn_trace_observer = TurnTraceObserver( self._turn_tracking_observer, + 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) @@ -306,6 +328,39 @@ class PipelineTask(BasePipelineTask): self._heartbeat_push_task: Optional[asyncio.Task] = None self._heartbeat_monitor_task: Optional[asyncio.Task] = None + # RTVI support + self._rtvi = None + prepend_rtvi = False + external_rtvi = self._find_processor(pipeline, RTVIProcessor) + external_observer_found = any(isinstance(o, RTVIObserver) for o in observers) + + if external_rtvi and not external_observer_found: + logger.error( + f"{self}: RTVIProcessor found in pipeline but no RTVIObserver in observers. " + "Make sure to add both." + ) + elif not external_rtvi and external_observer_found: + logger.error( + f"{self}: RTVIObserver found in observers but no RTVIProcessor in pipeline. " + "Make sure to add both." + ) + elif external_rtvi and external_observer_found: + logger.warning( + f"{self}: RTVIProcessor and RTVIObserver found, skipping default ones. " + "They are both added by default, no need to add them yourself." + ) + self._rtvi = external_rtvi + elif enable_rtvi: + self._rtvi = rtvi_processor or RTVIProcessor() + observers.append(self._rtvi.create_rtvi_observer(params=rtvi_observer_params)) + prepend_rtvi = True + + if self._rtvi: + # Automatically call RTVIProcessor.set_bot_ready() + @self.rtvi.event_handler("on_client_ready") + async def on_client_ready(rtvi: RTVIProcessor): + await rtvi.set_bot_ready() + # This is the idle event. When selected frames are pushed from any # processor we consider the pipeline is not idle. We use an observer # which will be listening any part of the pipeline. @@ -334,8 +389,12 @@ class PipelineTask(BasePipelineTask): # source allows us to receive and react to upstream frames, and the sink # allows us to receive and react to downstream frames. source = PipelineSource(self._source_push_frame, name=f"{self}::Source") - sink = PipelineSink(self._sink_push_frame, name=f"{self}::Sink") - self._pipeline = Pipeline([pipeline], source=source, sink=sink) + self._sink = PipelineSink(self._sink_push_frame, name=f"{self}::Sink") + # Only prepend the RTVIProcessor if we created it ourselves. When the + # user already placed it inside their pipeline we must not insert it + # again or it will appear twice in the frame chain. + processors = [self._rtvi, pipeline] if prepend_rtvi else [pipeline] + self._pipeline = Pipeline(processors, source=source, sink=self._sink) # The task observer acts as a proxy to the provided observers. This way, # we only need to pass a single observer (using the StartFrame) which @@ -348,8 +407,8 @@ class PipelineTask(BasePipelineTask): # in. This is mainly for efficiency reason because each event handler # creates a task and most likely you only care about one or two frame # types. - self._reached_upstream_types: Tuple[Type[Frame], ...] = () - self._reached_downstream_types: Tuple[Type[Frame], ...] = () + self._reached_upstream_types: Set[Type[Frame]] = set() + self._reached_downstream_types: Set[Type[Frame]] = set() self._register_event_handler("on_frame_reached_upstream") self._register_event_handler("on_frame_reached_downstream") self._register_event_handler("on_idle_timeout") @@ -398,6 +457,35 @@ class PipelineTask(BasePipelineTask): """ return self._turn_trace_observer + @property + def rtvi(self) -> RTVIProcessor: + """Get the RTVI processor if RTVI is enabled. + + Returns: + The RTVI processor added to the pipeline when RTVI is enabled. + """ + if not self._rtvi: + raise Exception(f"{self} RTVI is not enabled.") + return self._rtvi + + @property + def reached_upstream_types(self) -> Tuple[Type[Frame], ...]: + """Get the currently configured upstream frame type filters. + + Returns: + Tuple of frame types that trigger the on_frame_reached_upstream event. + """ + return tuple(self._reached_upstream_types) + + @property + def reached_downstream_types(self) -> Tuple[Type[Frame], ...]: + """Get the currently configured downstream frame type filters. + + Returns: + Tuple of frame types that trigger the on_frame_reached_downstream event. + """ + return tuple(self._reached_downstream_types) + def event_handler(self, event_name: str): """Decorator for registering event handlers. @@ -441,7 +529,7 @@ class PipelineTask(BasePipelineTask): Args: types: Tuple of frame types to monitor for upstream events. """ - self._reached_upstream_types = types + self._reached_upstream_types = set(types) def set_reached_downstream_filter(self, types: Tuple[Type[Frame], ...]): """Set which frame types trigger the on_frame_reached_downstream event. @@ -449,7 +537,23 @@ class PipelineTask(BasePipelineTask): Args: types: Tuple of frame types to monitor for downstream events. """ - self._reached_downstream_types = types + self._reached_downstream_types = set(types) + + def add_reached_upstream_filter(self, types: Tuple[Type[Frame], ...]): + """Add frame types to trigger the on_frame_reached_upstream event. + + Args: + types: Tuple of frame types to add to upstream monitoring. + """ + self._reached_upstream_types.update(types) + + def add_reached_downstream_filter(self, types: Tuple[Type[Frame], ...]): + """Add frame types to trigger the on_frame_reached_downstream event. + + Args: + types: Tuple of frame types to add to downstream monitoring. + """ + self._reached_downstream_types.update(types) def has_finished(self) -> bool: """Check if the pipeline task has finished execution. @@ -521,26 +625,43 @@ class PipelineTask(BasePipelineTask): self._finished = True logger.debug(f"Pipeline task {self} has finished") - async def queue_frame(self, frame: Frame): - """Queue a single frame to be pushed down the pipeline. + async def queue_frame( + self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM + ): + """Queue a single frame to be pushed through the pipeline. + + Downstream frames are pushed from the beginning of the pipeline. + Upstream frames are pushed from the end of the pipeline. Args: frame: The frame to be processed. + direction: The direction to push the frame. Defaults to downstream. """ - await self._push_queue.put(frame) + if direction == FrameDirection.DOWNSTREAM: + await self._push_queue.put(frame) + else: + await self._sink.queue_frame(frame, direction) - async def queue_frames(self, frames: Iterable[Frame] | AsyncIterable[Frame]): - """Queues multiple frames to be pushed down the pipeline. + async def queue_frames( + self, + frames: Iterable[Frame] | AsyncIterable[Frame], + direction: FrameDirection = FrameDirection.DOWNSTREAM, + ): + """Queue multiple frames to be pushed through the pipeline. + + Downstream frames are pushed from the beginning of the pipeline. + Upstream frames are pushed from the end of the pipeline. Args: frames: An iterable or async iterable of frames to be processed. + direction: The direction to push the frames. Defaults to downstream. """ if isinstance(frames, AsyncIterable): async for frame in frames: - await self.queue_frame(frame) + await self.queue_frame(frame, direction) elif isinstance(frames, Iterable): for frame in frames: - await self.queue_frame(frame) + await self.queue_frame(frame, direction) async def _cancel(self, *, reason: Optional[str] = None): """Internal cancellation logic for the pipeline task. @@ -719,6 +840,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) @@ -749,27 +871,27 @@ class PipelineTask(BasePipelineTask): pipeline to be stopped (e.g. EndTaskFrame) in which case we would send an EndFrame down the pipeline. """ - if isinstance(frame, self._reached_upstream_types): + if isinstance(frame, tuple(self._reached_upstream_types)): await self._call_event_handler("on_frame_reached_upstream", frame) if isinstance(frame, EndTaskFrame): # Tell the task we should end nicely. - logger.debug(f"{self}: received end task frame {frame}") + logger.debug(f"{self}: received end task frame upstream {frame}") await self.queue_frame(EndFrame(reason=frame.reason)) elif isinstance(frame, CancelTaskFrame): # Tell the task we should end right away. - logger.debug(f"{self}: received cancel task frame {frame}") + logger.debug(f"{self}: received cancel task frame upstream {frame}") await self.queue_frame(CancelFrame(reason=frame.reason)) elif isinstance(frame, StopTaskFrame): # Tell the task we should stop nicely. - logger.debug(f"{self}: received stop task frame {frame}") + logger.debug(f"{self}: received stop task frame upstream {frame}") await self.queue_frame(StopFrame()) elif isinstance(frame, InterruptionTaskFrame): # Tell the task we should interrupt the pipeline. Note that we are # bypassing the push queue and directly queue into the # pipeline. This is in case the push task is blocked waiting for a # pipeline-ending frame to finish traversing the pipeline. - logger.debug(f"{self}: received interruption task frame {frame}") + logger.debug(f"{self}: received interruption task frame upstream {frame}") await self._pipeline.queue_frame(InterruptionFrame()) elif isinstance(frame, ErrorFrame): await self._call_event_handler("on_pipeline_error", frame) @@ -788,11 +910,12 @@ class PipelineTask(BasePipelineTask): processors have handled the EndFrame and therefore we can exit the task cleanly. """ - if isinstance(frame, self._reached_downstream_types): + if isinstance(frame, tuple(self._reached_downstream_types)): await self._call_event_handler("on_frame_reached_downstream", frame) if isinstance(frame, StartFrame): await self._call_event_handler("on_pipeline_started", frame) + await self._observer.on_pipeline_started() # Start heartbeat tasks now that StartFrame has been processed # by all processors in the pipeline @@ -811,6 +934,18 @@ class PipelineTask(BasePipelineTask): self._pipeline_end_event.set() elif isinstance(frame, HeartbeatFrame): await self._heartbeat_queue.put(frame) + elif isinstance(frame, EndTaskFrame): + logger.debug(f"{self}: received end task frame downstream {frame}") + await self.queue_frame(EndTaskFrame(reason=frame.reason), FrameDirection.UPSTREAM) + elif isinstance(frame, StopTaskFrame): + logger.debug(f"{self}: received stop task frame downstream {frame}") + await self.queue_frame(StopTaskFrame(), FrameDirection.UPSTREAM) + elif isinstance(frame, CancelTaskFrame): + logger.debug(f"{self}: received cancel task frame downstream {frame}") + await self.queue_frame(CancelTaskFrame(reason=frame.reason), FrameDirection.UPSTREAM) + elif isinstance(frame, InterruptionTaskFrame): + logger.debug(f"{self}: received interruption task frame downstream {frame}") + await self.queue_frame(InterruptionTaskFrame(), FrameDirection.UPSTREAM) async def _heartbeat_push_handler(self): """Push heartbeat frames at regular intervals.""" @@ -949,7 +1084,7 @@ class PipelineTask(BasePipelineTask): start_metadata = {} # NOTE(aleix): Remove when OpenAILLMContext/LLMUserContextAggregator is removed. - if self._find_deprecated_openaillmcontext(self._pipeline): + if self._find_processor(self._pipeline, LLMUserContextAggregator): start_metadata["deprecated_openaillmcontext"] = True # Update with user provided metadata. @@ -957,12 +1092,13 @@ class PipelineTask(BasePipelineTask): return start_metadata - def _find_deprecated_openaillmcontext(self, processor: FrameProcessor) -> bool: - """Check whether there is a deprecated LLMUserContextAggregator in the pipeline.""" - if isinstance(processor, LLMUserContextAggregator): - return True + def _find_processor(self, processor: FrameProcessor, processor_type: Type[T]) -> Optional[T]: + """Recursively find a processor of the given type in the pipeline.""" + if isinstance(processor, processor_type): + return processor for p in processor.processors: - if self._find_deprecated_openaillmcontext(p): - return True - return False + found = self._find_processor(p, processor_type) + if found: + return found + return None diff --git a/src/pipecat/pipeline/task_observer.py b/src/pipecat/pipeline/task_observer.py index 4d33fd60e..dc2040e07 100644 --- a/src/pipecat/pipeline/task_observer.py +++ b/src/pipecat/pipeline/task_observer.py @@ -39,6 +39,12 @@ class Proxy: observer: BaseObserver +class _PipelineStartedSignal: + """Internal sentinel queued to observers when the pipeline has started.""" + + pass + + class TaskObserver(BaseObserver): """Proxy observer that manages multiple observers without blocking the pipeline. @@ -129,6 +135,10 @@ class TaskObserver(BaseObserver): for proxy in self._proxies: await proxy.cleanup() + async def on_pipeline_started(self): + """Forward pipeline started signal to all managed observers.""" + await self._send_to_proxy(_PipelineStartedSignal()) + async def on_process_frame(self, data: FrameProcessed): """Queue frame data for all managed observers. @@ -186,7 +196,9 @@ class TaskObserver(BaseObserver): while True: data = await queue.get() - if isinstance(data, FramePushed): + if isinstance(data, _PipelineStartedSignal): + await observer.on_pipeline_started() + elif isinstance(data, FramePushed): if on_push_frame_deprecated: await observer.on_push_frame( data.source, data.destination, data.frame, data.direction, data.timestamp diff --git a/src/pipecat/pipeline/to_be_updated/merge_pipeline.py b/src/pipecat/pipeline/to_be_updated/merge_pipeline.py deleted file mode 100644 index 0254b6309..000000000 --- a/src/pipecat/pipeline/to_be_updated/merge_pipeline.py +++ /dev/null @@ -1,53 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -"""Sequential pipeline merging for Pipecat. - -This module provides a pipeline implementation that sequentially merges -the output from multiple pipelines, processing them one after another -in a specified order. -""" - -from typing import List - -from pipecat.frames.frames import EndFrame, EndPipeFrame -from pipecat.pipeline.pipeline import Pipeline - - -class SequentialMergePipeline(Pipeline): - """Pipeline that sequentially merges output from multiple pipelines. - - This pipeline merges the sink queues from a list of pipelines by processing - frames from each pipeline's sink sequentially in the order specified. Each - pipeline runs to completion before the next one begins processing. - """ - - def __init__(self, pipelines: List[Pipeline]): - """Initialize the sequential merge pipeline. - - Args: - pipelines: List of pipelines to merge sequentially. Pipelines will - be processed in the order they appear in this list. - """ - super().__init__([]) - self.pipelines = pipelines - - async def run_pipeline(self): - """Run all pipelines sequentially and merge their output. - - Processes each pipeline in order, consuming all frames from each - pipeline's sink until an EndFrame or EndPipeFrame is encountered, - then moves to the next pipeline. After all pipelines complete, - sends a final EndFrame to signal completion. - """ - for idx, pipeline in enumerate(self.pipelines): - while True: - frame = await pipeline.sink.get() - if isinstance(frame, EndFrame) or isinstance(frame, EndPipeFrame): - break - await self.sink.put(frame) - - await self.sink.put(EndFrame()) diff --git a/src/pipecat/processors/aggregators/dtmf_aggregator.py b/src/pipecat/processors/aggregators/dtmf_aggregator.py index 1b9c59158..ea56ba6fc 100644 --- a/src/pipecat/processors/aggregators/dtmf_aggregator.py +++ b/src/pipecat/processors/aggregators/dtmf_aggregator.py @@ -104,7 +104,7 @@ class DTMFAggregator(FrameProcessor): # For first digit, schedule interruption. if is_first_digit: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() # Check for immediate flush conditions if frame.button == self._termination_digit: diff --git a/src/pipecat/processors/aggregators/llm_context.py b/src/pipecat/processors/aggregators/llm_context.py index 205d55269..1375b8297 100644 --- a/src/pipecat/processors/aggregators/llm_context.py +++ b/src/pipecat/processors/aggregators/llm_context.py @@ -206,7 +206,7 @@ class LLMContext: """ content = [{"type": "text", "text": text}] - async def encode_audio(): + def encode_audio(): sample_rate = audio_frames[0].sample_rate num_channels = audio_frames[0].num_channels @@ -255,7 +255,7 @@ class LLMContext: this method, which is part of the public API of OpenAILLMContext but doesn't need to be for LLMContext. - .. deprecated:: + .. deprecated:: 0.0.92 Use `get_messages()` instead. Returns: diff --git a/src/pipecat/processors/aggregators/llm_context_summarizer.py b/src/pipecat/processors/aggregators/llm_context_summarizer.py new file mode 100644 index 000000000..4e55ffaf1 --- /dev/null +++ b/src/pipecat/processors/aggregators/llm_context_summarizer.py @@ -0,0 +1,480 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""This module defines a summarizer for managing LLM context summarization.""" + +import asyncio +import uuid +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +from loguru import logger + +from pipecat.frames.frames import ( + Frame, + InterruptionFrame, + LLMContextSummaryRequestFrame, + LLMContextSummaryResultFrame, + LLMFullResponseStartFrame, + LLMSummarizeContextFrame, +) +from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage +from pipecat.utils.asyncio.task_manager import BaseTaskManager +from pipecat.utils.base_object import BaseObject +from pipecat.utils.context.llm_context_summarization import ( + DEFAULT_SUMMARIZATION_TIMEOUT, + LLMAutoContextSummarizationConfig, + LLMContextSummarizationUtil, + LLMContextSummaryConfig, +) + +if TYPE_CHECKING: + from pipecat.services.llm_service import LLMService + + +@dataclass +class SummaryAppliedEvent: + """Event data emitted when context summarization completes successfully. + + Parameters: + original_message_count: Number of messages before summarization. + new_message_count: Number of messages after summarization. + summarized_message_count: Number of messages that were compressed + into the summary. + preserved_message_count: Number of recent messages preserved + uncompressed. + """ + + original_message_count: int + new_message_count: int + summarized_message_count: int + preserved_message_count: int + + +class LLMContextSummarizer(BaseObject): + """Summarizer for managing LLM context summarization. + + This class manages context summarization, either automatically when token or + message limits are reached, or on-demand when an ``LLMSummarizeContextFrame`` + is received. It monitors the LLM context size, triggers summarization requests, + and applies the results to compress conversation history. + + When ``auto_trigger=True`` (the default), summarization is triggered + automatically based on the configured thresholds in + ``LLMAutoContextSummarizationConfig``. When ``auto_trigger=False``, + threshold checks are skipped and summarization only happens when an + ``LLMSummarizeContextFrame`` is explicitly pushed into the pipeline. + + Both modes can coexist: set ``auto_trigger=True`` and also push + ``LLMSummarizeContextFrame`` at any time to force an immediate summarization + (subject to the ``_summarization_in_progress`` guard). + + Event handlers available: + + - on_request_summarization: Emitted when summarization should be triggered. + The aggregator should broadcast this frame to the LLM service. + + - on_summary_applied: Emitted after a summary has been successfully applied + to the context. Receives a SummaryAppliedEvent with metrics about the + compression. + + Example:: + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame: LLMContextSummaryRequestFrame): + await aggregator.broadcast_frame( + LLMContextSummaryRequestFrame, + request_id=frame.request_id, + context=frame.context, + ... + ) + + @summarizer.event_handler("on_summary_applied") + async def on_summary_applied(summarizer, event: SummaryAppliedEvent): + logger.info(f"Compressed {event.original_message_count} -> {event.new_message_count} messages") + """ + + def __init__( + self, + *, + context: LLMContext, + config: Optional[LLMAutoContextSummarizationConfig] = None, + auto_trigger: bool = True, + ): + """Initialize the context summarizer. + + Args: + context: The LLM context to monitor and summarize. + config: Auto-summarization configuration controlling both trigger + thresholds and default summary generation parameters. If None, + uses default ``LLMAutoContextSummarizationConfig`` values. + auto_trigger: Whether to automatically trigger summarization when + thresholds are reached. When False, summarization only happens + when an ``LLMSummarizeContextFrame`` is pushed into the pipeline. + Defaults to True. + """ + super().__init__() + + self._context = context + self._auto_config = config or LLMAutoContextSummarizationConfig() + self._auto_trigger = auto_trigger + + self._task_manager: Optional[BaseTaskManager] = None + + self._summarization_in_progress = False + self._pending_summary_request_id: Optional[str] = None + + self._register_event_handler("on_request_summarization", sync=True) + self._register_event_handler("on_summary_applied") + + @property + def task_manager(self) -> BaseTaskManager: + """Returns the configured task manager.""" + if not self._task_manager: + raise RuntimeError(f"{self} context summarizer was not properly setup") + return self._task_manager + + async def setup(self, task_manager: BaseTaskManager): + """Initialize the summarizer with the given task manager. + + Args: + task_manager: The task manager to be associated with this instance. + """ + self._task_manager = task_manager + + async def cleanup(self): + """Cleanup the summarizer.""" + await super().cleanup() + await self._clear_summarization_state() + + async def process_frame(self, frame: Frame): + """Process an incoming frame to detect when summarization is needed. + + Args: + frame: The frame to be processed. + """ + if isinstance(frame, LLMFullResponseStartFrame): + await self._handle_llm_response_start(frame) + elif isinstance(frame, LLMSummarizeContextFrame): + await self._handle_manual_summarization_request(frame) + elif isinstance(frame, LLMContextSummaryResultFrame): + await self._handle_summary_result(frame) + elif isinstance(frame, InterruptionFrame): + await self._handle_interruption() + + async def _handle_llm_response_start(self, frame: LLMFullResponseStartFrame): + """Handle LLM response start to check if summarization is needed. + + Args: + frame: The LLM response start frame. + """ + if self._should_summarize(): + await self._request_summarization() + + async def _handle_manual_summarization_request(self, frame: LLMSummarizeContextFrame): + """Handle an explicit on-demand summarization request. + + Reuses the same ``_request_summarization()`` code path as auto mode, + so bookkeeping (``_summarization_in_progress``, + ``_pending_summary_request_id``) is always updated correctly. + + Args: + frame: The manual summarization request frame, optionally carrying + a per-request :class:`~pipecat.utils.context.llm_context_summarization.LLMContextSummaryConfig`. + """ + if self._summarization_in_progress: + logger.debug(f"{self}: Summarization already in progress, ignoring manual request") + return + await self._request_summarization(config_override=frame.config) + + async def _handle_interruption(self): + """Handle interruption by canceling summarization in progress.""" + # Reset summarization state to allow new requests. This is necessary because + # the request frame (LLMContextSummaryRequestFrame) may have been cancelled + # during interruption. We preserve _pending_summary_request_id to handle the + # response frame (LLMContextSummaryResultFrame), which is uninterruptible and + # will still be delivered. + self._summarization_in_progress = False + + async def _clear_summarization_state(self): + """Cancel pending summarization.""" + if self._summarization_in_progress: + logger.debug(f"{self}: Clearing pending summarization") + self._summarization_in_progress = False + self._pending_summary_request_id = None + + def _should_summarize(self) -> bool: + """Determine if context summarization should be triggered. + + Evaluates whether the current context has reached either the token + threshold or message count threshold that warrants compression. + Either threshold can be ``None`` to disable that check; at least one + must be set (enforced at config construction time). + + Returns: + True if all conditions are met: + - ``auto_trigger`` is enabled + - No summarization currently in progress + - AND either: + - Token count exceeds ``max_context_tokens`` (when set) + - OR message count exceeds ``max_unsummarized_messages`` since last summary (when set) + """ + logger.trace(f"{self}: Checking if context summarization is needed") + + if not self._auto_trigger: + return False + + if self._summarization_in_progress: + logger.debug(f"{self}: Summarization already in progress") + return False + + # Estimate tokens in context + total_tokens = LLMContextSummarizationUtil.estimate_context_tokens(self._context) + num_messages = len(self._context.messages) + + # Check if we've reached the token limit + token_limit = self._auto_config.max_context_tokens + token_limit_exceeded = token_limit is not None and total_tokens >= token_limit + + # Check if we've exceeded max unsummarized messages + messages_since_summary = len(self._context.messages) - 1 + message_threshold = self._auto_config.max_unsummarized_messages + message_threshold_exceeded = ( + message_threshold is not None and messages_since_summary >= message_threshold + ) + + logger.trace( + f"{self}: Context has {num_messages} messages, " + f"~{total_tokens} tokens (limit: {token_limit if token_limit is not None else 'disabled'}), " + f"{messages_since_summary} messages since last summary " + f"(message threshold: {message_threshold if message_threshold is not None else 'disabled'})" + ) + + # Trigger if either limit is exceeded + if not token_limit_exceeded and not message_threshold_exceeded: + logger.trace( + f"{self}: Neither token limit nor message threshold exceeded, skipping summarization" + ) + return False + + reason = [] + if token_limit_exceeded: + reason.append(f"~{total_tokens} tokens (>={token_limit} limit)") + if message_threshold_exceeded: + reason.append(f"{messages_since_summary} messages (>={message_threshold} threshold)") + + logger.debug(f"{self}: ✓ Summarization needed - {', '.join(reason)}") + return True + + async def _request_summarization( + self, config_override: Optional[LLMContextSummaryConfig] = None + ): + """Request context summarization from LLM service. + + Creates a summarization request frame and either handles it directly + using a dedicated LLM (if configured) or emits it via event handler + for the pipeline's primary LLM. + Tracks the request ID to match async responses and prevent race conditions. + + Args: + config_override: Optional per-request summary configuration. If provided, + overrides the default summary generation settings from + ``self._auto_config.summary_config``. + """ + # Generate unique request ID + request_id = str(uuid.uuid4()) + summary_config = config_override or self._auto_config.summary_config + + # Mark summarization in progress + self._summarization_in_progress = True + self._pending_summary_request_id = request_id + + logger.debug(f"{self}: Sending summarization request (request_id={request_id})") + + # Create the request frame + request_frame = LLMContextSummaryRequestFrame( + request_id=request_id, + context=self._context, + min_messages_to_keep=summary_config.min_messages_after_summary, + target_context_tokens=summary_config.target_context_tokens, + summarization_prompt=summary_config.summary_prompt, + summarization_timeout=summary_config.summarization_timeout, + ) + + if summary_config.llm: + # Use dedicated LLM directly — no need to involve the pipeline + self.task_manager.create_task( + self._generate_summary_with_dedicated_llm(summary_config.llm, request_frame), + f"{self}-dedicated-llm-summary", + ) + else: + # Emit event for aggregator to broadcast to the pipeline LLM + await self._call_event_handler("on_request_summarization", request_frame) + + async def _generate_summary_with_dedicated_llm( + self, llm: "LLMService", frame: LLMContextSummaryRequestFrame + ): + """Generate summary using a dedicated LLM service. + + Calls the dedicated LLM's _generate_summary directly and feeds the + result back through _handle_summary_result, bypassing the pipeline. + + Args: + llm: The dedicated LLM service to use for summarization. + frame: The summarization request frame. + """ + timeout = frame.summarization_timeout or DEFAULT_SUMMARIZATION_TIMEOUT + + try: + summary, last_index = await asyncio.wait_for( + llm._generate_summary(frame), + timeout=timeout, + ) + result_frame = LLMContextSummaryResultFrame( + request_id=frame.request_id, + summary=summary, + last_summarized_index=last_index, + ) + except asyncio.TimeoutError: + error = f"Context summarization timed out after {timeout}s" + logger.error(f"{self}: {error}") + result_frame = LLMContextSummaryResultFrame( + request_id=frame.request_id, + summary="", + last_summarized_index=-1, + error=error, + ) + except Exception as e: + error = f"Error generating context summary: {e}" + logger.error(f"{self}: {error}") + result_frame = LLMContextSummaryResultFrame( + request_id=frame.request_id, + summary="", + last_summarized_index=-1, + error=error, + ) + + await self._handle_summary_result(result_frame) + + async def _handle_summary_result(self, frame: LLMContextSummaryResultFrame): + """Handle context summarization result from LLM service. + + Processes the summary result by validating the request ID, checking for + errors, validating context state, and applying the summary. + + Args: + frame: The summary result frame containing the generated summary. + """ + logger.debug(f"{self}: Received summary result (request_id={frame.request_id})") + + # Check if this is the result we're waiting for. Both auto and manual + # summarization set _pending_summary_request_id via _request_summarization(), + # so this check always applies. + if frame.request_id != self._pending_summary_request_id: + logger.debug(f"{self}: Ignoring stale summary result (request_id={frame.request_id})") + return + + # Clear pending state + await self._clear_summarization_state() + + # Check for errors + if frame.error: + logger.error(f"{self}: Context summarization failed: {frame.error}") + return + + # Validate context state + if not self._validate_summary_context(frame.last_summarized_index): + logger.warning(f"{self}: Context state changed, skipping summary application") + return + + # Apply summary + await self._apply_summary(frame.summary, frame.last_summarized_index) + + def _validate_summary_context(self, last_summarized_index: int) -> bool: + """Validate that context state is still valid for applying summary. + + Args: + last_summarized_index: The index of the last summarized message. + + Returns: + True if the context state is still consistent with the summary. + """ + if last_summarized_index < 0: + return False + + # Check if we still have enough messages + if last_summarized_index >= len(self._context.messages): + return False + + min_keep = self._auto_config.summary_config.min_messages_after_summary + remaining = len(self._context.messages) - 1 - last_summarized_index + if remaining < min_keep: + return False + + return True + + async def _apply_summary(self, summary: str, last_summarized_index: int): + """Apply summary to compress the conversation context. + + Reconstructs the context with: + [first_system_message] + [summary_message] + [recent_messages] + + Args: + summary: The generated summary text. + last_summarized_index: Index of the last message that was summarized. + """ + config = self._auto_config.summary_config + messages = self._context.messages + + # Find the first system message to preserve. LLMSpecificMessage instances are excluded + # because they are not dict-like and never represent a system message; they hold + # service-specific metadata (e.g. thinking blocks) that is always paired with a + # standard message. + first_system_msg = next( + ( + msg + for msg in messages + if not isinstance(msg, LLMSpecificMessage) and msg.get("role") == "system" + ), + None, + ) + + # Get recent messages to keep + recent_messages = messages[last_summarized_index + 1 :] + + # Create summary message as a user message (the summary is context + # provided *to* the assistant, not something the assistant said) + summary_content = config.summary_message_template.format(summary=summary) + summary_message = {"role": "user", "content": summary_content} + + # Reconstruct context + new_messages = [] + if first_system_msg: + new_messages.append(first_system_msg) + new_messages.append(summary_message) + new_messages.extend(recent_messages) + + # Update context + original_message_count = len(messages) + num_system_preserved = 1 if first_system_msg else 0 + self._context.set_messages(new_messages) + + # Messages actually summarized = index range minus the preserved system message + summarized_count = last_summarized_index + 1 - num_system_preserved + + logger.info( + f"{self}: Applied context summary, compressed {summarized_count} messages " + f"into summary. Context now has {len(new_messages)} messages (was {original_message_count})" + ) + + # Emit event for observability + event = SummaryAppliedEvent( + original_message_count=original_message_count, + new_message_count=len(new_messages), + summarized_message_count=summarized_count, + preserved_message_count=len(recent_messages) + num_system_preserved, + ) + await self._call_event_handler("on_summary_applied", event) diff --git a/src/pipecat/processors/aggregators/llm_response.py b/src/pipecat/processors/aggregators/llm_response.py index 81a92800a..7c246b209 100644 --- a/src/pipecat/processors/aggregators/llm_response.py +++ b/src/pipecat/processors/aggregators/llm_response.py @@ -581,7 +581,7 @@ class LLMUserContextAggregator(LLMContextResponseAggregator): logger.debug( "Interruption conditions met - pushing interruption and aggregation" ) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self._process_aggregation() else: logger.debug("Interruption conditions not met - not pushing aggregation") @@ -1024,10 +1024,8 @@ class LLMAssistantContextAggregator(LLMContextResponseAggregator): logger.debug( f"{self} FunctionCallCancelFrame: [{frame.function_name}:{frame.tool_call_id}]" ) - if frame.tool_call_id not in self._function_calls_in_progress: - return - - if self._function_calls_in_progress[frame.tool_call_id].cancel_on_interruption: + function_call = self._function_calls_in_progress.get(frame.tool_call_id) + if function_call and function_call.cancel_on_interruption: await self.handle_function_call_cancel(frame) del self._function_calls_in_progress[frame.tool_call_id] @@ -1044,6 +1042,11 @@ class LLMAssistantContextAggregator(LLMContextResponseAggregator): del self._function_calls_in_progress[frame.request.tool_call_id] + # Call the result_callback if provided. This signals that the image + # has been retrieved and the function call can now complete. + if frame.request and frame.request.result_callback: + await frame.request.result_callback(None) + await self.handle_user_image_frame(frame) await self.push_aggregation() await self.push_context_frame(FrameDirection.UPSTREAM) @@ -1056,7 +1059,7 @@ class LLMAssistantContextAggregator(LLMContextResponseAggregator): await self.push_aggregation() async def _handle_text(self, frame: TextFrame): - if not self._started or not frame.append_to_context: + if not frame.append_to_context: return if self._params.expect_stripped_words: diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index c47579fba..605db31f6 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -21,6 +21,8 @@ from typing import Any, Dict, List, Literal, Optional, Set, Type from loguru import logger from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.audio.vad.vad_analyzer import VADAnalyzer +from pipecat.audio.vad.vad_controller import VADController from pipecat.frames.frames import ( AssistantImageRawFrame, CancelFrame, @@ -33,8 +35,10 @@ from pipecat.frames.frames import ( InputAudioRawFrame, InterimTranscriptionFrame, InterruptionFrame, + LLMAssistantPushAggregationFrame, LLMContextAssistantTimestampFrame, LLMContextFrame, + LLMContextSummaryRequestFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMMessagesAppendFrame, @@ -45,11 +49,16 @@ from pipecat.frames.frames import ( LLMThoughtEndFrame, LLMThoughtStartFrame, LLMThoughtTextFrame, + LLMUpdateSettingsFrame, SpeechControlParamsFrame, StartFrame, TextFrame, TranscriptionFrame, + TranslationFrame, UserImageRawFrame, + UserMuteStartedFrame, + UserMuteStoppedFrame, + UserSpeakingFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, @@ -61,12 +70,23 @@ from pipecat.processors.aggregators.llm_context import ( LLMSpecificMessage, NotGiven, ) -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pipecat.turns.mute import BaseUserMuteStrategy +from pipecat.processors.aggregators.llm_context_summarizer import ( + LLMContextSummarizer, + SummaryAppliedEvent, +) +from pipecat.processors.frame_processor import FrameCallback, FrameDirection, FrameProcessor +from pipecat.services.settings import LLMSettings +from pipecat.turns.user_idle_controller import UserIdleController +from pipecat.turns.user_mute import BaseUserMuteStrategy from pipecat.turns.user_start import BaseUserTurnStartStrategy, UserTurnStartedParams from pipecat.turns.user_stop import BaseUserTurnStopStrategy, UserTurnStoppedParams +from pipecat.turns.user_turn_completion_mixin import UserTurnCompletionConfig from pipecat.turns.user_turn_controller import UserTurnController from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies, UserTurnStrategies +from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, + LLMContextSummarizationConfig, +) from pipecat.utils.string import TextPartForConcatenation, concatenate_aggregated_text from pipecat.utils.time import time_now_iso8601 @@ -80,11 +100,27 @@ 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: 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. + When enabled, the LLM outputs a turn completion marker at the start of + each response: ✓ (complete), ○ (incomplete short), or ◐ (incomplete long). + Incomplete responses are suppressed and timeouts trigger re-prompting. + user_turn_completion_config: Configuration for turn completion behavior including + custom instructions, timeouts, and prompts. Only used when + filter_incomplete_user_turns is True. """ 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: float = 0 + vad_analyzer: Optional[VADAnalyzer] = None + filter_incomplete_user_turns: bool = False + user_turn_completion_config: Optional[UserTurnCompletionConfig] = None @dataclass @@ -96,9 +132,53 @@ class LLMAssistantAggregatorParams: in text frames by adding spaces between tokens. This parameter is ignored when used with the newer LLMAssistantAggregator, which handles word spacing automatically. + enable_auto_context_summarization: Enable automatic context summarization when token + or message-count limits are reached (disabled by default). When enabled, + older conversation messages are automatically compressed into summaries to + manage context size. + auto_context_summarization_config: Configuration for automatic context + summarization. Controls trigger thresholds, message preservation, and + summarization prompts. If None, uses default + ``LLMAutoContextSummarizationConfig`` values. """ expect_stripped_words: bool = True + enable_auto_context_summarization: bool = False + auto_context_summarization_config: Optional[LLMAutoContextSummarizationConfig] = None + + # --------------------------------------------------------------------------- + # Deprecated field names — kept for backward compatibility. + # Use enable_auto_context_summarization and auto_context_summarization_config instead. + # --------------------------------------------------------------------------- + enable_context_summarization: Optional[bool] = None + context_summarization_config: Optional[LLMContextSummarizationConfig] = None + + def __post_init__(self): + if self.enable_context_summarization is not None: + warnings.warn( + "LLMAssistantAggregatorParams.enable_context_summarization is deprecated. " + "Use enable_auto_context_summarization instead.", + DeprecationWarning, + stacklevel=2, + ) + self.enable_auto_context_summarization = self.enable_context_summarization + self.enable_context_summarization = None + + if self.context_summarization_config is not None: + warnings.warn( + "LLMAssistantAggregatorParams.context_summarization_config is deprecated. " + "Use auto_context_summarization_config (LLMAutoContextSummarizationConfig) instead.", + DeprecationWarning, + stacklevel=2, + ) + if isinstance(self.context_summarization_config, LLMContextSummarizationConfig): + self.auto_context_summarization_config = ( + self.context_summarization_config.to_auto_config() + ) + else: + # Accept LLMAutoContextSummarizationConfig passed to the deprecated field + self.auto_context_summarization_config = self.context_summarization_config # type: ignore[assignment] + self.context_summarization_config = None @dataclass @@ -291,11 +371,14 @@ class LLMUserAggregator(LLMContextAggregator): - on_user_turn_started: Called when the user turn starts - on_user_turn_stopped: Called when the user turn ends - on_user_turn_stop_timeout: Called when no user turn stop strategy triggers + - on_user_turn_idle: Called when the user has been idle for the configured timeout + - on_user_mute_started: Called when the user becomes muted + - on_user_mute_stopped: Called when the user becomes unmuted Example:: @aggregator.event_handler("on_user_turn_started") - async def on_user_turn_started(aggregator, strategy: BaseUserTurnStartStrategy]): + async def on_user_turn_started(aggregator, strategy: BaseUserTurnStartStrategy): ... @aggregator.event_handler("on_user_turn_stopped") @@ -306,6 +389,18 @@ class LLMUserAggregator(LLMContextAggregator): async def on_user_turn_stop_timeout(aggregator): ... + @aggregator.event_handler("on_user_turn_idle") + async def on_user_turn_idle(aggregator): + ... + + @aggregator.event_handler("on_user_mute_started") + async def on_user_mute_started(aggregator): + ... + + @aggregator.event_handler("on_user_mute_stopped") + async def on_user_mute_stopped(aggregator): + ... + """ def __init__( @@ -328,6 +423,9 @@ class LLMUserAggregator(LLMContextAggregator): self._register_event_handler("on_user_turn_started") self._register_event_handler("on_user_turn_stopped") self._register_event_handler("on_user_turn_stop_timeout") + self._register_event_handler("on_user_turn_idle") + self._register_event_handler("on_user_mute_started") + self._register_event_handler("on_user_mute_stopped") user_turn_strategies = self._params.user_turn_strategies or UserTurnStrategies() @@ -349,6 +447,31 @@ class LLMUserAggregator(LLMContextAggregator): self._user_turn_controller.add_event_handler( "on_user_turn_stop_timeout", self._on_user_turn_stop_timeout ) + self._user_turn_controller.add_event_handler( + "on_reset_aggregation", self._on_reset_aggregation + ) + + 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 + if self._params.vad_analyzer: + self._vad_controller = VADController(self._params.vad_analyzer) + self._vad_controller.add_event_handler("on_speech_started", self._on_vad_speech_started) + self._vad_controller.add_event_handler("on_speech_stopped", self._on_vad_speech_stopped) + self._vad_controller.add_event_handler( + "on_speech_activity", self._on_vad_speech_activity + ) + self._vad_controller.add_event_handler("on_push_frame", self._on_push_frame) + self._vad_controller.add_event_handler("on_broadcast_frame", self._on_broadcast_frame) + + # NOTE(aleix): Probably just needed temporarily. This was added to + # prevent processing self-queued frames (SpeechControlParamsFrame) + # pushed by strategies. + self._self_queued_frames = set() async def cleanup(self): """Clean up processor resources.""" @@ -367,6 +490,9 @@ class LLMUserAggregator(LLMContextAggregator): if await self._maybe_mute_frame(frame): return + if self._vad_controller: + await self._vad_controller.process_frame(frame) + if isinstance(frame, StartFrame): # Push StartFrame before start(), because we want StartFrame to be # processed by every processor before any other frame is processed. @@ -382,6 +508,10 @@ class LLMUserAggregator(LLMContextAggregator): await self.push_frame(frame, direction) elif isinstance(frame, TranscriptionFrame): await self._handle_transcription(frame) + elif isinstance(frame, (InterimTranscriptionFrame, TranslationFrame)): + # Interim transcriptions and translations are consumed here + # and not pushed downstream, same as final TranscriptionFrame. + pass elif isinstance(frame, LLMRunFrame): await self._handle_llm_run(frame) elif isinstance(frame, LLMMessagesAppendFrame): @@ -405,6 +535,8 @@ class LLMUserAggregator(LLMContextAggregator): await self._user_turn_controller.process_frame(frame) + await self._user_idle_controller.process_frame(frame) + async def push_aggregation(self) -> str: """Push the current aggregation.""" if len(self._aggregation) == 0: @@ -420,22 +552,49 @@ class LLMUserAggregator(LLMContextAggregator): async def _start(self, frame: StartFrame): await self._user_turn_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) + # Enable incomplete turn filtering on the LLM if configured + if self._params.filter_incomplete_user_turns: + # Get config or use defaults + config = self._params.user_turn_completion_config or UserTurnCompletionConfig() + + # Enable the feature on the LLM with config + await self.push_frame( + LLMUpdateSettingsFrame( + delta=LLMSettings( + filter_incomplete_user_turns=True, + user_turn_completion_config=config, + ) + ) + ) + async def _stop(self, frame: EndFrame): + await self._maybe_emit_user_turn_stopped(on_session_end=True) await self._cleanup() async def _cancel(self, frame: CancelFrame): + await self._maybe_emit_user_turn_stopped(on_session_end=True) await self._cleanup() async def _cleanup(self): await self._user_turn_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, ( @@ -461,6 +620,14 @@ class LLMUserAggregator(LLMContextAggregator): logger.debug(f"{self}: user is now {'muted' if should_mute_next_time else 'unmuted'}") self._user_is_muted = should_mute_next_time + # Emit mute state change events + if self._user_is_muted: + await self._call_event_handler("on_user_mute_started") + await self.broadcast_frame(UserMuteStartedFrame) + else: + await self._call_event_handler("on_user_mute_stopped") + await self.broadcast_frame(UserMuteStoppedFrame) + return should_mute_frame async def _handle_llm_run(self, frame: LLMRunFrame): @@ -477,28 +644,13 @@ class LLMUserAggregator(LLMContextAggregator): await self.push_context_frame() async def _handle_speech_control_params(self, frame: SpeechControlParamsFrame): + if frame.id in self._self_queued_frames: + return + if not frame.turn_params: return - logger.warning( - f"{self}: `turn_analyzer` in base input transport is deprecated. " - "Use `LLMUserAggregator`'s new `user_turn_strategies` parameter with " - "`TurnAnalyzerUserTurnStopStrategy` instead:\n" - "\n" - " context_aggregator = LLMContextAggregatorPair(\n" - " context,\n" - " user_params=LLMUserAggregatorParams(\n" - " ...,\n" - " user_turn_strategies=UserTurnStrategies(\n" - " stop=[\n" - " TurnAnalyzerUserTurnStopStrategy(\n" - " turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams())\n" - " )\n" - " ],\n" - " )\n" - " ),\n" - " )" - ) + logger.warning(f"{self}: `turn_analyzer` in base input transport is deprecated.") await self._user_turn_controller.update_strategies(ExternalUserTurnStrategies()) @@ -516,13 +668,53 @@ class LLMUserAggregator(LLMContextAggregator): ) ) + async def _internal_queue_frame( + self, + frame: Frame, + direction: FrameDirection = FrameDirection.DOWNSTREAM, + callback: Optional[FrameCallback] = None, + ): + """Queues the given frame to ourselves.""" + self._self_queued_frames.add(frame.id) + await self.queue_frame(frame, direction, callback) + + async def _queued_broadcast_frame(self, frame_cls: Type[Frame], **kwargs): + """Broadcasts a frame upstream and queues it for internal processing. + + Queues the frame so it flows through `process_frame` and is handled + internally (e.g. by the `UserTurnController`). The upstream frame is + pushed directly. + + Args: + frame_cls: The class of the frame to be broadcasted. + **kwargs: Keyword arguments to be passed to the frame's constructor. + + """ + await self._internal_queue_frame(frame_cls(**kwargs)) + await self.push_frame(frame_cls(**kwargs), FrameDirection.UPSTREAM) + async def _on_push_frame( self, controller, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM ): - await self.push_frame(frame, direction) + await self._internal_queue_frame(frame, direction) async def _on_broadcast_frame(self, controller, frame_cls: Type[Frame], **kwargs): - await self.broadcast_frame(frame_cls, **kwargs) + await self._queued_broadcast_frame(frame_cls, **kwargs) + + async def _on_vad_speech_started(self, controller): + await self._queued_broadcast_frame( + VADUserStartedSpeakingFrame, + start_secs=controller._vad_analyzer.params.start_secs, + ) + + async def _on_vad_speech_stopped(self, controller): + await self._queued_broadcast_frame( + VADUserStoppedSpeakingFrame, + stop_secs=controller._vad_analyzer.params.stop_secs, + ) + + async def _on_vad_speech_activity(self, controller): + await self._queued_broadcast_frame(UserSpeakingFrame) async def _on_user_turn_started( self, @@ -530,15 +722,17 @@ class LLMUserAggregator(LLMContextAggregator): strategy: BaseUserTurnStartStrategy, params: UserTurnStartedParams, ): - logger.debug(f"{self}: User started speaking (user turn start strategy: {strategy})") + logger.debug(f"{self}: User started speaking (strategy: {strategy})") self._user_turn_start_timestamp = time_now_iso8601() 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() + await self.broadcast_interruption() await self._call_event_handler("on_user_turn_started", strategy) @@ -548,23 +742,47 @@ class LLMUserAggregator(LLMContextAggregator): strategy: BaseUserTurnStopStrategy, params: UserTurnStoppedParams, ): - logger.debug(f"{self}: User stopped speaking (user turn stop strategy: {strategy})") + logger.debug(f"{self}: User stopped speaking (strategy: {strategy})") if params.enable_user_speaking_frames: await self.broadcast_frame(UserStoppedSpeakingFrame) - # Always push context frame. - aggregation = await self.push_aggregation() + await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame()) - message = UserTurnStoppedMessage( - content=aggregation, timestamp=self._user_turn_start_timestamp - ) - await self._call_event_handler("on_user_turn_stopped", strategy, message) - self._user_turn_start_timestamp = "" + await self._maybe_emit_user_turn_stopped(strategy) + + async def _on_reset_aggregation( + self, controller: UserTurnController, strategy: BaseUserTurnStartStrategy + ): + logger.debug(f"{self}: Resetting aggregation (strategy: {strategy})") + await self.reset() async def _on_user_turn_stop_timeout(self, controller): await self._call_event_handler("on_user_turn_stop_timeout") + async def _on_user_turn_idle(self, controller): + await self._call_event_handler("on_user_turn_idle") + + async def _maybe_emit_user_turn_stopped( + self, + strategy: Optional[BaseUserTurnStopStrategy] = None, + on_session_end: bool = False, + ): + """Maybe emit user turn stopped event. + + Args: + strategy: The strategy that triggered the turn stop. + on_session_end: If True, only emit if there's unemitted content + (avoids duplicate events when session ends). + """ + aggregation = await self.push_aggregation() + if not on_session_end or aggregation: + message = UserTurnStoppedMessage( + content=aggregation, timestamp=self._user_turn_start_timestamp + ) + await self._call_event_handler("on_user_turn_stopped", strategy, message) + self._user_turn_start_timestamp = "" + class LLMAssistantAggregator(LLMContextAggregator): """Assistant LLM aggregator that processes bot responses and function calls. @@ -585,6 +803,7 @@ class LLMAssistantAggregator(LLMContextAggregator): - on_assistant_turn_started: Called when the assistant turn starts - on_assistant_turn_stopped: Called when the assistant turn ends - on_assistant_thought: Called when an assistant thought is available + - on_summary_applied: Called when a context summarization is applied Example:: @@ -600,6 +819,10 @@ class LLMAssistantAggregator(LLMContextAggregator): async def on_assistant_thought(aggregator, message: AssistantThoughtMessage): ... + @aggregator.event_handler("on_summary_applied") + async def on_summary_applied(aggregator, summarizer, event: SummaryAppliedEvent): + ... + """ def __init__( @@ -639,8 +862,8 @@ class LLMAssistantAggregator(LLMContextAggregator): DeprecationWarning, ) - self._started = 0 self._function_calls_in_progress: Dict[str, Optional[FunctionCallInProgressFrame]] = {} + self._function_calls_image_results: Dict[str, UserImageRawFrame] = {} self._context_updated_tasks: Set[asyncio.Task] = set() self._assistant_turn_start_timestamp = "" @@ -650,9 +873,24 @@ class LLMAssistantAggregator(LLMContextAggregator): self._thought_aggregation: List[TextPartForConcatenation] = [] self._thought_start_time: str = "" + # Context summarization — always create the summarizer so that manually + # pushed LLMSummarizeContextFrame frames are always handled. + # Auto-triggering based on thresholds is only enabled when + # enable_auto_context_summarization is True. + self._summarizer: Optional[LLMContextSummarizer] = LLMContextSummarizer( + context=self._context, + config=self._params.auto_context_summarization_config, + auto_trigger=self._params.enable_auto_context_summarization, + ) + self._summarizer.add_event_handler( + "on_request_summarization", self._on_request_summarization + ) + self._summarizer.add_event_handler("on_summary_applied", self._on_summary_applied) + self._register_event_handler("on_assistant_turn_started") self._register_event_handler("on_assistant_turn_stopped") self._register_event_handler("on_assistant_thought") + self._register_event_handler("on_summary_applied") @property def has_function_calls_in_progress(self) -> bool: @@ -683,9 +921,19 @@ class LLMAssistantAggregator(LLMContextAggregator): """ await super().process_frame(frame, direction) - if isinstance(frame, InterruptionFrame): + if isinstance(frame, StartFrame): + # Push StartFrame before start(), because we want StartFrame to be + # processed by every processor before any other frame is processed. + await self.push_frame(frame, direction) + await self._start(frame) + elif isinstance(frame, InterruptionFrame): await self._handle_interruptions(frame) await self.push_frame(frame, direction) + elif isinstance(frame, (EndFrame, CancelFrame)): + await self._handle_end_or_cancel(frame) + await self.push_frame(frame, direction) + elif isinstance(frame, LLMAssistantPushAggregationFrame): + await self.push_aggregation() elif isinstance(frame, LLMFullResponseStartFrame): await self._handle_llm_start(frame) elif isinstance(frame, LLMFullResponseEndFrame): @@ -723,6 +971,14 @@ class LLMAssistantAggregator(LLMContextAggregator): else: await self.push_frame(frame, direction) + # Pass frames to summarizer for monitoring + if self._summarizer: + await self._summarizer.process_frame(frame) + + async def _start(self, frame: StartFrame): + if self._summarizer: + await self._summarizer.setup(self.task_manager) + async def push_aggregation(self) -> str: """Push the current assistant aggregation with timestamp.""" if not self._aggregation: @@ -757,9 +1013,13 @@ class LLMAssistantAggregator(LLMContextAggregator): async def _handle_interruptions(self, frame: InterruptionFrame): await self._trigger_assistant_turn_stopped() - self._started = 0 await self.reset() + async def _handle_end_or_cancel(self, frame: Frame): + await self._trigger_assistant_turn_stopped() + if self._summarizer: + await self._summarizer.cleanup() + async def _handle_function_calls_started(self, frame: FunctionCallsStartedFrame): function_names = [f"{f.function_name}:{f.tool_call_id}" for f in frame.function_calls] logger.debug(f"{self} FunctionCallsStartedFrame: {function_names}") @@ -780,7 +1040,7 @@ class LLMAssistantAggregator(LLMContextAggregator): "id": frame.tool_call_id, "function": { "name": frame.function_name, - "arguments": json.dumps(frame.arguments), + "arguments": json.dumps(frame.arguments, ensure_ascii=False), }, "type": "function", } @@ -813,13 +1073,22 @@ class LLMAssistantAggregator(LLMContextAggregator): # Update context with the function call result if frame.result: - result = json.dumps(frame.result) + result = json.dumps(frame.result, ensure_ascii=False) self._update_function_call_result(frame.function_name, frame.tool_call_id, result) else: self._update_function_call_result(frame.function_name, frame.tool_call_id, "COMPLETED") run_llm = False + # Append any images that were generated by function calls. + if frame.tool_call_id in self._function_calls_image_results: + image_frame = self._function_calls_image_results[frame.tool_call_id] + + del self._function_calls_image_results[frame.tool_call_id] + + # If an image frame has been added to the context, let's run inference. + run_llm = await self._maybe_append_image_to_context(image_frame) + # Run inference if the function call result requires it. if frame.result: if properties and properties.run_llm is not None: @@ -848,39 +1117,32 @@ class LLMAssistantAggregator(LLMContextAggregator): logger.debug( f"{self} FunctionCallCancelFrame: [{frame.function_name}:{frame.tool_call_id}]" ) - if frame.tool_call_id not in self._function_calls_in_progress: - return - - if self._function_calls_in_progress[frame.tool_call_id].cancel_on_interruption: + function_call = self._function_calls_in_progress.get(frame.tool_call_id) + if function_call and function_call.cancel_on_interruption: # Update context with the function call cancellation self._update_function_call_result(frame.function_name, frame.tool_call_id, "CANCELLED") del self._function_calls_in_progress[frame.tool_call_id] - def _update_function_call_result(self, function_name: str, tool_call_id: str, result: Any): - for message in self._context.get_messages(): - if ( - not isinstance(message, LLMSpecificMessage) - and message["role"] == "tool" - and message["tool_call_id"] - and message["tool_call_id"] == tool_call_id - ): - message["content"] = result - async def _handle_user_image_frame(self, frame: UserImageRawFrame): - if not frame.append_to_context: - return + image_appended = False - logger.debug(f"{self} Appending UserImageRawFrame to LLM context (size: {frame.size})") + # Check if this image is a result of a function call. + if ( + frame.request + and frame.request.tool_call_id + and frame.request.tool_call_id in self._function_calls_in_progress + ): + self._function_calls_image_results[frame.request.tool_call_id] = frame - await self._context.add_image_frame_message( - format=frame.format, - size=frame.size, - image=frame.image, - text=frame.text, - ) + # Call the result_callback if provided. This signals that the image + # has been retrieved and the function call can now complete. + if frame.request.result_callback: + await frame.request.result_callback(None) + else: + image_appended = await self._maybe_append_image_to_context(frame) - await self._trigger_assistant_turn_stopped() - await self.push_context_frame(FrameDirection.UPSTREAM) + if image_appended: + await self.push_context_frame(FrameDirection.UPSTREAM) async def _handle_assistant_image_frame(self, frame: AssistantImageRawFrame): logger.debug(f"{self} Appending AssistantImageRawFrame to LLM context (size: {frame.size})") @@ -901,15 +1163,17 @@ class LLMAssistantAggregator(LLMContextAggregator): ) async def _handle_llm_start(self, _: LLMFullResponseStartFrame): - self._started += 1 await self._trigger_assistant_turn_started() async def _handle_llm_end(self, _: LLMFullResponseEndFrame): - self._started -= 1 await self._trigger_assistant_turn_stopped() async def _handle_text(self, frame: TextFrame): - if not self._started or not frame.append_to_context: + # Skip TextFrame types not intended to build the assistant context + if isinstance(frame, (TranscriptionFrame, TranslationFrame, InterimTranscriptionFrame)): + return + + if not frame.append_to_context: return # Make sure we really have text (spaces count, too!) @@ -923,18 +1187,12 @@ class LLMAssistantAggregator(LLMContextAggregator): ) async def _handle_thought_start(self, frame: LLMThoughtStartFrame): - if not self._started: - return - await self._reset_thought_aggregation() self._thought_append_to_context = frame.append_to_context self._thought_llm = frame.llm self._thought_start_time = time_now_iso8601() async def _handle_thought_text(self, frame: LLMThoughtTextFrame): - if not self._started: - return - # Make sure we really have text (spaces count, too!) if len(frame.text) == 0: return @@ -946,11 +1204,7 @@ class LLMAssistantAggregator(LLMContextAggregator): ) async def _handle_thought_end(self, frame: LLMThoughtEndFrame): - if not self._started: - return - thought = concatenate_aggregated_text(self._thought_aggregation) - await self._reset_thought_aggregation() if self._thought_append_to_context: llm = self._thought_llm @@ -966,8 +1220,36 @@ class LLMAssistantAggregator(LLMContextAggregator): ) message = AssistantThoughtMessage(content=thought, timestamp=self._thought_start_time) + + await self._reset_thought_aggregation() + await self._call_event_handler("on_assistant_thought", message) + async def _maybe_append_image_to_context(self, frame: UserImageRawFrame) -> bool: + if not frame.append_to_context: + return False + + logger.debug(f"{self} Appending UserImageRawFrame to LLM context (size: {frame.size})") + + await self._context.add_image_frame_message( + format=frame.format, + size=frame.size, + image=frame.image, + text=frame.text, + ) + + return True + + def _update_function_call_result(self, function_name: str, tool_call_id: str, result: Any): + for message in self._context.get_messages(): + if ( + not isinstance(message, LLMSpecificMessage) + and message["role"] == "tool" + and message["tool_call_id"] + and message["tool_call_id"] == tool_call_id + ): + message["content"] = result + def _context_updated_task_finished(self, task: asyncio.Task): self._context_updated_tasks.discard(task) @@ -979,13 +1261,66 @@ class LLMAssistantAggregator(LLMContextAggregator): async def _trigger_assistant_turn_stopped(self): aggregation = await self.push_aggregation() if aggregation: + # Strip turn completion markers from the transcript + content = self._maybe_strip_turn_completion_markers(aggregation) message = AssistantTurnStoppedMessage( - content=aggregation, timestamp=self._assistant_turn_start_timestamp + content=content, timestamp=self._assistant_turn_start_timestamp ) await self._call_event_handler("on_assistant_turn_stopped", message) self._assistant_turn_start_timestamp = "" + def _maybe_strip_turn_completion_markers(self, text: str) -> str: + """Strip turn completion markers from assistant transcript. + + These markers (✓, ○, ◐) are used internally for turn completion + detection and shouldn't appear in the final transcript. + """ + from pipecat.turns.user_turn_completion_mixin import ( + USER_TURN_COMPLETE_MARKER, + USER_TURN_INCOMPLETE_LONG_MARKER, + USER_TURN_INCOMPLETE_SHORT_MARKER, + ) + + marker_found = False + for marker in ( + USER_TURN_COMPLETE_MARKER, + USER_TURN_INCOMPLETE_SHORT_MARKER, + USER_TURN_INCOMPLETE_LONG_MARKER, + ): + if marker in text: + text = text.replace(marker, "") + marker_found = True + + # Only strip whitespace if we removed a marker + return text.strip() if marker_found else text + + async def _on_request_summarization( + self, summarizer: LLMContextSummarizer, frame: LLMContextSummaryRequestFrame + ): + """Handle summarization request from the summarizer. + + Push the request frame UPSTREAM to the LLM service for processing. + + Args: + summarizer: The summarizer that generated the request. + frame: The summarization request frame to broadcast. + """ + await self.push_frame(frame, FrameDirection.UPSTREAM) + + async def _on_summary_applied( + self, summarizer: LLMContextSummarizer, event: SummaryAppliedEvent + ): + """Handle summary applied event from the summarizer. + + Forwards the event to any registered `on_summary_applied` handlers. + + Args: + summarizer: The summarizer that applied the summary. + event: The summary applied event. + """ + await self._call_event_handler("on_summary_applied", summarizer, event) + class LLMContextAggregatorPair: """Pair of LLM context aggregators for updating context with user and assistant messages.""" @@ -994,8 +1329,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. @@ -1004,6 +1339,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) @@ -1022,3 +1359,15 @@ class LLMContextAggregatorPair: The assistant context aggregator instance. """ return self._assistant + + def __iter__(self): + """Allow tuple unpacking of the aggregator pair. + + This enables both usage patterns:: + pair = LLMContextAggregatorPair(context) # Returns the instance + user, assistant = LLMContextAggregatorPair(context) # Unpacks into tuple + + Yields: + The user aggregator, then the assistant aggregator. + """ + return iter((self._user, self._assistant)) diff --git a/src/pipecat/processors/aggregators/openai_llm_context.py b/src/pipecat/processors/aggregators/openai_llm_context.py index 41df3b5e8..f75625156 100644 --- a/src/pipecat/processors/aggregators/openai_llm_context.py +++ b/src/pipecat/processors/aggregators/openai_llm_context.py @@ -34,7 +34,6 @@ from PIL import Image from pipecat.adapters.base_llm_adapter import BaseLLMAdapter from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.frames.frames import AudioRawFrame, Frame -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor # JSON custom encoder to handle bytes arrays so that we can log contexts # with images to the console. diff --git a/src/pipecat/processors/audio/audio_buffer_processor.py b/src/pipecat/processors/audio/audio_buffer_processor.py index 0d3ed76d4..ceadddf95 100644 --- a/src/pipecat/processors/audio/audio_buffer_processor.py +++ b/src/pipecat/processors/audio/audio_buffer_processor.py @@ -11,7 +11,6 @@ of audio from both user input and bot output sources, with support for various a configurations and event-driven processing. """ -import time from typing import Optional from pipecat.audio.utils import create_stream_resampler, interleave_stereo_audio, mix_audio @@ -104,10 +103,6 @@ class AudioBufferProcessor(FrameProcessor): self._user_turn_audio_buffer = bytearray() self._bot_turn_audio_buffer = bytearray() - # Intermittent (non continous user stream variables) - self._last_user_frame_at = 0 - self._last_bot_frame_at = 0 - self._recording = False self._input_resampler = create_stream_resampler() @@ -211,23 +206,31 @@ class AudioBufferProcessor(FrameProcessor): """Process audio frames for recording.""" resampled = None if isinstance(frame, InputAudioRawFrame): - # Add silence if we need to. - silence = self._compute_silence(self._last_user_frame_at) - self._user_audio_buffer.extend(silence) - # Add user audio. resampled = await self._resample_input_audio(frame) - self._user_audio_buffer.extend(resampled) - # Save time of frame so we can compute silence. - self._last_user_frame_at = time.time() + # Ignoring in case we don't have audio + if len(resampled) > 0: + # Sync bot buffer to current user position before adding user audio. + # We sync BEFORE extending to align both buffers at the same starting timestamp. + # For example, user buffer is at 100 bytes, and you receive 20 bytes of new audio + # - Bot buffer sees User is at 100. Bot pads itself to 100. + # - User buffer adds 20. User is now at 120. + # - Outcome: At index 100-120, we have User Audio and (potentially) Bot Audio or silence. They are aligned + # This gives the opportunity to the bot to send audio. + # + # If we synced AFTER, we'd pad the bot buffer with silence for the same + # window we just gave to the user, effectively "overwriting" that time slot + # with silence and causing the bot's audio to flicker or cut out. + self._sync_buffer_to_position(self._bot_audio_buffer, len(self._user_audio_buffer)) + # Add user audio. + self._user_audio_buffer.extend(resampled) elif self._recording and isinstance(frame, OutputAudioRawFrame): - # Add silence if we need to. - silence = self._compute_silence(self._last_bot_frame_at) - self._bot_audio_buffer.extend(silence) - # Add bot audio. resampled = await self._resample_output_audio(frame) - self._bot_audio_buffer.extend(resampled) - # Save time of frame so we can compute silence. - self._last_bot_frame_at = time.time() + # Ignoring in case we don't have audio + if len(resampled) > 0: + # Sync user buffer to current bot position before adding bot audio + self._sync_buffer_to_position(self._user_audio_buffer, len(self._bot_audio_buffer)) + # Add bot audio. + self._bot_audio_buffer.extend(resampled) if self._buffer_size > 0 and ( len(self._user_audio_buffer) >= self._buffer_size @@ -240,6 +243,21 @@ class AudioBufferProcessor(FrameProcessor): if self._enable_turn_audio: await self._process_turn_recording(frame, resampled) + def _sync_buffer_to_position(self, buffer: bytearray, target_position: int): + """Pad buffer with silence if it's behind the target position. + + This ensures both buffers stay synchronized by padding the lagging + buffer before new audio is added to the other buffer. + + Args: + buffer: The buffer to potentially pad. + target_position: The position (in bytes) the buffer should reach. + """ + current_len = len(buffer) + if current_len < target_position: + silence_needed = target_position - current_len + buffer.extend(b"\x00" * silence_needed) + async def _process_turn_recording(self, frame: Frame, resampled_audio: Optional[bytes] = None): """Process frames for turn-based audio recording.""" if isinstance(frame, UserStartedSpeakingFrame): @@ -281,8 +299,8 @@ class AudioBufferProcessor(FrameProcessor): if len(self._user_audio_buffer) == 0 and len(self._bot_audio_buffer) == 0: return + # Final alignment before we send the audio self._align_track_buffers() - flush_time = time.time() # Call original handler with merged audio merged_audio = self.merge_audio_buffers() @@ -299,9 +317,6 @@ class AudioBufferProcessor(FrameProcessor): self._num_channels, ) - self._last_user_frame_at = flush_time - self._last_bot_frame_at = flush_time - def _buffer_has_audio(self, buffer: bytearray) -> bool: """Check if a buffer contains audio data.""" return buffer is not None and len(buffer) > 0 @@ -309,8 +324,6 @@ class AudioBufferProcessor(FrameProcessor): def _reset_recording(self): """Reset recording state and buffers.""" self._reset_all_audio_buffers() - self._last_user_frame_at = time.time() - self._last_bot_frame_at = time.time() def _reset_all_audio_buffers(self): """Reset all audio buffers to empty state.""" @@ -336,11 +349,9 @@ class AudioBufferProcessor(FrameProcessor): target_len = max(user_len, bot_len) if user_len < target_len: - self._user_audio_buffer.extend(b"\x00" * (target_len - user_len)) - self._last_user_frame_at = max(self._last_user_frame_at, self._last_bot_frame_at) + self._sync_buffer_to_position(self._user_audio_buffer, target_len) if bot_len < target_len: - self._bot_audio_buffer.extend(b"\x00" * (target_len - bot_len)) - self._last_bot_frame_at = max(self._last_bot_frame_at, self._last_user_frame_at) + self._sync_buffer_to_position(self._bot_audio_buffer, target_len) async def _resample_input_audio(self, frame: InputAudioRawFrame) -> bytes: """Resample audio frame to the target sample rate.""" @@ -353,14 +364,3 @@ class AudioBufferProcessor(FrameProcessor): return await self._output_resampler.resample( frame.audio, frame.sample_rate, self._sample_rate ) - - def _compute_silence(self, from_time: float) -> bytes: - """Compute silence to insert based on time gap.""" - quiet_time = time.time() - from_time - # We should get audio frames very frequently. We introduce silence only - # if there's a big enough gap of 1s. - if from_time == 0 or quiet_time < 1.0: - return b"" - num_bytes = int(quiet_time * self._sample_rate) * 2 - silence = b"\x00" * num_bytes - return silence diff --git a/src/pipecat/processors/audio/vad_processor.py b/src/pipecat/processors/audio/vad_processor.py new file mode 100644 index 000000000..9145f52cb --- /dev/null +++ b/src/pipecat/processors/audio/vad_processor.py @@ -0,0 +1,108 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Voice Activity Detection processor for detecting speech in audio streams. + +This module provides a VADProcessor that wraps a VADController to process +audio frames and push VAD-related frames into the pipeline. +""" + +from typing import Type + +from loguru import logger + +from pipecat.audio.vad.vad_analyzer import VADAnalyzer +from pipecat.audio.vad.vad_controller import VADController +from pipecat.frames.frames import ( + Frame, + UserSpeakingFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor + + +class VADProcessor(FrameProcessor): + """Processes audio frames through voice activity detection. + + This processor wraps a VADController to detect speech in audio streams + and push VAD frames into the pipeline: + + - ``VADUserStartedSpeakingFrame``: Pushed when speech begins. + - ``VADUserStoppedSpeakingFrame``: Pushed when speech ends. + - ``UserSpeakingFrame``: Pushed periodically while speech is detected. + + Example:: + + vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer()) + """ + + def __init__( + self, + *, + vad_analyzer: VADAnalyzer, + speech_activity_period: float = 0.2, + **kwargs, + ): + """Initialize the VAD processor. + + Args: + vad_analyzer: The VADAnalyzer instance for processing audio. + speech_activity_period: Minimum interval in seconds between + UserSpeakingFrame pushes. Defaults to 0.2. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(**kwargs) + self._vad_controller = VADController( + vad_analyzer, speech_activity_period=speech_activity_period + ) + + # Push VAD frames when speech events are detected + @self._vad_controller.event_handler("on_speech_started") + async def on_speech_started(_controller): + logger.debug(f"{self}: User started speaking") + await self.broadcast_frame( + VADUserStartedSpeakingFrame, + start_secs=_controller._vad_analyzer.params.start_secs, + ) + + @self._vad_controller.event_handler("on_speech_stopped") + async def on_speech_stopped(_controller): + logger.debug(f"{self}: User stopped speaking") + await self.broadcast_frame( + VADUserStoppedSpeakingFrame, + stop_secs=_controller._vad_analyzer.params.stop_secs, + ) + + @self._vad_controller.event_handler("on_speech_activity") + async def on_speech_activity(_controller): + await self.broadcast_frame(UserSpeakingFrame) + + # Wire up frame pushing from controller to processor + @self._vad_controller.event_handler("on_push_frame") + async def on_push_frame(_controller, frame: Frame, direction: FrameDirection): + await self.push_frame(frame, direction) + + @self._vad_controller.event_handler("on_broadcast_frame") + async def on_broadcast_frame(_controller, frame_cls: Type[Frame], **kwargs): + await self.broadcast_frame(frame_cls, **kwargs) + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process a frame through VAD and forward it. + + Args: + frame: The frame to process. + direction: The direction of frame flow in the pipeline. + """ + await super().process_frame(frame, direction) + + # Forward the frame first, then let VAD controller process. This ensures: + # 1. StartFrame reaches downstream before SpeechControlParamsFrame is broadcast + # 2. Audio flows through immediately while VAD detection happens after + await self.push_frame(frame, direction) + + # Let the VAD controller handle the frame + await self._vad_controller.process_frame(frame) diff --git a/src/pipecat/processors/filters/function_filter.py b/src/pipecat/processors/filters/function_filter.py index 28567653f..46b1945ce 100644 --- a/src/pipecat/processors/filters/function_filter.py +++ b/src/pipecat/processors/filters/function_filter.py @@ -10,11 +10,13 @@ This module provides a processor that filters frames based on a custom function, allowing for flexible frame filtering logic in processing pipelines. """ -from typing import Awaitable, Callable +from typing import Awaitable, Callable, Optional from pipecat.frames.frames import CancelFrame, EndFrame, Frame, StartFrame, SystemFrame from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +FilterType = Callable[[Frame], Awaitable[bool]] + class FunctionFilter(FrameProcessor): """A frame processor that filters frames using a custom function. @@ -26,9 +28,10 @@ class FunctionFilter(FrameProcessor): def __init__( self, - filter: Callable[[Frame], Awaitable[bool]], - direction: FrameDirection = FrameDirection.DOWNSTREAM, + filter: FilterType, + direction: Optional[FrameDirection] = FrameDirection.DOWNSTREAM, filter_system_frames: bool = False, + **kwargs, ): """Initialize the function filter. @@ -36,10 +39,13 @@ class FunctionFilter(FrameProcessor): filter: An async function that takes a Frame and returns True if the frame should pass through, False otherwise. direction: The direction to apply filtering. Only frames moving in - this direction will be filtered. Defaults to DOWNSTREAM. + this direction will be filtered; frames in the other direction + pass through unfiltered. If None, frames in both directions + are filtered. Defaults to DOWNSTREAM. filter_system_frames: Whether to filter system frames. Defaults to False. + **kwargs: Additional arguments passed to parent class. """ - super().__init__() + super().__init__(**kwargs) self._filter = filter self._direction = direction self._filter_system_frames = filter_system_frames @@ -51,7 +57,7 @@ class FunctionFilter(FrameProcessor): def _should_passthrough_frame(self, frame, direction): """Check if a frame should pass through without filtering.""" # Always passthrough frames in the wrong direction - if direction != self._direction: + if self._direction and direction != self._direction: return True # Always passthrough lifecycle frames diff --git a/src/pipecat/processors/filters/wake_check_filter.py b/src/pipecat/processors/filters/wake_check_filter.py index 792c4a68d..6a9e524e6 100644 --- a/src/pipecat/processors/filters/wake_check_filter.py +++ b/src/pipecat/processors/filters/wake_check_filter.py @@ -6,6 +6,9 @@ """Wake phrase detection filter for Pipecat transcription processing. +.. deprecated:: 0.0.106 + Use :class:`~pipecat.turns.user_start.WakePhraseUserTurnStartStrategy` instead. + This module provides a frame processor that filters transcription frames, only allowing them through after wake phrases have been detected. Includes keepalive functionality to maintain conversation flow after wake detection. @@ -13,18 +16,24 @@ keepalive functionality to maintain conversation flow after wake detection. import re import time +import warnings from enum import Enum from typing import List from loguru import logger -from pipecat.frames.frames import ErrorFrame, Frame, TranscriptionFrame +from pipecat.frames.frames import Frame, TranscriptionFrame from pipecat.processors.frame_processor import FrameDirection, FrameProcessor class WakeCheckFilter(FrameProcessor): """Frame processor that filters transcription frames based on wake phrase detection. + .. deprecated:: 0.0.106 + Use :class:`~pipecat.turns.user_start.WakePhraseUserTurnStartStrategy` instead, + which integrates with the user turn strategy system and supports configurable + timeouts and single-activation mode. + This filter monitors transcription frames for configured wake phrases and only passes frames through after a wake phrase has been detected. Maintains a keepalive timeout to allow continued conversation after wake detection. @@ -65,12 +74,21 @@ class WakeCheckFilter(FrameProcessor): def __init__(self, wake_phrases: List[str], keepalive_timeout: float = 3): """Initialize the wake phrase filter. + .. deprecated:: 0.0.106 + Use :class:`~pipecat.turns.user_start.WakePhraseUserTurnStartStrategy` instead. + Args: wake_phrases: List of wake phrases to detect in transcriptions. keepalive_timeout: Duration in seconds to keep passing frames after wake detection. Defaults to 3 seconds. """ super().__init__() + warnings.warn( + "WakeCheckFilter is deprecated since v0.0.106. " + "Use WakePhraseUserTurnStartStrategy instead.", + DeprecationWarning, + stacklevel=2, + ) self._participant_states = {} self._keepalive_timeout = keepalive_timeout self._wake_patterns = [] diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index 9c26fe382..f3d9fbdea 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -12,6 +12,7 @@ management, and frame flow control mechanisms. """ import asyncio +import dataclasses import traceback from dataclasses import dataclass from enum import Enum @@ -40,7 +41,6 @@ from pipecat.frames.frames import ( FrameProcessorResumeFrame, FrameProcessorResumeUrgentFrame, InterruptionFrame, - InterruptionTaskFrame, StartFrame, SystemFrame, UninterruptibleFrame, @@ -239,14 +239,6 @@ class FrameProcessor(BaseObject): self.__process_frame_task: Optional[asyncio.Task] = None self.__process_current_frame: Optional[Frame] = None - # To interrupt a pipeline, we push an `InterruptionTaskFrame` upstream. - # Then we wait for the corresponding `InterruptionFrame` to travel from - # the start of the pipeline back to the processor that sent the - # `InterruptionTaskFrame`. This wait is handled using the following - # event. - self._wait_for_interruption = False - self._wait_interruption_event = asyncio.Event() - # Frame processor events. self._register_event_handler("on_before_process_frame", sync=True) self._register_event_handler("on_after_process_frame", sync=True) @@ -332,7 +324,7 @@ class FrameProcessor(BaseObject): warnings.simplefilter("always") warnings.warn( "`FrameProcessor.interruptions_allowed` is deprecated. " - "Use `LLMUserAggregator`'s new `user_mute_strategies` parameter instead.", + "Use `LLMUserAggregator`'s new `user_mute_strategies` parameter instead.", DeprecationWarning, stacklevel=2, ) @@ -420,27 +412,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) @@ -466,10 +480,23 @@ class FrameProcessor(BaseObject): if frame: await self.push_frame(frame) + async def start_text_aggregation_metrics(self): + """Start text aggregation time metrics collection.""" + if self.can_generate_metrics() and self.metrics_enabled: + await self._metrics.start_text_aggregation_metrics() + + async def stop_text_aggregation_metrics(self): + """Stop text aggregation time metrics collection and push results.""" + if self.can_generate_metrics() and self.metrics_enabled: + frame = await self._metrics.stop_text_aggregation_metrics() + if frame: + await self.push_frame(frame) + async def stop_all_metrics(self): """Stop all active metrics collection.""" await self.stop_ttfb_metrics() await self.stop_processing_metrics() + await self.stop_text_aggregation_metrics() def create_task(self, coroutine: Coroutine, name: Optional[str] = None) -> asyncio.Task: """Create a new task managed by this processor. @@ -599,14 +626,6 @@ class FrameProcessor(BaseObject): if self._cancelling: return - # If we are waiting for an interruption we will bypass all queued system - # frames and we will process the frame right away. This is because a - # previous system frame might be waiting for the interruption frame and - # it's blocking the input task. - if self._wait_for_interruption and isinstance(frame, InterruptionFrame): - await self.__process_frame(frame, direction, callback) - return - if self._enable_direct_mode: await self.__process_frame(frame, direction, callback) else: @@ -741,46 +760,85 @@ class FrameProcessor(BaseObject): await self._call_event_handler("on_after_push_frame", frame) - # If we are waiting for an interruption and we get an interruption, then - # we can unblock `push_interruption_task_frame_and_wait()`. - if self._wait_for_interruption and isinstance(frame, InterruptionFrame): - self._wait_interruption_event.set() + async def broadcast_interruption(self): + """Broadcast an `InterruptionFrame` both upstream and downstream.""" + logger.debug(f"{self}: broadcasting interruption") + self.__reset_process_task() + await self.stop_all_metrics() + await self.broadcast_frame(InterruptionFrame) - async def push_interruption_task_frame_and_wait(self): + async def push_interruption_task_frame_and_wait(self, *, timeout: float = 5.0): """Push an interruption task frame upstream and wait for the interruption. - This function sends an `InterruptionTaskFrame` upstream to the pipeline - task and waits to receive the corresponding `InterruptionFrame`. When - the function finishes it is guaranteed that the `InterruptionFrame` has - been pushed downstream. + .. deprecated:: 0.0.104 + Use :meth:`broadcast_interruption` instead. This method now + delegates to ``broadcast_interruption()`` and ignores *timeout*. """ - self._wait_for_interruption = True + import warnings - await self.push_frame(InterruptionTaskFrame(), FrameDirection.UPSTREAM) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "`FrameProcessor.push_interruption_task_frame_and_wait()` is deprecated. " + "Use `FrameProcessor.broadcast_interruption()` instead.", + DeprecationWarning, + stacklevel=2, + ) - # Wait for an `InterruptionFrame` to come to this processor and be - # pushed. Take a look at `push_frame()` to see how we first push the - # `InterruptionFrame` and then we set the event in order to maintain - # frame ordering. - await self._wait_interruption_event.wait() - - # Clean the event. - self._wait_interruption_event.clear() - - self._wait_for_interruption = False + await self.broadcast_interruption() async def broadcast_frame(self, frame_cls: Type[Frame], **kwargs): """Broadcasts a frame of the specified class upstream and downstream. This method creates two instances of the given frame class using the - provided keyword arguments and pushes them upstream and downstream. + provided keyword arguments (without deep-copying them) and pushes them + upstream and downstream. Args: 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. + + This method creates two new frame instances shallow-copying all fields + from the original frame except `id` and `name`, which get fresh values. + + Args: + frame: The frame instance to broadcast. + + Note: + Prefer using `broadcast_frame()` when possible, as it is more + efficient. This method should only be used when you are not the + creator of the frame and need to broadcast an existing instance. + """ + frame_cls = type(frame) + init_fields = {f.name: getattr(frame, f.name) for f in dataclasses.fields(frame) if f.init} + extra_fields = { + f.name: getattr(frame, f.name) + for f in dataclasses.fields(frame) + if not f.init and f.name not in ("id", "name") + } + + downstream_frame = frame_cls(**init_fields) + for k, v in extra_fields.items(): + setattr(downstream_frame, k, v) + + upstream_frame = frame_cls(**init_fields) + for k, v in extra_fields.items(): + 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. @@ -834,15 +892,7 @@ class FrameProcessor(BaseObject): async def _start_interruption(self): """Start handling an interruption by cancelling current tasks.""" try: - if self._wait_for_interruption: - # If we get here we know the process task was just waiting for - # an interruption (push_interruption_task_frame_and_wait()), so - # we can't cancel the task because it might still need to do - # more things (e.g. pushing a frame after the - # interruption). Instead we just drain the queue because this is - # an interruption. - self.__reset_process_task() - elif isinstance(self.__process_current_frame, UninterruptibleFrame): + if isinstance(self.__process_current_frame, UninterruptibleFrame): # We don't want to cancel UninterruptibleFrame, so we simply # cleanup the queue. self.__reset_process_queue() @@ -866,7 +916,7 @@ class FrameProcessor(BaseObject): try: timestamp = self._clock.get_time() if self._clock else 0 if direction == FrameDirection.DOWNSTREAM and self._next: - logger.trace(f"Pushing {frame} from {self} to {self._next}") + logger.trace(f"Pushing {frame} downstream from {self} to {self._next}") if self._observer: data = FramePushed( @@ -950,7 +1000,8 @@ class FrameProcessor(BaseObject): # Process current queue and keep UninterruptibleFrame frames. while not self.__process_queue.empty(): item = self.__process_queue.get_nowait() - if isinstance(item, UninterruptibleFrame): + frame = item[0] + if isinstance(frame, UninterruptibleFrame): new_queue.put_nowait(item) self.__process_queue.task_done() diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py deleted file mode 100644 index 7ea04ecf2..000000000 --- a/src/pipecat/processors/frameworks/rtvi.py +++ /dev/null @@ -1,1916 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -"""RTVI (Real-Time Voice Interface) protocol implementation for Pipecat. - -This module provides the RTVI protocol implementation for real-time voice interactions -between clients and AI agents. It includes message handling, action processing, -and frame observation for the RTVI protocol. -""" - -import asyncio -import base64 -import time -from dataclasses import dataclass -from typing import ( - Any, - Awaitable, - Callable, - Dict, - List, - Literal, - Mapping, - Optional, - Tuple, - Union, -) - -from loguru import logger -from pydantic import BaseModel, Field, PrivateAttr, ValidationError - -from pipecat import version as pipecat_version -from pipecat.audio.utils import calculate_audio_volume -from pipecat.frames.frames import ( - AggregatedTextFrame, - AggregationType, - BotStartedSpeakingFrame, - BotStoppedSpeakingFrame, - CancelFrame, - DataFrame, - EndFrame, - EndTaskFrame, - ErrorFrame, - Frame, - FunctionCallResultFrame, - InputAudioRawFrame, - InputTransportMessageFrame, - InterimTranscriptionFrame, - LLMConfigureOutputFrame, - LLMContextFrame, - LLMFullResponseEndFrame, - LLMFullResponseStartFrame, - LLMMessagesAppendFrame, - LLMTextFrame, - MetricsFrame, - OutputTransportMessageUrgentFrame, - StartFrame, - SystemFrame, - TranscriptionFrame, - TTSAudioRawFrame, - TTSStartedFrame, - TTSStoppedFrame, - TTSTextFrame, - UserStartedSpeakingFrame, - UserStoppedSpeakingFrame, -) -from pipecat.metrics.metrics import ( - LLMUsageMetricsData, - ProcessingMetricsData, - TTFBMetricsData, - TTSUsageMetricsData, -) -from pipecat.observers.base_observer import BaseObserver, FramePushed -from pipecat.processors.aggregators.openai_llm_context import ( - OpenAILLMContext, - OpenAILLMContextFrame, -) -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pipecat.services.llm_service import ( - FunctionCallParams, # TODO(aleix): we shouldn't import `services` from `processors` -) -from pipecat.transports.base_input import BaseInputTransport -from pipecat.transports.base_output import BaseOutputTransport -from pipecat.transports.base_transport import BaseTransport -from pipecat.utils.string import match_endofsentence - -RTVI_PROTOCOL_VERSION = "1.1.0" - -RTVI_MESSAGE_LABEL = "rtvi-ai" -RTVIMessageLiteral = Literal["rtvi-ai"] - -ActionResult = Union[bool, int, float, str, list, dict] - - -class RTVIServiceOption(BaseModel): - """Configuration option for an RTVI service. - - Defines a configurable option that can be set for an RTVI service, - including its name, type, and handler function. - - .. deprecated:: 0.0.75 - Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - name: str - type: Literal["bool", "number", "string", "array", "object"] - handler: Callable[["RTVIProcessor", str, "RTVIServiceOptionConfig"], Awaitable[None]] = Field( - exclude=True - ) - - -class RTVIService(BaseModel): - """An RTVI service definition. - - Represents a service that can be configured and used within the RTVI protocol, - containing a name and list of configurable options. - - .. deprecated:: 0.0.75 - Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - name: str - options: List[RTVIServiceOption] - _options_dict: Dict[str, RTVIServiceOption] = PrivateAttr(default={}) - - def model_post_init(self, __context: Any) -> None: - """Initialize the options dictionary after model creation.""" - self._options_dict = {} - for option in self.options: - self._options_dict[option.name] = option - return super().model_post_init(__context) - - -class RTVIActionArgumentData(BaseModel): - """Data for an RTVI action argument. - - Contains the name and value of an argument passed to an RTVI action. - - .. deprecated:: 0.0.75 - Actions have been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - name: str - value: Any - - -class RTVIActionArgument(BaseModel): - """Definition of an RTVI action argument. - - Specifies the name and expected type of an argument for an RTVI action. - - .. deprecated:: 0.0.75 - Actions have been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - name: str - type: Literal["bool", "number", "string", "array", "object"] - - -class RTVIAction(BaseModel): - """An RTVI action definition. - - Represents an action that can be executed within the RTVI protocol, - including its service, name, arguments, and handler function. - - .. deprecated:: 0.0.75 - Actions have been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - service: str - action: str - arguments: List[RTVIActionArgument] = Field(default_factory=list) - result: Literal["bool", "number", "string", "array", "object"] - handler: Callable[["RTVIProcessor", str, Dict[str, Any]], Awaitable[ActionResult]] = Field( - exclude=True - ) - _arguments_dict: Dict[str, RTVIActionArgument] = PrivateAttr(default={}) - - def model_post_init(self, __context: Any) -> None: - """Initialize the arguments dictionary after model creation.""" - self._arguments_dict = {} - for arg in self.arguments: - self._arguments_dict[arg.name] = arg - return super().model_post_init(__context) - - -class RTVIServiceOptionConfig(BaseModel): - """Configuration value for an RTVI service option. - - Contains the name and value to set for a specific service option. - - .. deprecated:: 0.0.75 - Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - name: str - value: Any - - -class RTVIServiceConfig(BaseModel): - """Configuration for an RTVI service. - - Contains the service name and list of option configurations to apply. - - .. deprecated:: 0.0.75 - Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - service: str - options: List[RTVIServiceOptionConfig] - - -class RTVIConfig(BaseModel): - """Complete RTVI configuration. - - Contains the full configuration for all RTVI services. - - .. deprecated:: 0.0.75 - Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - config: List[RTVIServiceConfig] - - -# -# Client -> Pipecat messages. -# - - -# deprecated -class RTVIUpdateConfig(BaseModel): - """Request to update RTVI configuration. - - Contains new configuration settings and whether to interrupt the bot. - - .. deprecated:: 0.0.75 - Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - config: List[RTVIServiceConfig] - interrupt: bool = False - - -class RTVIActionRunArgument(BaseModel): - """Argument for running an RTVI action. - - Contains the name and value of an argument to pass to an action. - - .. deprecated:: 0.0.75 - Actions have been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - name: str - value: Any - - -class RTVIActionRun(BaseModel): - """Request to run an RTVI action. - - Contains the service, action name, and optional arguments. - - .. deprecated:: 0.0.75 - Actions have been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - service: str - action: str - arguments: Optional[List[RTVIActionRunArgument]] = None - - -@dataclass -class RTVIActionFrame(DataFrame): - """Frame containing an RTVI action to execute. - - Parameters: - rtvi_action_run: The action to execute. - message_id: Optional message ID for response correlation. - - .. deprecated:: 0.0.75 - Actions have been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - rtvi_action_run: RTVIActionRun - message_id: Optional[str] = None - - -class RTVIRawClientMessageData(BaseModel): - """Data structure expected from client messages sent to the RTVI server.""" - - t: str - d: Optional[Any] = None - - -class RTVIClientMessage(BaseModel): - """Cleansed data structure for client messages for handling.""" - - msg_id: str - type: str - data: Optional[Any] = None - - -@dataclass -class RTVIClientMessageFrame(SystemFrame): - """A frame for sending messages from the client to the RTVI server. - - This frame is meant for custom messaging from the client to the server - and expects a server-response message. - """ - - msg_id: str - type: str - data: Optional[Any] = None - - -@dataclass -class RTVIServerResponseFrame(SystemFrame): - """A frame for responding to a client RTVI message. - - This frame should be sent in response to an RTVIClientMessageFrame - and include the original RTVIClientMessageFrame to ensure the response - is properly attributed to the original request. To respond with an error, - set the `error` field to a string describing the error. This will result - in the client receiving a `response-error` message instead of a - `server-response` message. - """ - - client_msg: RTVIClientMessageFrame - data: Optional[Any] = None - error: Optional[str] = None - - -class RTVIRawServerResponseData(BaseModel): - """Data structure for server responses to client messages.""" - - t: str - d: Optional[Any] = None - - -class RTVIServerResponse(BaseModel): - """The RTVI-formatted message response from the server to the client. - - This message is used to respond to custom messages sent by the client. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["server-response"] = "server-response" - id: str - data: RTVIRawServerResponseData - - -class RTVIMessage(BaseModel): - """Base RTVI message structure. - - Represents the standard format for RTVI protocol messages. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: str - id: str - data: Optional[Dict[str, Any]] = None - - -# -# Pipecat -> Client responses and messages. -# - - -class RTVIErrorResponseData(BaseModel): - """Data for an RTVI error response. - - Contains the error message to send back to the client. - """ - - error: str - - -class RTVIErrorResponse(BaseModel): - """RTVI error response message. - - RTVI Formatted error response message for relaying failed client requests. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["error-response"] = "error-response" - id: str - data: RTVIErrorResponseData - - -class RTVIErrorData(BaseModel): - """Data for an RTVI error event. - - Contains error information including whether it's fatal. - """ - - error: str - fatal: bool # Indicates the pipeline has stopped due to this error - - -class RTVIError(BaseModel): - """RTVI error event message. - - RTVI Formatted error message for relaying errors in the pipeline. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["error"] = "error" - data: RTVIErrorData - - -class RTVIDescribeConfigData(BaseModel): - """Data for describing available RTVI configuration. - - Contains the list of available services and their options. - - .. deprecated:: 0.0.75 - Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - config: List[RTVIService] - - -class RTVIDescribeConfig(BaseModel): - """Message describing available RTVI configuration. - - Sent in response to a describe-config request. - - .. deprecated:: 0.0.75 - Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["config-available"] = "config-available" - id: str - data: RTVIDescribeConfigData - - -class RTVIDescribeActionsData(BaseModel): - """Data for describing available RTVI actions. - - Contains the list of available actions that can be executed. - - .. deprecated:: 0.0.75 - Actions have been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - actions: List[RTVIAction] - - -class RTVIDescribeActions(BaseModel): - """Message describing available RTVI actions. - - Sent in response to a describe-actions request. - - .. deprecated:: 0.0.75 - Actions have been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["actions-available"] = "actions-available" - id: str - data: RTVIDescribeActionsData - - -class RTVIConfigResponse(BaseModel): - """Response containing current RTVI configuration. - - Sent in response to a get-config request. - - .. deprecated:: 0.0.75 - Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["config"] = "config" - id: str - data: RTVIConfig - - -class RTVIActionResponseData(BaseModel): - """Data for an RTVI action response. - - Contains the result of executing an action. - - .. deprecated:: 0.0.75 - Actions have been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - result: ActionResult - - -class RTVIActionResponse(BaseModel): - """Response to an RTVI action execution. - - Sent after successfully executing an action. - - .. deprecated:: 0.0.75 - Actions have been removed as part of the RTVI protocol 1.0.0. - Use custom client and server messages instead. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["action-response"] = "action-response" - id: str - data: RTVIActionResponseData - - -class AboutClientData(BaseModel): - """Data about the RTVI client. - - Contains information about the client, including which RTVI library it - is using, what platform it is on and any additional details, if available. - """ - - library: str - library_version: Optional[str] = None - platform: Optional[str] = None - platform_version: Optional[str] = None - platform_details: Optional[Any] = None - - -class RTVIClientReadyData(BaseModel): - """Data format of client ready messages. - - Contains the RTVIprotocol version and client information. - """ - - version: str - about: AboutClientData - - -class RTVIBotReadyData(BaseModel): - """Data for bot ready notification. - - Contains protocol version and initial configuration. - """ - - version: str - # The config field is deprecated and will not be included if - # the client's rtvi version is 1.0.0 or higher. - config: Optional[List[RTVIServiceConfig]] = None - about: Optional[Mapping[str, Any]] = None - - -class RTVIBotReady(BaseModel): - """Message indicating bot is ready for interaction. - - Sent after bot initialization is complete. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-ready"] = "bot-ready" - id: str - data: RTVIBotReadyData - - -class RTVILLMFunctionCallMessageData(BaseModel): - """Data for LLM function call notification. - - Contains function call details including name, ID, and arguments. - """ - - function_name: str - tool_call_id: str - args: Mapping[str, Any] - - -class RTVILLMFunctionCallMessage(BaseModel): - """Message notifying of an LLM function call. - - Sent when the LLM makes a function call. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["llm-function-call"] = "llm-function-call" - data: RTVILLMFunctionCallMessageData - - -class RTVISendTextOptions(BaseModel): - """Options for sending text input to the LLM. - - Contains options for how the pipeline should process the text input. - """ - - run_immediately: bool = True - audio_response: bool = True - - -class RTVISendTextData(BaseModel): - """Data format for sending text input to the LLM. - - Contains the text content to send and any options for how the pipeline should process it. - - """ - - content: str - options: Optional[RTVISendTextOptions] = None - - -class RTVIAppendToContextData(BaseModel): - """Data format for appending messages to the context. - - Contains the role, content, and whether to run the message immediately. - - .. deprecated:: 0.0.85 - The RTVI message, append-to-context, has been deprecated. Use send-text - or custom client and server messages instead. - """ - - role: Literal["user", "assistant"] | str - content: Any - run_immediately: bool = False - - -class RTVIAppendToContext(BaseModel): - """RTVI Message format to append content to the LLM context.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["append-to-context"] = "append-to-context" - data: RTVIAppendToContextData - - -class RTVILLMFunctionCallStartMessageData(BaseModel): - """Data for LLM function call start notification. - - Contains the function name being called. - """ - - function_name: str - - -class RTVILLMFunctionCallStartMessage(BaseModel): - """Message notifying that an LLM function call has started. - - Sent when the LLM begins a function call. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["llm-function-call-start"] = "llm-function-call-start" - data: RTVILLMFunctionCallStartMessageData - - -class RTVILLMFunctionCallResultData(BaseModel): - """Data for LLM function call result. - - Contains function call details and result. - """ - - function_name: str - tool_call_id: str - arguments: dict - result: dict | str - - -class RTVIBotLLMStartedMessage(BaseModel): - """Message indicating bot LLM processing has started.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-llm-started"] = "bot-llm-started" - - -class RTVIBotLLMStoppedMessage(BaseModel): - """Message indicating bot LLM processing has stopped.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-llm-stopped"] = "bot-llm-stopped" - - -class RTVIBotTTSStartedMessage(BaseModel): - """Message indicating bot TTS processing has started.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-tts-started"] = "bot-tts-started" - - -class RTVIBotTTSStoppedMessage(BaseModel): - """Message indicating bot TTS processing has stopped.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-tts-stopped"] = "bot-tts-stopped" - - -class RTVITextMessageData(BaseModel): - """Data for text-based RTVI messages. - - Contains text content. - """ - - text: str - - -class RTVIBotOutputMessageData(RTVITextMessageData): - """Data for bot output RTVI messages. - - Extends RTVITextMessageData to include metadata about the output. - """ - - spoken: bool = False # Indicates if the text has been spoken by TTS - aggregated_by: AggregationType | str - # Indicates what form the text is in (e.g., by word, sentence, etc.) - - -class RTVIBotOutputMessage(BaseModel): - """Message containing bot output text. - - An event meant to holistically represent what the bot is outputting, - along with metadata about the output and if it has been spoken. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-output"] = "bot-output" - data: RTVIBotOutputMessageData - - -class RTVIBotTranscriptionMessage(BaseModel): - """Message containing bot transcription text. - - Sent when the bot's speech is transcribed. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-transcription"] = "bot-transcription" - data: RTVITextMessageData - - -class RTVIBotLLMTextMessage(BaseModel): - """Message containing bot LLM text output. - - Sent when the bot's LLM generates text. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-llm-text"] = "bot-llm-text" - data: RTVITextMessageData - - -class RTVIBotTTSTextMessage(BaseModel): - """Message containing bot TTS text output. - - Sent when text is being processed by TTS. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-tts-text"] = "bot-tts-text" - data: RTVITextMessageData - - -class RTVIAudioMessageData(BaseModel): - """Data for audio-based RTVI messages. - - Contains audio data and metadata. - """ - - audio: str - sample_rate: int - num_channels: int - - -class RTVIBotTTSAudioMessage(BaseModel): - """Message containing bot TTS audio output. - - Sent when the bot's TTS generates audio. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-tts-audio"] = "bot-tts-audio" - data: RTVIAudioMessageData - - -class RTVIUserTranscriptionMessageData(BaseModel): - """Data for user transcription messages. - - Contains transcription text and metadata. - """ - - text: str - user_id: str - timestamp: str - final: bool - - -class RTVIUserTranscriptionMessage(BaseModel): - """Message containing user transcription. - - Sent when user speech is transcribed. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["user-transcription"] = "user-transcription" - data: RTVIUserTranscriptionMessageData - - -class RTVIUserLLMTextMessage(BaseModel): - """Message containing user text input for LLM. - - Sent when user text is processed by the LLM. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["user-llm-text"] = "user-llm-text" - data: RTVITextMessageData - - -class RTVIUserStartedSpeakingMessage(BaseModel): - """Message indicating user has started speaking.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["user-started-speaking"] = "user-started-speaking" - - -class RTVIUserStoppedSpeakingMessage(BaseModel): - """Message indicating user has stopped speaking.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["user-stopped-speaking"] = "user-stopped-speaking" - - -class RTVIBotStartedSpeakingMessage(BaseModel): - """Message indicating bot has started speaking.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-started-speaking"] = "bot-started-speaking" - - -class RTVIBotStoppedSpeakingMessage(BaseModel): - """Message indicating bot has stopped speaking.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-stopped-speaking"] = "bot-stopped-speaking" - - -class RTVIMetricsMessage(BaseModel): - """Message containing performance metrics. - - Sent to provide performance and usage metrics. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["metrics"] = "metrics" - data: Mapping[str, Any] - - -class RTVIServerMessage(BaseModel): - """Generic server message. - - Used for custom server-to-client messages. - """ - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["server-message"] = "server-message" - data: Any - - -class RTVIAudioLevelMessageData(BaseModel): - """Data format for sending audio levels.""" - - value: float - - -class RTVIUserAudioLevelMessage(BaseModel): - """Message indicating user audio level.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["user-audio-level"] = "user-audio-level" - data: RTVIAudioLevelMessageData - - -class RTVIBotAudioLevelMessage(BaseModel): - """Message indicating bot audio level.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["bot-audio-level"] = "bot-audio-level" - data: RTVIAudioLevelMessageData - - -class RTVISystemLogMessage(BaseModel): - """Message including a system log.""" - - label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL - type: Literal["system-log"] = "system-log" - data: RTVITextMessageData - - -@dataclass -class RTVIServerMessageFrame(SystemFrame): - """A frame for sending server messages to the client. - - Parameters: - data: The message data to send to the client. - """ - - data: Any - - def __str__(self): - """String representation of the RTVI server message frame.""" - return f"{self.name}(data: {self.data})" - - -@dataclass -class RTVIObserverParams: - """Parameters for configuring RTVI Observer behavior. - - .. deprecated:: 0.0.87 - Parameter `errors_enabled` is deprecated. Error messages are always enabled. - - Parameters: - bot_output_enabled: Indicates if bot output messages should be sent. - bot_llm_enabled: Indicates if the bot's LLM messages should be sent. - bot_tts_enabled: Indicates if the bot's TTS messages should be sent. - bot_speaking_enabled: Indicates if the bot's started/stopped speaking messages should be sent. - bot_audio_level_enabled: Indicates if bot's audio level messages should be sent. - user_llm_enabled: Indicates if the user's LLM input messages should be sent. - user_speaking_enabled: Indicates if the user's started/stopped speaking messages should be sent. - user_transcription_enabled: Indicates if user's transcription messages should be sent. - user_audio_level_enabled: Indicates if user's audio level messages should be sent. - 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. - 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. - bot_output_transforms: A list of callables to transform text before just before sending it - to TTS. Each callable takes the aggregated text and its type, and returns the - transformed text. To register, provide a list of tuples of - (aggregation_type | '*', transform_function). - audio_level_period_secs: How often audio levels should be sent if enabled. - """ - - bot_output_enabled: bool = True - bot_llm_enabled: bool = True - bot_tts_enabled: bool = True - bot_speaking_enabled: bool = True - bot_audio_level_enabled: bool = False - user_llm_enabled: bool = True - user_speaking_enabled: bool = True - user_transcription_enabled: bool = True - user_audio_level_enabled: bool = False - metrics_enabled: bool = True - system_logs_enabled: bool = False - errors_enabled: Optional[bool] = None - skip_aggregator_types: Optional[List[AggregationType | str]] = None - bot_output_transforms: Optional[ - List[ - Tuple[ - AggregationType | str, - Callable[[str, AggregationType | str], Awaitable[str]], - ] - ] - ] = None - audio_level_period_secs: float = 0.15 - - -class RTVIObserver(BaseObserver): - """Pipeline frame observer for RTVI server message handling. - - This observer monitors pipeline frames and converts them into appropriate RTVI messages - for client communication. It handles various frame types including speech events, - transcriptions, LLM responses, and TTS events. - - Note: - This observer only handles outgoing messages. Incoming RTVI client messages - are handled by the RTVIProcessor. - """ - - def __init__( - self, - rtvi: Optional["RTVIProcessor"] = None, - *, - params: Optional[RTVIObserverParams] = None, - **kwargs, - ): - """Initialize the RTVI observer. - - Args: - rtvi: The RTVI processor to push frames to. - params: Settings to enable/disable specific messages. - **kwargs: Additional arguments passed to parent class. - """ - super().__init__(**kwargs) - self._rtvi = rtvi - self._params = params or RTVIObserverParams() - - self._frames_seen = set() - - self._bot_transcription = "" - self._last_user_audio_level = 0 - self._last_bot_audio_level = 0 - - if self._params.system_logs_enabled: - self._system_logger_id = logger.add(self._logger_sink) - - if self._params.errors_enabled is not None: - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "Parameter `errors_enabled` is deprecated. Error messages are always enabled.", - DeprecationWarning, - ) - - self._aggregation_transforms: List[ - Tuple[AggregationType | str, Callable[[str, AggregationType | str], Awaitable[str]]] - ] = self._params.bot_output_transforms or [] - - def add_bot_output_transformer( - self, - transform_function: Callable[[str, AggregationType | str], Awaitable[str]], - aggregation_type: AggregationType | str = "*", - ): - """Transform text for a specific aggregation type before sending as Bot Output or TTS. - - Args: - transform_function: The function to apply for transformation. This function should take - the text and aggregation type as input and return the transformed text. - Ex.: async def my_transform(text: str, aggregation_type: str) -> str: - aggregation_type: The type of aggregation to transform. This value defaults to "*" to - handle all text before sending to the client. - """ - self._aggregation_transforms.append((aggregation_type, transform_function)) - - def remove_bot_output_transformer( - self, - transform_function: Callable[[str, AggregationType | str], Awaitable[str]], - aggregation_type: AggregationType | str = "*", - ): - """Remove a text transformer for a specific aggregation type. - - Args: - transform_function: The function to remove. - aggregation_type: The type of aggregation to remove the transformer for. - """ - self._aggregation_transforms = [ - (agg_type, func) - for agg_type, func in self._aggregation_transforms - if not (agg_type == aggregation_type and func == transform_function) - ] - - async def _logger_sink(self, message): - """Logger sink so we can send system logs to RTVI clients.""" - message = RTVISystemLogMessage(data=RTVITextMessageData(text=message)) - await self.send_rtvi_message(message) - - async def cleanup(self): - """Cleanup RTVI observer resources.""" - await super().cleanup() - if self._params.system_logs_enabled: - logger.remove(self._system_logger_id) - - async def send_rtvi_message(self, model: BaseModel, exclude_none: bool = True): - """Send an RTVI message. - - By default, we push a transport frame. But this function can be - overriden by subclass to send RTVI messages in different ways. - - Args: - model: The message to send. - exclude_none: Whether to exclude None values from the model dump. - - """ - if self._rtvi: - await self._rtvi.push_transport_message(model, exclude_none) - - async def on_push_frame(self, data: FramePushed): - """Process a frame being pushed through the pipeline. - - Args: - data: Frame push event data containing source, frame, direction, and timestamp. - """ - src = data.source - frame = data.frame - direction = data.direction - - # If we have already seen this frame, let's skip it. - if frame.id in self._frames_seen: - return - - # This tells whether the frame is already processed. If false, we will try - # again the next time we see the frame. - mark_as_seen = True - - if ( - isinstance(frame, (UserStartedSpeakingFrame, UserStoppedSpeakingFrame)) - and (direction == FrameDirection.DOWNSTREAM) - and self._params.user_speaking_enabled - ): - await self._handle_interruptions(frame) - elif ( - isinstance(frame, (BotStartedSpeakingFrame, BotStoppedSpeakingFrame)) - and (direction == FrameDirection.UPSTREAM) - and self._params.bot_speaking_enabled - ): - await self._handle_bot_speaking(frame) - elif ( - isinstance(frame, (TranscriptionFrame, InterimTranscriptionFrame)) - and self._params.user_transcription_enabled - ): - await self._handle_user_transcriptions(frame) - elif ( - isinstance(frame, (OpenAILLMContextFrame, LLMContextFrame)) - and self._params.user_llm_enabled - ): - await self._handle_context(frame) - elif isinstance(frame, LLMFullResponseStartFrame) and self._params.bot_llm_enabled: - await self.send_rtvi_message(RTVIBotLLMStartedMessage()) - elif isinstance(frame, LLMFullResponseEndFrame) and self._params.bot_llm_enabled: - await self.send_rtvi_message(RTVIBotLLMStoppedMessage()) - elif isinstance(frame, LLMTextFrame) and self._params.bot_llm_enabled: - await self._handle_llm_text_frame(frame) - elif isinstance(frame, TTSStartedFrame) and self._params.bot_tts_enabled: - await self.send_rtvi_message(RTVIBotTTSStartedMessage()) - elif isinstance(frame, TTSStoppedFrame) and self._params.bot_tts_enabled: - await self.send_rtvi_message(RTVIBotTTSStoppedMessage()) - elif isinstance(frame, AggregatedTextFrame) and ( - self._params.bot_output_enabled or self._params.bot_tts_enabled - ): - if isinstance(frame, TTSTextFrame) and not isinstance(src, BaseOutputTransport): - # This check is to make sure we handle the frame when it has gone - # through the transport and has correct timing. - mark_as_seen = False - else: - await self._handle_aggregated_llm_text(frame) - elif isinstance(frame, MetricsFrame) and self._params.metrics_enabled: - await self._handle_metrics(frame) - elif isinstance(frame, RTVIServerMessageFrame): - message = RTVIServerMessage(data=frame.data) - await self.send_rtvi_message(message) - elif isinstance(frame, RTVIServerResponseFrame): - if frame.error is not None: - await self._send_error_response(frame) - else: - await self._send_server_response(frame) - elif isinstance(frame, InputAudioRawFrame) and self._params.user_audio_level_enabled: - curr_time = time.time() - diff_time = curr_time - self._last_user_audio_level - if diff_time > self._params.audio_level_period_secs: - level = calculate_audio_volume(frame.audio, frame.sample_rate) - message = RTVIUserAudioLevelMessage(data=RTVIAudioLevelMessageData(value=level)) - await self.send_rtvi_message(message) - self._last_user_audio_level = curr_time - elif isinstance(frame, TTSAudioRawFrame) and self._params.bot_audio_level_enabled: - curr_time = time.time() - diff_time = curr_time - self._last_bot_audio_level - if diff_time > self._params.audio_level_period_secs: - level = calculate_audio_volume(frame.audio, frame.sample_rate) - message = RTVIBotAudioLevelMessage(data=RTVIAudioLevelMessageData(value=level)) - await self.send_rtvi_message(message) - self._last_bot_audio_level = curr_time - - if mark_as_seen: - self._frames_seen.add(frame.id) - - async def _handle_interruptions(self, frame: Frame): - """Handle user speaking interruption frames.""" - message = None - if isinstance(frame, UserStartedSpeakingFrame): - message = RTVIUserStartedSpeakingMessage() - elif isinstance(frame, UserStoppedSpeakingFrame): - message = RTVIUserStoppedSpeakingMessage() - - if message: - await self.send_rtvi_message(message) - - async def _handle_bot_speaking(self, frame: Frame): - """Handle bot speaking event frames.""" - message = None - if isinstance(frame, BotStartedSpeakingFrame): - message = RTVIBotStartedSpeakingMessage() - elif isinstance(frame, BotStoppedSpeakingFrame): - message = RTVIBotStoppedSpeakingMessage() - - if message: - await self.send_rtvi_message(message) - - async def _handle_aggregated_llm_text(self, frame: AggregatedTextFrame): - """Handle aggregated LLM text output frames.""" - # Skip certain aggregator types if configured to do so. - if ( - self._params.skip_aggregator_types - and frame.aggregated_by in self._params.skip_aggregator_types - ): - return - - text = frame.text - type = frame.aggregated_by - for aggregation_type, transform in self._aggregation_transforms: - if aggregation_type == type or aggregation_type == "*": - text = await transform(text, type) - - isTTS = isinstance(frame, TTSTextFrame) - if self._params.bot_output_enabled: - message = RTVIBotOutputMessage( - data=RTVIBotOutputMessageData(text=text, spoken=isTTS, aggregated_by=type) - ) - await self.send_rtvi_message(message) - - if isTTS and self._params.bot_tts_enabled: - tts_message = RTVIBotTTSTextMessage(data=RTVITextMessageData(text=text)) - await self.send_rtvi_message(tts_message) - - async def _handle_llm_text_frame(self, frame: LLMTextFrame): - """Handle LLM text output frames.""" - message = RTVIBotLLMTextMessage(data=RTVITextMessageData(text=frame.text)) - await self.send_rtvi_message(message) - - # TODO (mrkb): Remove all this logic when we fully deprecate bot-transcription messages. - self._bot_transcription += frame.text - - if match_endofsentence(self._bot_transcription) and len(self._bot_transcription) > 0: - await self.send_rtvi_message( - RTVIBotTranscriptionMessage(data=RTVITextMessageData(text=self._bot_transcription)) - ) - self._bot_transcription = "" - - async def _handle_user_transcriptions(self, frame: Frame): - """Handle user transcription frames.""" - message = None - if isinstance(frame, TranscriptionFrame): - message = RTVIUserTranscriptionMessage( - data=RTVIUserTranscriptionMessageData( - text=frame.text, user_id=frame.user_id, timestamp=frame.timestamp, final=True - ) - ) - elif isinstance(frame, InterimTranscriptionFrame): - message = RTVIUserTranscriptionMessage( - data=RTVIUserTranscriptionMessageData( - text=frame.text, user_id=frame.user_id, timestamp=frame.timestamp, final=False - ) - ) - - if message: - await self.send_rtvi_message(message) - - async def _handle_context(self, frame: OpenAILLMContextFrame | LLMContextFrame): - """Process LLM context frames to extract user messages for the RTVI client.""" - try: - if isinstance(frame, OpenAILLMContextFrame): - messages = frame.context.messages - else: - messages = frame.context.get_messages() - if not messages: - return - - message = messages[-1] - - # Handle Google LLM format (protobuf objects with attributes) - # Note: not possible if frame is a universal LLMContextFrame - if hasattr(message, "role") and message.role == "user" and hasattr(message, "parts"): - text = "".join(part.text for part in message.parts if hasattr(part, "text")) - if text: - rtvi_message = RTVIUserLLMTextMessage(data=RTVITextMessageData(text=text)) - await self.send_rtvi_message(rtvi_message) - - # Handle OpenAI format (original implementation) - elif isinstance(message, dict): - if message["role"] == "user": - content = message["content"] - if isinstance(content, list): - text = " ".join(item["text"] for item in content if "text" in item) - else: - text = content - rtvi_message = RTVIUserLLMTextMessage(data=RTVITextMessageData(text=text)) - await self.send_rtvi_message(rtvi_message) - - except Exception as e: - logger.warning(f"Caught an error while trying to handle context: {e}") - - async def _handle_metrics(self, frame: MetricsFrame): - """Handle metrics frames and convert to RTVI metrics messages.""" - metrics = {} - for d in frame.data: - if isinstance(d, TTFBMetricsData): - if "ttfb" not in metrics: - metrics["ttfb"] = [] - metrics["ttfb"].append(d.model_dump(exclude_none=True)) - elif isinstance(d, ProcessingMetricsData): - if "processing" not in metrics: - metrics["processing"] = [] - metrics["processing"].append(d.model_dump(exclude_none=True)) - elif isinstance(d, LLMUsageMetricsData): - if "tokens" not in metrics: - metrics["tokens"] = [] - metrics["tokens"].append(d.value.model_dump(exclude_none=True)) - elif isinstance(d, TTSUsageMetricsData): - if "characters" not in metrics: - metrics["characters"] = [] - metrics["characters"].append(d.model_dump(exclude_none=True)) - - message = RTVIMetricsMessage(data=metrics) - await self.send_rtvi_message(message) - - async def _send_server_response(self, frame: RTVIServerResponseFrame): - """Send a response to the client for a specific request.""" - message = RTVIServerResponse( - id=str(frame.client_msg.msg_id), - data=RTVIRawServerResponseData(t=frame.client_msg.type, d=frame.data), - ) - await self.send_rtvi_message(message) - - async def _send_error_response(self, frame: RTVIServerResponseFrame): - """Send a response to the client for a specific request.""" - message = RTVIErrorResponse( - id=str(frame.client_msg.msg_id), data=RTVIErrorResponseData(error=frame.error) - ) - await self.send_rtvi_message(message) - - -class RTVIProcessor(FrameProcessor): - """Main processor for handling RTVI protocol messages and actions. - - This processor manages the RTVI protocol communication including client-server - handshaking, configuration management, action execution, and message routing. - It serves as the central hub for RTVI protocol operations. - """ - - def __init__( - self, - *, - config: Optional[RTVIConfig] = None, - transport: Optional[BaseTransport] = None, - **kwargs, - ): - """Initialize the RTVI processor. - - Args: - config: Initial RTVI configuration. - transport: Transport layer for communication. - **kwargs: Additional arguments passed to parent class. - """ - super().__init__(**kwargs) - self._config = config or RTVIConfig(config=[]) - - self._bot_ready = False - self._client_ready = False - self._client_ready_id = "" - # Default to 0.3.0 which is the last version before actually having a - # "client-version". - self._client_version = [0, 3, 0] - self._llm_skip_tts: bool = False # Keep in sync with llm_service.py's configuration. - - self._registered_actions: Dict[str, RTVIAction] = {} - self._registered_services: Dict[str, RTVIService] = {} - - # A task to process incoming action frames. - self._action_task: Optional[asyncio.Task] = None - - # A task to process incoming transport messages. - self._message_task: Optional[asyncio.Task] = None - - self._register_event_handler("on_bot_started") - self._register_event_handler("on_client_ready") - self._register_event_handler("on_client_message") - - self._input_transport = None - self._transport = transport - if self._transport: - input_transport = self._transport.input() - if isinstance(input_transport, BaseInputTransport): - self._input_transport = input_transport - self._input_transport.enable_audio_in_stream_on_start(False) - - def register_action(self, action: RTVIAction): - """Register an action that can be executed via RTVI. - - Args: - action: The action to register. - """ - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "The actions API is deprecated, use server and client messages instead.", - DeprecationWarning, - ) - - id = self._action_id(action.service, action.action) - self._registered_actions[id] = action - - def register_service(self, service: RTVIService): - """Register a service that can be configured via RTVI. - - Args: - service: The service to register. - """ - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "The actions API is deprecated, use server and client messages instead.", - DeprecationWarning, - ) - - self._registered_services[service.name] = service - - async def set_client_ready(self): - """Mark the client as ready and trigger the ready event.""" - self._client_ready = True - await self._call_event_handler("on_client_ready") - - async def set_bot_ready(self, about: Mapping[str, Any] = None): - """Mark the bot as ready and send the bot-ready message. - - Args: - about: Optional information about the bot to include in the ready message. - If left as None, the Pipecat library and version will be used. - """ - self._bot_ready = True - # Only call the (deprecated) _update_config method if the we're using a - # config (which is deprecated). Otherwise we'd always print an - # unnecessary deprecation warning. - if self._config.config: - await self._update_config(self._config, False) - await self._send_bot_ready(about=about) - - async def interrupt_bot(self): - """Send a bot interruption frame upstream.""" - await self.push_interruption_task_frame_and_wait() - - async def send_server_message(self, data: Any): - """Send a server message to the client.""" - message = RTVIServerMessage(data=data) - await self._send_server_message(message) - - async def send_server_response(self, client_msg: RTVIClientMessage, data: Any): - """Send a server response for a given client message.""" - message = RTVIServerResponse( - id=client_msg.msg_id, data=RTVIRawServerResponseData(t=client_msg.type, d=data) - ) - await self._send_server_message(message) - - async def send_error_response(self, client_msg: RTVIClientMessage, error: str): - """Send an error response for a given client message.""" - await self._send_error_response(id=client_msg.msg_id, error=error) - - async def send_error(self, error: str): - """Send an error message to the client. - - Args: - error: The error message to send. - """ - await self._send_error_frame(ErrorFrame(error=error)) - - async def push_transport_message(self, model: BaseModel, exclude_none: bool = True): - """Push a transport message frame.""" - frame = OutputTransportMessageUrgentFrame( - message=model.model_dump(exclude_none=exclude_none) - ) - await self.push_frame(frame) - - async def handle_message(self, message: RTVIMessage): - """Handle an incoming RTVI message. - - Args: - message: The RTVI message to handle. - """ - await self._message_queue.put(message) - - async def handle_function_call(self, params: FunctionCallParams): - """Handle a function call from the LLM. - - Args: - params: The function call parameters. - """ - fn = RTVILLMFunctionCallMessageData( - function_name=params.function_name, - tool_call_id=params.tool_call_id, - args=params.arguments, - ) - message = RTVILLMFunctionCallMessage(data=fn) - await self.push_transport_message(message, exclude_none=False) - - async def handle_function_call_start( - self, function_name: str, llm: FrameProcessor, context: OpenAILLMContext - ): - """Handle the start of a function call from the LLM. - - .. deprecated:: 0.0.66 - This method is deprecated and will be removed in a future version. - Use `RTVIProcessor.handle_function_call()` instead. - - Args: - function_name: Name of the function being called. - llm: The LLM processor making the call. - context: The LLM context. - """ - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "Function `RTVIProcessor.handle_function_call_start()` is deprecated, use `RTVIProcessor.handle_function_call()` instead.", - DeprecationWarning, - ) - - fn = RTVILLMFunctionCallStartMessageData(function_name=function_name) - message = RTVILLMFunctionCallStartMessage(data=fn) - await self.push_transport_message(message, exclude_none=False) - - async def process_frame(self, frame: Frame, direction: FrameDirection): - """Process incoming frames through the RTVI processor. - - Args: - frame: The frame to process. - direction: The direction of frame flow. - """ - await super().process_frame(frame, direction) - - # Specific system frames - if isinstance(frame, StartFrame): - # Push StartFrame before start(), because we want StartFrame to be - # processed by every processor before any other frame is processed. - await self.push_frame(frame, direction) - await self._start(frame) - elif isinstance(frame, CancelFrame): - await self._cancel(frame) - await self.push_frame(frame, direction) - elif isinstance(frame, ErrorFrame): - await self._send_error_frame(frame) - await self.push_frame(frame, direction) - elif isinstance(frame, InputTransportMessageFrame): - await self._handle_transport_message(frame) - # All other system frames - elif isinstance(frame, SystemFrame): - await self.push_frame(frame, direction) - # Control frames - elif isinstance(frame, EndFrame): - # Push EndFrame before stop(), because stop() waits on the task to - # finish and the task finishes when EndFrame is processed. - await self.push_frame(frame, direction) - await self._stop(frame) - # Data frames - elif isinstance(frame, RTVIActionFrame): - await self._action_queue.put(frame) - elif isinstance(frame, LLMConfigureOutputFrame): - self._llm_skip_tts = frame.skip_tts - await self.push_frame(frame, direction) - # Other frames - else: - await self.push_frame(frame, direction) - - async def _start(self, frame: StartFrame): - """Start the RTVI processor tasks.""" - if not self._action_task: - self._action_queue = asyncio.Queue() - self._action_task = self.create_task(self._action_task_handler()) - if not self._message_task: - self._message_queue = asyncio.Queue() - self._message_task = self.create_task(self._message_task_handler()) - await self._call_event_handler("on_bot_started") - - async def _stop(self, frame: EndFrame): - """Stop the RTVI processor tasks.""" - await self._cancel_tasks() - - async def _cancel(self, frame: CancelFrame): - """Cancel the RTVI processor tasks.""" - await self._cancel_tasks() - - async def _cancel_tasks(self): - """Cancel all running tasks.""" - if self._action_task: - await self.cancel_task(self._action_task) - self._action_task = None - - if self._message_task: - await self.cancel_task(self._message_task) - self._message_task = None - - async def _action_task_handler(self): - """Handle incoming action frames.""" - while True: - frame = await self._action_queue.get() - await self._handle_action(frame.message_id, frame.rtvi_action_run) - self._action_queue.task_done() - - async def _message_task_handler(self): - """Handle incoming transport messages.""" - while True: - message = await self._message_queue.get() - await self._handle_message(message) - self._message_queue.task_done() - - async def _handle_transport_message(self, frame: InputTransportMessageFrame): - """Handle an incoming transport message frame.""" - try: - transport_message = frame.message - if transport_message.get("label") != RTVI_MESSAGE_LABEL: - logger.warning(f"Ignoring not RTVI message: {transport_message}") - return - message = RTVIMessage.model_validate(transport_message) - await self._message_queue.put(message) - except ValidationError as e: - await self.send_error(f"Invalid RTVI transport message: {e}") - logger.warning(f"Invalid RTVI transport message: {e}") - - async def _handle_message(self, message: RTVIMessage): - """Handle a parsed RTVI message.""" - try: - match message.type: - case "client-ready": - data = None - try: - data = RTVIClientReadyData.model_validate(message.data) - except ValidationError: - # Not all clients have been updated to RTVI 1.0.0. - # For now, that's okay, we just log their info as unknown. - data = None - pass - await self._handle_client_ready(message.id, data) - case "describe-actions": - await self._handle_describe_actions(message.id) - case "describe-config": - await self._handle_describe_config(message.id) - case "get-config": - await self._handle_get_config(message.id) - case "update-config": - update_config = RTVIUpdateConfig.model_validate(message.data) - await self._handle_update_config(message.id, update_config) - case "disconnect-bot": - await self.push_frame(EndTaskFrame(), FrameDirection.UPSTREAM) - case "client-message": - data = RTVIRawClientMessageData.model_validate(message.data) - await self._handle_client_message(message.id, data) - case "action": - action = RTVIActionRun.model_validate(message.data) - action_frame = RTVIActionFrame(message_id=message.id, rtvi_action_run=action) - await self._action_queue.put(action_frame) - case "llm-function-call-result": - data = RTVILLMFunctionCallResultData.model_validate(message.data) - await self._handle_function_call_result(data) - case "send-text": - data = RTVISendTextData.model_validate(message.data) - await self._handle_send_text(data) - case "append-to-context": - logger.warning( - f"The append-to-context message is deprecated, use send-text instead." - ) - data = RTVIAppendToContextData.model_validate(message.data) - await self._handle_update_context(data) - case "raw-audio" | "raw-audio-batch": - await self._handle_audio_buffer(message.data) - - case _: - await self._send_error_response(message.id, f"Unsupported type {message.type}") - - except ValidationError as e: - await self._send_error_response(message.id, f"Invalid message: {e}") - logger.warning(f"Invalid message: {e}") - except Exception as e: - await self._send_error_response(message.id, f"Exception processing message: {e}") - logger.warning(f"Exception processing message: {e}") - - async def _handle_client_ready(self, request_id: str, data: RTVIClientReadyData | None): - """Handle the client-ready message from the client.""" - version = data.version if data else None - logger.debug(f"Received client-ready: version {version}") - if version: - try: - self._client_version = [int(v) for v in version.split(".")] - except ValueError: - logger.warning(f"Invalid client version format: {version}") - about = data.about if data else {"library": "unknown"} - logger.debug(f"Client Details: {about}") - if self._input_transport: - await self._input_transport.start_audio_in_streaming() - - self._client_ready_id = request_id - await self.set_client_ready() - - async def _handle_audio_buffer(self, data): - """Handle incoming audio buffer data.""" - if not self._input_transport: - return - - # Extract audio batch ensuring it's a list - audio_list = data.get("base64AudioBatch") or [data.get("base64Audio")] - - try: - for base64_audio in filter(None, audio_list): # Filter out None values - pcm_bytes = base64.b64decode(base64_audio) - frame = InputAudioRawFrame( - audio=pcm_bytes, - sample_rate=data["sampleRate"], - num_channels=data["numChannels"], - ) - await self._input_transport.push_audio_frame(frame) - - except (KeyError, TypeError, ValueError) as e: - # Handle missing keys, decoding errors, and invalid types - logger.error(f"Error processing audio buffer: {e}") - - async def _handle_describe_config(self, request_id: str): - """Handle a describe-config request.""" - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.", - DeprecationWarning, - ) - - services = list(self._registered_services.values()) - message = RTVIDescribeConfig(id=request_id, data=RTVIDescribeConfigData(config=services)) - await self.push_transport_message(message) - - async def _handle_describe_actions(self, request_id: str): - """Handle a describe-actions request.""" - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "The Actions API is deprecated, use custom server and client messages instead.", - DeprecationWarning, - ) - - actions = list(self._registered_actions.values()) - message = RTVIDescribeActions(id=request_id, data=RTVIDescribeActionsData(actions=actions)) - await self.push_transport_message(message) - - async def _handle_get_config(self, request_id: str): - """Handle a get-config request.""" - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.", - DeprecationWarning, - ) - - message = RTVIConfigResponse(id=request_id, data=self._config) - await self.push_transport_message(message) - - def _update_config_option(self, service: str, config: RTVIServiceOptionConfig): - """Update a specific configuration option.""" - for service_config in self._config.config: - if service_config.service == service: - for option_config in service_config.options: - if option_config.name == config.name: - option_config.value = config.value - return - # If we couldn't find a value for this config, we simply need to - # add it. - service_config.options.append(config) - - async def _update_service_config(self, config: RTVIServiceConfig): - """Update configuration for a specific service.""" - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.", - DeprecationWarning, - ) - - service = self._registered_services[config.service] - for option in config.options: - handler = service._options_dict[option.name].handler - await handler(self, service.name, option) - self._update_config_option(service.name, option) - - async def _update_config(self, data: RTVIConfig, interrupt: bool): - """Update the RTVI configuration.""" - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.", - DeprecationWarning, - ) - - if interrupt: - await self.interrupt_bot() - for service_config in data.config: - await self._update_service_config(service_config) - - async def _handle_update_config(self, request_id: str, data: RTVIUpdateConfig): - """Handle an update-config request.""" - await self._update_config(RTVIConfig(config=data.config), data.interrupt) - await self._handle_get_config(request_id) - - async def _handle_send_text(self, data: RTVISendTextData): - """Handle a send-text message from the client.""" - opts = data.options if data.options is not None else RTVISendTextOptions() - if opts.run_immediately: - await self.interrupt_bot() - cur_llm_skip_tts = self._llm_skip_tts - should_skip_tts = not opts.audio_response - toggle_skip_tts = cur_llm_skip_tts != should_skip_tts - if toggle_skip_tts: - output_frame = LLMConfigureOutputFrame(skip_tts=should_skip_tts) - await self.push_frame(output_frame) - text_frame = LLMMessagesAppendFrame( - messages=[{"role": "user", "content": data.content}], - run_llm=opts.run_immediately, - ) - await self.push_frame(text_frame) - if toggle_skip_tts: - output_frame = LLMConfigureOutputFrame(skip_tts=cur_llm_skip_tts) - await self.push_frame(output_frame) - - async def _handle_update_context(self, data: RTVIAppendToContextData): - if data.run_immediately: - await self.interrupt_bot() - frame = LLMMessagesAppendFrame( - messages=[{"role": data.role, "content": data.content}], - run_llm=data.run_immediately, - ) - await self.push_frame(frame) - - async def _handle_client_message(self, msg_id: str, data: RTVIRawClientMessageData): - """Handle a client message frame.""" - if not data: - await self._send_error_response(msg_id, "Malformed client message") - return - - # Create a RTVIClientMessageFrame to push the message - frame = RTVIClientMessageFrame(msg_id=msg_id, type=data.t, data=data.d) - await self.push_frame(frame) - await self._call_event_handler( - "on_client_message", - RTVIClientMessage( - msg_id=msg_id, - type=data.t, - data=data.d, - ), - ) - - async def _handle_function_call_result(self, data): - """Handle a function call result from the client.""" - frame = FunctionCallResultFrame( - function_name=data.function_name, - tool_call_id=data.tool_call_id, - arguments=data.arguments, - result=data.result, - ) - await self.push_frame(frame) - - async def _handle_action(self, request_id: Optional[str], data: RTVIActionRun): - """Handle an action execution request.""" - action_id = self._action_id(data.service, data.action) - if action_id not in self._registered_actions: - await self._send_error_response(request_id, f"Action {action_id} not registered") - return - action = self._registered_actions[action_id] - arguments = {} - if data.arguments: - for arg in data.arguments: - arguments[arg.name] = arg.value - result = await action.handler(self, action.service, arguments) - # Only send a response if request_id is present. Things that don't care about - # action responses (such as webhooks) don't set a request_id - if request_id: - message = RTVIActionResponse(id=request_id, data=RTVIActionResponseData(result=result)) - await self.push_transport_message(message) - - async def _send_bot_ready(self, about: Mapping[str, Any] = None): - """Send the bot-ready message to the client. - - Args: - about: Optional information about the bot to include in the ready message. - If left as None, the pipecat library and version will be used. - """ - config = None - if self._client_version and self._client_version[0] < 1: - config = self._config.config - if not about: - about = {"library": "pipecat-ai", "library_version": f"{pipecat_version()}"} - message = RTVIBotReady( - id=self._client_ready_id, - data=RTVIBotReadyData(version=RTVI_PROTOCOL_VERSION, about=about, config=config), - ) - await self.push_transport_message(message) - - async def _send_server_message(self, message: RTVIServerMessage | RTVIServerResponse): - """Send a message or response to the client.""" - await self.push_transport_message(message) - - async def _send_error_frame(self, frame: ErrorFrame): - """Send an error frame as an RTVI error message.""" - message = RTVIError(data=RTVIErrorData(error=frame.error, fatal=frame.fatal)) - await self.push_transport_message(message) - - async def _send_error_response(self, id: str, error: str): - """Send an error response message.""" - message = RTVIErrorResponse(id=id, data=RTVIErrorResponseData(error=error)) - await self.push_transport_message(message) - - def _action_id(self, service: str, action: str) -> str: - """Generate an action ID from service and action names.""" - return f"{service}:{action}" diff --git a/src/pipecat/processors/frameworks/rtvi/__init__.py b/src/pipecat/processors/frameworks/rtvi/__init__.py new file mode 100644 index 000000000..eed90ed09 --- /dev/null +++ b/src/pipecat/processors/frameworks/rtvi/__init__.py @@ -0,0 +1,73 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""RTVI (Real-Time Voice Interface) protocol implementation for Pipecat.""" + +from pipecat.processors.frameworks.rtvi.frames import ( + RTVIActionFrame, + RTVIClientMessageFrame, + RTVIServerMessageFrame, + RTVIServerResponseFrame, +) +from pipecat.processors.frameworks.rtvi.models_deprecated import ( + ActionResult, + RTVIAction, + RTVIActionArgument, + RTVIActionArgumentData, + RTVIActionResponse, + RTVIActionResponseData, + RTVIActionRun, + RTVIActionRunArgument, + RTVIBotReadyDataDeprecated, + RTVIConfig, + RTVIConfigResponse, + RTVIDescribeActions, + RTVIDescribeActionsData, + RTVIDescribeConfig, + RTVIDescribeConfigData, + RTVIService, + RTVIServiceConfig, + RTVIServiceOption, + RTVIServiceOptionConfig, + RTVIUpdateConfig, +) +from pipecat.processors.frameworks.rtvi.observer import ( + RTVIFunctionCallReportLevel, + RTVIObserver, + RTVIObserverParams, +) +from pipecat.processors.frameworks.rtvi.processor import RTVIProcessor + +__all__ = [ + "ActionResult", + "RTVIAction", + "RTVIActionArgument", + "RTVIActionArgumentData", + "RTVIActionFrame", + "RTVIActionResponse", + "RTVIActionResponseData", + "RTVIActionRun", + "RTVIActionRunArgument", + "RTVIBotReadyDataDeprecated", + "RTVIClientMessageFrame", + "RTVIConfig", + "RTVIConfigResponse", + "RTVIDescribeActions", + "RTVIDescribeActionsData", + "RTVIDescribeConfig", + "RTVIDescribeConfigData", + "RTVIFunctionCallReportLevel", + "RTVIObserver", + "RTVIObserverParams", + "RTVIProcessor", + "RTVIServerMessageFrame", + "RTVIServerResponseFrame", + "RTVIService", + "RTVIServiceConfig", + "RTVIServiceOption", + "RTVIServiceOptionConfig", + "RTVIUpdateConfig", +] diff --git a/src/pipecat/processors/frameworks/rtvi/frames.py b/src/pipecat/processors/frameworks/rtvi/frames.py new file mode 100644 index 000000000..6a771f7e4 --- /dev/null +++ b/src/pipecat/processors/frameworks/rtvi/frames.py @@ -0,0 +1,74 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""RTVI pipeline frame definitions.""" + +from dataclasses import dataclass +from typing import Any, Optional + +from pipecat.frames.frames import DataFrame, SystemFrame + + +@dataclass +class RTVIActionFrame(DataFrame): + """Frame containing an RTVI action to execute. + + Parameters: + rtvi_action_run: The action to execute. + message_id: Optional message ID for response correlation. + + .. deprecated:: 0.0.75 + Actions have been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + rtvi_action_run: Any + message_id: Optional[str] = None + + +@dataclass +class RTVIServerMessageFrame(SystemFrame): + """A frame for sending server messages to the client. + + Parameters: + data: The message data to send to the client. + """ + + data: Any + + def __str__(self): + """String representation of the RTVI server message frame.""" + return f"{self.name}(data: {self.data})" + + +@dataclass +class RTVIClientMessageFrame(SystemFrame): + """A frame for sending messages from the client to the RTVI server. + + This frame is meant for custom messaging from the client to the server + and expects a server-response message. + """ + + msg_id: str + type: str + data: Optional[Any] = None + + +@dataclass +class RTVIServerResponseFrame(SystemFrame): + """A frame for responding to a client RTVI message. + + This frame should be sent in response to an RTVIClientMessageFrame + and include the original RTVIClientMessageFrame to ensure the response + is properly attributed to the original request. To respond with an error, + set the `error` field to a string describing the error. This will result + in the client receiving an `error-response` message instead of a + `server-response` message. + """ + + client_msg: RTVIClientMessageFrame + data: Optional[Any] = None + error: Optional[str] = None diff --git a/src/pipecat/processors/frameworks/rtvi/models.py b/src/pipecat/processors/frameworks/rtvi/models.py new file mode 100644 index 000000000..7a9d4e633 --- /dev/null +++ b/src/pipecat/processors/frameworks/rtvi/models.py @@ -0,0 +1,581 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""RTVI protocol v1 message models. + +Contains all RTVI protocol v1 message definitions and data structures. +Import this module under the ``RTVI`` alias to use as a namespace:: + + import pipecat.processors.frameworks.rtvi.models as RTVI + + msg = RTVI.BotReady(id="1", data=RTVI.BotReadyData(version=RTVI.PROTOCOL_VERSION)) +""" + +from typing import ( + Any, + Dict, + Literal, + Mapping, + Optional, +) + +from pydantic import BaseModel + +from pipecat.frames.frames import ( + AggregationType, +) + +# -- Constants -- +PROTOCOL_VERSION = "1.2.0" + +MESSAGE_LABEL = "rtvi-ai" +MessageLiteral = Literal["rtvi-ai"] + +# -- Base Message Structure -- + + +class Message(BaseModel): + """Base RTVI message structure. + + Represents the standard format for RTVI protocol messages. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: str + id: str + data: Optional[Dict[str, Any]] = None + + +# -- Client -> Pipecat messages. + + +class RawClientMessageData(BaseModel): + """Data structure expected from client messages sent to the RTVI server.""" + + t: str + d: Optional[Any] = None + + +class ClientMessage(BaseModel): + """Cleansed data structure for client messages for handling.""" + + msg_id: str + type: str + data: Optional[Any] = None + + +class RawServerResponseData(BaseModel): + """Data structure for server responses to client messages.""" + + t: str + d: Optional[Any] = None + + +class ServerResponse(BaseModel): + """The RTVI-formatted message response from the server to the client. + + This message is used to respond to custom messages sent by the client. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["server-response"] = "server-response" + id: str + data: RawServerResponseData + + +class AboutClientData(BaseModel): + """Data about the RTVI client. + + Contains information about the client, including which RTVI library it + is using, what platform it is on and any additional details, if available. + """ + + library: str + library_version: Optional[str] = None + platform: Optional[str] = None + platform_version: Optional[str] = None + platform_details: Optional[Any] = None + + +class ClientReadyData(BaseModel): + """Data format of client ready messages. + + Contains the RTVI protocol version and client information. + """ + + version: str + about: AboutClientData + + +# -- Pipecat -> Client errors + + +class ErrorResponseData(BaseModel): + """Data for an RTVI error response. + + Contains the error message to send back to the client. + """ + + error: str + + +class ErrorResponse(BaseModel): + """RTVI error response message. + + RTVI formatted error response message for relaying failed client requests. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["error-response"] = "error-response" + id: str + data: ErrorResponseData + + +class ErrorData(BaseModel): + """Data for an RTVI error event. + + Contains error information including whether it's fatal. + """ + + error: str + fatal: bool # Indicates the pipeline has stopped due to this error + + +class Error(BaseModel): + """RTVI error event message. + + RTVI formatted error message for relaying errors in the pipeline. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["error"] = "error" + data: ErrorData + + +# -- Pipecat -> Client responses and messages. + + +class BotReadyData(BaseModel): + """Data for bot ready notification. + + Contains protocol version and initial configuration. + """ + + version: str + about: Optional[Mapping[str, Any]] = None + + +class BotReady(BaseModel): + """Message indicating bot is ready for interaction. + + Sent after bot initialization is complete. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-ready"] = "bot-ready" + id: str + data: BotReadyData + + +class LLMFunctionCallMessageData(BaseModel): + """Data for LLM function call notification. + + Contains function call details including name, ID, and arguments. + + .. deprecated:: 0.0.102 + Use ``LLMFunctionCallInProgressMessageData`` instead. + """ + + function_name: str + tool_call_id: str + args: Mapping[str, Any] + + +class LLMFunctionCallMessage(BaseModel): + """Message notifying of an LLM function call. + + Sent when the LLM makes a function call. + + .. deprecated:: 0.0.102 + Use ``LLMFunctionCallInProgressMessage`` with the + ``llm-function-call-in-progress`` event type instead. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["llm-function-call"] = "llm-function-call" + data: LLMFunctionCallMessageData + + +class SendTextOptions(BaseModel): + """Options for sending text input to the LLM. + + Contains options for how the pipeline should process the text input. + """ + + run_immediately: bool = True + audio_response: bool = True + + +class SendTextData(BaseModel): + """Data format for sending text input to the LLM. + + Contains the text content to send and any options for how the pipeline should process it. + """ + + content: str + options: Optional[SendTextOptions] = None + + +class AppendToContextData(BaseModel): + """Data format for appending messages to the context. + + Contains the role, content, and whether to run the message immediately. + + .. deprecated:: 0.0.85 + The RTVI message, append-to-context, has been deprecated. Use send-text + or custom client and server messages instead. + """ + + role: Literal["user", "assistant"] | str + content: Any + run_immediately: bool = False + + +class AppendToContext(BaseModel): + """RTVI message format to append content to the LLM context. + + .. deprecated:: 0.0.85 + The RTVI message, append-to-context, has been deprecated. Use send-text + or custom client and server messages instead. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["append-to-context"] = "append-to-context" + data: AppendToContextData + + +class LLMFunctionCallStartMessageData(BaseModel): + """Data for LLM function call start notification. + + Contains the function name being called. Fields may be omitted based on + the configured function_call_report_level for security. + """ + + function_name: Optional[str] = None + + +class LLMFunctionCallStartMessage(BaseModel): + """Message notifying that an LLM function call has started. + + Sent when the LLM begins a function call. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["llm-function-call-started"] = "llm-function-call-started" + data: LLMFunctionCallStartMessageData + + +class LLMFunctionCallResultData(BaseModel): + """Data for LLM function call result. + + Contains function call details and result. + """ + + function_name: str + tool_call_id: str + arguments: dict + result: dict | str + + +class LLMFunctionCallInProgressMessageData(BaseModel): + """Data for LLM function call in-progress notification. + + Contains function call details including name, ID, and arguments. + Fields may be omitted based on the configured function_call_report_level for security. + """ + + tool_call_id: str + function_name: Optional[str] = None + arguments: Optional[Mapping[str, Any]] = None + + +class LLMFunctionCallInProgressMessage(BaseModel): + """Message notifying that an LLM function call is in progress. + + Sent when the LLM function call execution begins. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["llm-function-call-in-progress"] = "llm-function-call-in-progress" + data: LLMFunctionCallInProgressMessageData + + +class LLMFunctionCallStoppedMessageData(BaseModel): + """Data for LLM function call stopped notification. + + Contains details about the function call that stopped, including + whether it was cancelled or completed with a result. + Fields may be omitted based on the configured function_call_report_level for security. + """ + + tool_call_id: str + cancelled: bool + function_name: Optional[str] = None + result: Optional[Any] = None + + +class LLMFunctionCallStoppedMessage(BaseModel): + """Message notifying that an LLM function call has stopped. + + Sent when a function call completes (with result) or is cancelled. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["llm-function-call-stopped"] = "llm-function-call-stopped" + data: LLMFunctionCallStoppedMessageData + + +class BotLLMStartedMessage(BaseModel): + """Message indicating bot LLM processing has started.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-llm-started"] = "bot-llm-started" + + +class BotLLMStoppedMessage(BaseModel): + """Message indicating bot LLM processing has stopped.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-llm-stopped"] = "bot-llm-stopped" + + +class BotTTSStartedMessage(BaseModel): + """Message indicating bot TTS processing has started.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-tts-started"] = "bot-tts-started" + + +class BotTTSStoppedMessage(BaseModel): + """Message indicating bot TTS processing has stopped.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-tts-stopped"] = "bot-tts-stopped" + + +class TextMessageData(BaseModel): + """Data for text-based RTVI messages. + + Contains text content. + """ + + text: str + + +class BotOutputMessageData(TextMessageData): + """Data for bot output RTVI messages. + + Extends TextMessageData to include metadata about the output. + """ + + spoken: bool = False # Indicates if the text has been spoken by TTS + aggregated_by: AggregationType | str + # Indicates what form the text is in (e.g., by word, sentence, etc.) + + +class BotOutputMessage(BaseModel): + """Message containing bot output text. + + An event meant to holistically represent what the bot is outputting, + along with metadata about the output and if it has been spoken. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-output"] = "bot-output" + data: BotOutputMessageData + + +class BotTranscriptionMessage(BaseModel): + """Message containing bot transcription text. + + Sent when the bot's speech is transcribed. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-transcription"] = "bot-transcription" + data: TextMessageData + + +class BotLLMTextMessage(BaseModel): + """Message containing bot LLM text output. + + Sent when the bot's LLM generates text. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-llm-text"] = "bot-llm-text" + data: TextMessageData + + +class BotTTSTextMessage(BaseModel): + """Message containing bot TTS text output. + + Sent when text is being processed by TTS. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-tts-text"] = "bot-tts-text" + data: TextMessageData + + +class AudioMessageData(BaseModel): + """Data for audio-based RTVI messages. + + Contains audio data and metadata. + """ + + audio: str + sample_rate: int + num_channels: int + + +class BotTTSAudioMessage(BaseModel): + """Message containing bot TTS audio output. + + Sent when the bot's TTS generates audio. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-tts-audio"] = "bot-tts-audio" + data: AudioMessageData + + +class UserTranscriptionMessageData(BaseModel): + """Data for user transcription messages. + + Contains transcription text and metadata. + """ + + text: str + user_id: str + timestamp: str + final: bool + + +class UserTranscriptionMessage(BaseModel): + """Message containing user transcription. + + Sent when user speech is transcribed. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["user-transcription"] = "user-transcription" + data: UserTranscriptionMessageData + + +class UserLLMTextMessage(BaseModel): + """Message containing user text input for LLM. + + Sent when user text is processed by the LLM. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["user-llm-text"] = "user-llm-text" + data: TextMessageData + + +class UserStartedSpeakingMessage(BaseModel): + """Message indicating user has started speaking.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["user-started-speaking"] = "user-started-speaking" + + +class UserStoppedSpeakingMessage(BaseModel): + """Message indicating user has stopped speaking.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["user-stopped-speaking"] = "user-stopped-speaking" + + +class UserMuteStartedMessage(BaseModel): + """Message indicating user has been muted.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["user-mute-started"] = "user-mute-started" + + +class UserMuteStoppedMessage(BaseModel): + """Message indicating user has been unmuted.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["user-mute-stopped"] = "user-mute-stopped" + + +class BotStartedSpeakingMessage(BaseModel): + """Message indicating bot has started speaking.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-started-speaking"] = "bot-started-speaking" + + +class BotStoppedSpeakingMessage(BaseModel): + """Message indicating bot has stopped speaking.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-stopped-speaking"] = "bot-stopped-speaking" + + +class MetricsMessage(BaseModel): + """Message containing performance metrics. + + Sent to provide performance and usage metrics. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["metrics"] = "metrics" + data: Mapping[str, Any] + + +class ServerMessage(BaseModel): + """Generic server message. + + Used for custom server-to-client messages. + """ + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["server-message"] = "server-message" + data: Any + + +class AudioLevelMessageData(BaseModel): + """Data format for sending audio levels.""" + + value: float + + +class UserAudioLevelMessage(BaseModel): + """Message indicating user audio level.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["user-audio-level"] = "user-audio-level" + data: AudioLevelMessageData + + +class BotAudioLevelMessage(BaseModel): + """Message indicating bot audio level.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["bot-audio-level"] = "bot-audio-level" + data: AudioLevelMessageData + + +class SystemLogMessage(BaseModel): + """Message including a system log.""" + + label: MessageLiteral = MESSAGE_LABEL + type: Literal["system-log"] = "system-log" + data: TextMessageData diff --git a/src/pipecat/processors/frameworks/rtvi/models_deprecated.py b/src/pipecat/processors/frameworks/rtvi/models_deprecated.py new file mode 100644 index 000000000..07c998f6f --- /dev/null +++ b/src/pipecat/processors/frameworks/rtvi/models_deprecated.py @@ -0,0 +1,330 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""RTVI pre-1.0 protocol models (deprecated). + +All classes here are kept for backward compatibility only. Pipeline configuration +and the actions API were removed in RTVI protocol 1.0.0. Use custom client and +server messages instead. +""" + +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + Literal, + Optional, + Union, +) + +from pydantic import BaseModel, Field, PrivateAttr + +import pipecat.processors.frameworks.rtvi.models as RTVI + +ActionResult = Union[bool, int, float, str, list, dict] + + +class RTVIServiceOption(BaseModel): + """Configuration option for an RTVI service. + + Defines a configurable option that can be set for an RTVI service, + including its name, type, and handler function. + + .. deprecated:: 0.0.75 + Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + name: str + type: Literal["bool", "number", "string", "array", "object"] + handler: Callable[..., Awaitable[None]] = Field(exclude=True) + + +class RTVIService(BaseModel): + """An RTVI service definition. + + Represents a service that can be configured and used within the RTVI protocol, + containing a name and list of configurable options. + + .. deprecated:: 0.0.75 + Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + name: str + options: List[RTVIServiceOption] + _options_dict: Dict[str, RTVIServiceOption] = PrivateAttr(default={}) + + def model_post_init(self, __context: Any) -> None: + """Initialize the options dictionary after model creation.""" + self._options_dict = {} + for option in self.options: + self._options_dict[option.name] = option + return super().model_post_init(__context) + + +class RTVIActionArgumentData(BaseModel): + """Data for an RTVI action argument. + + Contains the name and value of an argument passed to an RTVI action. + + .. deprecated:: 0.0.75 + Actions have been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + name: str + value: Any + + +class RTVIActionArgument(BaseModel): + """Definition of an RTVI action argument. + + Specifies the name and expected type of an argument for an RTVI action. + + .. deprecated:: 0.0.75 + Actions have been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + name: str + type: Literal["bool", "number", "string", "array", "object"] + + +class RTVIAction(BaseModel): + """An RTVI action definition. + + Represents an action that can be executed within the RTVI protocol, + including its service, name, arguments, and handler function. + + .. deprecated:: 0.0.75 + Actions have been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + service: str + action: str + arguments: List[RTVIActionArgument] = Field(default_factory=list) + result: Literal["bool", "number", "string", "array", "object"] + handler: Callable[..., Awaitable[ActionResult]] = Field(exclude=True) + _arguments_dict: Dict[str, RTVIActionArgument] = PrivateAttr(default={}) + + def model_post_init(self, __context: Any) -> None: + """Initialize the arguments dictionary after model creation.""" + self._arguments_dict = {} + for arg in self.arguments: + self._arguments_dict[arg.name] = arg + return super().model_post_init(__context) + + +class RTVIServiceOptionConfig(BaseModel): + """Configuration value for an RTVI service option. + + Contains the name and value to set for a specific service option. + + .. deprecated:: 0.0.75 + Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + name: str + value: Any + + +class RTVIServiceConfig(BaseModel): + """Configuration for an RTVI service. + + Contains the service name and list of option configurations to apply. + + .. deprecated:: 0.0.75 + Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + service: str + options: List[RTVIServiceOptionConfig] + + +class RTVIConfig(BaseModel): + """Complete RTVI configuration. + + Contains the full configuration for all RTVI services. + + .. deprecated:: 0.0.75 + Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + config: List[RTVIServiceConfig] + + +# +# Client -> Pipecat messages. +# + + +class RTVIUpdateConfig(BaseModel): + """Request to update RTVI configuration. + + Contains new configuration settings and whether to interrupt the bot. + + .. deprecated:: 0.0.75 + Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + config: List[RTVIServiceConfig] + interrupt: bool = False + + +class RTVIActionRunArgument(BaseModel): + """Argument for running an RTVI action. + + Contains the name and value of an argument to pass to an action. + + .. deprecated:: 0.0.75 + Actions have been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + name: str + value: Any + + +class RTVIActionRun(BaseModel): + """Request to run an RTVI action. + + Contains the service, action name, and optional arguments. + + .. deprecated:: 0.0.75 + Actions have been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + service: str + action: str + arguments: Optional[List[RTVIActionRunArgument]] = None + + +# +# Pipecat -> Client responses and messages. +# + + +class RTVIBotReadyDataDeprecated(RTVI.BotReadyData): + """Data for bot ready notification. + + Contains protocol version and initial configuration. + """ + + # The config field is deprecated and will not be included if + # the client's rtvi version is 1.0.0 or higher. + config: Optional[List[RTVIServiceConfig]] = None + + +class RTVIDescribeConfigData(BaseModel): + """Data for describing available RTVI configuration. + + Contains the list of available services and their options. + + .. deprecated:: 0.0.75 + Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + config: List[RTVIService] + + +class RTVIDescribeConfig(BaseModel): + """Message describing available RTVI configuration. + + Sent in response to a describe-config request. + + .. deprecated:: 0.0.75 + Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + label: RTVI.MessageLiteral = RTVI.MESSAGE_LABEL + type: Literal["config-available"] = "config-available" + id: str + data: RTVIDescribeConfigData + + +class RTVIDescribeActionsData(BaseModel): + """Data for describing available RTVI actions. + + Contains the list of available actions that can be executed. + + .. deprecated:: 0.0.75 + Actions have been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + actions: List[RTVIAction] + + +class RTVIDescribeActions(BaseModel): + """Message describing available RTVI actions. + + Sent in response to a describe-actions request. + + .. deprecated:: 0.0.75 + Actions have been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + label: RTVI.MessageLiteral = RTVI.MESSAGE_LABEL + type: Literal["actions-available"] = "actions-available" + id: str + data: RTVIDescribeActionsData + + +class RTVIConfigResponse(BaseModel): + """Response containing current RTVI configuration. + + Sent in response to a get-config request. + + .. deprecated:: 0.0.75 + Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + label: RTVI.MessageLiteral = RTVI.MESSAGE_LABEL + type: Literal["config"] = "config" + id: str + data: RTVIConfig + + +class RTVIActionResponseData(BaseModel): + """Data for an RTVI action response. + + Contains the result of executing an action. + + .. deprecated:: 0.0.75 + Actions have been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + result: ActionResult + + +class RTVIActionResponse(BaseModel): + """Response to an RTVI action execution. + + Sent after successfully executing an action. + + .. deprecated:: 0.0.75 + Actions have been removed as part of the RTVI protocol 1.0.0. + Use custom client and server messages instead. + """ + + label: RTVI.MessageLiteral = RTVI.MESSAGE_LABEL + type: Literal["action-response"] = "action-response" + id: str + data: RTVIActionResponseData diff --git a/src/pipecat/processors/frameworks/rtvi/observer.py b/src/pipecat/processors/frameworks/rtvi/observer.py new file mode 100644 index 000000000..3e4553b2f --- /dev/null +++ b/src/pipecat/processors/frameworks/rtvi/observer.py @@ -0,0 +1,664 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""RTVI observer for converting pipeline frames to outgoing RTVI messages.""" + +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import ( + TYPE_CHECKING, + Awaitable, + Callable, + Dict, + List, + Optional, + Set, + Tuple, +) + +from loguru import logger +from pydantic import BaseModel + +import pipecat.processors.frameworks.rtvi.models as RTVI +from pipecat.audio.utils import calculate_audio_volume +from pipecat.frames.frames import ( + AggregatedTextFrame, + AggregationType, + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + Frame, + FunctionCallCancelFrame, + FunctionCallInProgressFrame, + FunctionCallResultFrame, + FunctionCallsStartedFrame, + InputAudioRawFrame, + InterimTranscriptionFrame, + LLMContextFrame, + LLMFullResponseEndFrame, + LLMFullResponseStartFrame, + LLMTextFrame, + MetricsFrame, + TranscriptionFrame, + TTSAudioRawFrame, + TTSStartedFrame, + TTSStoppedFrame, + TTSTextFrame, + UserMuteStartedFrame, + UserMuteStoppedFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, +) +from pipecat.metrics.metrics import ( + LLMUsageMetricsData, + ProcessingMetricsData, + TTFBMetricsData, + TTSUsageMetricsData, +) +from pipecat.observers.base_observer import BaseObserver, FramePushed +from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContextFrame +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.processors.frameworks.rtvi.frames import ( + RTVIServerMessageFrame, + RTVIServerResponseFrame, +) +from pipecat.transports.base_output import BaseOutputTransport +from pipecat.utils.string import match_endofsentence + +if TYPE_CHECKING: + from pipecat.processors.frameworks.rtvi.processor import RTVIProcessor + + +class RTVIFunctionCallReportLevel(str, Enum): + """Level of detail to include in function call RTVI events. + + Controls what information is exposed in function call events for security. + + Values: + DISABLED: No events emitted for this function call. + NONE: Events only with tool_call_id, no function name or metadata (most secure). + NAME: Events with function name, no arguments or results. + FULL: Events with function name, arguments, and results. + """ + + DISABLED = "disabled" + NONE = "none" + NAME = "name" + FULL = "full" + + +@dataclass +class RTVIObserverParams: + """Parameters for configuring RTVI Observer behavior. + + .. deprecated:: 0.0.87 + Parameter `errors_enabled` is deprecated. Error messages are always enabled. + + Parameters: + bot_output_enabled: Indicates if bot output messages should be sent. + bot_llm_enabled: Indicates if the bot's LLM messages should be sent. + bot_tts_enabled: Indicates if the bot's TTS messages should be sent. + bot_speaking_enabled: Indicates if the bot's started/stopped speaking messages should be sent. + bot_audio_level_enabled: Indicates if bot's audio level messages should be sent. + user_llm_enabled: Indicates if the user's LLM input messages should be sent. + user_speaking_enabled: Indicates if the user's started/stopped speaking messages should be sent. + user_transcription_enabled: Indicates if user's transcription messages should be sent. + user_audio_level_enabled: Indicates if user's audio level messages should be sent. + 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. + bot_output_transforms: A list of callables to transform text before just before sending it + to TTS. Each callable takes the aggregated text and its type, and returns the + transformed text. To register, provide a list of tuples of + (aggregation_type | '*', transform_function). + audio_level_period_secs: How often audio levels should be sent if enabled. + function_call_report_level: Controls what information is exposed in function call + events for security. A dict mapping function names to levels, where ``"*"`` + sets the default level for unlisted functions:: + + function_call_report_level={ + "*": RTVIFunctionCallReportLevel.NONE, # Default: events with no metadata + "get_weather": RTVIFunctionCallReportLevel.FULL, # Expose everything + } + + Levels: + - DISABLED: No events emitted for this function. + - NONE: Events with tool_call_id only (most secure when events needed). + - NAME: Adds function name to events. + - FULL: Adds function name, arguments, and results. + + Defaults to ``{"*": RTVIFunctionCallReportLevel.NONE}``. + """ + + bot_output_enabled: bool = True + bot_llm_enabled: bool = True + bot_tts_enabled: bool = True + bot_speaking_enabled: bool = True + bot_audio_level_enabled: bool = False + user_llm_enabled: bool = True + user_speaking_enabled: bool = True + user_mute_enabled: bool = True + user_transcription_enabled: bool = True + user_audio_level_enabled: bool = False + 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[ + Tuple[ + AggregationType | str, + Callable[[str, AggregationType | str], Awaitable[str]], + ] + ] + ] = None + audio_level_period_secs: float = 0.15 + function_call_report_level: Dict[str, RTVIFunctionCallReportLevel] = field( + default_factory=lambda: {"*": RTVIFunctionCallReportLevel.NONE} + ) + + +class RTVIObserver(BaseObserver): + """Pipeline frame observer for RTVI server message handling. + + This observer monitors pipeline frames and converts them into appropriate RTVI messages + for client communication. It handles various frame types including speech events, + transcriptions, LLM responses, and TTS events. + + Note: + This observer only handles outgoing messages. Incoming RTVI client messages + are handled by the RTVIProcessor. + """ + + def __init__( + self, + rtvi: Optional["RTVIProcessor"] = None, + *, + params: Optional[RTVIObserverParams] = None, + **kwargs, + ): + """Initialize the RTVI observer. + + Args: + rtvi: The RTVI processor to push frames to. + params: Settings to enable/disable specific messages. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(**kwargs) + 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) + + if self._params.errors_enabled is not None: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Parameter `errors_enabled` is deprecated. Error messages are always enabled.", + DeprecationWarning, + ) + + self._aggregation_transforms: List[ + Tuple[AggregationType | str, Callable[[str, AggregationType | str], Awaitable[str]]] + ] = self._params.bot_output_transforms or [] + + def add_bot_output_transformer( + self, + transform_function: Callable[[str, AggregationType | str], Awaitable[str]], + aggregation_type: AggregationType | str = "*", + ): + """Transform text for a specific aggregation type before sending as Bot Output or TTS. + + Args: + transform_function: The function to apply for transformation. This function should take + the text and aggregation type as input and return the transformed text. + Ex.: async def my_transform(text: str, aggregation_type: str) -> str: + aggregation_type: The type of aggregation to transform. This value defaults to "*" to + handle all text before sending to the client. + """ + self._aggregation_transforms.append((aggregation_type, transform_function)) + + def remove_bot_output_transformer( + self, + transform_function: Callable[[str, AggregationType | str], Awaitable[str]], + aggregation_type: AggregationType | str = "*", + ): + """Remove a text transformer for a specific aggregation type. + + Args: + transform_function: The function to remove. + aggregation_type: The type of aggregation to remove the transformer for. + """ + self._aggregation_transforms = [ + (agg_type, func) + for agg_type, func in self._aggregation_transforms + 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. + + Args: + function_name: The name of the function to get the report level for. + + Returns: + The report level for the function. Looks up the function name first, + then falls back to "*" key, then NONE. + """ + levels = self._params.function_call_report_level + if function_name in levels: + return levels[function_name] + return levels.get("*", RTVIFunctionCallReportLevel.NONE) + + async def _logger_sink(self, message): + """Logger sink so we can send system logs to RTVI clients.""" + message = RTVI.SystemLogMessage(data=RTVI.TextMessageData(text=message)) + await self.send_rtvi_message(message) + + async def cleanup(self): + """Cleanup RTVI observer resources.""" + await super().cleanup() + if self._params.system_logs_enabled: + logger.remove(self._system_logger_id) + + async def send_rtvi_message(self, model: BaseModel, exclude_none: bool = True): + """Send an RTVI message. + + By default, we push a transport frame. But this function can be + overridden by subclass to send RTVI messages in different ways. + + Args: + model: The message to send. + exclude_none: Whether to exclude None values from the model dump. + + """ + if self._rtvi: + await self._rtvi.push_transport_message(model, exclude_none) + + async def on_push_frame(self, data: FramePushed): + """Process a frame being pushed through the pipeline. + + Args: + data: Frame push event data containing source, frame, direction, and timestamp. + """ + src = data.source + frame = data.frame + direction = data.direction + + # Frames from explicitly ignored sources are always skipped. + if self._ignored_sources and src in self._ignored_sources: + return + + # For broadcast frames (pushed in both directions), only process + # the downstream copy to avoid sending duplicate RTVI messages. + if frame.broadcast_sibling_id is not None and direction != FrameDirection.DOWNSTREAM: + return + + # If we have already seen this frame, let's skip it. + if frame.id in self._frames_seen: + return + + # This tells whether the frame is already processed. If false, we will try + # again the next time we see the frame. + mark_as_seen = True + + if ( + isinstance(frame, (UserStartedSpeakingFrame, UserStoppedSpeakingFrame)) + and self._params.user_speaking_enabled + ): + await self._handle_interruptions(frame) + elif ( + isinstance(frame, (UserMuteStartedFrame, UserMuteStoppedFrame)) + and self._params.user_mute_enabled + ): + await self._handle_user_mute(frame) + elif ( + isinstance(frame, (BotStartedSpeakingFrame, BotStoppedSpeakingFrame)) + and self._params.bot_speaking_enabled + ): + await self._handle_bot_speaking(frame) + elif ( + isinstance(frame, (TranscriptionFrame, InterimTranscriptionFrame)) + and self._params.user_transcription_enabled + ): + await self._handle_user_transcriptions(frame) + elif ( + isinstance(frame, (OpenAILLMContextFrame, LLMContextFrame)) + and self._params.user_llm_enabled + ): + await self._handle_context(frame) + elif isinstance(frame, LLMFullResponseStartFrame) and self._params.bot_llm_enabled: + await self.send_rtvi_message(RTVI.BotLLMStartedMessage()) + elif isinstance(frame, LLMFullResponseEndFrame) and self._params.bot_llm_enabled: + await self.send_rtvi_message(RTVI.BotLLMStoppedMessage()) + elif isinstance(frame, LLMTextFrame) and self._params.bot_llm_enabled: + await self._handle_llm_text_frame(frame) + elif isinstance(frame, TTSStartedFrame) and self._params.bot_tts_enabled: + await self.send_rtvi_message(RTVI.BotTTSStartedMessage()) + elif isinstance(frame, TTSStoppedFrame) and self._params.bot_tts_enabled: + await self.send_rtvi_message(RTVI.BotTTSStoppedMessage()) + elif isinstance(frame, AggregatedTextFrame) and ( + self._params.bot_output_enabled or self._params.bot_tts_enabled + ): + if isinstance(frame, TTSTextFrame) and not isinstance(src, BaseOutputTransport): + # This check is to make sure we handle the frame when it has gone + # through the transport and has correct timing. + mark_as_seen = False + else: + await self._handle_aggregated_llm_text(frame) + elif isinstance(frame, MetricsFrame) and self._params.metrics_enabled: + await self._handle_metrics(frame) + elif isinstance(frame, FunctionCallsStartedFrame): + for function_call in frame.function_calls: + report_level = self._get_function_call_report_level(function_call.function_name) + if report_level == RTVIFunctionCallReportLevel.DISABLED: + continue + data = RTVI.LLMFunctionCallStartMessageData() + if report_level in ( + RTVIFunctionCallReportLevel.NAME, + RTVIFunctionCallReportLevel.FULL, + ): + data.function_name = function_call.function_name + message = RTVI.LLMFunctionCallStartMessage(data=data) + await self.send_rtvi_message(message) + elif isinstance(frame, FunctionCallInProgressFrame): + report_level = self._get_function_call_report_level(frame.function_name) + if report_level != RTVIFunctionCallReportLevel.DISABLED: + data = RTVI.LLMFunctionCallInProgressMessageData(tool_call_id=frame.tool_call_id) + if report_level in ( + RTVIFunctionCallReportLevel.NAME, + RTVIFunctionCallReportLevel.FULL, + ): + data.function_name = frame.function_name + if report_level == RTVIFunctionCallReportLevel.FULL: + data.arguments = frame.arguments + message = RTVI.LLMFunctionCallInProgressMessage(data=data) + await self.send_rtvi_message(message) + elif isinstance(frame, FunctionCallCancelFrame): + report_level = self._get_function_call_report_level(frame.function_name) + if report_level != RTVIFunctionCallReportLevel.DISABLED: + data = RTVI.LLMFunctionCallStoppedMessageData( + tool_call_id=frame.tool_call_id, + cancelled=True, + ) + if report_level in ( + RTVIFunctionCallReportLevel.NAME, + RTVIFunctionCallReportLevel.FULL, + ): + data.function_name = frame.function_name + message = RTVI.LLMFunctionCallStoppedMessage(data=data) + await self.send_rtvi_message(message) + elif isinstance(frame, FunctionCallResultFrame): + report_level = self._get_function_call_report_level(frame.function_name) + if report_level != RTVIFunctionCallReportLevel.DISABLED: + data = RTVI.LLMFunctionCallStoppedMessageData( + tool_call_id=frame.tool_call_id, + cancelled=False, + ) + if report_level in ( + RTVIFunctionCallReportLevel.NAME, + RTVIFunctionCallReportLevel.FULL, + ): + data.function_name = frame.function_name + if report_level == RTVIFunctionCallReportLevel.FULL: + data.result = frame.result if frame.result else None + message = RTVI.LLMFunctionCallStoppedMessage(data=data) + await self.send_rtvi_message(message) + elif isinstance(frame, RTVIServerMessageFrame): + message = RTVI.ServerMessage(data=frame.data) + await self.send_rtvi_message(message) + elif isinstance(frame, RTVIServerResponseFrame): + if frame.error is not None: + await self._send_error_response(frame) + else: + await self._send_server_response(frame) + elif isinstance(frame, InputAudioRawFrame) and self._params.user_audio_level_enabled: + curr_time = time.time() + diff_time = curr_time - self._last_user_audio_level + if diff_time > self._params.audio_level_period_secs: + level = calculate_audio_volume(frame.audio, frame.sample_rate) + message = RTVI.UserAudioLevelMessage(data=RTVI.AudioLevelMessageData(value=level)) + await self.send_rtvi_message(message) + self._last_user_audio_level = curr_time + elif isinstance(frame, TTSAudioRawFrame) and self._params.bot_audio_level_enabled: + curr_time = time.time() + diff_time = curr_time - self._last_bot_audio_level + if diff_time > self._params.audio_level_period_secs: + level = calculate_audio_volume(frame.audio, frame.sample_rate) + message = RTVI.BotAudioLevelMessage(data=RTVI.AudioLevelMessageData(value=level)) + await self.send_rtvi_message(message) + self._last_bot_audio_level = curr_time + + if mark_as_seen: + self._frames_seen.add(frame.id) + + async def _handle_interruptions(self, frame: Frame): + """Handle user speaking interruption frames.""" + message = None + if isinstance(frame, UserStartedSpeakingFrame): + message = RTVI.UserStartedSpeakingMessage() + elif isinstance(frame, UserStoppedSpeakingFrame): + message = RTVI.UserStoppedSpeakingMessage() + + if message: + await self.send_rtvi_message(message) + + async def _handle_user_mute(self, frame: Frame): + """Handle user mute/unmute frames.""" + message = None + if isinstance(frame, UserMuteStartedFrame): + message = RTVI.UserMuteStartedMessage() + elif isinstance(frame, UserMuteStoppedFrame): + message = RTVI.UserMuteStoppedMessage() + + if message: + await self.send_rtvi_message(message) + + async def _handle_bot_speaking(self, frame: Frame): + """Handle bot speaking event frames.""" + if isinstance(frame, BotStartedSpeakingFrame): + message = RTVI.BotStartedSpeakingMessage() + 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 = RTVI.BotStoppedSpeakingMessage() + 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 + and frame.aggregated_by in self._params.skip_aggregator_types + ): + return + + text = frame.text + agg_type = frame.aggregated_by + for aggregation_type, transform in self._aggregation_transforms: + if aggregation_type == agg_type or aggregation_type == "*": + text = await transform(text, agg_type) + + isTTS = isinstance(frame, TTSTextFrame) + if self._params.bot_output_enabled: + message = RTVI.BotOutputMessage( + data=RTVI.BotOutputMessageData(text=text, spoken=isTTS, aggregated_by=agg_type) + ) + await self.send_rtvi_message(message) + + if isTTS and self._params.bot_tts_enabled: + tts_message = RTVI.BotTTSTextMessage(data=RTVI.TextMessageData(text=text)) + await self.send_rtvi_message(tts_message) + + async def _handle_llm_text_frame(self, frame: LLMTextFrame): + """Handle LLM text output frames.""" + message = RTVI.BotLLMTextMessage(data=RTVI.TextMessageData(text=frame.text)) + await self.send_rtvi_message(message) + + # TODO (mrkb): Remove all this logic when we fully deprecate bot-transcription messages. + self._bot_transcription += frame.text + + if match_endofsentence(self._bot_transcription) and len(self._bot_transcription) > 0: + await self.send_rtvi_message( + RTVI.BotTranscriptionMessage( + data=RTVI.TextMessageData(text=self._bot_transcription) + ) + ) + self._bot_transcription = "" + + async def _handle_user_transcriptions(self, frame: Frame): + """Handle user transcription frames.""" + message = None + if isinstance(frame, TranscriptionFrame): + message = RTVI.UserTranscriptionMessage( + data=RTVI.UserTranscriptionMessageData( + text=frame.text, user_id=frame.user_id, timestamp=frame.timestamp, final=True + ) + ) + elif isinstance(frame, InterimTranscriptionFrame): + message = RTVI.UserTranscriptionMessage( + data=RTVI.UserTranscriptionMessageData( + text=frame.text, user_id=frame.user_id, timestamp=frame.timestamp, final=False + ) + ) + + if message: + await self.send_rtvi_message(message) + + async def _handle_context(self, frame: OpenAILLMContextFrame | LLMContextFrame): + """Process LLM context frames to extract user messages for the RTVI client.""" + try: + if isinstance(frame, OpenAILLMContextFrame): + messages = frame.context.messages + else: + messages = frame.context.get_messages() + if not messages: + return + + message = messages[-1] + + # Handle Google LLM format (protobuf objects with attributes) + # Note: not possible if frame is a universal LLMContextFrame + if hasattr(message, "role") and message.role == "user" and hasattr(message, "parts"): + text = "".join(part.text for part in message.parts if hasattr(part, "text")) + if text: + rtvi_message = RTVI.UserLLMTextMessage(data=RTVI.TextMessageData(text=text)) + await self.send_rtvi_message(rtvi_message) + + # Handle OpenAI format (original implementation) + elif isinstance(message, dict): + if message["role"] == "user": + content = message["content"] + if isinstance(content, list): + text = " ".join(item["text"] for item in content if "text" in item) + else: + text = content + rtvi_message = RTVI.UserLLMTextMessage(data=RTVI.TextMessageData(text=text)) + await self.send_rtvi_message(rtvi_message) + + except Exception as e: + logger.warning(f"Caught an error while trying to handle context: {e}") + + async def _handle_metrics(self, frame: MetricsFrame): + """Handle metrics frames and convert to RTVI metrics messages.""" + metrics = {} + for d in frame.data: + if isinstance(d, TTFBMetricsData): + if "ttfb" not in metrics: + metrics["ttfb"] = [] + metrics["ttfb"].append(d.model_dump(exclude_none=True)) + elif isinstance(d, ProcessingMetricsData): + if "processing" not in metrics: + metrics["processing"] = [] + metrics["processing"].append(d.model_dump(exclude_none=True)) + elif isinstance(d, LLMUsageMetricsData): + if "tokens" not in metrics: + metrics["tokens"] = [] + metrics["tokens"].append(d.value.model_dump(exclude_none=True)) + elif isinstance(d, TTSUsageMetricsData): + if "characters" not in metrics: + metrics["characters"] = [] + metrics["characters"].append(d.model_dump(exclude_none=True)) + + message = RTVI.MetricsMessage(data=metrics) + await self.send_rtvi_message(message) + + async def _send_server_response(self, frame: RTVIServerResponseFrame): + """Send a response to the client for a specific request.""" + message = RTVI.ServerResponse( + id=str(frame.client_msg.msg_id), + data=RTVI.RawServerResponseData(t=frame.client_msg.type, d=frame.data), + ) + await self.send_rtvi_message(message) + + async def _send_error_response(self, frame: RTVIServerResponseFrame): + """Send a response to the client for a specific request.""" + message = RTVI.ErrorResponse( + id=str(frame.client_msg.msg_id), data=RTVI.ErrorResponseData(error=frame.error) + ) + await self.send_rtvi_message(message) diff --git a/src/pipecat/processors/frameworks/rtvi/processor.py b/src/pipecat/processors/frameworks/rtvi/processor.py new file mode 100644 index 000000000..b750bd70b --- /dev/null +++ b/src/pipecat/processors/frameworks/rtvi/processor.py @@ -0,0 +1,649 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""RTVIProcessor: main RTVI protocol processor.""" + +import asyncio +import base64 +from typing import Any, Dict, Mapping, Optional + +from loguru import logger +from pydantic import BaseModel, ValidationError + +import pipecat.processors.frameworks.rtvi.models as RTVI +from pipecat import version as pipecat_version +from pipecat.frames.frames import ( + CancelFrame, + EndFrame, + EndTaskFrame, + ErrorFrame, + Frame, + FunctionCallResultFrame, + InputAudioRawFrame, + InputTransportMessageFrame, + LLMConfigureOutputFrame, + LLMMessagesAppendFrame, + OutputTransportMessageUrgentFrame, + StartFrame, + SystemFrame, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.processors.frameworks.rtvi.frames import RTVIActionFrame, RTVIClientMessageFrame +from pipecat.processors.frameworks.rtvi.models_deprecated import ( + RTVIAction, + RTVIActionResponse, + RTVIActionResponseData, + RTVIActionRun, + RTVIBotReadyDataDeprecated, + RTVIConfig, + RTVIConfigResponse, + RTVIDescribeActions, + RTVIDescribeActionsData, + RTVIDescribeConfig, + RTVIDescribeConfigData, + RTVIService, + RTVIServiceConfig, + RTVIServiceOptionConfig, + RTVIUpdateConfig, +) +from pipecat.processors.frameworks.rtvi.observer import RTVIObserver, RTVIObserverParams +from pipecat.services.llm_service import ( + FunctionCallParams, # TODO(aleix): we shouldn't import `services` from `processors` +) +from pipecat.transports.base_input import BaseInputTransport +from pipecat.transports.base_transport import BaseTransport + + +class RTVIProcessor(FrameProcessor): + """Main processor for handling RTVI protocol messages and actions. + + This processor manages the RTVI protocol communication including client-server + handshaking, configuration management, action execution, and message routing. + It serves as the central hub for RTVI protocol operations. + """ + + def __init__( + self, + *, + config: Optional[RTVIConfig] = None, + transport: Optional[BaseTransport] = None, + **kwargs, + ): + """Initialize the RTVI processor. + + Args: + config: Initial RTVI configuration. + transport: Transport layer for communication. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(**kwargs) + self._config = config or RTVIConfig(config=[]) + + self._bot_ready = False + self._client_ready = False + self._client_ready_id = "" + # Default to 0.3.0 which is the last version before actually having a + # "client-version". + self._client_version = [0, 3, 0] + self._llm_skip_tts: bool = False # Keep in sync with llm_service.py's configuration. + + self._registered_actions: Dict[str, RTVIAction] = {} + self._registered_services: Dict[str, RTVIService] = {} + + # A task to process incoming action frames. + self._action_task: Optional[asyncio.Task] = None + + # A task to process incoming transport messages. + self._message_task: Optional[asyncio.Task] = None + + self._register_event_handler("on_bot_started") + self._register_event_handler("on_client_ready") + self._register_event_handler("on_client_message") + + self._input_transport = None + self._transport = transport + if self._transport: + input_transport = self._transport.input() + if isinstance(input_transport, BaseInputTransport): + self._input_transport = input_transport + self._input_transport.enable_audio_in_stream_on_start(False) + + def register_action(self, action: RTVIAction): + """Register an action that can be executed via RTVI. + + Args: + action: The action to register. + """ + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "The actions API is deprecated, use server and client messages instead.", + DeprecationWarning, + ) + + id = self._action_id(action.service, action.action) + self._registered_actions[id] = action + + def register_service(self, service: RTVIService): + """Register a service that can be configured via RTVI. + + Args: + service: The service to register. + """ + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "The actions API is deprecated, use server and client messages instead.", + DeprecationWarning, + ) + + self._registered_services[service.name] = service + + def create_rtvi_observer(self, *, params: Optional[RTVIObserverParams] = None, **kwargs): + """Creates a new RTVI Observer. + + Args: + params: Settings to enable/disable specific messages. + **kwargs: Additional arguments passed to the observer. + + Returns: + A new RTVI observer. + """ + return RTVIObserver(self, params=params, **kwargs) + + async def set_client_ready(self): + """Mark the client as ready and trigger the ready event.""" + self._client_ready = True + await self._call_event_handler("on_client_ready") + + async def set_bot_ready(self, about: Mapping[str, Any] = None): + """Mark the bot as ready and send the bot-ready message. + + Args: + about: Optional information about the bot to include in the ready message. + If left as None, the Pipecat library and version will be used. + """ + self._bot_ready = True + # Only call the (deprecated) _update_config method if the we're using a + # config (which is deprecated). Otherwise we'd always print an + # unnecessary deprecation warning. + if self._config.config: + await self._update_config(self._config, False) + await self._send_bot_ready(about=about) + + async def interrupt_bot(self): + """Send a bot interruption frame upstream.""" + await self.broadcast_interruption() + + async def send_server_message(self, data: Any): + """Send a server message to the client.""" + message = RTVI.ServerMessage(data=data) + await self._send_server_message(message) + + async def send_server_response(self, client_msg: RTVI.ClientMessage, data: Any): + """Send a server response for a given client message.""" + message = RTVI.ServerResponse( + id=client_msg.msg_id, data=RTVI.RawServerResponseData(t=client_msg.type, d=data) + ) + await self._send_server_message(message) + + async def send_error_response(self, client_msg: RTVI.ClientMessage, error: str): + """Send an error response for a given client message.""" + await self._send_error_response(id=client_msg.msg_id, error=error) + + async def send_error(self, error: str): + """Send an error message to the client. + + Args: + error: The error message to send. + """ + await self._send_error_frame(ErrorFrame(error=error)) + + async def push_transport_message(self, model: BaseModel, exclude_none: bool = True): + """Push a transport message frame.""" + frame = OutputTransportMessageUrgentFrame( + message=model.model_dump(exclude_none=exclude_none) + ) + await self.push_frame(frame) + + async def handle_message(self, message: RTVI.Message): + """Handle an incoming RTVI message. + + Args: + message: The RTVI message to handle. + """ + await self._message_queue.put(message) + + async def handle_function_call(self, params: FunctionCallParams): + """Handle a function call from the LLM. + + Args: + params: The function call parameters. + + .. deprecated:: 0.0.102 + This method is deprecated. Function call events are now automatically + sent by ``RTVIObserver`` using the ``llm-function-call-in-progress`` event. + Configure reporting level via ``RTVIObserverParams.function_call_report_level``. + """ + import warnings + + warnings.warn( + "handle_function_call is deprecated. Function call events are now " + "automatically sent by RTVIObserver using llm-function-call-in-progress.", + DeprecationWarning, + stacklevel=2, + ) + fn = RTVI.LLMFunctionCallMessageData( + function_name=params.function_name, + tool_call_id=params.tool_call_id, + args=params.arguments, + ) + message = RTVI.LLMFunctionCallMessage(data=fn) + await self.push_transport_message(message, exclude_none=False) + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process incoming frames through the RTVI processor. + + Args: + frame: The frame to process. + direction: The direction of frame flow. + """ + await super().process_frame(frame, direction) + + # Specific system frames + if isinstance(frame, StartFrame): + # Push StartFrame before start(), because we want StartFrame to be + # processed by every processor before any other frame is processed. + await self.push_frame(frame, direction) + await self._start(frame) + elif isinstance(frame, CancelFrame): + await self._cancel(frame) + await self.push_frame(frame, direction) + elif isinstance(frame, ErrorFrame): + await self._send_error_frame(frame) + await self.push_frame(frame, direction) + elif isinstance(frame, InputTransportMessageFrame): + await self._handle_transport_message(frame) + # All other system frames + elif isinstance(frame, SystemFrame): + await self.push_frame(frame, direction) + # Control frames + elif isinstance(frame, EndFrame): + # Push EndFrame before stop(), because stop() waits on the task to + # finish and the task finishes when EndFrame is processed. + await self.push_frame(frame, direction) + await self._stop(frame) + # Data frames + elif isinstance(frame, RTVIActionFrame): + await self._action_queue.put(frame) + elif isinstance(frame, LLMConfigureOutputFrame): + self._llm_skip_tts = frame.skip_tts + await self.push_frame(frame, direction) + # Other frames + else: + await self.push_frame(frame, direction) + + async def _start(self, frame: StartFrame): + """Start the RTVI processor tasks.""" + if not self._action_task: + self._action_queue = asyncio.Queue() + self._action_task = self.create_task(self._action_task_handler()) + if not self._message_task: + self._message_queue = asyncio.Queue() + self._message_task = self.create_task(self._message_task_handler()) + await self._call_event_handler("on_bot_started") + + async def _stop(self, frame: EndFrame): + """Stop the RTVI processor tasks.""" + await self._cancel_tasks() + + async def _cancel(self, frame: CancelFrame): + """Cancel the RTVI processor tasks.""" + await self._cancel_tasks() + + async def _cancel_tasks(self): + """Cancel all running tasks.""" + if self._action_task: + await self.cancel_task(self._action_task) + self._action_task = None + + if self._message_task: + await self.cancel_task(self._message_task) + self._message_task = None + + async def _action_task_handler(self): + """Handle incoming action frames.""" + while True: + frame = await self._action_queue.get() + await self._handle_action(frame.message_id, frame.rtvi_action_run) + self._action_queue.task_done() + + async def _message_task_handler(self): + """Handle incoming transport messages.""" + while True: + message = await self._message_queue.get() + await self._handle_message(message) + self._message_queue.task_done() + + async def _handle_transport_message(self, frame: InputTransportMessageFrame): + """Handle an incoming transport message frame.""" + try: + transport_message = frame.message + if transport_message.get("label") != RTVI.MESSAGE_LABEL: + logger.warning(f"Ignoring not RTVI message: {transport_message}") + return + message = RTVI.Message.model_validate(transport_message) + await self._message_queue.put(message) + except ValidationError as e: + await self.send_error(f"Invalid RTVI transport message: {e}") + logger.warning(f"Invalid RTVI transport message: {e}") + + async def _handle_message(self, message: RTVI.Message): + """Handle a parsed RTVI message.""" + try: + match message.type: + case "client-ready": + data = None + try: + data = RTVI.ClientReadyData.model_validate(message.data) + except ValidationError: + # Not all clients have been updated to RTVI 1.0.0. + # For now, that's okay, we just log their info as unknown. + data = None + pass + await self._handle_client_ready(message.id, data) + case "describe-actions": + await self._handle_describe_actions(message.id) + case "describe-config": + await self._handle_describe_config(message.id) + case "get-config": + await self._handle_get_config(message.id) + case "update-config": + update_config = RTVIUpdateConfig.model_validate(message.data) + await self._handle_update_config(message.id, update_config) + case "disconnect-bot": + await self.push_frame(EndTaskFrame(), FrameDirection.UPSTREAM) + case "client-message": + data = RTVI.RawClientMessageData.model_validate(message.data) + await self._handle_client_message(message.id, data) + case "action": + action = RTVIActionRun.model_validate(message.data) + action_frame = RTVIActionFrame(message_id=message.id, rtvi_action_run=action) + await self._action_queue.put(action_frame) + case "llm-function-call-result": + data = RTVI.LLMFunctionCallResultData.model_validate(message.data) + await self._handle_function_call_result(data) + case "send-text": + data = RTVI.SendTextData.model_validate(message.data) + await self._handle_send_text(data) + case "append-to-context": + logger.warning( + f"The append-to-context message is deprecated, use send-text instead." + ) + data = RTVI.AppendToContextData.model_validate(message.data) + await self._handle_update_context(data) + case "raw-audio" | "raw-audio-batch": + await self._handle_audio_buffer(message.data) + + case _: + await self._send_error_response(message.id, f"Unsupported type {message.type}") + + except ValidationError as e: + await self._send_error_response(message.id, f"Invalid message: {e}") + logger.warning(f"Invalid message: {e}") + except Exception as e: + await self._send_error_response(message.id, f"Exception processing message: {e}") + logger.warning(f"Exception processing message: {e}") + + async def _handle_client_ready(self, request_id: str, data: RTVI.ClientReadyData | None): + """Handle the client-ready message from the client.""" + version = data.version if data else None + logger.debug(f"Received client-ready: version {version}") + if version: + try: + self._client_version = [int(v) for v in version.split(".")] + except ValueError: + logger.warning(f"Invalid client version format: {version}") + about = data.about if data else {"library": "unknown"} + logger.debug(f"Client Details: {about}") + if self._input_transport: + await self._input_transport.start_audio_in_streaming() + + self._client_ready_id = request_id + await self.set_client_ready() + + async def _handle_audio_buffer(self, data): + """Handle incoming audio buffer data.""" + if not self._input_transport: + return + + # Extract audio batch ensuring it's a list + audio_list = data.get("base64AudioBatch") or [data.get("base64Audio")] + + try: + for base64_audio in filter(None, audio_list): # Filter out None values + pcm_bytes = base64.b64decode(base64_audio) + frame = InputAudioRawFrame( + audio=pcm_bytes, + sample_rate=data["sampleRate"], + num_channels=data["numChannels"], + ) + await self._input_transport.push_audio_frame(frame) + + except (KeyError, TypeError, ValueError) as e: + # Handle missing keys, decoding errors, and invalid types + logger.error(f"Error processing audio buffer: {e}") + + async def _handle_describe_config(self, request_id: str): + """Handle a describe-config request.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.", + DeprecationWarning, + ) + + services = list(self._registered_services.values()) + message = RTVIDescribeConfig(id=request_id, data=RTVIDescribeConfigData(config=services)) + await self.push_transport_message(message) + + async def _handle_describe_actions(self, request_id: str): + """Handle a describe-actions request.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "The Actions API is deprecated, use custom server and client messages instead.", + DeprecationWarning, + ) + + actions = list(self._registered_actions.values()) + message = RTVIDescribeActions(id=request_id, data=RTVIDescribeActionsData(actions=actions)) + await self.push_transport_message(message) + + async def _handle_get_config(self, request_id: str): + """Handle a get-config request.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.", + DeprecationWarning, + ) + + message = RTVIConfigResponse(id=request_id, data=self._config) + await self.push_transport_message(message) + + def _update_config_option(self, service: str, config: RTVIServiceOptionConfig): + """Update a specific configuration option.""" + for service_config in self._config.config: + if service_config.service == service: + for option_config in service_config.options: + if option_config.name == config.name: + option_config.value = config.value + return + # If we couldn't find a value for this config, we simply need to + # add it. + service_config.options.append(config) + + async def _update_service_config(self, config: RTVIServiceConfig): + """Update configuration for a specific service.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.", + DeprecationWarning, + ) + + service = self._registered_services[config.service] + for option in config.options: + handler = service._options_dict[option.name].handler + await handler(self, service.name, option) + self._update_config_option(service.name, option) + + async def _update_config(self, data: RTVIConfig, interrupt: bool): + """Update the RTVI configuration.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.", + DeprecationWarning, + ) + + if interrupt: + await self.interrupt_bot() + for service_config in data.config: + await self._update_service_config(service_config) + + async def _handle_update_config(self, request_id: str, data: RTVIUpdateConfig): + """Handle an update-config request.""" + await self._update_config(RTVIConfig(config=data.config), data.interrupt) + await self._handle_get_config(request_id) + + async def _handle_send_text(self, data: RTVI.SendTextData): + """Handle a send-text message from the client.""" + opts = data.options if data.options is not None else RTVI.SendTextOptions() + if opts.run_immediately: + await self.interrupt_bot() + cur_llm_skip_tts = self._llm_skip_tts + should_skip_tts = not opts.audio_response + toggle_skip_tts = cur_llm_skip_tts != should_skip_tts + if toggle_skip_tts: + output_frame = LLMConfigureOutputFrame(skip_tts=should_skip_tts) + await self.push_frame(output_frame) + text_frame = LLMMessagesAppendFrame( + messages=[{"role": "user", "content": data.content}], + run_llm=opts.run_immediately, + ) + await self.push_frame(text_frame) + if toggle_skip_tts: + output_frame = LLMConfigureOutputFrame(skip_tts=cur_llm_skip_tts) + await self.push_frame(output_frame) + + async def _handle_update_context(self, data: RTVI.AppendToContextData): + if data.run_immediately: + await self.interrupt_bot() + frame = LLMMessagesAppendFrame( + messages=[{"role": data.role, "content": data.content}], + run_llm=data.run_immediately, + ) + await self.push_frame(frame) + + async def _handle_client_message(self, msg_id: str, data: RTVI.RawClientMessageData): + """Handle a client message frame.""" + # Create a RTVIClientMessageFrame to push the message + frame = RTVIClientMessageFrame(msg_id=msg_id, type=data.t, data=data.d) + await self.push_frame(frame) + await self._call_event_handler( + "on_client_message", + RTVI.ClientMessage( + msg_id=msg_id, + type=data.t, + data=data.d, + ), + ) + + async def _handle_function_call_result(self, data): + """Handle a function call result from the client.""" + frame = FunctionCallResultFrame( + function_name=data.function_name, + tool_call_id=data.tool_call_id, + arguments=data.arguments, + result=data.result, + ) + await self.push_frame(frame) + + async def _handle_action(self, request_id: Optional[str], data: RTVIActionRun): + """Handle an action execution request.""" + action_id = self._action_id(data.service, data.action) + if action_id not in self._registered_actions: + await self._send_error_response(request_id, f"Action {action_id} not registered") + return + action = self._registered_actions[action_id] + arguments = {} + if data.arguments: + for arg in data.arguments: + arguments[arg.name] = arg.value + result = await action.handler(self, action.service, arguments) + # Only send a response if request_id is present. Things that don't care about + # action responses (such as webhooks) don't set a request_id + if request_id: + message = RTVIActionResponse(id=request_id, data=RTVIActionResponseData(result=result)) + await self.push_transport_message(message) + + async def _send_bot_ready(self, about: Mapping[str, Any] = None): + """Send the bot-ready message to the client. + + Args: + about: Optional information about the bot to include in the ready message. + If left as None, the pipecat library and version will be used. + """ + if not about: + about = {"library": "pipecat-ai", "library_version": f"{pipecat_version()}"} + if self._client_version and self._client_version[0] < 1: + config = self._config.config + message = RTVI.BotReady( + id=self._client_ready_id, + data=RTVIBotReadyDataDeprecated( + version=RTVI.PROTOCOL_VERSION, about=about, config=config + ), + ) + else: + message = RTVI.BotReady( + id=self._client_ready_id, + data=RTVI.BotReadyData(version=RTVI.PROTOCOL_VERSION, about=about), + ) + await self.push_transport_message(message) + + async def _send_server_message(self, message: RTVI.ServerMessage | RTVI.ServerResponse): + """Send a message or response to the client.""" + await self.push_transport_message(message) + + async def _send_error_frame(self, frame: ErrorFrame): + """Send an error frame as an RTVI error message.""" + message = RTVI.Error(data=RTVI.ErrorData(error=frame.error, fatal=frame.fatal)) + await self.push_transport_message(message) + + async def _send_error_response(self, id: str, error: str): + """Send an error response message.""" + message = RTVI.ErrorResponse(id=id, data=RTVI.ErrorResponseData(error=error)) + await self.push_transport_message(message) + + def _action_id(self, service: str, action: str) -> str: + """Generate an action ID from service and action names.""" + return f"{service}:{action}" diff --git a/src/pipecat/processors/metrics/frame_processor_metrics.py b/src/pipecat/processors/metrics/frame_processor_metrics.py index b8beba6e2..7a52895a2 100644 --- a/src/pipecat/processors/metrics/frame_processor_metrics.py +++ b/src/pipecat/processors/metrics/frame_processor_metrics.py @@ -17,6 +17,7 @@ from pipecat.metrics.metrics import ( LLMUsageMetricsData, MetricsData, ProcessingMetricsData, + TextAggregationMetricsData, TTFBMetricsData, TTSUsageMetricsData, ) @@ -43,6 +44,7 @@ class FrameProcessorMetrics(BaseObject): self._task_manager = None self._start_ttfb_time = 0 self._start_processing_time = 0 + self._start_text_aggregation_time = 0 self._last_ttfb_time = 0 self._should_report_ttfb = True @@ -107,49 +109,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() ) @@ -190,3 +213,24 @@ class FrameProcessorMetrics(BaseObject): ) logger.debug(f"{self._processor_name()} usage characters: {characters.value}") return MetricsFrame(data=[characters]) + + async def start_text_aggregation_metrics(self): + """Start measuring text aggregation time (first token to first sentence).""" + self._start_text_aggregation_time = time.time() + + async def stop_text_aggregation_metrics(self): + """Stop text aggregation measurement and generate metrics frame. + + Returns: + MetricsFrame containing text aggregation time, or None if not measuring. + """ + if self._start_text_aggregation_time == 0: + return None + + value = time.time() - self._start_text_aggregation_time + logger.debug(f"{self._processor_name()} text aggregation time: {value}") + aggregation = TextAggregationMetricsData( + processor=self._processor_name(), value=value, model=self._model_name() + ) + self._start_text_aggregation_time = 0 + return MetricsFrame(data=[aggregation]) diff --git a/src/pipecat/processors/metrics/sentry.py b/src/pipecat/processors/metrics/sentry.py index db2c6de63..c865ee470 100644 --- a/src/pipecat/processors/metrics/sentry.py +++ b/src/pipecat/processors/metrics/sentry.py @@ -7,6 +7,7 @@ """Sentry integration for frame processor metrics.""" import asyncio +from typing import Optional from loguru import logger @@ -70,13 +71,18 @@ class SentryMetrics(FrameProcessorMetrics): logger.trace(f"{self} Flushing Sentry metrics") sentry_sdk.flush(timeout=5.0) - 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 tracking time-to-first-byte metrics. Args: + start_time: Optional start timestamp override. report_only_initial_ttfb: Whether to report only the initial TTFB measurement. """ - await super().start_ttfb_metrics(report_only_initial_ttfb) + await super().start_ttfb_metrics( + start_time=start_time, report_only_initial_ttfb=report_only_initial_ttfb + ) if self._should_report_ttfb and self._sentry_available: self._ttfb_metrics_tx = sentry_sdk.start_transaction( @@ -87,23 +93,25 @@ class SentryMetrics(FrameProcessorMetrics): f"{self} Sentry transaction started (ID: {self._ttfb_metrics_tx.span_id} Name: {self._ttfb_metrics_tx.name})" ) - async def stop_ttfb_metrics(self): + async def stop_ttfb_metrics(self, *, end_time: Optional[float] = None): """Stop tracking time-to-first-byte metrics. - Queues the TTFB transaction for completion and transmission to Sentry. + Args: + end_time: Optional end timestamp override. """ - await super().stop_ttfb_metrics() + await super().stop_ttfb_metrics(end_time=end_time) if self._sentry_available and self._ttfb_metrics_tx: await self._sentry_queue.put(self._ttfb_metrics_tx) self._ttfb_metrics_tx = None - async def start_processing_metrics(self): + async def start_processing_metrics(self, *, start_time: Optional[float] = None): """Start tracking frame processing metrics. - Creates a new Sentry transaction to track processing performance. + Args: + start_time: Optional start timestamp override. """ - await super().start_processing_metrics() + await super().start_processing_metrics(start_time=start_time) if self._sentry_available: self._processing_metrics_tx = sentry_sdk.start_transaction( @@ -114,12 +122,13 @@ class SentryMetrics(FrameProcessorMetrics): f"{self} Sentry transaction started (ID: {self._processing_metrics_tx.span_id} Name: {self._processing_metrics_tx.name})" ) - async def stop_processing_metrics(self): + async def stop_processing_metrics(self, *, end_time: Optional[float] = None): """Stop tracking frame processing metrics. - Queues the processing transaction for completion and transmission to Sentry. + Args: + end_time: Optional end timestamp override. """ - await super().stop_processing_metrics() + await super().stop_processing_metrics(end_time=end_time) if self._sentry_available and self._processing_metrics_tx: await self._sentry_queue.put(self._processing_metrics_tx) diff --git a/src/pipecat/processors/user_idle_processor.py b/src/pipecat/processors/user_idle_processor.py index 6dc6dd47c..f7ea48599 100644 --- a/src/pipecat/processors/user_idle_processor.py +++ b/src/pipecat/processors/user_idle_processor.py @@ -8,6 +8,7 @@ import asyncio import inspect +import warnings from typing import Awaitable, Callable, Union from pipecat.frames.frames import ( @@ -26,6 +27,10 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor class UserIdleProcessor(FrameProcessor): """Monitors user inactivity and triggers callbacks after timeout periods. + .. deprecated:: 0.0.100 + UserIdleProcessor is deprecated in 0.0.100 and will be removed in a future version. + Use LLMUserAggregator with user_idle_timeout parameter instead. + This processor tracks user activity and triggers configurable callbacks when users become idle. It starts monitoring only after the first conversation activity and supports both basic and retry-based callback patterns. @@ -70,6 +75,14 @@ class UserIdleProcessor(FrameProcessor): **kwargs: Additional arguments passed to FrameProcessor. """ super().__init__(**kwargs) + + warnings.warn( + "UserIdleProcessor is deprecated in 0.0.100 and will be removed in a " + "future version. Use LLMUserAggregator with user_idle_timeout parameter " + "instead.", + DeprecationWarning, + ) + self._callback = self._wrap_callback(callback) self._timeout = timeout self._retry_count = 0 diff --git a/src/pipecat/runner/daily.py b/src/pipecat/runner/daily.py index 6747dbd62..a60b84a6a 100644 --- a/src/pipecat/runner/daily.py +++ b/src/pipecat/runner/daily.py @@ -17,7 +17,7 @@ Functions: Environment variables: - DAILY_API_KEY - Daily API key for room/token creation (required) -- DAILY_SAMPLE_ROOM_URL (optional) - Existing room URL to use. If not provided, +- DAILY_ROOM_URL (optional) - Existing room URL to use. If not provided, a temporary room will be created automatically. Example:: @@ -79,19 +79,22 @@ async def configure( aiohttp_session: aiohttp.ClientSession, *, api_key: Optional[str] = None, - room_exp_duration: Optional[float] = 2.0, - token_exp_duration: Optional[float] = 2.0, + room_exp_duration: float = 2.0, + token_exp_duration: float = 2.0, sip_caller_phone: Optional[str] = None, - sip_enable_video: Optional[bool] = False, - sip_num_endpoints: Optional[int] = 1, + sip_enable_video: bool = False, + sip_num_endpoints: int = 1, + enable_dialout: bool = False, sip_codecs: Optional[Dict[str, List[str]]] = None, + sip_provider: Optional[str] = None, + room_geo: Optional[str] = None, room_properties: Optional[DailyRoomProperties] = None, - token_properties: Optional["DailyMeetingTokenProperties"] = None, + token_properties: Optional[DailyMeetingTokenProperties] = None, ) -> DailyRoomConfig: """Configure Daily room URL and token with optional SIP capabilities. This function will either: - 1. Use an existing room URL from DAILY_SAMPLE_ROOM_URL environment variable (standard mode only) + 1. Use an existing room URL from DAILY_ROOM_URL environment variable (standard mode only) 2. Create a new temporary room automatically if no URL is provided Args: @@ -103,8 +106,14 @@ async def configure( When provided, enables SIP functionality and returns SipRoomConfig. sip_enable_video: Whether video is enabled for SIP. sip_num_endpoints: Number of allowed SIP endpoints. + enable_dialout: Whether to enable outbound dialing (PSTN or SIP) on the room. + Requires dial-out entitlement on your Daily account. sip_codecs: Codecs to support for audio and video. If None, uses Daily defaults. Example: {"audio": ["OPUS"], "video": ["H264"]} + sip_provider: SIP provider name (e.g., "daily"). Only used when + sip_caller_phone is provided and room_properties is not. + room_geo: Daily room geographic region (e.g., "us-east-1"). Only used + when room_properties is not provided. room_properties: Optional DailyRoomProperties to use instead of building from individual parameters. When provided, this overrides room_exp_duration and SIP-related parameters. If not provided, properties are built from the @@ -153,7 +162,10 @@ async def configure( sip_caller_phone is not None, sip_enable_video is not False, sip_num_endpoints != 1, + enable_dialout is not False, sip_codecs is not None, + sip_provider is not None, + room_geo is not None, ] ) if individual_params_provided: @@ -176,23 +188,26 @@ async def configure( aiohttp_session=aiohttp_session, ) + token_expiry_seconds: float = token_exp_duration * 60 * 60 + # Check for existing room URL (only in standard mode) - existing_room_url = os.getenv("DAILY_SAMPLE_ROOM_URL") + existing_room_url = os.getenv("DAILY_ROOM_URL") if existing_room_url and not sip_enabled: # Use existing room (standard mode only) logger.info(f"Using existing Daily room: {existing_room_url}") room_url = existing_room_url # Create token and return standard format - expiry_time: float = token_exp_duration * 60 * 60 token_params = None if token_properties: token_params = DailyMeetingTokenParams(properties=token_properties) - token = await daily_rest_helper.get_token(room_url, expiry_time, params=token_params) + token = await daily_rest_helper.get_token( + room_url, token_expiry_seconds, params=token_params + ) return DailyRoomConfig(room_url=room_url, token=token) # Create a new room - room_prefix = "pipecat-sip" if sip_enabled else "pipecat" + room_prefix = "pipecat-telephony" if (sip_enabled or enable_dialout) else "pipecat" room_name = f"{room_prefix}-{uuid.uuid4().hex[:8]}" logger.info(f"Creating new Daily room: {room_name}") @@ -207,6 +222,12 @@ async def configure( eject_at_room_exp=True, ) + if room_geo: + room_properties.geo = room_geo + + if enable_dialout: + room_properties.enable_dialout = True + # Add SIP configuration if enabled if sip_enabled: sip_params = DailyRoomSipParams( @@ -215,9 +236,9 @@ async def configure( sip_mode="dial-in", num_endpoints=sip_num_endpoints, codecs=sip_codecs, + provider=sip_provider, ) room_properties.sip = sip_params - room_properties.enable_dialout = True # Enable outbound calls if needed room_properties.start_video_off = not sip_enable_video # Voice-only by default # Create room parameters @@ -229,7 +250,6 @@ async def configure( logger.info(f"Created Daily room: {room_url}") # Create meeting token - token_expiry_seconds = token_exp_duration * 60 * 60 token_params = None if token_properties: token_params = DailyMeetingTokenParams(properties=token_properties) diff --git a/src/pipecat/runner/run.py b/src/pipecat/runner/run.py index 0396e393b..76c870468 100644 --- a/src/pipecat/runner/run.py +++ b/src/pipecat/runner/run.py @@ -153,26 +153,18 @@ def _get_bot_module(): ) -async def _run_telephony_bot(websocket: WebSocket): +async def _run_telephony_bot(websocket: WebSocket, args: argparse.Namespace): """Run a bot for telephony transports.""" bot_module = _get_bot_module() # Just pass the WebSocket - let the bot handle parsing runner_args = WebSocketRunnerArguments(websocket=websocket) + runner_args.cli_args = args await bot_module.bot(runner_args) -def _create_server_app( - *, - transport_type: str, - host: str = "localhost", - proxy: str, - esp32_mode: bool = False, - whatsapp_enabled: bool = False, - folder: Optional[str] = None, - dialin_enabled: bool = False, -): +def _create_server_app(args: argparse.Namespace): """Create FastAPI app with transport-specific routes.""" app = FastAPI() @@ -185,23 +177,21 @@ def _create_server_app( ) # Set up transport-specific routes - if transport_type == "webrtc": - _setup_webrtc_routes(app, esp32_mode=esp32_mode, host=host, folder=folder) - if whatsapp_enabled: - _setup_whatsapp_routes(app) - elif transport_type == "daily": - _setup_daily_routes(app, dialin_enabled=dialin_enabled) - elif transport_type in TELEPHONY_TRANSPORTS: - _setup_telephony_routes(app, transport_type=transport_type, proxy=proxy) + if args.transport == "webrtc": + _setup_webrtc_routes(app, args) + if args.whatsapp: + _setup_whatsapp_routes(app, args) + elif args.transport == "daily": + _setup_daily_routes(app, args) + elif args.transport in TELEPHONY_TRANSPORTS: + _setup_telephony_routes(app, args) else: - logger.warning(f"Unknown transport type: {transport_type}") + logger.warning(f"Unknown transport type: {args.transport}") return app -def _setup_webrtc_routes( - app: FastAPI, *, esp32_mode: bool = False, host: str = "localhost", folder: Optional[str] = None -): +def _setup_webrtc_routes(app: FastAPI, args: argparse.Namespace): """Set up WebRTC-specific routes.""" try: from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI @@ -241,11 +231,11 @@ def _setup_webrtc_routes( @app.get("/files/{filename:path}") async def download_file(filename: str): """Handle file downloads.""" - if not folder: + if not args.folder: logger.warning(f"Attempting to dowload {filename}, but downloads folder not setup.") return - file_path = Path(folder) / filename + file_path = Path(args.folder) / filename if not os.path.exists(file_path): raise HTTPException(404) @@ -255,7 +245,7 @@ def _setup_webrtc_routes( # Initialize the SmallWebRTC request handler small_webrtc_handler: SmallWebRTCRequestHandler = SmallWebRTCRequestHandler( - esp32_mode=esp32_mode, host=host + esp32_mode=args.esp32, host=args.host ) @app.post("/api/offer") @@ -263,12 +253,13 @@ def _setup_webrtc_routes( """Handle WebRTC offer requests via SmallWebRTCRequestHandler.""" # Prepare runner arguments with the callback to run your bot - async def webrtc_connection_callback(connection): + async def webrtc_connection_callback(connection: SmallWebRTCConnection): bot_module = _get_bot_module() runner_args = SmallWebRTCRunnerArguments( webrtc_connection=connection, body=request.request_data ) + runner_args.cli_args = args background_tasks.add_task(bot_module.bot, runner_args) # Delegate handling to SmallWebRTCRequestHandler @@ -298,7 +289,7 @@ def _setup_webrtc_routes( # Store session info immediately in memory, replicate the behavior expected on Pipecat Cloud session_id = str(uuid.uuid4()) - active_sessions[session_id] = request_data + active_sessions[session_id] = request_data.get("body", {}) result: StartBotResult = {"sessionId": session_id} if request_data.get("enableDefaultIceServers"): @@ -331,7 +322,8 @@ def _setup_webrtc_routes( pc_id=request_data.get("pc_id"), restart_pc=request_data.get("restart_pc"), request_data=request_data.get("request_data") - or request_data.get("requestData"), + or request_data.get("requestData") + or active_session, ) return await offer(webrtc_request, background_tasks) elif request.method == HTTPMethod.PATCH.value: @@ -380,8 +372,8 @@ def _add_lifespan_to_app(app: FastAPI, new_lifespan): app.router.lifespan_context = new_lifespan -def _setup_whatsapp_routes(app: FastAPI): - """Set up WebRTC-specific routes.""" +def _setup_whatsapp_routes(app: FastAPI, args: argparse.Namespace): + """Set up WhatsApp-specific routes.""" WHATSAPP_APP_SECRET = os.getenv("WHATSAPP_APP_SECRET") WHATSAPP_PHONE_NUMBER_ID = os.getenv("WHATSAPP_PHONE_NUMBER_ID") WHATSAPP_TOKEN = os.getenv("WHATSAPP_TOKEN") @@ -406,13 +398,7 @@ def _setup_whatsapp_routes(app: FastAPI): return try: - from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI - from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection - from pipecat.transports.smallwebrtc.request_handler import ( - SmallWebRTCRequest, - SmallWebRTCRequestHandler, - ) from pipecat.transports.whatsapp.api import WhatsAppWebhookRequest from pipecat.transports.whatsapp.client import WhatsAppClient except ImportError as e: @@ -489,6 +475,7 @@ def _setup_whatsapp_routes(app: FastAPI): """ bot_module = _get_bot_module() runner_args = SmallWebRTCRunnerArguments(webrtc_connection=connection) + runner_args.cli_args = args background_tasks.add_task(bot_module.bot, runner_args) try: @@ -534,13 +521,8 @@ def _setup_whatsapp_routes(app: FastAPI): _add_lifespan_to_app(app, whatsapp_lifespan) -def _setup_daily_routes(app: FastAPI, dialin_enabled: bool = False): - """Set up Daily-specific routes. - - Args: - app: FastAPI application instance - dialin_enabled: If True, adds /daily-dialin-webhook endpoint for PSTN dial-in handling - """ +def _setup_daily_routes(app: FastAPI, args: argparse.Namespace): + """Set up Daily-specific routes.""" @app.get("/") async def create_room_and_start_agent(): @@ -557,6 +539,7 @@ def _setup_daily_routes(app: FastAPI, dialin_enabled: bool = False): # Start the bot in the background with empty body for GET requests bot_module = _get_bot_module() runner_args = DailyRunnerArguments(room_url=room_url, token=token) + runner_args.cli_args = args asyncio.create_task(bot_module.bot(runner_args)) return RedirectResponse(room_url) @@ -589,13 +572,13 @@ def _setup_daily_routes(app: FastAPI, dialin_enabled: bool = False): bot_module = _get_bot_module() - existing_room_url = os.getenv("DAILY_SAMPLE_ROOM_URL") + existing_room_url = os.getenv("DAILY_ROOM_URL") result = None # Configure room if: # 1. Explicitly requested via createDailyRoom in payload - # 2. Using pre-configured room from DAILY_SAMPLE_ROOM_URL env var + # 2. Using pre-configured room from DAILY_ROOM_URL env var if create_daily_room or existing_room_url: import aiohttp @@ -640,12 +623,15 @@ def _setup_daily_routes(app: FastAPI, dialin_enabled: bool = False): else: runner_args = RunnerArguments(body=body) + # Update CLI args. + runner_args.cli_args = args + # Start the bot in the background asyncio.create_task(bot_module.bot(runner_args)) return result - if dialin_enabled: + if args.dialin: @app.post("/daily-dialin-webhook") async def handle_dialin_webhook(request: Request): @@ -742,6 +728,7 @@ def _setup_daily_routes(app: FastAPI, dialin_enabled: bool = False): token=room_config.token, body=request_body.model_dump(), ) + runner_args.cli_args = args asyncio.create_task(bot_module.bot(runner_args)) @@ -756,44 +743,44 @@ def _setup_daily_routes(app: FastAPI, dialin_enabled: bool = False): } -def _setup_telephony_routes(app: FastAPI, *, transport_type: str, proxy: str): +def _setup_telephony_routes(app: FastAPI, args: argparse.Namespace): """Set up telephony-specific routes.""" # XML response templates (Exotel doesn't use XML webhooks) XML_TEMPLATES = { "twilio": f""" - + """, "telnyx": f""" - + """, "plivo": f""" - wss://{proxy}/ws + wss://{args.proxy}/ws """, } @app.post("/") async def start_call(): """Handle telephony webhook and return XML response.""" - if transport_type == "exotel": + if args.transport == "exotel": # Exotel doesn't use POST webhooks - redirect to proper documentation logger.debug("POST Exotel endpoint - not used") return { "error": "Exotel doesn't use POST webhooks", - "websocket_url": f"wss://{proxy}/ws", + "websocket_url": f"wss://{args.proxy}/ws", "note": "Configure the WebSocket URL above in your Exotel App Bazaar Voicebot Applet", } else: - logger.debug(f"POST {transport_type.upper()} XML") - xml_content = XML_TEMPLATES.get(transport_type, "") + logger.debug(f"POST {args.transport.upper()} XML") + xml_content = XML_TEMPLATES.get(args.transport, "") return HTMLResponse(content=xml_content, media_type="application/xml") @app.websocket("/ws") @@ -801,15 +788,15 @@ def _setup_telephony_routes(app: FastAPI, *, transport_type: str, proxy: str): """Handle WebSocket connections for telephony.""" await websocket.accept() logger.debug("WebSocket connection accepted") - await _run_telephony_bot(websocket) + await _run_telephony_bot(websocket, args) @app.get("/") async def start_agent(): """Simple status endpoint for telephony transports.""" - return {"status": f"Bot started with {transport_type}"} + return {"status": f"Bot started with {args.transport}"} -async def _run_daily_direct(): +async def _run_daily_direct(args: argparse.Namespace): """Run Daily bot with direct connection (no FastAPI server).""" try: from pipecat.runner.daily import configure @@ -825,6 +812,7 @@ async def _run_daily_direct(): # Direct connections have no request body, so use empty dict runner_args = DailyRunnerArguments(room_url=room_url, token=token) runner_args.handle_sigint = True + runner_args.cli_args = args # Get the bot module and run it directly bot_module = _get_bot_module() @@ -872,29 +860,38 @@ def runner_port() -> int: return RUNNER_PORT -def main(): +def main(parser: Optional[argparse.ArgumentParser] = None): """Start the Pipecat development runner. Parses command-line arguments and starts a FastAPI server configured - for the specified transport type. The runner will discover and run - any bot() function found in the current directory. + for the specified transport type. + + The runner discovers and runs any ``bot(runner_args)`` function found in the + calling module. Command-line arguments: + - --host: Server host address (default: localhost) 879 + - --port: Server port (default: 7860) + - -t/--transport: Transport type (daily, webrtc, twilio, telnyx, plivo, exotel) + - -x/--proxy: Public proxy hostname for telephony webhooks + - -d/--direct: Connect directly to Daily room (automatically sets transport to daily) + - -f/--folder: Path to downloads folder + - --dialin: Enable Daily PSTN dial-in webhook handling (requires Daily transport) + - --esp32: Enable SDP munging for ESP32 compatibility (requires --host with IP address) + - --whatsapp: Ensure requried WhatsApp environment variables are present + - -v/--verbose: Increase logging verbosity Args: - --host: Server host address (default: localhost) - --port: Server port (default: 7860) - -t/--transport: Transport type (daily, webrtc, twilio, telnyx, plivo, exotel) - -x/--proxy: Public proxy hostname for telephony webhooks - --esp32: Enable SDP munging for ESP32 compatibility (requires --host with IP address) - -d/--direct: Connect directly to Daily room (automatically sets transport to daily) - -v/--verbose: Increase logging verbosity + parser: Optional custom argument parser. If provided, default runner + arguments are added to it so bots can define their own CLI + arguments. Custom arguments should not conflict with the default + ones. Custom args are accessible via `runner_args.cli_args`. - The bot file must contain a `bot(runner_args)` function as the entry point. """ global RUNNER_DOWNLOADS_FOLDER, RUNNER_HOST, RUNNER_PORT - parser = argparse.ArgumentParser(description="Pipecat Development Runner") + if not parser: + parser = argparse.ArgumentParser(description="Pipecat Development Runner") parser.add_argument("--host", type=str, default=RUNNER_HOST, help="Host address") parser.add_argument("--port", type=int, default=RUNNER_PORT, help="Port number") parser.add_argument( @@ -905,13 +902,7 @@ def main(): default="webrtc", help="Transport type", ) - parser.add_argument("--proxy", "-x", help="Public proxy host name") - parser.add_argument( - "--esp32", - action="store_true", - default=False, - help="Enable SDP munging for ESP32 compatibility (requires --host with IP address)", - ) + parser.add_argument("-x", "--proxy", help="Public proxy host name") parser.add_argument( "-d", "--direct", @@ -921,13 +912,7 @@ def main(): ) parser.add_argument("-f", "--folder", type=str, help="Path to downloads folder") parser.add_argument( - "--verbose", "-v", action="count", default=0, help="Increase logging verbosity" - ) - parser.add_argument( - "--whatsapp", - action="store_true", - default=False, - help="Ensure requried WhatsApp environment variables are present", + "-v", "--verbose", action="count", default=0, help="Increase logging verbosity" ) parser.add_argument( "--dialin", @@ -935,6 +920,18 @@ def main(): default=False, help="Enable Daily PSTN dial-in webhook handling (requires Daily transport)", ) + parser.add_argument( + "--esp32", + action="store_true", + default=False, + help="Enable SDP munging for ESP32 compatibility (requires --host with IP address)", + ) + parser.add_argument( + "--whatsapp", + action="store_true", + default=False, + help="Ensure requried WhatsApp environment variables are present", + ) args = parser.parse_args() @@ -970,7 +967,7 @@ def main(): print() # Run direct Daily connection - asyncio.run(_run_daily_direct()) + asyncio.run(_run_daily_direct(args)) return # Print startup message for server-based transports @@ -1001,15 +998,7 @@ def main(): RUNNER_PORT = args.port # Create the app with transport-specific setup - app = _create_server_app( - transport_type=args.transport, - host=args.host, - proxy=args.proxy, - esp32_mode=args.esp32, - whatsapp_enabled=args.whatsapp, - folder=args.folder, - dialin_enabled=args.dialin, - ) + app = _create_server_app(args) # Run the server uvicorn.run(app, host=args.host, port=args.port) diff --git a/src/pipecat/runner/types.py b/src/pipecat/runner/types.py index 4d480749a..e48f10a08 100644 --- a/src/pipecat/runner/types.py +++ b/src/pipecat/runner/types.py @@ -10,6 +10,7 @@ These types are used by the development runner to pass transport-specific information to bot functions. """ +import argparse from dataclasses import dataclass, field from typing import Any, Dict, Optional @@ -64,6 +65,7 @@ class RunnerArguments: handle_sigterm: bool = field(init=False, kw_only=True) pipeline_idle_timeout_secs: int = field(init=False, kw_only=True) body: Optional[Any] = field(default_factory=dict, kw_only=True) + cli_args: Optional[argparse.Namespace] = field(default=None, init=False, kw_only=True) def __post_init__(self): self.handle_sigint = False @@ -106,3 +108,18 @@ class SmallWebRTCRunnerArguments(RunnerArguments): """ webrtc_connection: Any + + +@dataclass +class LiveKitRunnerArguments(RunnerArguments): + """LiveKit transport session arguments for the runner. + + Parameters: + room_name: LiveKit room name to join + token: Authentication token for the room + body: Additional request data + """ + + room_name: str + url: str + token: Optional[str] = None diff --git a/src/pipecat/runner/utils.py b/src/pipecat/runner/utils.py index 78f94b285..d0bb44a88 100644 --- a/src/pipecat/runner/utils.py +++ b/src/pipecat/runner/utils.py @@ -39,6 +39,7 @@ from loguru import logger from pipecat.runner.types import ( DailyRunnerArguments, + LiveKitRunnerArguments, SmallWebRTCRunnerArguments, WebSocketRunnerArguments, ) @@ -95,6 +96,9 @@ def _detect_transport_type_from_message(message_data: dict) -> str: async def parse_telephony_websocket(websocket: WebSocket): """Parse telephony WebSocket messages and return transport type and call data. + Args: + websocket: FastAPI WebSocket connection from telephony provider. + Returns: tuple: (transport_type: str, call_data: dict) @@ -135,6 +139,9 @@ async def parse_telephony_websocket(websocket: WebSocket): "to": str, } + Raises: + ValueError: If WebSocket closes before sending any messages. + Example usage:: transport_type, call_data = await parse_telephony_websocket(websocket) @@ -142,25 +149,31 @@ async def parse_telephony_websocket(websocket: WebSocket): user_id = call_data["body"]["user_id"] """ # Read first two messages - start_data = websocket.iter_text() + message_stream = websocket.iter_text() + first_message = {} + second_message = {} try: - # First message - first_message_raw = await start_data.__anext__() + # First message - required + first_message_raw = await message_stream.__anext__() logger.trace(f"First message: {first_message_raw}") - try: - first_message = json.loads(first_message_raw) - except json.JSONDecodeError: - first_message = {} + first_message = json.loads(first_message_raw) if first_message_raw else {} + except json.JSONDecodeError: + pass + except StopAsyncIteration: + raise ValueError("WebSocket closed before receiving telephony handshake messages") - # Second message - second_message_raw = await start_data.__anext__() + try: + # Second message - optional, some providers may only send one + second_message_raw = await message_stream.__anext__() logger.trace(f"Second message: {second_message_raw}") - try: - second_message = json.loads(second_message_raw) - except json.JSONDecodeError: - second_message = {} + second_message = json.loads(second_message_raw) if second_message_raw else {} + except json.JSONDecodeError: + pass + except StopAsyncIteration: + logger.warning("Only received one WebSocket message, expected two") + try: # Try auto-detection on both messages detected_type_first = _detect_transport_type_from_message(first_message) detected_type_second = _detect_transport_type_from_message(second_message) @@ -568,6 +581,17 @@ async def create_transport( return await _create_telephony_transport( runner_args.websocket, params, transport_type, call_data ) + elif isinstance(runner_args, LiveKitRunnerArguments): + params = _get_transport_params("livekit", transport_params) + + from pipecat.transports.livekit.transport import LiveKitTransport + + return LiveKitTransport( + runner_args.url, + runner_args.token, + runner_args.room_name, + params=params, + ) else: raise ValueError(f"Unsupported runner arguments type: {type(runner_args)}") diff --git a/src/pipecat/serializers/base_serializer.py b/src/pipecat/serializers/base_serializer.py index 490951a69..d9414e43d 100644 --- a/src/pipecat/serializers/base_serializer.py +++ b/src/pipecat/serializers/base_serializer.py @@ -6,12 +6,22 @@ """Frame serialization interfaces for Pipecat.""" -from abc import ABC, abstractmethod +from abc import abstractmethod +from typing import Optional -from pipecat.frames.frames import Frame, StartFrame +from pydantic import BaseModel + +import pipecat.processors.frameworks.rtvi.models as RTVI +from pipecat.frames.frames import ( + Frame, + OutputTransportMessageFrame, + OutputTransportMessageUrgentFrame, + StartFrame, +) +from pipecat.utils.base_object import BaseObject -class FrameSerializer(ABC): +class FrameSerializer(BaseObject): """Abstract base class for frame serialization implementations. Defines the interface for converting frames to/from serialized formats @@ -19,6 +29,46 @@ class FrameSerializer(ABC): serialize/deserialize methods. """ + class InputParams(BaseModel): + """Base configuration parameters for FrameSerializer. + + Parameters: + ignore_rtvi_messages: Whether to ignore RTVI protocol messages during serialization. + Defaults to True to prevent RTVI messages from being sent to external transports. + """ + + ignore_rtvi_messages: bool = True + + def __init__(self, params: Optional[InputParams] = None, **kwargs): + """Initialize the FrameSerializer. + + Args: + params: Configuration parameters. + **kwargs: Additional arguments passed to BaseObject (e.g., name). + """ + super().__init__(**kwargs) + self._params = params or FrameSerializer.InputParams() + + def should_ignore_frame(self, frame: Frame) -> bool: + """Check if a frame should be ignored during serialization. + + This method filters out RTVI protocol messages when ignore_rtvi_messages is enabled. + Subclasses can override this to add additional filtering logic. + + Args: + frame: The frame to check. + + Returns: + True if the frame should be ignored, False otherwise. + """ + if ( + self._params.ignore_rtvi_messages + and isinstance(frame, (OutputTransportMessageFrame, OutputTransportMessageUrgentFrame)) + and frame.message.get("label") == RTVI.MESSAGE_LABEL + ): + return True + return False + async def setup(self, frame: StartFrame): """Initialize the serializer with startup configuration. diff --git a/src/pipecat/serializers/exotel.py b/src/pipecat/serializers/exotel.py index 61d6eeada..abf170d65 100644 --- a/src/pipecat/serializers/exotel.py +++ b/src/pipecat/serializers/exotel.py @@ -11,7 +11,6 @@ import json from typing import Optional from loguru import logger -from pydantic import BaseModel from pipecat.audio.dtmf.types import KeypadEntry from pipecat.audio.utils import create_stream_resampler @@ -39,12 +38,13 @@ class ExotelFrameSerializer(FrameSerializer): https://support.exotel.com/support/solutions/articles/3000108630-working-with-the-stream-and-voicebot-applet """ - class InputParams(BaseModel): + class InputParams(FrameSerializer.InputParams): """Configuration parameters for ExotelFrameSerializer. Parameters: exotel_sample_rate: Sample rate used by Exotel, defaults to 8000 Hz. sample_rate: Optional override for pipeline input sample rate. + ignore_rtvi_messages: Inherited from base FrameSerializer, defaults to True. """ exotel_sample_rate: int = 8000 @@ -60,9 +60,10 @@ class ExotelFrameSerializer(FrameSerializer): call_sid: The associated Exotel Call SID (optional, not used in this implementation). params: Configuration parameters. """ + super().__init__(params or ExotelFrameSerializer.InputParams()) + self._stream_sid = stream_sid self._call_sid = call_sid - self._params = params or ExotelFrameSerializer.InputParams() self._exotel_sample_rate = self._params.exotel_sample_rate self._sample_rate = 0 # Pipeline input rate @@ -113,6 +114,8 @@ class ExotelFrameSerializer(FrameSerializer): return json.dumps(answer) elif isinstance(frame, (OutputTransportMessageFrame, OutputTransportMessageUrgentFrame)): + if self.should_ignore_frame(frame): + return None return json.dumps(frame.message) return None diff --git a/src/pipecat/serializers/genesys.py b/src/pipecat/serializers/genesys.py new file mode 100644 index 000000000..4e0c11504 --- /dev/null +++ b/src/pipecat/serializers/genesys.py @@ -0,0 +1,963 @@ +"""Genesys AudioHook Serializer for Pipecat. + +This module provides a serializer for integrating Pipecat pipelines with +Genesys Cloud Contact Center via the AudioHook protocol. + +Features: +- Bidirectional audio streaming (PCMU μ-law at 8kHz) +- Automatic protocol handshake handling (open/opened, close/closed, ping/pong) +- Input/output variables for Architect flow integration +- DTMF event support +- Barge-in (interruption) events +- Pause/resume support for hold scenarios (optional) + +Protocol Reference: +- https://developer.genesys.cloud/devapps/audiohook + +Audio Format: +- PCMU (μ-law) at 8kHz sample rate (preferred) +- L16 (16-bit linear PCM) at 8kHz also supported +- Mono (external channel) or Stereo (external on left, internal on right) +""" + +import json +import uuid +from datetime import timedelta +from enum import Enum +from typing import Any, Dict, List, Optional + +from loguru import logger + +from pipecat.audio.dtmf.types import KeypadEntry +from pipecat.audio.resamplers.soxr_stream_resampler import SOXRStreamAudioResampler +from pipecat.audio.utils import pcm_to_ulaw, ulaw_to_pcm +from pipecat.frames.frames import ( + AudioRawFrame, + CancelFrame, + EndFrame, + Frame, + InputAudioRawFrame, + InputDTMFFrame, + InterruptionFrame, + OutputTransportMessageFrame, + OutputTransportMessageUrgentFrame, + StartFrame, +) +from pipecat.serializers.base_serializer import FrameSerializer + + +class AudioHookMessageType(str, Enum): + """AudioHook protocol message types.""" + + OPEN = "open" + OPENED = "opened" + CLOSE = "close" + CLOSED = "closed" + PAUSE = "pause" + RESUMED = "resumed" + PING = "ping" + PONG = "pong" + UPDATE = "update" + EVENT = "event" + ERROR = "error" + DISCONNECT = "disconnect" + + +class AudioHookChannel(str, Enum): + """AudioHook audio channel configuration.""" + + EXTERNAL = "external" # Customer audio only (mono) + INTERNAL = "internal" # Agent audio only (mono) + BOTH = "both" # Stereo: external=left, internal=right + + +class AudioHookMediaFormat(str, Enum): + """Supported audio formats.""" + + PCMU = "PCMU" # μ-law, 8kHz + L16 = "L16" # 16-bit linear PCM, 8kHz + + +class GenesysAudioHookSerializer(FrameSerializer): + """Serializer for Genesys AudioHook WebSocket protocol. + + This serializer handles converting between Pipecat frames and Genesys + AudioHook protocol messages. It supports: + + - Bidirectional audio streaming (PCMU at 8kHz) + - Automatic protocol handshake (open/opened, close/closed, ping/pong) + - Session lifecycle management with pause/resume support + - Custom input/output variables for Architect flow integration + - DTMF event handling + - Barge-in events for interruption support + + The AudioHook protocol uses: + - Text WebSocket frames for JSON control messages + - Binary WebSocket frames for audio data + + Example usage: + ```python + serializer = GenesysAudioHookSerializer( + params=GenesysAudioHookSerializer.InputParams( + channel=AudioHookChannel.EXTERNAL, + supported_languages=["en-US", "es-ES"], + selected_language="en-US", + ) + ) + + # Use with FastAPI WebSocket transport + transport = FastAPIWebsocketTransport( + websocket=websocket, + params=FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + serializer=serializer, + audio_out_fixed_packet_size=1600, # Important: prevents 429 rate limiting from Genesys + ), + ) + + # Access call information after connection + participant = serializer.participant # ani, dnis, etc. + input_vars = serializer.input_variables # Custom vars from Architect + + # Set output variables to return to Architect + serializer.set_output_variables({"intent": "billing", "resolved": True}) + ``` + + Attributes: + PROTOCOL_VERSION: The AudioHook protocol version (currently "2"). + """ + + PROTOCOL_VERSION = "2" + + class InputParams(FrameSerializer.InputParams): + """Configuration parameters for GenesysAudioHookSerializer. + + Attributes: + genesys_sample_rate: Sample rate used by Genesys (default: 8000 Hz). + sample_rate: Optional override for pipeline input sample rate. + channel: Which audio channels to process (external, internal, both). + media_format: Audio format (PCMU or L16). + process_external: Whether to process external (customer) audio. + process_internal: Whether to process internal (agent) audio. + supported_languages: List of language codes the bot supports (e.g., ["en-US", "es-ES"]). + selected_language: Default language code to use. + start_paused: Whether to start the session in paused state. + ignore_rtvi_messages: Inherited from base FrameSerializer, defaults to True. + """ + + genesys_sample_rate: int = 8000 + sample_rate: Optional[int] = None + channel: AudioHookChannel = AudioHookChannel.EXTERNAL + media_format: AudioHookMediaFormat = AudioHookMediaFormat.PCMU + process_external: bool = True + process_internal: bool = False + supported_languages: Optional[List[str]] = None + selected_language: Optional[str] = None + start_paused: bool = False + + def __init__( + self, + params: Optional[InputParams] = None, + **kwargs, + ): + """Initialize the GenesysAudioHookSerializer. + + Args: + params: Configuration parameters. + **kwargs: Additional arguments passed to BaseObject (e.g., name). + """ + super().__init__(params or GenesysAudioHookSerializer.InputParams(), **kwargs) + + self._genesys_sample_rate = self._params.genesys_sample_rate + self._sample_rate = 0 # Pipeline input rate, set in setup() + self._session_id = str(uuid.uuid4()) + + # Use Pipecat's official resampler if needed (SOXR) + # Only used for TTS output (16kHz → 8kHz), input goes without resampling + self._input_resampler = SOXRStreamAudioResampler() + self._output_resampler = SOXRStreamAudioResampler() + + # Protocol state + self._client_seq = 0 + self._server_seq = 0 + self._is_open = False + self._is_paused = False + self._position = timedelta(0) + + # Session metadata + self._conversation_id: Optional[str] = None + self._participant: Optional[Dict[str, Any]] = None + self._custom_config: Optional[Dict[str, Any]] = None + self._media_info: Optional[List[Dict[str, Any]]] = None + self._input_variables: Optional[Dict[str, Any]] = None # Custom input from Genesys + self._output_variables: Optional[Dict[str, Any]] = None # Custom output to Genesys + + # Event handlers + self._register_event_handler("on_open") + self._register_event_handler("on_close") + self._register_event_handler("on_ping") + self._register_event_handler("on_pause") + self._register_event_handler("on_update") + self._register_event_handler("on_error") + self._register_event_handler("on_dtmf") + + @property + def session_id(self) -> str: + """Get the Genesys AudioHook session ID generated by the serializer.""" + return self._session_id + + @property + def conversation_id(self) -> Optional[str]: + """Get the Genesys conversation ID.""" + return self._conversation_id + + @property + def is_open(self) -> bool: + """Check if the AudioHook session is open.""" + return self._is_open + + @property + def is_paused(self) -> bool: + """Check if audio streaming is paused.""" + return self._is_paused + + @property + def participant(self) -> Optional[Dict[str, Any]]: + """Get participant info (ani, dnis, etc.) from the open message.""" + return self._participant + + @property + def input_variables(self) -> Optional[Dict[str, Any]]: + """Get custom input variables from the open message.""" + return self._input_variables + + @property + def output_variables(self) -> Optional[Dict[str, Any]]: + """Get custom output variables to send back to Genesys.""" + return self._output_variables + + def set_output_variables(self, variables: Dict[str, Any]) -> None: + """Set custom output variables to send back to Genesys on close. + + These variables will be included in the 'closed' response when Genesys + closes the connection, making them available in the Architect flow. + + Args: + variables: Dictionary of custom variables to send to Genesys. + + Example: + ```python + # During the conversation, collect data and set it + serializer.set_output_variables({ + "intent": "billing_inquiry", + "customer_verified": True, + "summary": "Customer asked about their bill", + "transfer_to": "billing_queue" + }) + ``` + """ + self._output_variables = variables + logger.debug(f"Output variables set: {variables}") + + async def setup(self, frame: StartFrame): + """Sets up the serializer with pipeline configuration. + + Args: + frame: The StartFrame containing pipeline configuration. + """ + self._sample_rate = self._params.sample_rate or frame.audio_in_sample_rate + logger.debug(f"GenesysAudioHookSerializer setup with sample_rate={self._sample_rate}") + + def _format_position(self, position: timedelta) -> str: + """Format a timedelta as ISO 8601 duration string. + + Args: + position: The timedelta to format. + + Returns: + ISO 8601 duration string (e.g., "PT1.5S"). + """ + total_seconds = position.total_seconds() + return f"PT{total_seconds:.3f}S" + + def _parse_position(self, position_str: str) -> timedelta: + """Parse an ISO 8601 duration string to timedelta. + + Args: + position_str: ISO 8601 duration string (e.g., "PT1.5S"). + + Returns: + Corresponding timedelta. + """ + # Simple parser for PT#S or PT#.#S format + if position_str.startswith("PT") and position_str.endswith("S"): + try: + seconds = float(position_str[2:-1]) + return timedelta(seconds=seconds) + except ValueError: + pass + return timedelta(0) + + def _next_server_seq(self) -> int: + """Get the next server sequence number.""" + self._server_seq += 1 + return self._server_seq + + def _create_message( + self, + msg_type: AudioHookMessageType, + parameters: Optional[Dict[str, Any]] = None, + include_position: bool = True, + ) -> Dict[str, Any]: + """Create a protocol message with common fields. + + Based on the Genesys AudioHook protocol, responses include: + - seq: Server's sequence number (incremented per message) + - clientseq: Echo of the client's last sequence number + + Args: + msg_type: The message type. + parameters: Optional parameters object. + include_position: Whether to include position field. + + Returns: + The message dictionary. + """ + seq = self._next_server_seq() + msg = { + "version": self.PROTOCOL_VERSION, + "type": msg_type.value, + "seq": seq, + "clientseq": self._client_seq, + "id": self._session_id, + } + + if include_position: + msg["position"] = self._format_position(self._position) + + msg["parameters"] = parameters if parameters is not None else {} + + return msg + + def create_opened_response( + self, + start_paused: bool = False, + supported_languages: Optional[List[str]] = None, + selected_language: Optional[str] = None, + ) -> Dict[str, Any]: + """Create an 'opened' response message for the client. + + This should be sent in response to an 'open' message from Genesys. + + Args: + start_paused: Whether to start the session paused. + supported_languages: List of supported language codes. + selected_language: The selected language code. + + Returns: + Dictionary of the opened response message. + """ + # Build channels list based on configuration + channels: list[str] = [] + + if self._params.channel == AudioHookChannel.EXTERNAL: + channels = ["external"] + elif self._params.channel == AudioHookChannel.INTERNAL: + channels = ["internal"] + elif self._params.channel == AudioHookChannel.BOTH: + channels = ["external", "internal"] + + parameters = { + "startPaused": start_paused, + "media": [ + { + "type": "audio", + "format": self._params.media_format.value, + "channels": channels, + "rate": self._genesys_sample_rate, + } + ], + } + + if supported_languages: + parameters["supportedLanguages"] = supported_languages + if selected_language: + parameters["selectedLanguage"] = selected_language + + msg = self._create_message( + AudioHookMessageType.OPENED, + parameters=parameters, + include_position=False, # opened doesn't need position + ) + + self._is_open = True + + logger.debug(f"AudioHook session opened: {self._session_id}") + + return msg + + def create_closed_response( + self, + output_variables: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Create a 'closed' response message. + + This should be sent in response to a 'close' message from Genesys. + + Args: + output_variables: Optional custom variables to pass back to Genesys. + These will be available in the Architect flow after the AudioHook + action completes. + + Returns: + Dictionary of the closed response message. + + Example: + ```python + # Pass custom data back to Genesys + serializer.create_closed_response( + output_variables={ + "intent": "billing_inquiry", + "customer_verified": True, + "summary": "Customer asked about their bill" + } + ) + ``` + """ + parameters: Optional[Dict[str, Any]] = None + + if output_variables: + parameters = {"outputVariables": output_variables} + + msg = self._create_message( + AudioHookMessageType.CLOSED, + parameters=parameters, + ) + + self._is_open = False + logger.debug(f"AudioHook session closed: {self._session_id}") + + return msg + + def create_pong_response(self) -> Dict[str, Any]: + """Create a 'pong' response message. + + This should be sent in response to a 'ping' message from Genesys. + + Returns: + Dictionary of the pong response message. + """ + msg = self._create_message(AudioHookMessageType.PONG) + return msg + + def create_resumed_response(self) -> Dict[str, Any]: + """Create a 'resumed' response message. + + This should be sent in response to a 'pause' message when ready to resume. + + Returns: + Dictionary of the resumed response message. + """ + msg = self._create_message(AudioHookMessageType.RESUMED) + + self._is_paused = False + logger.debug(f"AudioHook session resumed: {self._session_id}") + + return msg + + def create_barge_in_event(self) -> Dict[str, Any]: + """Create a barge-in event message. + + This notifies Genesys Cloud that the user has interrupted the bot's + audio output. Genesys will stop any queued audio playback. + + Returns: + Dictionary of the barge-in event message. + """ + msg = self._create_message( + AudioHookMessageType.EVENT, + parameters={"entities": [{"type": "barge_in", "data": {}}]}, + ) + + logger.debug("🔇 Barge-in event sent to Genesys") + + return msg + + def create_disconnect_message( + self, + reason: str = "completed", + action: str = "transfer", + output_variables: Optional[Dict[str, Any]] = None, + info: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a 'disconnect' message to initiate session termination. + + Args: + reason: Disconnect reason (e.g., "completed", "error"). + action: Action to take ("transfer" to agent, "finished" if completed). + output_variables: Custom output variables to pass back to Genesys. + info: Optional additional information. + + Returns: + Dictionary of the disconnect message. + """ + parameters: Dict[str, Any] = {"reason": reason} + + # Build outputVariables + out_vars = {"action": action} + if output_variables: + out_vars.update(output_variables) + parameters["outputVariables"] = out_vars + + if info: + parameters["info"] = info + + msg = self._create_message( + AudioHookMessageType.DISCONNECT, + parameters=parameters, + ) + + logger.debug(f"AudioHook disconnect: reason={reason}, action={action}") + return msg + + def create_error_message( + self, + code: int, + message: str, + retryable: bool = False, + ) -> Dict[str, Any]: + """Create an 'error' message. + + Args: + code: Error code. + message: Error message. + retryable: Whether the operation can be retried. + + Returns: + Dictionary of the error message. + """ + parameters = { + "code": code, + "message": message, + "retryable": retryable, + } + + msg = self._create_message( + AudioHookMessageType.ERROR, + parameters=parameters, + ) + + logger.error(f"AudioHook error: {code} - {message}") + return msg + + async def serialize(self, frame: Frame) -> str | bytes | None: + """Serializes a Pipecat frame to Genesys AudioHook format. + + Handles conversion of various frame types to AudioHook messages: + - AudioRawFrame -> Binary PCMU audio data (resampled to 8kHz) + - EndFrame/CancelFrame -> Disconnect message (JSON) + - InterruptionFrame -> Barge-in event (JSON) + - OutputTransportMessageFrame -> Pass-through JSON + + Args: + frame: The Pipecat frame to serialize. + + Returns: + Serialized data as string (JSON) or bytes (audio), or None if + the frame type is not handled or session is not open. + """ + if isinstance(frame, (EndFrame, CancelFrame)): + return json.dumps( + self.create_disconnect_message( + output_variables=self.output_variables, reason="completed" + ) + ) + + elif isinstance(frame, AudioRawFrame): + if not self._is_open or self._is_paused: + return None + + data = frame.audio + + # Convert PCM to μ-law at 8kHz for Genesys + if self._params.media_format == AudioHookMediaFormat.PCMU: + serialized_data = await pcm_to_ulaw( + data, + frame.sample_rate, + self._genesys_sample_rate, + self._output_resampler, + ) + else: + # L16 format - just resample if needed + logger.warning("L16 format not yet fully implemented") + return None + + if serialized_data is None or len(serialized_data) == 0: + return None + + return bytes(serialized_data) + + elif isinstance(frame, InterruptionFrame): + return json.dumps(self.create_barge_in_event()) + + elif isinstance(frame, (OutputTransportMessageFrame, OutputTransportMessageUrgentFrame)): + # Filter out RTVI messages using base class method + if self.should_ignore_frame(frame): + return None + # Only pass through AudioHook protocol messages (those with "version" field) + # Filter out RTVI and other non-AudioHook messages + if isinstance(frame.message, dict) and "version" in frame.message: + return json.dumps(frame.message) + else: + # Not an AudioHook message, ignore + return None + + # Ignore other frames - we don't need to process them here + return None + + async def deserialize(self, data: str | bytes) -> Frame | None: + """Deserializes Genesys AudioHook data to Pipecat frames. + + Handles: + - Binary data -> InputAudioRawFrame (converted from PCMU to PCM) + - JSON 'open' -> OutputTransportMessageUrgentFrame with 'opened' response + - JSON 'close' -> OutputTransportMessageUrgentFrame with 'closed' response + - JSON 'ping' -> OutputTransportMessageUrgentFrame with 'pong' response + - JSON 'pause' -> Sets is_paused=True, returns None + - JSON 'dtmf' -> InputDTMFFrame + - JSON 'update' -> Updates participant info, returns None + - JSON 'error' -> Logs error, returns None + + Protocol responses (opened, closed, pong) are returned as urgent frames + to be sent immediately through the transport. + + Args: + data: The raw WebSocket data from Genesys (binary audio or JSON text). + + Returns: + A Pipecat frame to process, or None if handled internally. + """ + # Binary data = audio + if isinstance(data, bytes): + return await self._deserialize_audio(data) + + # Text data = JSON control message + try: + message = json.loads(data) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse AudioHook message: {e}") + return None + + return await self._handle_control_message(message) + + async def _deserialize_audio(self, data: bytes) -> Frame | None: + """Deserialize binary audio data to an InputAudioRawFrame. + + Args: + data: Raw audio bytes (PCMU or L16). + + Returns: + InputAudioRawFrame with PCM audio at pipeline sample rate. + """ + if not self._is_open or self._is_paused: + return None + + audio_data = data + original_len = len(data) + + # If Genesys sends stereo audio (BOTH channels), extract only the external channel (left) + # Stereo audio comes interleaved: [L0, R0, L1, R1, ...] + if self._params.channel == AudioHookChannel.BOTH and len(data) > 0: + # For PCMU, each sample is 1 byte + # Extract only bytes at even positions (left channel = external) + audio_data = bytes(data[i] for i in range(0, len(data), 2)) + logger.debug( + f"🔊 Stereo audio: {original_len} bytes → {len(audio_data)} bytes (external channel)" + ) + + if self._params.media_format == AudioHookMediaFormat.PCMU: + # Convert μ-law at 8kHz to PCM at pipeline rate + deserialized_data = await ulaw_to_pcm( + audio_data, + self._genesys_sample_rate, + self._sample_rate, + self._input_resampler, + ) + else: + # L16 format + logger.warning("L16 format not yet fully implemented") + return None + + if deserialized_data is None or len(deserialized_data) == 0: + return None + + # Always use mono for STT - ElevenLabs expects single channel + num_channels = 1 + + audio_frame = InputAudioRawFrame( + audio=deserialized_data, + num_channels=num_channels, + sample_rate=self._sample_rate, + ) + + return audio_frame + + async def _handle_control_message(self, message: Dict[str, Any]) -> Frame | None: + """Handle a JSON control message from Genesys. + + Args: + message: Parsed JSON message. + + Returns: + Frame if the message should be passed to the pipeline, None otherwise. + """ + msg_type = message.get("type", "") + self._client_seq = message.get("seq", 0) + + # Update position if provided + if "position" in message: + self._position = self._parse_position(message["position"]) + + if msg_type == AudioHookMessageType.OPEN.value: + return await self._handle_open(message) + + elif msg_type == AudioHookMessageType.CLOSE.value: + return await self._handle_close(message) + + elif msg_type == AudioHookMessageType.PING.value: + return await self._handle_ping(message) + + elif msg_type == AudioHookMessageType.PAUSE.value: + return await self._handle_pause(message) + + elif msg_type == AudioHookMessageType.UPDATE.value: + return await self._handle_update(message) + + elif msg_type == AudioHookMessageType.ERROR.value: + return await self._handle_error(message) + + elif msg_type == "dtmf": + return await self._handle_dtmf(message) + + elif msg_type == "playback_started": + logger.debug("Playback started (from Genesys)") + return None + + elif msg_type == "playback_completed": + logger.debug("Playback completed (from Genesys)") + return None + else: + logger.warning(f"Unknown AudioHook message type: {msg_type}") + return None + + async def _handle_open(self, message: Dict[str, Any]) -> Frame | None: + """Handle an 'open' message from Genesys. + + This initializes the session with metadata from Genesys Cloud and + automatically responds with an 'opened' message using the configured + InputParams (supported_languages, selected_language, start_paused). + + Extracts and stores: + - session_id: The AudioHook session identifier + - conversation_id: The Genesys conversation ID + - participant: Caller info (ani, dnis, etc.) + - input_variables: Custom variables from Architect flow + - media_info: Audio configuration from Genesys + + Args: + message: The open message from Genesys. + + Returns: + OutputTransportMessageUrgentFrame with the 'opened' response. + """ + self._session_id = message.get("id", str(uuid.uuid4())) + + params = message.get("parameters", {}) + self._conversation_id = params.get("conversationId") + self._participant = params.get("participant") + self._custom_config = params.get("customConfig") + self._media_info = params.get("media") # This is a list of media objects + self._input_variables = params.get("inputVariables") # Custom vars from Genesys + + # Extract media configuration if present + # media is a list like: [{"type": "audio", "format": "PCMU", "channels": ["external"], "rate": 8000}] + media_list = self._media_info + if media_list and isinstance(media_list, list) and len(media_list) > 0: + audio_media: Dict[str, Any] = media_list[0] # Get first media entry + channels = audio_media.get("channels", []) + logger.debug( + f"📡 Genesys audio config: format={audio_media.get('format')}, channels={channels}, rate={audio_media.get('rate')}" + ) + # channels is a list like ["external"] or ["external", "internal"] + if isinstance(channels, list): + if "external" in channels and "internal" in channels: + self._params.channel = AudioHookChannel.BOTH + logger.debug("📡 Stereo mode: extracting external channel") + elif "external" in channels: + self._params.channel = AudioHookChannel.EXTERNAL + logger.debug("📡 Mono mode: external channel") + elif "internal" in channels: + self._params.channel = AudioHookChannel.INTERNAL + logger.debug("📡 Mono mode: internal channel") + + # Log participant info for debugging + ani = self._participant.get("ani", "unknown") if self._participant else "unknown" + logger.info( + f"AudioHook open request: session={self._session_id}, " + f"conversation={self._conversation_id}, ani={ani}" + ) + + await self._call_event_handler("on_open", message) + + return OutputTransportMessageUrgentFrame( + message=self.create_opened_response( + start_paused=self._params.start_paused, + supported_languages=self._params.supported_languages, + selected_language=self._params.selected_language, + ) + ) + + async def _handle_close(self, message: Dict[str, Any]) -> Frame | None: + """Handle a 'close' message from Genesys. + + Automatically responds with a 'closed' message. If output_variables + were set via set_output_variables(), they will be included in the + response and made available in the Architect flow. + + Args: + message: The close message from Genesys. + + Returns: + OutputTransportMessageUrgentFrame with the closed response + (includes outputVariables if set). + """ + params = message.get("parameters", {}) + reason = params.get("reason", "unknown") + + logger.info(f"🔴 Genesys closed the connection: {reason}") + + self._is_open = False + + logger.info(f"Sending closed response to Genesys...") + + await self._call_event_handler("on_close", message) + + # Return as urgent frame to be sent through pipeline immediately + # Include any output variables that were set during the session + return OutputTransportMessageUrgentFrame( + message=self.create_closed_response(output_variables=self._output_variables) + ) + + async def _handle_ping(self, message: Dict[str, Any]) -> Frame | None: + """Handle a 'ping' message from Genesys. + + Automatically responds with a 'pong' message to maintain the connection. + + Args: + message: The ping message from Genesys. + + Returns: + OutputTransportMessageUrgentFrame with pong response. + """ + logger.info(f"Sending pong response to Genesys...") + + await self._call_event_handler("on_ping", message) + + # Return as urgent frame to be sent through pipeline immediately + return OutputTransportMessageUrgentFrame(message=self.create_pong_response()) + + async def _handle_pause(self, message: Dict[str, Any]) -> Frame | None: + """Handle a 'pause' message from Genesys. + + This is used when audio streaming is temporarily suspended + (e.g., during hold). + + Args: + message: The pause message. + + Returns: + None (response should be sent via create_resumed_response()). + """ + params = message.get("parameters", {}) + reason = params.get("reason", "unknown") + + logger.info(f"AudioHook pause request: reason={reason}") + + self._is_paused = True + + await self._call_event_handler("on_pause", message) + + # Note: Application should call create_resumed_response() when ready + return None + + async def _handle_update(self, message: Dict[str, Any]) -> Frame | None: + """Handle an 'update' message from Genesys. + + Updates may include changes to participants or configuration. + + Args: + message: The update message. + + Returns: + None. + """ + params = message.get("parameters", {}) + + if "participant" in params: + self._participant = params["participant"] + + logger.debug(f"AudioHook update received: {params}") + + await self._call_event_handler("on_update", message) + + return None + + async def _handle_error(self, message: Dict[str, Any]) -> Frame | None: + """Handle an 'error' message from Genesys. + + Args: + message: The error message. + + Returns: + None. + """ + params = message.get("parameters", {}) + code = params.get("code", 0) + error_msg = params.get("message", "Unknown error") + + logger.error(f"AudioHook error from Genesys: {code} - {error_msg}") + + await self._call_event_handler("on_error", message) + + return None + + async def _handle_dtmf(self, message: Dict[str, Any]) -> Frame | None: + """Handle a 'dtmf' message from Genesys. + + DTMF (Dual-Tone Multi-Frequency) events are sent when the user + presses keys on their phone keypad. + + Args: + message: The DTMF message. + + Returns: + InputDTMFFrame with the pressed digit. + """ + params = message.get("parameters", {}) + digit = params.get("digit", "") + + if not digit: + logger.warning("DTMF message received without digit") + return None + + logger.info(f"DTMF received: {digit}") + + await self._call_event_handler("on_dtmf", message) + + try: + return InputDTMFFrame(KeypadEntry(digit)) + except ValueError: + # Invalid digit + logger.warning(f"Invalid DTMF digit: {digit}") + return None diff --git a/src/pipecat/serializers/plivo.py b/src/pipecat/serializers/plivo.py index 2a57d3698..b6346d542 100644 --- a/src/pipecat/serializers/plivo.py +++ b/src/pipecat/serializers/plivo.py @@ -11,7 +11,6 @@ import json from typing import Optional from loguru import logger -from pydantic import BaseModel from pipecat.audio.dtmf.types import KeypadEntry from pipecat.audio.utils import create_stream_resampler, pcm_to_ulaw, ulaw_to_pcm @@ -42,13 +41,14 @@ class PlivoFrameSerializer(FrameSerializer): credentials to be provided. """ - class InputParams(BaseModel): + class InputParams(FrameSerializer.InputParams): """Configuration parameters for PlivoFrameSerializer. Parameters: plivo_sample_rate: Sample rate used by Plivo, defaults to 8000 Hz. sample_rate: Optional override for pipeline input sample rate. auto_hang_up: Whether to automatically terminate call on EndFrame. + ignore_rtvi_messages: Inherited from base FrameSerializer, defaults to True. """ plivo_sample_rate: int = 8000 @@ -72,11 +72,12 @@ class PlivoFrameSerializer(FrameSerializer): auth_token: Plivo auth token (required for auto hang-up). params: Configuration parameters. """ + super().__init__(params or PlivoFrameSerializer.InputParams()) + self._stream_id = stream_id self._call_id = call_id self._auth_id = auth_id self._auth_token = auth_token - self._params = params or PlivoFrameSerializer.InputParams() self._plivo_sample_rate = self._params.plivo_sample_rate self._sample_rate = 0 # Pipeline input rate @@ -140,6 +141,8 @@ class PlivoFrameSerializer(FrameSerializer): return json.dumps(answer) elif isinstance(frame, (OutputTransportMessageFrame, OutputTransportMessageUrgentFrame)): + if self.should_ignore_frame(frame): + return None return json.dumps(frame.message) # Return None for unhandled frames diff --git a/src/pipecat/serializers/protobuf.py b/src/pipecat/serializers/protobuf.py index 6d989c7dd..fd21af2bf 100644 --- a/src/pipecat/serializers/protobuf.py +++ b/src/pipecat/serializers/protobuf.py @@ -8,6 +8,7 @@ import dataclasses import json +from typing import Optional from loguru import logger @@ -60,9 +61,13 @@ class ProtobufFrameSerializer(FrameSerializer): } DESERIALIZABLE_FIELDS = {v: k for k, v in DESERIALIZABLE_TYPES.items()} - def __init__(self): - """Initialize the Protobuf frame serializer.""" - pass + def __init__(self, params: Optional[FrameSerializer.InputParams] = None): + """Initialize the Protobuf frame serializer. + + Args: + params: Configuration parameters. + """ + super().__init__(params) async def serialize(self, frame: Frame) -> str | bytes | None: """Serialize a frame to Protocol Buffer binary format. @@ -75,6 +80,8 @@ class ProtobufFrameSerializer(FrameSerializer): """ # Wrapping this messages as a JSONFrame to send if isinstance(frame, (OutputTransportMessageFrame, OutputTransportMessageUrgentFrame)): + if self.should_ignore_frame(frame): + return None frame = MessageFrame( data=json.dumps(frame.message), ) @@ -126,7 +133,7 @@ class ProtobufFrameSerializer(FrameSerializer): if "pts" in args_dict: del args_dict["pts"] - # Special handling for MessageFrame -> OutputTransportMessageUrgentFrame + # Special handling for MessageFrame -> InputTransportMessageFrame if class_name == MessageFrame: try: msg = json.loads(args_dict["data"]) diff --git a/src/pipecat/serializers/telnyx.py b/src/pipecat/serializers/telnyx.py index 769244f93..1c0405ade 100644 --- a/src/pipecat/serializers/telnyx.py +++ b/src/pipecat/serializers/telnyx.py @@ -198,7 +198,7 @@ class TelnyxFrameSerializer(FrameSerializer): f"Telnyx call {call_control_id} was already terminated" ) return - except: + except Exception: pass # Fall through to log the raw error # Log other 422 errors diff --git a/src/pipecat/serializers/twilio.py b/src/pipecat/serializers/twilio.py index 2e60399fd..4d4b5344a 100644 --- a/src/pipecat/serializers/twilio.py +++ b/src/pipecat/serializers/twilio.py @@ -11,7 +11,6 @@ import json from typing import Optional from loguru import logger -from pydantic import BaseModel from pipecat.audio.dtmf.types import KeypadEntry from pipecat.audio.utils import create_stream_resampler, pcm_to_ulaw, ulaw_to_pcm @@ -42,13 +41,14 @@ class TwilioFrameSerializer(FrameSerializer): credentials to be provided. """ - class InputParams(BaseModel): + class InputParams(FrameSerializer.InputParams): """Configuration parameters for TwilioFrameSerializer. Parameters: twilio_sample_rate: Sample rate used by Twilio, defaults to 8000 Hz. sample_rate: Optional override for pipeline input sample rate. auto_hang_up: Whether to automatically terminate call on EndFrame. + ignore_rtvi_messages: Inherited from base FrameSerializer, defaults to True. """ twilio_sample_rate: int = 8000 @@ -76,7 +76,7 @@ class TwilioFrameSerializer(FrameSerializer): edge: Twilio edge location (e.g., "sydney", "dublin"). Must be specified with region. params: Configuration parameters. """ - self._params = params or TwilioFrameSerializer.InputParams() + super().__init__(params or TwilioFrameSerializer.InputParams()) # Validate hangup-related parameters if auto_hang_up is enabled if self._params.auto_hang_up: @@ -167,6 +167,8 @@ class TwilioFrameSerializer(FrameSerializer): return json.dumps(answer) elif isinstance(frame, (OutputTransportMessageFrame, OutputTransportMessageUrgentFrame)): + if self.should_ignore_frame(frame): + return None return json.dumps(frame.message) # Return None for unhandled frames @@ -209,7 +211,7 @@ class TwilioFrameSerializer(FrameSerializer): if error_data.get("code") == 20404: logger.debug(f"Twilio call {call_sid} was already terminated") return - except: + except Exception: pass # Fall through to log the raw error # Log other 404 errors diff --git a/src/pipecat/serializers/vonage.py b/src/pipecat/serializers/vonage.py new file mode 100644 index 000000000..c14ae4025 --- /dev/null +++ b/src/pipecat/serializers/vonage.py @@ -0,0 +1,183 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Vonage Audio Connector WebSocket serializer for Pipecat.""" + +import json +from typing import Optional + +from loguru import logger + +from pipecat.audio.dtmf.types import KeypadEntry +from pipecat.audio.utils import create_stream_resampler +from pipecat.frames.frames import ( + AudioRawFrame, + Frame, + InputAudioRawFrame, + InputDTMFFrame, + InterruptionFrame, + OutputTransportMessageFrame, + OutputTransportMessageUrgentFrame, + StartFrame, +) +from pipecat.serializers.base_serializer import FrameSerializer + + +class VonageFrameSerializer(FrameSerializer): + """Serializer for Vonage Video API Audio Connector WebSocket protocol. + + This serializer converts between Pipecat frames and the Vonage Audio Connector + WebSocket streaming protocol. + + Note: + Ref docs: https://developer.vonage.com/en/video/guides/audio-connector + """ + + class InputParams(FrameSerializer.InputParams): + """Configuration parameters for VonageFrameSerializer. + + Parameters: + vonage_sample_rate: Sample rate used by Vonage, defaults to 16000 Hz. + Common values: 8000, 16000, 24000 Hz. + sample_rate: Optional override for pipeline input sample rate. + ignore_rtvi_messages: Inherited from base FrameSerializer, defaults to True. + """ + + vonage_sample_rate: int = 16000 + sample_rate: Optional[int] = None + + def __init__(self, params: Optional[InputParams] = None): + """Initialize the VonageFrameSerializer. + + Args: + params: Configuration parameters. + """ + super().__init__(params or VonageFrameSerializer.InputParams()) + + self._vonage_sample_rate = self._params.vonage_sample_rate + self._sample_rate = 0 # Pipeline input rate + + self._input_resampler = create_stream_resampler() + self._output_resampler = create_stream_resampler() + + async def setup(self, frame: StartFrame): + """Sets up the serializer with pipeline configuration. + + Args: + frame: The StartFrame containing pipeline configuration. + """ + self._sample_rate = self._params.sample_rate or frame.audio_in_sample_rate + + async def serialize(self, frame: Frame) -> str | bytes | None: + """Serializes a Pipecat frame to Vonage WebSocket format. + + Handles conversion of various frame types to Vonage WebSocket messages. + + Args: + frame: The Pipecat frame to serialize. + + Returns: + Serialized data as string (JSON commands) or bytes (audio), or None if the frame isn't handled. + """ + if isinstance(frame, InterruptionFrame): + # Clear the audio buffer to stop playback immediately + answer = {"action": "clear"} + return json.dumps(answer) + elif isinstance(frame, AudioRawFrame): + data = frame.audio + + # Output: Convert PCM at frame's rate to Vonage's sample rate (16-bit linear PCM) + serialized_data = await self._output_resampler.resample( + data, frame.sample_rate, self._vonage_sample_rate + ) + if serialized_data is None or len(serialized_data) == 0: + # Ignoring in case we don't have audio + return None + + # Vonage expects raw binary PCM data (not base64 encoded) + return serialized_data + elif isinstance(frame, (OutputTransportMessageFrame, OutputTransportMessageUrgentFrame)): + if self.should_ignore_frame(frame): + return None + # Allow sending custom JSON commands (e.g., notify) + return json.dumps(frame.message) + + return None + + async def deserialize(self, data: str | bytes) -> Frame | None: + """Deserializes Vonage WebSocket data to Pipecat frames. + + Handles conversion of Vonage events to appropriate Pipecat frames. + - Binary messages contain audio data (16-bit linear PCM) + - Text messages contain JSON events (websocket:connected, websocket:cleared, dtmf, etc.) + + Args: + data: The raw WebSocket data from Vonage. + + Returns: + A Pipecat frame corresponding to the Vonage event, or None if unhandled. + """ + # Check if this is binary audio data + if isinstance(data, bytes): + # Binary message = audio data (16-bit linear PCM) + payload = data + + # Input: Convert Vonage's PCM audio to pipeline sample rate + deserialized_data = await self._input_resampler.resample( + payload, + self._vonage_sample_rate, + self._sample_rate, + ) + if deserialized_data is None or len(deserialized_data) == 0: + # Ignoring in case we don't have audio + return None + + audio_frame = InputAudioRawFrame( + audio=deserialized_data, + num_channels=1, # Vonage uses mono audio + sample_rate=self._sample_rate, # Use the configured pipeline input rate + ) + return audio_frame + else: + # Text message = JSON event + try: + message = json.loads(data) + event = message.get("event") + + # Handle different event types + if event == "websocket:connected": + logger.debug( + f"Vonage WebSocket connected: content-type={message.get('content-type')}" + ) + return None + elif event == "websocket:cleared": + logger.debug("Vonage audio buffer cleared") + return None + elif event == "websocket:notify": + logger.debug(f"Vonage notify event: {message.get('payload')}") + return None + elif event == "websocket:dtmf": + # Handle DTMF input + # Vonage may send digit in different formats, try both + digit = message.get("digit") or message.get("dtmf", {}).get("digit") + if digit is None: + logger.warning(f"DTMF event received but no digit found: {message}") + return None + + digit = str(digit) + logger.debug(f"Received DTMF digit: {digit}") + try: + return InputDTMFFrame(KeypadEntry(digit)) + except ValueError: + logger.warning(f"Invalid DTMF digit received: {digit}") + return None + else: + logger.debug(f"Vonage event: {event}") + return None + + except json.JSONDecodeError: + logger.warning(f"Failed to parse JSON message from Vonage: {data}") + return None diff --git a/src/pipecat/services/ai_service.py b/src/pipecat/services/ai_service.py index a9952fa00..dd9ef1dba 100644 --- a/src/pipecat/services/ai_service.py +++ b/src/pipecat/services/ai_service.py @@ -10,7 +10,8 @@ Provides the foundation for all AI services in the Pipecat framework, including model management, settings handling, and frame processing lifecycle methods. """ -from typing import Any, AsyncGenerator, Dict, Mapping +import warnings +from typing import Any, AsyncGenerator, Dict from loguru import logger @@ -23,6 +24,7 @@ from pipecat.frames.frames import ( ) from pipecat.metrics.metrics import MetricsData from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.services.settings import ServiceSettings class AIService(FrameProcessor): @@ -34,34 +36,38 @@ class AIService(FrameProcessor): this base infrastructure. """ - def __init__(self, **kwargs): + def __init__(self, settings: ServiceSettings | None = None, **kwargs): """Initialize the AI service. Args: + settings: The runtime-updatable settings for the AI service. **kwargs: Additional arguments passed to the parent FrameProcessor. """ super().__init__(**kwargs) - self._model_name: str = "" - self._settings: Dict[str, Any] = {} + self._settings: ServiceSettings = ( + settings + # Here in case subclass doesn't implement more specific settings + # (which hopefully should be rare) + or ServiceSettings() + ) + self._sync_model_name_to_metrics() self._session_properties: Dict[str, Any] = {} + self._tracing_enabled: bool = False + self._tracing_context = None - @property - def model_name(self) -> str: - """Get the current model name. + def _sync_model_name_to_metrics(self): + """Sync the current AI model name (in `self._settings.model`) for usage in metrics. - Returns: - The name of the AI model being used. - """ - return self._model_name - - def set_model_name(self, model: str): - """Set the AI model name and update metrics. + We don't store model name here because there's already a single source + of truth for it in `self._settings.model`. This method is just for + syncing the model name to the metrics data. Args: model: The name of the AI model to use. """ - self._model_name = model - self.set_core_metrics_data(MetricsData(processor=self.name, model=self._model_name)) + self.set_core_metrics_data( + MetricsData(processor=self.name, model=self._settings.model or "") + ) async def start(self, frame: StartFrame): """Start the AI service. @@ -72,7 +78,9 @@ class AIService(FrameProcessor): Args: frame: The start frame containing initialization parameters. """ - pass + self._settings.validate_complete() + self._tracing_enabled = frame.enable_tracing + self._tracing_context = frame.tracing_context async def stop(self, frame: EndFrame): """Stop the AI service. @@ -96,44 +104,82 @@ class AIService(FrameProcessor): """ pass - async def _update_settings(self, settings: Mapping[str, Any]): - from pipecat.services.openai.realtime.events import SessionProperties + async def _update_settings(self, delta: ServiceSettings) -> Dict[str, Any]: + """Apply a settings delta and return the changed fields. - for key, value in settings.items(): - logger.debug("Update request for:", key, value) + The delta is applied to ``_settings`` and a dict mapping each changed + field name to its **pre-update** value is returned. The ``model`` + field is handled specially: when it changes, ``set_model_name`` is + called. - if key in self._settings: - logger.info(f"Updating LLM setting {key} to: [{value}]") - self._settings[key] = value - elif key in SessionProperties.model_fields: - logger.debug("Attempting to update", key, value) + Concrete services should override this method (calling ``super()``) + to react to specific changed fields (e.g. reconnect on voice change). - try: - from pipecat.services.openai.realtime.events import TurnDetection + Args: + delta: A delta-mode settings object. - if isinstance(self._session_properties, SessionProperties): - current_properties = self._session_properties - else: - current_properties = SessionProperties(**self._session_properties) + Returns: + Dict mapping changed field names to their previous values. + """ + changed = self._settings.apply_update(delta) - if key == "turn_detection" and isinstance(value, dict): - turn_detection = TurnDetection(**value) - setattr(current_properties, key, turn_detection) - else: - setattr(current_properties, key, value) + if "model" in changed: + self._sync_model_name_to_metrics() - validated_properties = SessionProperties.model_validate( - current_properties.model_dump() - ) - logger.info(f"Updating LLM setting {key} to: [{value}]") - self._session_properties = validated_properties.model_dump() - except Exception as e: - logger.warning(f"Unexpected error updating session property {key}: {e}") - elif key == "model": - logger.info(f"Updating LLM setting {key} to: [{value}]") - self.set_model_name(value) - else: - logger.warning(f"Unknown setting for {self.name} service: {key}") + if changed: + logger.info(f"{self.name}: updated settings fields: {set(changed)}") + + return changed + + def _warn_init_param_moved_to_settings( + self, + param_name: str, + settings_field: str | None = None, + stacklevel: int = 3, + ): + """Warn that an ``__init__`` param has moved to ``Settings``. + + Emits a ``DeprecationWarning`` directing users to the canonical + ``settings=ServiceClass.Settings(field=...)`` API. + + Args: + param_name: Name of the deprecated ``__init__`` parameter. + settings_field: The corresponding field on the ``Settings`` + dataclass, if different from *param_name*. When ``None`` + the message omits the field hint. + stacklevel: Stack depth for the warning. Default ``3`` targets + the caller's caller (i.e. user code that instantiated the + service). + """ + label = f"{type(self).__name__}.Settings" + if settings_field: + msg = ( + f"The `{param_name}` parameter is deprecated. " + f"Use `settings={label}({settings_field}=...)` instead. " + f"If both are provided, `settings` takes precedence." + ) + else: + msg = ( + f"The `{param_name}` parameter is deprecated. " + f"Use `settings={label}(...)` instead. " + f"If both are provided, `settings` takes precedence." + ) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn(msg, DeprecationWarning, stacklevel=stacklevel) + + def _warn_unhandled_updated_settings(self, unhandled): + """Log a warning for settings changes that won't take effect at runtime. + + Convenience helper for ``_update_settings`` overrides. Accepts any + iterable of field names (a ``dict``, ``set``, ``dict_keys``, etc.). + + Args: + unhandled: Field names that changed but are not applied. + """ + if unhandled: + fields = ", ".join(sorted(unhandled)) + logger.warning(f"{self.name}: runtime update of [{fields}] is not currently supported") async def process_frame(self, frame: Frame, direction: FrameDirection): """Process frames and handle service lifecycle. @@ -148,11 +194,11 @@ class AIService(FrameProcessor): await super().process_frame(frame, direction) if isinstance(frame, StartFrame): - await self.start(frame) - elif isinstance(frame, CancelFrame): - await self.cancel(frame) + await self._start(frame) elif isinstance(frame, EndFrame): - await self.stop(frame) + await self._stop(frame) + elif isinstance(frame, CancelFrame): + await self._cancel(frame) async def process_generator(self, generator: AsyncGenerator[Frame | None, None]): """Process frames from an async generator. @@ -169,3 +215,21 @@ class AIService(FrameProcessor): await self.push_error_frame(f) else: await self.push_frame(f) + + async def _start(self, frame: StartFrame): + try: + await self.start(frame) + except Exception as e: + logger.error(f"{self}: exception processing {frame}: {e}") + + async def _stop(self, frame: EndFrame): + try: + await self.stop(frame) + except Exception as e: + logger.error(f"{self}: exception processing {frame}: {e}") + + async def _cancel(self, frame: CancelFrame): + try: + await self.cancel(frame) + except Exception as e: + logger.error(f"{self}: exception processing {frame}: {e}") diff --git a/src/pipecat/services/anthropic/llm.py b/src/pipecat/services/anthropic/llm.py index 1d9bd6df9..2f375df82 100644 --- a/src/pipecat/services/anthropic/llm.py +++ b/src/pipecat/services/anthropic/llm.py @@ -16,7 +16,7 @@ import copy import io import json import re -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Dict, List, Literal, Optional, Union import httpx @@ -38,11 +38,9 @@ from pipecat.frames.frames import ( LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMMessagesFrame, - LLMTextFrame, LLMThoughtEndFrame, LLMThoughtStartFrame, LLMThoughtTextFrame, - LLMUpdateSettingsFrame, UserImageRawFrame, ) from pipecat.metrics.metrics import LLMTokenUsage @@ -59,6 +57,8 @@ from pipecat.processors.aggregators.openai_llm_context import ( ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService +from pipecat.services.settings import NOT_GIVEN as _NOT_GIVEN +from pipecat.services.settings import LLMSettings, _NotGiven, is_given from pipecat.utils.tracing.service_decorators import traced_llm try: @@ -69,6 +69,52 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +class AnthropicThinkingConfig(BaseModel): + """Configuration for extended thinking. + + Parameters: + type: Type of thinking mode (currently only "enabled" or "disabled"). + budget_tokens: Maximum number of tokens for thinking. + With today's models, the minimum is 1024. + Currently required when type is "enabled", not allowed when "disabled". + """ + + # Why `| str` here? To not break compatibility in case Anthropic adds + # more types in the future. + type: Literal["enabled", "disabled"] | str + + # No client-side validation on budget_tokens — we let the server + # enforce the rules so we stay forward-compatible if they change. + budget_tokens: Optional[int] = None + + +@dataclass +class AnthropicLLMSettings(LLMSettings): + """Settings for AnthropicLLMService. + + Parameters: + enable_prompt_caching: Whether to enable prompt caching. + thinking: Extended thinking configuration. + """ + + enable_prompt_caching: bool | _NotGiven = field(default_factory=lambda: _NOT_GIVEN) + thinking: Union["AnthropicLLMService.ThinkingConfig", _NotGiven] = field( + default_factory=lambda: _NOT_GIVEN + ) + + @classmethod + def from_mapping(cls, settings): + """Convert a plain dict to settings, coercing thinking dicts. + + For backward compatibility, a ``thinking`` value that is a plain dict + is converted to a :class:`AnthropicLLMService.ThinkingConfig`. + """ + instance = super().from_mapping(settings) + if is_given(instance.thinking) and isinstance(instance.thinking, dict): + instance.thinking = AnthropicLLMService.ThinkingConfig(**instance.thinking) + return instance + + @dataclass class AnthropicContextAggregatorPair: """Pair of context aggregators for Anthropic conversations. @@ -115,30 +161,22 @@ class AnthropicLLMService(LLMService): Can use custom clients like AsyncAnthropicBedrock and AsyncAnthropicVertex. """ + Settings = AnthropicLLMSettings + _settings: Settings + # Overriding the default adapter to use the Anthropic one. adapter_class = AnthropicLLMAdapter - class ThinkingConfig(BaseModel): - """Configuration for extended thinking. - - Parameters: - type: Type of thinking mode (currently only "enabled" or "disabled"). - budget_tokens: Maximum number of tokens for thinking. - With today's models, the minimum is 1024. - Only allowed if type is "enabled". - """ - - # Why `| str` here? To not break compatibility in case Anthropic adds - # more types in the future. - type: Literal["enabled", "disabled"] | str - - # Why not enforce minimnum of 1024 here? To not break compatibility in - # case Anthropic changes this requirement in the future. - budget_tokens: int + # Backward compatibility: ThinkingConfig used to be defined inline here. + ThinkingConfig = AnthropicThinkingConfig class InputParams(BaseModel): """Input parameters for Anthropic model inference. + .. deprecated:: 0.0.105 + Use ``AnthropicLLMService.Settings`` instead. Pass settings directly via the + ``settings`` parameter of :class:`AnthropicLLMService`. + Parameters: enable_prompt_caching: Whether to enable the prompt caching feature. enable_prompt_caching_beta (deprecated): Whether to enable the beta prompt caching feature. @@ -184,8 +222,9 @@ class AnthropicLLMService(LLMService): self, *, api_key: str, - model: str = "claude-sonnet-4-5-20250929", + model: Optional[str] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, client=None, retry_timeout_secs: Optional[float] = 5.0, retry_on_timeout: Optional[bool] = False, @@ -195,38 +234,87 @@ 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. + + .. deprecated:: 0.0.105 + Use ``settings=AnthropicLLMService.Settings(model=...)`` instead. + params: Optional model parameters for inference. + + .. deprecated:: 0.0.105 + Use ``settings=AnthropicLLMService.Settings(...)`` instead. + + settings: Runtime-updatable settings for this service. When both + deprecated parameters and *settings* are provided, *settings* + values take precedence. client: Optional custom Anthropic client instance. retry_timeout_secs: Request timeout in seconds for retry logic. retry_on_timeout: Whether to retry the request once if it times out. **kwargs: Additional arguments passed to parent LLMService. """ - super().__init__(**kwargs) - params = params or AnthropicLLMService.InputParams() + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="claude-sonnet-4-6", + system_instruction=None, + max_tokens=4096, + enable_prompt_caching=False, + temperature=NOT_GIVEN, + top_k=NOT_GIVEN, + top_p=NOT_GIVEN, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + thinking=NOT_GIVEN, + extra={}, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.max_tokens = params.max_tokens + default_settings.temperature = params.temperature + default_settings.top_k = params.top_k + default_settings.top_p = params.top_p + default_settings.thinking = params.thinking + if isinstance(params.extra, dict): + default_settings.extra = params.extra + # Handle enable_prompt_caching / enable_prompt_caching_beta + enable_prompt_caching = params.enable_prompt_caching + if params.enable_prompt_caching_beta is not None: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "enable_prompt_caching_beta is deprecated. " + "Use enable_prompt_caching instead.", + DeprecationWarning, + stacklevel=2, + ) + if enable_prompt_caching is None: + enable_prompt_caching = params.enable_prompt_caching_beta + default_settings.enable_prompt_caching = enable_prompt_caching or False + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(settings=default_settings, **kwargs) self._client = client or AsyncAnthropic( api_key=api_key ) # if the client is provided, use it and remove it, otherwise create a new one - self.set_model_name(model) self._retry_timeout_secs = retry_timeout_secs self._retry_on_timeout = retry_on_timeout - self._settings = { - "max_tokens": params.max_tokens, - "enable_prompt_caching": ( - params.enable_prompt_caching - if params.enable_prompt_caching is not None - else ( - params.enable_prompt_caching_beta - if params.enable_prompt_caching_beta is not None - else False - ) - ), - "temperature": params.temperature, - "top_k": params.top_k, - "top_p": params.top_p, - "thinking": params.thinking, - "extra": params.extra if isinstance(params.extra, dict) else {}, - } + if self._settings.system_instruction: + logger.debug(f"{self}: Using system instruction: {self._settings.system_instruction}") def can_generate_metrics(self) -> bool: """Check if this service can generate usage metrics. @@ -261,11 +349,20 @@ class AnthropicLLMService(LLMService): response = await api_call(**params) return response - async def run_inference(self, context: LLMContext | OpenAILLMContext) -> Optional[str]: + async def run_inference( + self, + context: LLMContext | OpenAILLMContext, + max_tokens: Optional[int] = None, + system_instruction: Optional[str] = None, + ) -> Optional[str]: """Run a one-shot, out-of-band (i.e. out-of-pipeline) inference with the given LLM context. Args: context: The LLM context containing conversation history. + max_tokens: Optional maximum number of tokens to generate. If provided, + overrides the service's default max_tokens setting. + system_instruction: Optional system instruction to use for this inference. + If provided, overrides any system instruction in the context. Returns: The LLM's response as a string, or None if no response is generated. @@ -276,7 +373,7 @@ class AnthropicLLMService(LLMService): if isinstance(context, LLMContext): adapter: AnthropicLLMAdapter = self.get_llm_adapter() invocation_params = adapter.get_llm_invocation_params( - context, enable_prompt_caching=self._settings["enable_prompt_caching"] + context, enable_prompt_caching=self._settings.enable_prompt_caching ) messages = invocation_params["messages"] system = invocation_params["system"] @@ -287,23 +384,32 @@ class AnthropicLLMService(LLMService): system = getattr(context, "system", NOT_GIVEN) tools = context.tools or [] + # Override system instruction if provided + if system_instruction is not None: + if system and system is not NOT_GIVEN: + logger.warning( + f"{self}: Both system_instruction and a system message in context are set." + " Using system_instruction." + ) + system = system_instruction + # Build params using the same method as streaming completions params = { - "model": self.model_name, - "max_tokens": self._settings["max_tokens"], + "model": self._settings.model, + "max_tokens": max_tokens if max_tokens is not None else self._settings.max_tokens, "stream": False, - "temperature": self._settings["temperature"], - "top_k": self._settings["top_k"], - "top_p": self._settings["top_p"], + "temperature": self._settings.temperature, + "top_k": self._settings.top_k, + "top_p": self._settings.top_p, "messages": messages, "system": system, "tools": tools, "betas": ["interleaved-thinking-2025-05-14"], } - if self._settings["thinking"]: - params["thinking"] = self._settings["thinking"].model_dump(exclude_unset=True) + if self._settings.thinking: + params["thinking"] = self._settings.thinking.model_dump(exclude_unset=True) - params.update(self._settings["extra"]) + params.update(self._settings.extra) # LLM completion response = await self._client.beta.messages.create(**params) @@ -353,15 +459,22 @@ class AnthropicLLMService(LLMService): # Universal LLMContext if isinstance(context, LLMContext): adapter: AnthropicLLMAdapter = self.get_llm_adapter() - params = adapter.get_llm_invocation_params( - context, enable_prompt_caching=self._settings["enable_prompt_caching"] + params: AnthropicLLMInvocationParams = adapter.get_llm_invocation_params( + context, enable_prompt_caching=self._settings.enable_prompt_caching ) + if self._settings.system_instruction: + if params["system"] is not NOT_GIVEN: + logger.warning( + f"{self}: Both system_instruction and a system message in context are" + " set. Using system_instruction." + ) + params["system"] = self._settings.system_instruction return params # Anthropic-specific context messages = ( context.get_messages_with_cache_control_markers() - if self._settings["enable_prompt_caching"] + if self._settings.enable_prompt_caching else context.messages ) return AnthropicLLMInvocationParams( @@ -403,22 +516,22 @@ class AnthropicLLMService(LLMService): await self.start_ttfb_metrics() params = { - "model": self.model_name, - "max_tokens": self._settings["max_tokens"], + "model": self._settings.model, + "max_tokens": self._settings.max_tokens, "stream": True, - "temperature": self._settings["temperature"], - "top_k": self._settings["top_k"], - "top_p": self._settings["top_p"], + "temperature": self._settings.temperature, + "top_k": self._settings.top_k, + "top_p": self._settings.top_p, } # Add thinking parameter if set - if self._settings["thinking"]: - params["thinking"] = self._settings["thinking"].model_dump(exclude_unset=True) + if self._settings.thinking: + params["thinking"] = self._settings.thinking.model_dump(exclude_unset=True) # Messages, system, tools params.update(params_from_context) - params.update(self._settings["extra"]) + params.update(self._settings.extra) # "Interleaved thinking" needed to allow thinking between sequences # of function calls, when extended thinking is enabled. @@ -439,7 +552,7 @@ class AnthropicLLMService(LLMService): if event.type == "content_block_delta": if hasattr(event.delta, "text"): - await self.push_frame(LLMTextFrame(event.delta.text)) + await self._push_llm_text(event.delta.text) completion_tokens_estimate += self._estimate_tokens(event.delta.text) elif hasattr(event.delta, "partial_json") and tool_use_block: json_accumulator += event.delta.partial_json @@ -572,11 +685,9 @@ class AnthropicLLMService(LLMService): # NOTE: LLMMessagesFrame is deprecated, so we don't support the newer universal # LLMContext with it context = AnthropicLLMContext.from_messages(frame.messages) - elif isinstance(frame, LLMUpdateSettingsFrame): - await self._update_settings(frame.settings) elif isinstance(frame, LLMEnablePromptCachingFrame): logger.debug(f"Setting enable prompt caching to: [{frame.enable}]") - self._settings["enable_prompt_caching"] = frame.enable + self._settings.enable_prompt_caching = frame.enable else: await self.push_frame(frame, direction) @@ -1135,7 +1246,7 @@ class AnthropicAssistantContextAggregator(LLMAssistantContextAggregator): frame: Frame containing function call result. """ if frame.result: - result = json.dumps(frame.result) + result = json.dumps(frame.result, ensure_ascii=False) await self._update_function_call_result(frame.function_name, frame.tool_call_id, result) else: await self._update_function_call_result( diff --git a/src/pipecat/services/assemblyai/models.py b/src/pipecat/services/assemblyai/models.py index ca58cb848..cffebcf06 100644 --- a/src/pipecat/services/assemblyai/models.py +++ b/src/pipecat/services/assemblyai/models.py @@ -12,7 +12,8 @@ transcription WebSocket messages and connection configuration. from typing import List, Literal, Optional -from pydantic import BaseModel, Field +from loguru import logger +from pydantic import BaseModel, ConfigDict, Field, model_validator class Word(BaseModel): @@ -68,8 +69,16 @@ class TurnMessage(BaseMessage): transcript: The transcribed text for this turn. end_of_turn_confidence: Confidence score for end-of-turn detection. words: List of individual words with timing and confidence data. + language_code: Detected language code (e.g., "es", "fr"). Only present with + complete utterances or when end_of_turn is True. + language_confidence: Confidence score (0-1) for language detection. Only present + with complete utterances or when end_of_turn is True. + speaker: Speaker label (e.g., "A", "B"). Only present when speaker_labels is + enabled and end_of_turn is True. Maps to 'speaker_label' in JSON response. """ + model_config = ConfigDict(populate_by_name=True) + type: Literal["Turn"] = "Turn" turn_order: int turn_is_formatted: bool @@ -77,6 +86,21 @@ class TurnMessage(BaseMessage): transcript: str end_of_turn_confidence: float words: List[Word] + language_code: Optional[str] = None + language_confidence: Optional[float] = None + speaker: Optional[str] = Field(default=None, alias="speaker_label") + + +class SpeechStartedMessage(BaseMessage): + """Message sent when speech is first detected in the audio stream. + + Parameters: + type: Always "SpeechStarted" for this message type. + timestamp: Audio timestamp in milliseconds when speech was detected. + """ + + type: Literal["SpeechStarted"] = "SpeechStarted" + timestamp: int class TerminationMessage(BaseMessage): @@ -94,32 +118,69 @@ class TerminationMessage(BaseMessage): # Union type for all possible message types -AnyMessage = BeginMessage | TurnMessage | TerminationMessage +AnyMessage = BeginMessage | TurnMessage | SpeechStartedMessage | TerminationMessage class AssemblyAIConnectionParams(BaseModel): """Configuration parameters for AssemblyAI WebSocket connection. + .. deprecated:: 0.0.105 + Use ``settings=AssemblyAISTTService.Settings(foo=...)`` instead. + Parameters: sample_rate: Audio sample rate in Hz. Defaults to 16000. encoding: Audio encoding format. Defaults to "pcm_s16le". - formatted_finals: Whether to enable transcript formatting. Defaults to True. - word_finalization_max_wait_time: Maximum time to wait for word finalization in milliseconds. end_of_turn_confidence_threshold: Confidence threshold for end-of-turn detection. - min_end_of_turn_silence_when_confident: Minimum silence duration when confident about end-of-turn. + min_turn_silence: Minimum silence duration when confident about end-of-turn. + min_end_of_turn_silence_when_confident: DEPRECATED. Use min_turn_silence instead. max_turn_silence: Maximum silence duration before forcing end-of-turn. keyterms_prompt: List of key terms to guide transcription. Will be JSON serialized before sending. - speech_model: Select between English and multilingual models. Defaults to "universal-streaming-english". + prompt: Optional text prompt to guide the transcription. Only used when speech_model is "u3-rt-pro". + speech_model: Select between English, multilingual, and u3-rt-pro models. Defaults to "u3-rt-pro". + language_detection: Enable automatic language detection. Only applicable to + universal-streaming-multilingual. When enabled, Turn messages include + language_code and language_confidence fields. Defaults to None (not sent). + format_turns: Whether to format transcript turns. Only applicable to + universal-streaming-english and universal-streaming-multilingual models. + For u3-rt-pro, formatting is automatic and built-in. Defaults to True. + speaker_labels: Enable speaker diarization. When enabled, final transcripts + (end_of_turn=True) include a speaker field identifying the speaker + (e.g., "Speaker A", "Speaker B"). Defaults to None (not sent). + vad_threshold: Voice activity detection confidence threshold. Only applicable to + u3-rt-pro. The confidence threshold (0.0 to 1.0) for classifying audio frames + as silence. Frames with VAD confidence below this value are considered silent. + Increase for noisy environments to reduce false speech detection. Defaults to + 0.3 (API default). For best performance when using with external VAD (e.g., Silero), + align this value with your VAD's activation threshold to avoid the "dead zone" + where AssemblyAI transcribes speech that your VAD hasn't detected yet. + Defaults to None (not sent). """ sample_rate: int = 16000 encoding: Literal["pcm_s16le", "pcm_mulaw"] = "pcm_s16le" - formatted_finals: bool = True - word_finalization_max_wait_time: Optional[int] = None end_of_turn_confidence_threshold: Optional[float] = None - min_end_of_turn_silence_when_confident: Optional[int] = None + min_turn_silence: Optional[int] = None + min_end_of_turn_silence_when_confident: Optional[int] = None # Deprecated max_turn_silence: Optional[int] = None keyterms_prompt: Optional[List[str]] = None - speech_model: Literal["universal-streaming-english", "universal-streaming-multilingual"] = ( - "universal-streaming-english" - ) + prompt: Optional[str] = None + speech_model: Literal[ + "universal-streaming-english", "universal-streaming-multilingual", "u3-rt-pro" + ] = "u3-rt-pro" + language_detection: Optional[bool] = None + format_turns: bool = True + speaker_labels: Optional[bool] = None + vad_threshold: Optional[float] = None + + @model_validator(mode="after") + def handle_deprecated_param(self): + """Handle deprecated min_end_of_turn_silence_when_confident parameter.""" + if self.min_end_of_turn_silence_when_confident is not None: + logger.warning( + "The 'min_end_of_turn_silence_when_confident' parameter is deprecated and will be " + "removed in a future version. Please use 'min_turn_silence' instead." + ) + # If min_turn_silence is not set, use the deprecated value + if self.min_turn_silence is None: + self.min_turn_silence = self.min_end_of_turn_silence_when_confident + return self diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index f54b4ff80..f52c1d935 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -12,7 +12,8 @@ WebSocket API for streaming audio transcription. import asyncio import json -from typing import Any, AsyncGenerator, Dict +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Dict, List, Optional from urllib.parse import urlencode from loguru import logger @@ -25,10 +26,14 @@ from pipecat.frames.frames import ( InterimTranscriptionFrame, StartFrame, TranscriptionFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven +from pipecat.services.stt_latency import ASSEMBLYAI_TTFS_P99 from pipecat.services.stt_service import WebsocketSTTService from pipecat.transcriptions.language import Language from pipecat.utils.time import time_now_iso8601 @@ -38,6 +43,7 @@ from .models import ( AssemblyAIConnectionParams, BaseMessage, BeginMessage, + SpeechStartedMessage, TerminationMessage, TurnMessage, ) @@ -51,6 +57,69 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +def map_language_from_assemblyai(language_code: str) -> Language: + """Map AssemblyAI language codes to Pipecat Language enum. + + AssemblyAI returns simple language codes like "es", "fr", etc. + This function maps them to the corresponding Language enum values. + + Args: + language_code: AssemblyAI language code (e.g., "es", "fr", "de") + + Returns: + Corresponding Language enum value, defaulting to Language.EN if not found. + """ + try: + # Try to match the language code directly + return Language(language_code.lower()) + except ValueError: + logger.warning( + f"Unknown language code from AssemblyAI: {language_code}, defaulting to English" + ) + return Language.EN + + +@dataclass +class AssemblyAISTTSettings(STTSettings): + """Settings for AssemblyAISTTService. + + Parameters: + formatted_finals: Whether to enable transcript formatting. + word_finalization_max_wait_time: Maximum time to wait for word + finalization in milliseconds. + end_of_turn_confidence_threshold: Confidence threshold for + end-of-turn detection. + min_turn_silence: Minimum silence duration when confident about + end-of-turn. + max_turn_silence: Maximum silence duration before forcing + end-of-turn. + keyterms_prompt: List of key terms to guide transcription. + prompt: Optional text prompt to guide the transcription. Only + used when model is "u3-rt-pro". + language_detection: Enable automatic language detection. + format_turns: Whether to format transcript turns. + speaker_labels: Enable speaker diarization. + vad_threshold: VAD confidence threshold (0.0–1.0) for classifying + audio frames as silence. Only applicable to u3-rt-pro. + """ + + formatted_finals: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + word_finalization_max_wait_time: int | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + end_of_turn_confidence_threshold: float | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + min_turn_silence: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + max_turn_silence: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + keyterms_prompt: List[str] | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + prompt: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + language_detection: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + format_turns: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + speaker_labels: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + vad_threshold: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class AssemblyAISTTService(WebsocketSTTService): """AssemblyAI real-time speech-to-text service. @@ -59,14 +128,23 @@ class AssemblyAISTTService(WebsocketSTTService): for audio processing and connection management. """ + Settings = AssemblyAISTTSettings + _settings: Settings + def __init__( self, *, api_key: str, - language: Language = Language.EN, # AssemblyAI only supports English + language: Optional[Language] = None, api_endpoint_base_url: str = "wss://streaming.assemblyai.com/v3/ws", - connection_params: AssemblyAIConnectionParams = AssemblyAIConnectionParams(), + sample_rate: int = 16000, + encoding: str = "pcm_s16le", + connection_params: Optional[AssemblyAIConnectionParams] = None, vad_force_turn_endpoint: bool = True, + should_interrupt: bool = True, + speaker_format: Optional[str] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = ASSEMBLYAI_TTFS_P99, **kwargs, ): """Initialize the AssemblyAI STT service. @@ -74,18 +152,140 @@ class AssemblyAISTTService(WebsocketSTTService): Args: api_key: AssemblyAI API key for authentication. language: Language code for transcription. Defaults to English (Language.EN). + + .. deprecated:: 0.0.105 + Use ``settings=AssemblyAISTTService.Settings(language=...)`` instead. + api_endpoint_base_url: WebSocket endpoint URL. Defaults to AssemblyAI's streaming endpoint. - connection_params: Connection configuration parameters. Defaults to AssemblyAIConnectionParams(). - vad_force_turn_endpoint: Whether to force turn endpoint on VAD stop. Defaults to True. + sample_rate: Audio sample rate in Hz. Defaults to 16000. + encoding: Audio encoding format. Defaults to "pcm_s16le". + connection_params: Connection configuration parameters. + + .. deprecated:: 0.0.105 + Use ``settings=AssemblyAISTTService.Settings(...)`` instead. + + vad_force_turn_endpoint: Controls turn detection mode. + When True (Pipecat mode, default): Forces AssemblyAI to return finals ASAP + so Pipecat's turn detection (e.g., Smart Turn) decides when the user is done. + - min_turn_silence defaults to 100ms (user can override) + - max_turn_silence is ALWAYS set equal to min_turn_silence + - VAD stop sends ForceEndpoint as ceiling + - No UserStarted/StoppedSpeakingFrame emitted from STT + When False (AssemblyAI turn detection mode, u3-rt-pro only): AssemblyAI's model + controls turn endings using built-in turn detection. + - Uses AssemblyAI API defaults for all parameters (unless user explicitly sets them) + - Emits UserStarted/StoppedSpeakingFrame from STT + - No ForceEndpoint on VAD stop + should_interrupt: Whether to interrupt the bot when the user starts speaking + in AssemblyAI turn detection mode (vad_force_turn_endpoint=False). Only applies + when using AssemblyAI's built-in turn detection. Defaults to True. + speaker_format: Optional format string for speaker labels when diarization is enabled. + Use {speaker} for speaker label and {text} for transcript text. + Example: "<{speaker}>{text}" or "{speaker}: {text}" + If None, transcript text is not modified. Defaults to None. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to parent STTService class. """ - super().__init__(sample_rate=connection_params.sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="u3-rt-pro", + language=Language.EN, + formatted_finals=True, + word_finalization_max_wait_time=None, + end_of_turn_confidence_threshold=None, + min_turn_silence=None, + max_turn_silence=None, + keyterms_prompt=None, + prompt=None, + language_detection=None, + format_turns=True, + speaker_labels=None, + vad_threshold=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if language is not None: + self._warn_init_param_moved_to_settings("language", "language") + default_settings.language = language + + # 3. Apply connection_params overrides (deprecated) — only if settings not provided + if connection_params is not None: + self._warn_init_param_moved_to_settings("connection_params") + if not settings: + sample_rate = connection_params.sample_rate + encoding = connection_params.encoding + default_settings.model = connection_params.speech_model + default_settings.formatted_finals = connection_params.formatted_finals + default_settings.word_finalization_max_wait_time = ( + connection_params.word_finalization_max_wait_time + ) + default_settings.end_of_turn_confidence_threshold = ( + connection_params.end_of_turn_confidence_threshold + ) + default_settings.min_turn_silence = connection_params.min_turn_silence + default_settings.max_turn_silence = connection_params.max_turn_silence + default_settings.keyterms_prompt = connection_params.keyterms_prompt + default_settings.prompt = connection_params.prompt + default_settings.language_detection = connection_params.language_detection + default_settings.format_turns = connection_params.format_turns + default_settings.speaker_labels = connection_params.speaker_labels + default_settings.vad_threshold = connection_params.vad_threshold + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + # 5. Validate final settings + is_u3_pro = default_settings.model == "u3-rt-pro" + if not vad_force_turn_endpoint and not is_u3_pro: + raise ValueError( + f"AssemblyAI turn detection mode (vad_force_turn_endpoint=False) requires " + f"u3-rt-pro for SpeechStarted support. Either set " + f"vad_force_turn_endpoint=True for {default_settings.model}, " + f"or use model='u3-rt-pro'." + ) + + if default_settings.prompt is not None and default_settings.keyterms_prompt is not None: + raise ValueError( + "The prompt and keyterms_prompt parameters cannot be used in the same request. " + "Please choose either one or the other based on your use case. When you use " + "keyterms_prompt, your boosted words are appended to the default prompt automatically. " + "Or to boost within prompt: + Make sure to boost the words " + "in the audio. " + "For more info go to: https://www.assemblyai.com/docs/streaming/universal-3-pro" + ) + + if default_settings.prompt is not None: + logger.warning( + "Custom prompt detected. Prompting is a beta feature. We recommend testing " + "with no prompt first, as this will use our optimized default prompt for " + "voice agents. Bad prompts may lead to bad results. If you'd like to create " + "your own prompt, check out our prompting guide at: " + "https://www.assemblyai.com/docs/streaming/prompting" + ) + + # 6. Configure pipecat turn mode (mutates default_settings) + if vad_force_turn_endpoint: + self._configure_pipecat_turn_mode(default_settings, is_u3_pro) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) self._api_key = api_key - self._language = language self._api_endpoint_base_url = api_endpoint_base_url - self._connection_params = connection_params self._vad_force_turn_endpoint = vad_force_turn_endpoint + self._should_interrupt = should_interrupt + self._speaker_format = speaker_format + + # Init-only audio config (not runtime-updatable) + self._encoding = encoding self._termination_event = asyncio.Event() self._received_termination = False @@ -97,6 +297,54 @@ class AssemblyAISTTService(WebsocketSTTService): self._chunk_size_ms = 50 self._chunk_size_bytes = 0 + self._user_speaking = False + + def _configure_pipecat_turn_mode(self, settings: Settings, is_u3_pro: bool): + """Configure settings for Pipecat turn detection mode. + + When vad_force_turn_endpoint is enabled, force AssemblyAI to return + finals as fast as possible so Pipecat's smart turn analyzer can decide + when the user is done speaking. VAD stop is the absolute ceiling. + + u3-rt-pro: + - min_turn_silence defaults to 100ms (user can override) + - max_turn_silence is ALWAYS set equal to min_turn_silence + to avoid double turn detection (AssemblyAI + Pipecat both analyzing) + - If user sets max_turn_silence, it's ignored with a warning + - end_of_turn_confidence_threshold: not set (API default) + + universal-streaming-*: + - end_of_turn_confidence_threshold=0.0 (disable semantic turn detection) + - min_turn_silence=160 + - max_turn_silence: not set (API default) + + Args: + settings: The settings to configure in place. + is_u3_pro: Whether using u3-rt-pro model. + """ + if is_u3_pro: + # u3-rt-pro: Synchronize max_turn_silence with min_turn_silence + min_silence = settings.min_turn_silence + if min_silence is None: + min_silence = 100 + + # Warn if user set max_turn_silence (will be overridden) + if settings.max_turn_silence is not None: + logger.warning( + f"Your max_turn_silence value ({settings.max_turn_silence}ms) will be " + f"OVERRIDDEN in Pipecat mode (vad_force_turn_endpoint=True). It will be set to " + f"{min_silence}ms (matching min_turn_silence) and SENT to " + f"AssemblyAI to avoid double turn detection. To use your max_turn_silence as-is, " + f"switch to AssemblyAI turn detection mode (vad_force_turn_endpoint=False)." + ) + + settings.min_turn_silence = min_silence + settings.max_turn_silence = min_silence + else: + # universal-streaming: Different configuration (works differently) + settings.end_of_turn_confidence_threshold = 1.0 + settings.min_turn_silence = 160 + def can_generate_metrics(self) -> bool: """Check if the service can generate metrics. @@ -105,6 +353,26 @@ class AssemblyAISTTService(WebsocketSTTService): """ return True + async def _update_settings(self, delta: Settings) -> dict[str, Any]: + """Apply a settings delta and reconnect to apply changes. + + Args: + delta: A settings delta with updated values. + + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + + if not changed: + return changed + + # Reconnect to apply updated settings (they become WS query params) + await self._disconnect() + await self._connect() + + return changed + async def start(self, frame: StartFrame): """Start the speech-to-text service. @@ -161,13 +429,14 @@ class AssemblyAISTTService(WebsocketSTTService): """ await super().process_frame(frame, direction) if isinstance(frame, VADUserStartedSpeakingFrame): - await self.start_ttfb_metrics() + pass elif isinstance(frame, VADUserStoppedSpeakingFrame): if ( self._vad_force_turn_endpoint and self._websocket and self._websocket.state is State.OPEN ): + self.request_finalize() await self._websocket.send(json.dumps({"type": "ForceEndpoint"})) await self.start_processing_metrics() @@ -178,16 +447,42 @@ class AssemblyAISTTService(WebsocketSTTService): def _build_ws_url(self) -> str: """Build WebSocket URL with query parameters using urllib.parse.urlencode.""" - params = {} - for k, v in self._connection_params.model_dump().items(): + s = self._settings + params: dict[str, Any] = {} + + # Init-only audio config + params["sample_rate"] = self.sample_rate + params["encoding"] = self._encoding + + # Map model → speech_model (AssemblyAI API naming) + if s.model is not None: + params["speech_model"] = s.model + + # Settings fields (skip None values) + optional_fields = { + "formatted_finals": s.formatted_finals, + "word_finalization_max_wait_time": s.word_finalization_max_wait_time, + "end_of_turn_confidence_threshold": s.end_of_turn_confidence_threshold, + "min_turn_silence": s.min_turn_silence, + "max_turn_silence": s.max_turn_silence, + "prompt": s.prompt, + "language_detection": s.language_detection, + "format_turns": s.format_turns, + "speaker_labels": s.speaker_labels, + "vad_threshold": s.vad_threshold, + } + + for k, v in optional_fields.items(): if v is not None: - if k == "keyterms_prompt": - params[k] = json.dumps(v) - elif isinstance(v, bool): + if isinstance(v, bool): params[k] = str(v).lower() else: params[k] = v + # Special handling for keyterms_prompt (needs JSON encoding) + if s.keyterms_prompt is not None: + params["keyterms_prompt"] = json.dumps(s.keyterms_prompt) + if params: query_string = urlencode(params) return f"{self._api_endpoint_base_url}?{query_string}" @@ -198,6 +493,8 @@ class AssemblyAISTTService(WebsocketSTTService): Establishes websocket connection and starts receive task. """ + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: @@ -208,6 +505,8 @@ class AssemblyAISTTService(WebsocketSTTService): Sends termination message, waits for acknowledgment, and cleans up. """ + await super()._disconnect() + if not self._connected or not self._websocket: return @@ -302,6 +601,9 @@ class AssemblyAISTTService(WebsocketSTTService): async for message in self._get_websocket(): try: data = json.loads(message) + # Log raw JSON for Turn messages to debug speaker_label + if data.get("type") == "Turn": + logger.trace(f"{self} RAW JSON from AssemblyAI: {json.dumps(data, indent=2)}") await self._handle_message(data) except json.JSONDecodeError: logger.warning(f"Received non-JSON message: {message}") @@ -314,6 +616,8 @@ class AssemblyAISTTService(WebsocketSTTService): return BeginMessage.model_validate(message) elif msg_type == "Turn": return TurnMessage.model_validate(message) + elif msg_type == "SpeechStarted": + return SpeechStartedMessage.model_validate(message) elif msg_type == "Termination": return TerminationMessage.model_validate(message) else: @@ -330,11 +634,33 @@ class AssemblyAISTTService(WebsocketSTTService): ) elif isinstance(parsed_message, TurnMessage): await self._handle_transcription(parsed_message) + elif isinstance(parsed_message, SpeechStartedMessage): + await self._handle_speech_started(parsed_message) elif isinstance(parsed_message, TerminationMessage): await self._handle_termination(parsed_message) except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + async def _handle_speech_started(self, message: SpeechStartedMessage): + """Handle SpeechStarted event — fast barge-in for AssemblyAI turn detection. + + Broadcasts UserStartedSpeakingFrame to signal the start of user + speech, then pushes an interruption to cancel any bot audio. + SpeechStarted fires before any transcript arrives, so the turn + is cleanly started before any transcription frames are pushed. + + Only applies when using AssemblyAI's built-in turn detection. When using + Pipecat turn detection, VAD + smart turn analyzer handle interruptions. + """ + if self._vad_force_turn_endpoint: + return # Pipecat mode: handled by aggregator + + await self.start_processing_metrics() + await self.broadcast_frame(UserStartedSpeakingFrame) + if self._should_interrupt: + await self.broadcast_interruption() + self._user_speaking = True + async def _handle_termination(self, message: TerminationMessage): """Handle termination message.""" self._received_termination = True @@ -347,31 +673,109 @@ class AssemblyAISTTService(WebsocketSTTService): await self.push_frame(EndFrame()) async def _handle_transcription(self, message: TurnMessage): - """Handle transcription results.""" + """Handle transcription results with two turn detection modes. + + Pipecat turn detection (vad_force_turn_endpoint=True): + - No UserStarted/StoppedSpeakingFrame from STT + - end_of_turn → TranscriptionFrame (finalized set by base class + if this is a ForceEndpoint response) + - else → InterimTranscriptionFrame + + AssemblyAI turn detection (vad_force_turn_endpoint=False): + - UserStartedSpeakingFrame on first transcript + - end_of_turn → TranscriptionFrame + UserStoppedSpeakingFrame + - else → InterimTranscriptionFrame + """ if not message.transcript: return - await self.stop_ttfb_metrics() - if message.end_of_turn and ( - not self._connection_params.formatted_finals or message.turn_is_formatted - ): - await self.push_frame( - TranscriptionFrame( - message.transcript, - self._user_id, - time_now_iso8601(), - self._language, - message, + + # Use detected language if available with sufficient confidence + language = Language.EN + if message.language_code and message.language_confidence: + if message.language_confidence >= 0.7: + language = map_language_from_assemblyai(message.language_code) + else: + logger.warning( + f"Low language detection confidence ({message.language_confidence:.2f}) " + f"for language '{message.language_code}', falling back to English" + ) + + # Handle speaker diarization + speaker_id = self._user_id + transcript_text = message.transcript + + if message.speaker: + speaker_id = message.speaker + # Format transcript with speaker labels if format string provided + if self._speaker_format: + transcript_text = self._speaker_format.format( + speaker=message.speaker, text=message.transcript + ) + + # Determine if this is a final turn from AssemblyAI + is_final_turn = message.end_of_turn and ( + not self._settings.format_turns or message.turn_is_formatted + ) + + if self._vad_force_turn_endpoint: + # --- Pipecat turn detection mode --- + # No UserStarted/StoppedSpeakingFrame — VAD + smart turn analyzer handle this + if is_final_turn: + finalize_confirmed = bool(message.turn_is_formatted) + if finalize_confirmed: + self.confirm_finalize() + logger.debug(f'{self} Transcript: "{transcript_text}"') + await self.push_frame( + TranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + ) + ) + await self._trace_transcription(transcript_text, True, language) + await self.stop_processing_metrics() + else: + await self.push_frame( + InterimTranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + ) ) - ) - await self._trace_transcription(message.transcript, True, self._language) - await self.stop_processing_metrics() else: - await self.push_frame( - InterimTranscriptionFrame( - message.transcript, - self._user_id, - time_now_iso8601(), - self._language, - message, + # --- AssemblyAI turn detection mode --- + # SpeechStarted always arrives before transcripts with u3-rt-pro, + # so UserStartedSpeakingFrame is guaranteed to be broadcast first. + if is_final_turn: + # AssemblyAI controls finalization, just mark as finalized + await self.push_frame( + TranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + finalized=True, + ) + ) + await self._trace_transcription(transcript_text, True, language) + await self.stop_processing_metrics() + # AAI is authoritative — emit UserStoppedSpeakingFrame immediately. + # broadcast_frame pushes downstream (same queue as TranscriptionFrame + # above, so ordering is preserved) and upstream. + await self.broadcast_frame(UserStoppedSpeakingFrame) + self._user_speaking = False + else: + await self.push_frame( + InterimTranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + ) ) - ) diff --git a/src/pipecat/services/asyncai/tts.py b/src/pipecat/services/asyncai/tts.py index a838e2465..d2ac74445 100644 --- a/src/pipecat/services/asyncai/tts.py +++ b/src/pipecat/services/asyncai/tts.py @@ -9,7 +9,8 @@ import asyncio import base64 import json -from typing import AsyncGenerator, Optional +from dataclasses import dataclass +from typing import Any, AsyncGenerator, Optional import aiohttp from loguru import logger @@ -20,14 +21,13 @@ from pipecat.frames.frames import ( EndFrame, ErrorFrame, Frame, - InterruptionFrame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, TTSStoppedFrame, ) from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.tts_service import InterruptibleTTSService, TTSService +from pipecat.services.settings import TTSSettings +from pipecat.services.tts_service import TextAggregationMode, TTSService, WebsocketTTSService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.tracing.service_decorators import traced_tts @@ -72,15 +72,28 @@ def language_to_async_language(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=True) -class AsyncAITTSService(InterruptibleTTSService): +@dataclass +class AsyncAITTSSettings(TTSSettings): + """Settings for AsyncAITTSService and AsyncAIHttpTTSService.""" + + pass + + +class AsyncAITTSService(WebsocketTTSService): """Async TTS service with WebSocket streaming. Provides text-to-speech using Async's streaming WebSocket API. """ + Settings = AsyncAITTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Async TTS configuration. + .. deprecated:: 0.0.105 + Use ``AsyncAITTSService.Settings`` directly via the ``settings`` parameter instead. + Parameters: language: Language to use for synthesis. """ @@ -91,15 +104,17 @@ class AsyncAITTSService(InterruptibleTTSService): self, *, api_key: str, - voice_id: str, + voice_id: Optional[str] = None, version: str = "v1", - url: str = "wss://api.async.ai/text_to_speech/websocket/ws", - model: str = "asyncflow_multilingual_v1.0", + url: str = "wss://api.async.com/text_to_speech/websocket/ws", + model: Optional[str] = None, sample_rate: Optional[int] = None, encoding: str = "pcm_s16le", container: str = "raw", params: Optional[InputParams] = None, - aggregate_sentences: Optional[bool] = True, + settings: Optional[Settings] = None, + aggregate_sentences: Optional[bool] = None, + text_aggregation_mode: Optional[TextAggregationMode] = None, **kwargs, ): """Initialize the Async TTS service. @@ -107,47 +122,97 @@ class AsyncAITTSService(InterruptibleTTSService): Args: api_key: Async API key. voice_id: UUID of the voice to use for synthesis. See docs for a full list: - https://docs.async.ai/list-voices-16699698e0 + https://docs.async.com/list-voices-16699698e0 + + .. deprecated:: 0.0.105 + Use ``settings=AsyncAITTSService.Settings(voice=...)`` instead. + version: Async API version. url: WebSocket URL for Async TTS API. - model: TTS model to use (e.g., "asyncflow_multilingual_v1.0"). + model: TTS model to use (e.g., "async_flash_v1.0"). + + .. deprecated:: 0.0.105 + Use ``settings=AsyncAITTSService.Settings(model=...)`` instead. + sample_rate: Audio sample rate. encoding: Audio encoding format. container: Audio container format. params: Additional input parameters for voice customization. - aggregate_sentences: Whether to aggregate sentences within the TTSService. + + .. deprecated:: 0.0.105 + Use ``settings=AsyncAITTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + aggregate_sentences: Deprecated. Use text_aggregation_mode instead. + + .. deprecated:: 0.0.104 + Use ``text_aggregation_mode`` instead. + + text_aggregation_mode: How to aggregate text before synthesis. **kwargs: Additional arguments passed to the parent service. """ - super().__init__( - aggregate_sentences=aggregate_sentences, - pause_frame_processing=True, - push_stop_frames=True, - sample_rate=sample_rate, - **kwargs, + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="async_flash_v1.0", + voice=None, + language=None, ) - params = params or AsyncAITTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = params.language + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + aggregate_sentences=aggregate_sentences, + text_aggregation_mode=text_aggregation_mode, + pause_frame_processing=True, + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._api_version = version self._url = url - self._settings = { - "output_format": { - "container": container, - "encoding": encoding, - "sample_rate": 0, - }, - "language": self.language_to_service_language(params.language) - if params.language - else None, - } - self.set_model_name(model) - self.set_voice(voice_id) + # Init-only audio format config (not runtime-updatable). + self._output_container = container + self._output_encoding = encoding + self._output_sample_rate = 0 # Set in start() self._receive_task = None self._keepalive_task = None - self._started = False + + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta. + + Settings are stored but not applied to the active connection. + """ + changed = await super()._update_settings(delta) + + if not changed: + return changed + + self._warn_unhandled_updated_settings(changed) + + return changed def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -168,8 +233,8 @@ class AsyncAITTSService(InterruptibleTTSService): """ return language_to_async_language(language) - def _build_msg(self, text: str = "", force: bool = False) -> str: - msg = {"transcript": text, "force": force} + def _build_msg(self, text: str = "", context_id: str = "", force: bool = False) -> str: + msg = {"transcript": text, "context_id": context_id, "force": force} return json.dumps(msg) async def start(self, frame: StartFrame): @@ -179,7 +244,7 @@ class AsyncAITTSService(InterruptibleTTSService): frame: The start frame containing initialization parameters. """ await super().start(frame) - self._settings["output_format"]["sample_rate"] = self.sample_rate + self._output_sample_rate = self.sample_rate await self._connect() async def stop(self, frame: EndFrame): @@ -201,6 +266,8 @@ class AsyncAITTSService(InterruptibleTTSService): await self._disconnect() async def _connect(self): + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: @@ -210,6 +277,8 @@ class AsyncAITTSService(InterruptibleTTSService): self._keepalive_task = self.create_task(self._keepalive_task_handler()) async def _disconnect(self): + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None @@ -229,10 +298,14 @@ class AsyncAITTSService(InterruptibleTTSService): f"{self._url}?api_key={self._api_key}&version={self._api_version}" ) init_msg = { - "model_id": self._model_name, - "voice": {"mode": "id", "id": self._voice_id}, - "output_format": self._settings["output_format"], - "language": self._settings["language"], + "model_id": self._settings.model, + "voice": {"mode": "id", "id": self._settings.voice}, + "output_format": { + "container": self._output_container, + "encoding": self._output_encoding, + "sample_rate": self._output_sample_rate, + }, + "language": self._settings.language, } await self._get_websocket().send(json.dumps(init_msg)) @@ -249,12 +322,16 @@ class AsyncAITTSService(InterruptibleTTSService): if self._websocket: logger.debug("Disconnecting from Async") + # Close all contexts and the socket + if self.has_active_audio_context(): + await self._websocket.send(json.dumps({"terminate": True})) await self._websocket.close() + logger.debug("Disconnected from Async") except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: self._websocket = None - self._started = False + await self.remove_active_audio_context() await self._call_event_handler("on_disconnected") def _get_websocket(self): @@ -262,12 +339,18 @@ class AsyncAITTSService(InterruptibleTTSService): return self._websocket raise Exception("Websocket not connected") - async def flush_audio(self): - """Flush any pending audio.""" - if not self._websocket: + async def flush_audio(self, context_id: Optional[str] = None): + """Flush any pending audio. + + Args: + context_id: The specific context to flush. If None, falls back to the + currently active context. + """ + flush_id = context_id or self.get_active_audio_context_id() + if not flush_id or not self._websocket: return logger.trace(f"{self}: flushing audio") - msg = self._build_msg(text=" ", force=True) + msg = self._build_msg(text=" ", context_id=flush_id, force=True) await self._websocket.send(msg) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): @@ -278,8 +361,6 @@ class AsyncAITTSService(InterruptibleTTSService): direction: The direction to push the frame. """ await super().push_frame(frame, direction) - if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)): - self._started = False async def _receive_messages(self): async for message in self._get_websocket(): @@ -287,41 +368,90 @@ class AsyncAITTSService(InterruptibleTTSService): if not msg: continue - elif msg.get("audio"): + received_ctx_id = msg.get("context_id") + # Handle final messages first, regardless of context availability + # At the moment, this message is received AFTER the close_context message is + # sent, so it doesn't serve any functional purpose. For now, we'll just log it. + if msg.get("final") is True: + logger.trace(f"Received final message for context {received_ctx_id}") + continue + + # Check if this message belongs to the current context. + if not self.audio_context_available(received_ctx_id): + if self.get_active_audio_context_id() == received_ctx_id: + logger.debug( + f"Received a delayed message, recreating the context: {received_ctx_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 + # is received. + logger.debug(f"Ignoring message from unavailable context: {received_ctx_id}") + continue + + if msg.get("audio"): await self.stop_ttfb_metrics() - frame = TTSAudioRawFrame( - audio=base64.b64decode(msg["audio"]), - sample_rate=self.sample_rate, - num_channels=1, - ) - await self.push_frame(frame) - elif msg.get("error_code"): - await self.push_frame(TTSStoppedFrame()) - await self.stop_all_metrics() - await self.push_error(error_msg=f"Error: {msg['message']}") - else: - await self.push_error(error_msg=f"Unknown message type: {msg}") + audio = base64.b64decode(msg["audio"]) + frame = TTSAudioRawFrame(audio, self.sample_rate, 1, context_id=received_ctx_id) + await self.append_to_audio_context(received_ctx_id, frame) async def _keepalive_task_handler(self): """Send periodic keepalive messages to maintain WebSocket connection.""" - KEEPALIVE_SLEEP = 3 + KEEPALIVE_SLEEP = 10 while True: await asyncio.sleep(KEEPALIVE_SLEEP) try: if self._websocket and self._websocket.state is State.OPEN: - keepalive_message = {"transcript": " "} - logger.trace("Sending keepalive message") + context_id = self.get_active_audio_context_id() + if context_id: + keepalive_message = { + "transcript": " ", + "context_id": context_id, + } + logger.trace("Sending keepalive message") + 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 + # an empty message to keep the connection alive. + keepalive_message = {"transcript": " "} + logger.trace("Sending keepalive without context") await self._websocket.send(json.dumps(keepalive_message)) except websockets.ConnectionClosed as e: logger.warning(f"{self} keepalive error: {e}") break + async def _close_context(self, context_id: str): + # Async AI requires explicit context closure to free server-side resources, + # both on interruption and on normal completion. + if context_id and self._websocket: + try: + await self._websocket.send( + json.dumps({"context_id": context_id, "close_context": True, "transcript": ""}) + ) + except Exception as e: + logger.error(f"{self}: Error closing context {context_id}: {e}") + + async def on_audio_context_interrupted(self, context_id: str): + """Close the Async AI context when the bot is interrupted.""" + await self._close_context(context_id) + + async def on_audio_context_completed(self, context_id: str): + """Close the Async AI context after all audio has been played. + + Async AI does not send a server-side signal when a context is + exhausted, so Pipecat must explicitly close it with + ``close_context: True`` to free server-side resources. + """ + await self._close_context(context_id) + @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Async API websocket endpoint. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -332,21 +462,14 @@ class AsyncAITTSService(InterruptibleTTSService): if not self._websocket or self._websocket.state is State.CLOSED: await self._connect() - if not self._started: - await self.start_ttfb_metrics() - yield TTSStartedFrame() - self._started = True - - msg = self._build_msg(text=text, force=True) - try: + 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) + except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() - await self._disconnect() - await self._connect() + yield TTSStoppedFrame(context_id=context_id) return yield None except Exception as e: @@ -361,9 +484,15 @@ class AsyncAIHttpTTSService(TTSService): connection is not required or desired. """ + Settings = AsyncAITTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Async API. + .. deprecated:: 0.0.105 + Use ``AsyncAIHttpTTSService.Settings`` directly via the ``settings`` parameter instead. + Parameters: language: Language to use for synthesis. """ @@ -374,15 +503,16 @@ class AsyncAIHttpTTSService(TTSService): self, *, api_key: str, - voice_id: str, + voice_id: Optional[str] = None, aiohttp_session: aiohttp.ClientSession, - model: str = "asyncflow_multilingual_v1.0", - url: str = "https://api.async.ai", + model: Optional[str] = None, + url: str = "https://api.async.com", version: str = "v1", sample_rate: Optional[int] = None, encoding: str = "pcm_s16le", container: str = "raw", params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Async TTS service. @@ -390,35 +520,71 @@ class AsyncAIHttpTTSService(TTSService): Args: api_key: Async API key. voice_id: ID of the voice to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=AsyncAIHttpTTSService.Settings(voice=...)`` instead. + aiohttp_session: An aiohttp session for making HTTP requests. - model: TTS model to use (e.g., "asyncflow_multilingual_v1.0"). + model: TTS model to use (e.g., "async_flash_v1.0"). + + .. deprecated:: 0.0.105 + Use ``settings=AsyncAIHttpTTSService.Settings(model=...)`` instead. + url: Base URL for Async API. version: API version string for Async API. sample_rate: Audio sample rate. encoding: Audio encoding format. container: Audio container format. params: Additional input parameters for voice customization. + + .. deprecated:: 0.0.105 + Use ``settings=AsyncAIHttpTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to the parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="async_flash_v1.0", + voice=None, + language=None, + ) - params = params or AsyncAIHttpTTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = params.language + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._base_url = url self._api_version = version - self._settings = { - "output_format": { - "container": container, - "encoding": encoding, - "sample_rate": 0, - }, - "language": self.language_to_service_language(params.language) - if params.language - else None, - } - self.set_voice(voice_id) - self.set_model_name(model) + + # Init-only audio format config (not runtime-updatable). + self._output_container = container + self._output_encoding = encoding + self._output_sample_rate = 0 # Set in start() self._session = aiohttp_session @@ -448,14 +614,15 @@ class AsyncAIHttpTTSService(TTSService): frame: The start frame containing initialization parameters. """ await super().start(frame) - self._settings["output_format"]["sample_rate"] = self.sample_rate + self._output_sample_rate = self.sample_rate @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Async's HTTP streaming API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -463,16 +630,20 @@ class AsyncAIHttpTTSService(TTSService): logger.debug(f"{self}: Generating TTS [{text}]") try: - voice_config = {"mode": "id", "id": self._voice_id} - await self.start_ttfb_metrics() + voice_config = {"mode": "id", "id": self._settings.voice} + payload = { - "model_id": self._model_name, + "model_id": self._settings.model, "transcript": text, "voice": voice_config, - "output_format": self._settings["output_format"], - "language": self._settings["language"], + "output_format": { + "container": self._output_container, + "encoding": self._output_encoding, + "sample_rate": self._output_sample_rate, + }, + "language": self._settings.language, } - yield TTSStartedFrame() + headers = { "version": self._api_version, "x-api-key": self._api_key, @@ -486,7 +657,14 @@ class AsyncAIHttpTTSService(TTSService): await self.push_error(error_msg=f"Async API error: {error_text}") raise Exception(f"Async API returned status {response.status}: {error_text}") - audio_data = await response.read() + # Read streaming bytes; stop TTFB on the *first* received chunk + buffer = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + if not chunk: + continue + await self.stop_ttfb_metrics() + buffer.extend(chunk) + audio_data = bytes(buffer) await self.start_tts_usage_metrics(text) @@ -494,6 +672,7 @@ class AsyncAIHttpTTSService(TTSService): audio=audio_data, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) yield frame @@ -502,4 +681,3 @@ class AsyncAIHttpTTSService(TTSService): await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: await self.stop_ttfb_metrics() - yield TTSStoppedFrame() diff --git a/src/pipecat/services/aws/llm.py b/src/pipecat/services/aws/llm.py index 1bfee4be0..92049dffb 100644 --- a/src/pipecat/services/aws/llm.py +++ b/src/pipecat/services/aws/llm.py @@ -18,7 +18,7 @@ import io import json import os import re -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Dict, List, Optional from loguru import logger @@ -39,8 +39,6 @@ from pipecat.frames.frames import ( LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMMessagesFrame, - LLMTextFrame, - LLMUpdateSettingsFrame, UserImageRawFrame, ) from pipecat.metrics.metrics import LLMTokenUsage @@ -57,6 +55,7 @@ from pipecat.processors.aggregators.openai_llm_context import ( ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import LLMService +from pipecat.services.settings import NOT_GIVEN, LLMSettings, _NotGiven from pipecat.utils.tracing.service_decorators import traced_llm try: @@ -71,6 +70,23 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class AWSBedrockLLMSettings(LLMSettings): + """Settings for AWSBedrockLLMService. + + Parameters: + stop_sequences: List of strings that stop generation. + latency: Performance mode - "standard" or "optimized". + additional_model_request_fields: Additional model-specific parameters. + """ + + stop_sequences: List[str] | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + latency: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + additional_model_request_fields: Dict[str, Any] | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + + @dataclass class AWSBedrockContextAggregatorPair: """Container for AWS Bedrock context aggregators. @@ -355,7 +371,7 @@ class AWSBedrockLLMContext(OpenAILLMContext): tool_result_content = [{"json": content_json}] else: tool_result_content = [{"text": message["content"]}] - except: + except (json.JSONDecodeError, ValueError, AttributeError): tool_result_content = [{"text": message["content"]}] return { @@ -675,7 +691,7 @@ class AWSBedrockAssistantContextAggregator(LLMAssistantContextAggregator): frame: The function call result frame to handle. """ if frame.result: - result = json.dumps(frame.result) + result = json.dumps(frame.result, ensure_ascii=False) await self._update_function_call_result(frame.function_name, frame.tool_call_id, result) else: await self._update_function_call_result( @@ -730,12 +746,19 @@ class AWSBedrockLLMService(LLMService): vision capabilities. """ + Settings = AWSBedrockLLMSettings + _settings: Settings + # Overriding the default adapter to use the Anthropic one. adapter_class = AWSBedrockLLMAdapter class InputParams(BaseModel): """Input parameters for AWS Bedrock LLM service. + .. deprecated:: 0.0.105 + Use ``AWSBedrockLLMService.Settings`` instead. Pass settings directly via the + ``settings`` parameter of :class:`AWSBedrockLLMService`. + Parameters: max_tokens: Maximum number of tokens to generate. temperature: Sampling temperature between 0.0 and 1.0. @@ -755,12 +778,14 @@ class AWSBedrockLLMService(LLMService): def __init__( self, *, - model: str, + model: Optional[str] = None, aws_access_key: Optional[str] = None, aws_secret_key: Optional[str] = None, aws_session_token: Optional[str] = None, aws_region: Optional[str] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, + stop_sequences: Optional[List[str]] = None, client_config: Optional[Config] = None, retry_timeout_secs: Optional[float] = 5.0, retry_on_timeout: Optional[bool] = False, @@ -770,19 +795,78 @@ class AWSBedrockLLMService(LLMService): Args: model: The AWS Bedrock model identifier to use. + + .. deprecated:: 0.0.105 + Use ``settings=AWSBedrockLLMService.Settings(model=...)`` instead. + aws_access_key: AWS access key ID. If None, uses default credentials. aws_secret_key: AWS secret access key. If None, uses default credentials. aws_session_token: AWS session token for temporary credentials. aws_region: AWS region for the Bedrock service. params: Model parameters and configuration. + + .. deprecated:: 0.0.105 + Use ``settings=AWSBedrockLLMService.Settings(...)`` instead. + + settings: Runtime-updatable settings for this service. When both + deprecated parameters and *settings* are provided, *settings* + values take precedence. + stop_sequences: List of strings that stop generation. + + .. deprecated:: 0.0.105 + Use ``settings=AWSBedrockLLMService.Settings(stop_sequences=...)`` instead. + client_config: Custom boto3 client configuration. retry_timeout_secs: Request timeout in seconds for retry logic. retry_on_timeout: Whether to retry the request once if it times out. **kwargs: Additional arguments passed to parent LLMService. """ - super().__init__(**kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="us.amazon.nova-lite-v1:0", + system_instruction=None, + max_tokens=None, + temperature=None, + top_p=None, + top_k=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + stop_sequences=None, + latency=None, + additional_model_request_fields={}, + ) - params = params or AWSBedrockLLMService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if stop_sequences is not None: + self._warn_init_param_moved_to_settings("stop_sequences", "stop_sequences") + default_settings.stop_sequences = stop_sequences + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.max_tokens = params.max_tokens + default_settings.temperature = params.temperature + default_settings.top_p = params.top_p + if params.stop_sequences: + default_settings.stop_sequences = params.stop_sequences + default_settings.latency = params.latency + if isinstance(params.additional_model_request_fields, dict): + default_settings.additional_model_request_fields = ( + params.additional_model_request_fields + ) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(settings=default_settings, **kwargs) # Initialize the AWS Bedrock client if not client_config: @@ -803,20 +887,12 @@ class AWSBedrockLLMService(LLMService): "config": client_config, } - self.set_model_name(model) self._retry_timeout_secs = retry_timeout_secs self._retry_on_timeout = retry_on_timeout - self._settings = { - "max_tokens": params.max_tokens, - "temperature": params.temperature, - "top_p": params.top_p, - "latency": params.latency, - "additional_model_request_fields": params.additional_model_request_fields - if isinstance(params.additional_model_request_fields, dict) - else {}, - } - logger.info(f"Using AWS Bedrock model: {model}") + logger.info(f"Using AWS Bedrock model: {self._settings.model}") + if self._settings.system_instruction: + logger.debug(f"{self}: Using system instruction: {self._settings.system_instruction}") def can_generate_metrics(self) -> bool: """Check if the service can generate usage metrics. @@ -836,19 +912,30 @@ class AWSBedrockLLMService(LLMService): Dictionary containing only the inference parameters that are not None. """ inference_config = {} - if self._settings["max_tokens"] is not None: - inference_config["maxTokens"] = self._settings["max_tokens"] - if self._settings["temperature"] is not None: - inference_config["temperature"] = self._settings["temperature"] - if self._settings["top_p"] is not None: - inference_config["topP"] = self._settings["top_p"] + if self._settings.max_tokens is not None: + inference_config["maxTokens"] = self._settings.max_tokens + if self._settings.temperature is not None: + inference_config["temperature"] = self._settings.temperature + if self._settings.top_p is not None: + inference_config["topP"] = self._settings.top_p + if self._settings.stop_sequences: + inference_config["stopSequences"] = self._settings.stop_sequences return inference_config - async def run_inference(self, context: LLMContext | OpenAILLMContext) -> Optional[str]: + async def run_inference( + self, + context: LLMContext | OpenAILLMContext, + max_tokens: Optional[int] = None, + system_instruction: Optional[str] = None, + ) -> Optional[str]: """Run a one-shot, out-of-band (i.e. out-of-pipeline) inference with the given LLM context. Args: context: The LLM context containing conversation history. + max_tokens: Optional maximum number of tokens to generate. If provided, + overrides the service's default max_tokens setting. + system_instruction: Optional system instruction to use for this inference. + If provided, overrides any system instruction in the context. Returns: The LLM's response as a string, or None if no response is generated. @@ -865,13 +952,26 @@ class AWSBedrockLLMService(LLMService): messages = context.messages system = getattr(context, "system", None) # [{"text": "system message"}] + # Override system instruction if provided + if system_instruction is not None: + if system: + logger.warning( + f"{self}: Both system_instruction and a system message in context are set." + " Using system_instruction." + ) + system = [{"text": system_instruction}] + # Prepare request parameters using the same method as streaming inference_config = self._build_inference_config() + # Override maxTokens if provided + if max_tokens is not None: + inference_config["maxTokens"] = max_tokens + request_params = { - "modelId": self.model_name, + "modelId": self._settings.model, "messages": messages, - "additionalModelRequestFields": self._settings["additional_model_request_fields"], + "additionalModelRequestFields": self._settings.additional_model_request_fields, } if inference_config: @@ -986,7 +1086,14 @@ class AWSBedrockLLMService(LLMService): # Universal LLMContext if isinstance(context, LLMContext): adapter: AWSBedrockLLMAdapter = self.get_llm_adapter() - params = adapter.get_llm_invocation_params(context) + params: AWSBedrockLLMInvocationParams = adapter.get_llm_invocation_params(context) + if self._settings.system_instruction: + if params["system"]: + logger.warning( + f"{self}: Both system_instruction and a system message in context are" + " set. Using system_instruction." + ) + params["system"] = [{"text": self._settings.system_instruction}] return params # AWS Bedrock-specific context @@ -1026,9 +1133,9 @@ class AWSBedrockLLMService(LLMService): # Prepare request parameters request_params = { - "modelId": self.model_name, + "modelId": self._settings.model, "messages": messages, - "additionalModelRequestFields": self._settings["additional_model_request_fields"], + "additionalModelRequestFields": self._settings.additional_model_request_fields, } # Only add inference config if it has parameters @@ -1073,8 +1180,8 @@ class AWSBedrockLLMService(LLMService): request_params["toolConfig"] = tool_config # Add performance config if latency is specified - if self._settings["latency"] in ["standard", "optimized"]: - request_params["performanceConfig"] = {"latency": self._settings["latency"]} + if self._settings.latency in ["standard", "optimized"]: + request_params["performanceConfig"] = {"latency": self._settings.latency} # Log request params with messages redacted for logging if isinstance(context, LLMContext): @@ -1107,7 +1214,7 @@ class AWSBedrockLLMService(LLMService): if "contentBlockDelta" in event: delta = event["contentBlockDelta"]["delta"] if "text" in delta: - await self.push_frame(LLMTextFrame(delta["text"])) + await self._push_llm_text(delta["text"]) completion_tokens_estimate += self._estimate_tokens(delta["text"]) elif "toolUse" in delta and "input" in delta["toolUse"]: # Handle partial JSON for tool use @@ -1199,8 +1306,6 @@ class AWSBedrockLLMService(LLMService): # NOTE: LLMMessagesFrame is deprecated, so we don't support the newer universal # LLMContext with it context = AWSBedrockLLMContext.from_messages(frame.messages) - elif isinstance(frame, LLMUpdateSettingsFrame): - await self._update_settings(frame.settings) else: await self.push_frame(frame, direction) diff --git a/src/pipecat/services/aws/nova_sonic/llm.py b/src/pipecat/services/aws/nova_sonic/llm.py index 26afc79cf..ffcbb5e5d 100644 --- a/src/pipecat/services/aws/nova_sonic/llm.py +++ b/src/pipecat/services/aws/nova_sonic/llm.py @@ -16,7 +16,7 @@ import json import time import uuid import wave -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from importlib.resources import files from typing import Any, List, Optional @@ -27,8 +27,8 @@ from pydantic import BaseModel, Field from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.adapters.services.aws_nova_sonic_adapter import AWSNovaSonicLLMAdapter, Role from pipecat.frames.frames import ( + AggregatedTextFrame, AggregationType, - BotStoppedSpeakingFrame, CancelFrame, EndFrame, Frame, @@ -38,6 +38,7 @@ from pipecat.frames.frames import ( LLMContextFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, + LLMTextFrame, StartFrame, TranscriptionFrame, TTSAudioRawFrame, @@ -59,6 +60,7 @@ from pipecat.processors.aggregators.openai_llm_context import ( ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import LLMService +from pipecat.services.settings import NOT_GIVEN, LLMSettings, _NotGiven from pipecat.utils.time import time_now_iso8601 try: @@ -147,6 +149,10 @@ class CurrentContent: class Params(BaseModel): """Configuration parameters for AWS Nova Sonic. + .. deprecated:: 0.0.105 + Use ``settings=AWSNovaSonicLLMService.Settings(...)`` for inference settings + and ``audio_config=AudioConfig(...)`` for audio configuration. + Parameters: input_sample_rate: Audio input sample rate in Hz. input_sample_size: Audio input sample size in bits. @@ -183,6 +189,55 @@ class Params(BaseModel): # Turn-taking endpointing_sensitivity: Optional[str] = Field(default=None) + @property + def audio_config(self) -> "AudioConfig": + """Return an ``AudioConfig`` populated from this instance's audio fields.""" + return AudioConfig( + input_sample_rate=self.input_sample_rate, + input_sample_size=self.input_sample_size, + input_channel_count=self.input_channel_count, + output_sample_rate=self.output_sample_rate, + output_sample_size=self.output_sample_size, + output_channel_count=self.output_channel_count, + ) + + +class AudioConfig(BaseModel): + """Audio configuration for AWS Nova Sonic. + + Parameters: + input_sample_rate: Audio input sample rate in Hz. + input_sample_size: Audio input sample size in bits. + input_channel_count: Number of input audio channels. + output_sample_rate: Audio output sample rate in Hz. + output_sample_size: Audio output sample size in bits. + output_channel_count: Number of output audio channels. + """ + + # Input + input_sample_rate: Optional[int] = Field(default=16000) + input_sample_size: Optional[int] = Field(default=16) + input_channel_count: Optional[int] = Field(default=1) + + # Output + output_sample_rate: Optional[int] = Field(default=24000) + output_sample_size: Optional[int] = Field(default=16) + output_channel_count: Optional[int] = Field(default=1) + + +@dataclass +class AWSNovaSonicLLMSettings(LLMSettings): + """Settings for AWSNovaSonicLLMService. + + Parameters: + voice: Voice identifier for speech synthesis. + endpointing_sensitivity: Controls how quickly Nova Sonic decides the + user has stopped speaking. Can be "LOW", "MEDIUM", or "HIGH". + """ + + voice: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + endpointing_sensitivity: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + class AWSNovaSonicLLMService(LLMService): """AWS Nova Sonic speech-to-speech LLM service. @@ -191,6 +246,9 @@ class AWSNovaSonicLLMService(LLMService): and function calling capabilities using AWS Nova Sonic model. """ + Settings = AWSNovaSonicLLMSettings + _settings: Settings + # Override the default adapter to use the AWSNovaSonicLLMAdapter one adapter_class = AWSNovaSonicLLMAdapter @@ -204,6 +262,8 @@ class AWSNovaSonicLLMService(LLMService): model: str = "amazon.nova-2-sonic-v1:0", voice_id: str = "matthew", params: Optional[Params] = None, + audio_config: Optional[AudioConfig] = None, + settings: Optional[Settings] = None, system_instruction: Optional[str] = None, tools: Optional[ToolsSchema] = None, send_transcription_frames: bool = True, @@ -220,13 +280,35 @@ class AWSNovaSonicLLMService(LLMService): - Nova 2 Sonic (the default model): "us-east-1", "us-west-2", "ap-northeast-1" - Nova Sonic (the older model): "us-east-1", "ap-northeast-1" model: Model identifier. Defaults to "amazon.nova-2-sonic-v1:0". + + .. deprecated:: 0.0.105 + Use ``settings=AWSNovaSonicLLMService.Settings(model=...)`` instead. + voice_id: Voice ID for speech synthesis. Note that some voices are designed for use with a specific language. Options: - Nova 2 Sonic (the default model): see https://docs.aws.amazon.com/nova/latest/nova2-userguide/sonic-language-support.html - Nova Sonic (the older model): see https://docs.aws.amazon.com/nova/latest/userguide/available-voices.html. + + .. deprecated:: 0.0.105 + Use ``settings=AWSNovaSonicLLMService.Settings(voice=...)`` instead. + params: Model parameters for audio configuration and inference. + + .. deprecated:: 0.0.105 + Use ``settings=AWSNovaSonicLLMService.Settings(...)`` for inference + settings and ``audio_config=AudioConfig(...)`` for audio + configuration. + + audio_config: Audio configuration (sample rates, sample sizes, + channel counts). If not provided, defaults are used. + settings: AWS Nova Sonic LLM settings. If provided together with + deprecated top-level parameters, the ``settings`` values take + precedence. system_instruction: System-level instruction for the model. + + .. deprecated:: 0.0.105 + Use ``settings=AWSNovaSonicLLMService.Settings(system_instruction=...)`` instead. tools: Available tools/functions for the model to use. send_transcription_frames: Whether to emit transcription frames. @@ -236,28 +318,86 @@ class AWSNovaSonicLLMService(LLMService): **kwargs: Additional arguments passed to the parent LLMService. """ - super().__init__(**kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="amazon.nova-2-sonic-v1:0", + system_instruction=None, + voice="matthew", + temperature=0.7, + max_tokens=1024, + top_p=0.9, + top_k=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + endpointing_sensitivity=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model != "amazon.nova-2-sonic-v1:0": + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if voice_id != "matthew": + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if system_instruction is not None: + self._warn_init_param_moved_to_settings("system_instruction", "system_instruction") + default_settings.system_instruction = system_instruction + + # 3. Apply params overrides — only if settings not provided + if params is not None: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "The `params` parameter is deprecated. " + "Use `settings=self.Settings(...)` for inference settings " + "(temperature, max_tokens, top_p, endpointing_sensitivity) " + "and `audio_config=AudioConfig(...)` for audio configuration " + "(sample rates, sample sizes, channel counts).", + DeprecationWarning, + stacklevel=2, + ) + if not settings: + default_settings.temperature = params.temperature + default_settings.max_tokens = params.max_tokens + default_settings.top_p = params.top_p + default_settings.endpointing_sensitivity = params.endpointing_sensitivity + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + settings=default_settings, + **kwargs, + ) self._secret_access_key = secret_access_key self._access_key_id = access_key_id self._session_token = session_token self._region = region - self._model = model self._client: Optional[BedrockRuntimeClient] = None - self._voice_id = voice_id - self._params = params or Params() - self._system_instruction = system_instruction + + # Audio I/O config (hardware settings, not runtime-tunable) + # Priority: audio_config > params (deprecated) > defaults + self._audio_config = audio_config or ( + params.audio_config if params is not None else AudioConfig() + ) self._tools = tools # Validate endpointing_sensitivity parameter if ( - self._params.endpointing_sensitivity + self._settings.endpointing_sensitivity and not self._is_endpointing_sensitivity_supported() ): logger.warning( - f"endpointing_sensitivity is not supported for model '{model}' and will be ignored. " + f"endpointing_sensitivity is not supported for model '{self._settings.model}' and will be ignored. " "This parameter is only supported starting with Nova 2 Sonic (amazon.nova-2-sonic-v1:0)." ) - self._params.endpointing_sensitivity = None + self._settings.endpointing_sensitivity = None if not send_transcription_frames: import warnings @@ -284,22 +424,44 @@ class AWSNovaSonicLLMService(LLMService): self._input_audio_content_name: Optional[str] = None self._content_being_received: Optional[CurrentContent] = None self._assistant_is_responding = False - self._may_need_repush_assistant_text = False self._ready_to_send_context = False - self._handling_bot_stopped_speaking = False self._triggering_assistant_response = False self._waiting_for_trigger_transcription = False self._disconnecting = False self._connected_time: Optional[float] = None self._wants_connection = False self._user_text_buffer = "" - self._assistant_text_buffer = "" self._completed_tool_calls = set() + self._audio_input_started = False + self._pending_speculative_text: Optional[str] = None file_path = files("pipecat.services.aws.nova_sonic").joinpath("ready.wav") with wave.open(file_path.open("rb"), "rb") as wav_file: self._assistant_response_trigger_audio = wav_file.readframes(wav_file.getnframes()) + # + # settings + # + + async def _update_settings(self, delta: Settings) -> dict[str, Any]: + """Apply a settings delta. + + Settings are stored but not applied to the active connection. + """ + changed = await super()._update_settings(delta) + + if not changed: + return changed + + # TODO: someday we could reconnect here to apply updated settings. + # Code might look something like the below: + # await self._disconnect() + # await self._start_connecting() + + self._warn_unhandled_updated_settings(changed) + + return changed + # # standard AIService frame handling # @@ -341,11 +503,13 @@ class AWSNovaSonicLLMService(LLMService): async def reset_conversation(self): """Reset the conversation state while preserving context. - Handles bot stopped speaking event, disconnects from the service, - and reconnects with the preserved context. + Cleans up any in-progress assistant response, disconnects from the + service, and reconnects with the preserved context. """ logger.debug("Resetting conversation") - await self._handle_bot_stopped_speaking(delay_to_catch_trailing_assistant_text=False) + if self._assistant_is_responding: + self._assistant_is_responding = False + await self._report_assistant_response_ended() # Grab context to carry through disconnect/reconnect context = self._context @@ -376,8 +540,6 @@ class AWSNovaSonicLLMService(LLMService): await self._handle_context(context) elif isinstance(frame, InputAudioRawFrame): await self._handle_input_audio_frame(frame) - elif isinstance(frame, BotStoppedSpeakingFrame): - await self._handle_bot_stopped_speaking(delay_to_catch_trailing_assistant_text=True) elif isinstance(frame, InterruptionFrame): await self._handle_interruption_frame() @@ -405,49 +567,8 @@ class AWSNovaSonicLLMService(LLMService): await self._send_user_audio_event(frame.audio) - async def _handle_bot_stopped_speaking(self, delay_to_catch_trailing_assistant_text: bool): - # Protect against back-to-back BotStoppedSpeaking calls, which I've observed - if self._handling_bot_stopped_speaking: - return - self._handling_bot_stopped_speaking = True - - async def finalize_assistant_response(): - if self._assistant_is_responding: - # Consider the assistant finished with their response (possibly after a short delay, - # to allow for any trailing FINAL assistant text block to come in that need to make - # it into context). - # - # TODO: ideally we could base this solely on the LLM output events, but I couldn't - # figure out a reliable way to determine when we've gotten our last FINAL text block - # after the LLM is done talking. - # - # First I looked at stopReason, but it doesn't seem like the last FINAL text block - # is reliably marked END_TURN (sometimes the *first* one is, but not the last... - # bug?) - # - # Then I considered schemes where we tally or match up SPECULATIVE text blocks with - # FINAL text blocks to know how many or which FINAL blocks to expect, but user - # interruptions throw a wrench in these schemes: depending on the exact timing of - # the interruption, we should or shouldn't expect some FINAL blocks. - if delay_to_catch_trailing_assistant_text: - # This delay length is a balancing act between "catching" trailing assistant - # text that is quite delayed but not waiting so long that user text comes in - # first and results in a bit of context message order scrambling. - await asyncio.sleep(1.25) - self._assistant_is_responding = False - await self._report_assistant_response_ended() - - self._handling_bot_stopped_speaking = False - - # Finalize the assistant response, either now or after a delay - if delay_to_catch_trailing_assistant_text: - self.create_task(finalize_assistant_response()) - else: - await finalize_assistant_response() - async def _handle_interruption_frame(self): - if self._assistant_is_responding: - self._may_need_repush_assistant_text = True + pass # # LLM communication: lifecycle @@ -470,7 +591,7 @@ class AWSNovaSonicLLMService(LLMService): # Start the bidirectional stream self._stream = await self._client.invoke_model_with_bidirectional_stream( - InvokeModelWithBidirectionalStreamOperationInput(model_id=self._model) + InvokeModelWithBidirectionalStreamOperationInput(model_id=self._settings.model) ) # Send session start event @@ -521,24 +642,40 @@ class AWSNovaSonicLLMService(LLMService): await self._send_prompt_start_event(tools) # Send system instruction. - # Instruction from context takes priority over self._system_instruction. + # Instruction from context takes priority over self._settings.system_instruction. system_instruction = ( llm_connection_params["system_instruction"] if llm_connection_params["system_instruction"] - else self._system_instruction + else self._settings.system_instruction ) logger.debug(f"Using system instruction: {system_instruction}") if system_instruction: await self._send_text_event(text=system_instruction, role=Role.SYSTEM) - # Send conversation history - for message in llm_connection_params["messages"]: + # Send conversation history (except for the last message if it's from the + # user, which we'll send as interactive after starting audio input) + messages = llm_connection_params["messages"] + last_user_message = None + for i, message in enumerate(messages): # logger.debug(f"Seeding conversation history with message: {message}") - await self._send_text_event(text=message.text, role=message.role) + is_last_message = i == len(messages) - 1 + if is_last_message and message.role == Role.USER: + # Save for sending after audio input starts + last_user_message = message + else: + await self._send_text_event(text=message.text, role=message.role) # Start audio input await self._send_audio_input_start_event() + # Now send the last user message as interactive to trigger bot response + if last_user_message: + # logger.debug( + # f"Sending last user message as interactive to trigger bot response: {last_user_message}") + await self._send_text_event( + text=last_user_message.text, role=last_user_message.role, interactive=True + ) + # Start receiving events self._receive_task = self.create_task(self._receive_task_handler()) @@ -591,16 +728,15 @@ class AWSNovaSonicLLMService(LLMService): self._input_audio_content_name = None self._content_being_received = None self._assistant_is_responding = False - self._may_need_repush_assistant_text = False self._ready_to_send_context = False - self._handling_bot_stopped_speaking = False self._triggering_assistant_response = False self._waiting_for_trigger_transcription = False self._disconnecting = False self._connected_time = None self._user_text_buffer = "" - self._assistant_text_buffer = "" self._completed_tool_calls = set() + self._audio_input_started = False + self._pending_speculative_text = None logger.info("Finished disconnecting") except Exception as e: @@ -620,7 +756,7 @@ class AWSNovaSonicLLMService(LLMService): def _is_first_generation_sonic_model(self) -> bool: # Nova Sonic (the older model) is identified by "amazon.nova-sonic-v1:0" - return self._model == "amazon.nova-sonic-v1:0" + return self._settings.model == "amazon.nova-sonic-v1:0" def _is_endpointing_sensitivity_supported(self) -> bool: # endpointing_sensitivity is only supported with Nova 2 Sonic (and, @@ -639,9 +775,9 @@ class AWSNovaSonicLLMService(LLMService): turn_detection_config = ( f""", "turnDetectionConfiguration": {{ - "endpointingSensitivity": "{self._params.endpointing_sensitivity}" + "endpointingSensitivity": "{self._settings.endpointing_sensitivity}" }}""" - if self._params.endpointing_sensitivity + if self._settings.endpointing_sensitivity else "" ) @@ -650,9 +786,9 @@ class AWSNovaSonicLLMService(LLMService): "event": {{ "sessionStart": {{ "inferenceConfiguration": {{ - "maxTokens": {self._params.max_tokens}, - "topP": {self._params.top_p}, - "temperature": {self._params.temperature} + "maxTokens": {self._settings.max_tokens}, + "topP": {self._settings.top_p}, + "temperature": {self._settings.temperature} }}{turn_detection_config} }} }} @@ -687,10 +823,10 @@ class AWSNovaSonicLLMService(LLMService): }}, "audioOutputConfiguration": {{ "mediaType": "audio/lpcm", - "sampleRateHertz": {self._params.output_sample_rate}, - "sampleSizeBits": {self._params.output_sample_size}, - "channelCount": {self._params.output_channel_count}, - "voiceId": "{self._voice_id}", + "sampleRateHertz": {self._audio_config.output_sample_rate}, + "sampleSizeBits": {self._audio_config.output_sample_size}, + "channelCount": {self._audio_config.output_channel_count}, + "voiceId": "{self._settings.voice}", "encoding": "base64", "audioType": "SPEECH" }}{tools_config} @@ -715,9 +851,9 @@ class AWSNovaSonicLLMService(LLMService): "role": "USER", "audioInputConfiguration": {{ "mediaType": "audio/lpcm", - "sampleRateHertz": {self._params.input_sample_rate}, - "sampleSizeBits": {self._params.input_sample_size}, - "channelCount": {self._params.input_channel_count}, + "sampleRateHertz": {self._audio_config.input_sample_rate}, + "sampleSizeBits": {self._audio_config.input_sample_size}, + "channelCount": {self._audio_config.input_channel_count}, "audioType": "SPEECH", "encoding": "base64" }} @@ -726,8 +862,18 @@ class AWSNovaSonicLLMService(LLMService): }} ''' await self._send_client_event(audio_content_start) + self._audio_input_started = True - async def _send_text_event(self, text: str, role: Role): + async def _send_text_event(self, text: str, role: Role, interactive: bool = False): + """Send a text event to the LLM. + + Args: + text: The text content to send. + role: The role associated with the text (e.g., USER, ASSISTANT, SYSTEM). + interactive: Whether the content is interactive. Defaults to False. + False: conversation history or system instruction, sent prior to interactive audio + True: text input sent during (or at the start of) interactive audio + """ if not self._stream or not self._prompt_name or not text: return @@ -740,7 +886,7 @@ class AWSNovaSonicLLMService(LLMService): "promptName": "{self._prompt_name}", "contentName": "{content_name}", "type": "TEXT", - "interactive": true, + "interactive": {json.dumps(interactive)}, "role": "{role.value}", "textInputConfiguration": {{ "mediaType": "text/plain" @@ -778,7 +924,7 @@ class AWSNovaSonicLLMService(LLMService): await self._send_client_event(text_content_end) async def _send_user_audio_event(self, audio: bytes): - if not self._stream: + if not self._stream or not self._audio_input_started: return blob = base64.b64encode(audio) @@ -853,7 +999,9 @@ class AWSNovaSonicLLMService(LLMService): "toolResult": { "promptName": self._prompt_name, "contentName": content_name, - "content": json.dumps(result) if isinstance(result, dict) else result, + "content": json.dumps(result, ensure_ascii=False) + if isinstance(result, dict) + else result, } } } @@ -960,10 +1108,11 @@ class AWSNovaSonicLLMService(LLMService): self._content_being_received = content if content.role == Role.ASSISTANT: - if content.type == ContentType.AUDIO: - # Note that an assistant response can comprise of multiple audio blocks - if not self._assistant_is_responding: - # The assistant has started responding. + if content.type == ContentType.TEXT: + if ( + content.text_stage == TextStage.SPECULATIVE + and not self._assistant_is_responding + ): self._assistant_is_responding = True await self._report_user_transcription_ended() # Consider user turn over await self._report_assistant_response_started() @@ -990,8 +1139,8 @@ class AWSNovaSonicLLMService(LLMService): audio = base64.b64decode(audio_content) frame = TTSAudioRawFrame( audio=audio, - sample_rate=self._params.output_sample_rate, - num_channels=self._params.output_channel_count, + sample_rate=self._audio_config.output_sample_rate, + num_channels=self._audio_config.output_channel_count, ) await self.push_frame(frame) @@ -1039,18 +1188,30 @@ class AWSNovaSonicLLMService(LLMService): if content.role == Role.ASSISTANT: if content.type == ContentType.TEXT: - # Ignore non-final text, and the "interrupted" message (which isn't meaningful text) - if content.text_stage == TextStage.FINAL and stop_reason != "INTERRUPTED": - if self._assistant_is_responding: - # Text added to the ongoing assistant response - await self._report_assistant_response_text_added(content.text_content) + if stop_reason != "INTERRUPTED": + if content.text_stage == TextStage.SPECULATIVE: + await self._report_llm_text(content.text_content) + elif self._assistant_is_responding: + # TEXT INTERRUPTED with no audio means the user interrupted + # before audio started. End the response here since no AUDIO + # contentEnd will arrive. + self._assistant_is_responding = False + await self._report_assistant_response_ended() + elif content.type == ContentType.AUDIO: + # Emit deferred TTSTextFrame after all audio chunks have been sent + await self._report_tts_text() + if stop_reason in ("END_TURN", "INTERRUPTED"): + # END_TURN: normal completion. INTERRUPTED: user interrupted + # mid-audio. Both mean no more audio for this turn. + self._assistant_is_responding = False + await self._report_assistant_response_ended() elif content.role == Role.USER: if content.type == ContentType.TEXT: if content.text_stage == TextStage.FINAL: # User transcription text added await self._report_user_transcription_text_added(content.text_content) - async def _handle_completion_end_event(self, event_json): + async def _handle_completion_end_event(self, _): pass # @@ -1063,31 +1224,40 @@ class AWSNovaSonicLLMService(LLMService): async def _report_assistant_response_started(self): logger.debug("Assistant response started") - - # Report the start of the assistant response. await self.push_frame(LLMFullResponseStartFrame()) # Report that equivalent of TTS (this is a speech-to-speech model) started await self.push_frame(TTSStartedFrame()) - async def _report_assistant_response_text_added(self, text): - if not self._context: # should never happen - return + async def _report_llm_text(self, text): + """Push speculative assistant text and defer TTSTextFrame. - logger.debug(f"Assistant response text added: {text}") + Speculative text arrives before each audio chunk, providing real-time + text that is synchronized with what the bot is saying. LLMTextFrame and + AggregatedTextFrame are pushed immediately for real-time text display. + TTSTextFrame emission is deferred to audio contentEnd so it aligns with + audio playout timing. + """ + logger.debug(f"Assistant speculative text: {text}") - # Report the text of the assistant response. - frame = TTSTextFrame(text, aggregated_by=AggregationType.SENTENCE) - frame.includes_inter_frame_spaces = True - await self.push_frame(frame) + llm_text_frame = LLMTextFrame(text) + llm_text_frame.append_to_context = False + await self.push_frame(llm_text_frame) - # HACK: here we're also buffering the assistant text ourselves as a - # backup rather than relying solely on the assistant context aggregator - # to do it, because the text arrives from Nova Sonic only after all the - # assistant audio frames have been pushed, meaning that if an - # interruption frame were to arrive we would lose all of it (the text - # frames sitting in the queue would be wiped). - self._assistant_text_buffer += text + aggregated_text_frame = AggregatedTextFrame(text, aggregated_by=AggregationType.SENTENCE) + aggregated_text_frame.append_to_context = False + await self.push_frame(aggregated_text_frame) + + self._pending_speculative_text = text + + async def _report_tts_text(self): + if self._pending_speculative_text: + tts_text_frame = TTSTextFrame( + self._pending_speculative_text, aggregated_by=AggregationType.SENTENCE + ) + tts_text_frame.includes_inter_frame_spaces = True + await self.push_frame(tts_text_frame) + self._pending_speculative_text = None async def _report_assistant_response_ended(self): if not self._context: # should never happen @@ -1095,39 +1265,12 @@ class AWSNovaSonicLLMService(LLMService): logger.debug("Assistant response ended") - # If an interruption frame arrived while the assistant was responding - # we may have lost all of the assistant text (see HACK, above), so - # re-push it downstream to the aggregator now. - if self._may_need_repush_assistant_text: - # Just in case, check that assistant text hasn't already made it - # into the context (sometimes it does, despite the interruption). - messages = self._context.get_messages() - last_message = messages[-1] if messages else None - if ( - not last_message - or last_message.get("role") != "assistant" - or last_message.get("content") != self._assistant_text_buffer - ): - # We also need to re-push the LLMFullResponseStartFrame since the - # TTSTextFrame would be ignored otherwise (the interruption frame - # would have cleared the assistant aggregator state). - await self.push_frame(LLMFullResponseStartFrame()) - frame = TTSTextFrame( - self._assistant_text_buffer, aggregated_by=AggregationType.SENTENCE - ) - frame.includes_inter_frame_spaces = True - await self.push_frame(frame) - self._may_need_repush_assistant_text = False - # Report the end of the assistant response. await self.push_frame(LLMFullResponseEndFrame()) # Report that equivalent of TTS (this is a speech-to-speech model) stopped. await self.push_frame(TTSStoppedFrame()) - # Clear out the buffered assistant text - self._assistant_text_buffer = "" - # # user transcription reporting # @@ -1157,6 +1300,12 @@ class AWSNovaSonicLLMService(LLMService): if not self._context: # should never happen return + # Nothing to report if no user speech was transcribed (e.g. the prompt + # was text-only, which is the case on the first user turn when the bot + # starts the conversation). + if not self._user_text_buffer: + return + logger.debug(f"User transcription ended") # Report to the upstream user context aggregator that some new user @@ -1187,7 +1336,7 @@ class AWSNovaSonicLLMService(LLMService): logger.debug( "Wrapping assistant response trigger transcription with upstream UserStarted/StoppedSpeakingFrames" ) - await self.push_frame(UserStartedSpeakingFrame(), direction=FrameDirection.UPSTREAM) + await self.broadcast_frame(UserStartedSpeakingFrame) # Send the transcription upstream for the user context aggregator frame = TranscriptionFrame( @@ -1197,7 +1346,7 @@ class AWSNovaSonicLLMService(LLMService): # Finish wrapping the upstream transcription in UserStarted/StoppedSpeakingFrames if needed if should_wrap_in_user_started_stopped_speaking_frames: - await self.push_frame(UserStoppedSpeakingFrame(), direction=FrameDirection.UPSTREAM) + await self.broadcast_frame(UserStoppedSpeakingFrame) # Clear out the buffered user text self._user_text_buffer = "" @@ -1262,7 +1411,7 @@ class AWSNovaSonicLLMService(LLMService): """ if not self._is_assistant_response_trigger_needed(): logger.warning( - f"Assistant response trigger not needed for model '{self._model}'; skipping. " + f"Assistant response trigger not needed for model '{self._settings.model}'; skipping. " "An LLMRunFrame() should be sufficient to prompt the assistant to respond, " "assuming the context ends in a user message." ) @@ -1290,9 +1439,9 @@ class AWSNovaSonicLLMService(LLMService): chunk_duration = 0.02 # what we might get from InputAudioRawFrame chunk_size = int( chunk_duration - * self._params.input_sample_rate - * self._params.input_channel_count - * (self._params.input_sample_size / 8) + * self._audio_config.input_sample_rate + * self._audio_config.input_channel_count + * (self._audio_config.input_sample_size / 8) ) # e.g. 0.02 seconds of 16-bit (2-byte) PCM mono audio at 16kHz is 640 bytes # Lead with a bit of blank audio, if needed. diff --git a/src/pipecat/services/aws/stt.py b/src/pipecat/services/aws/stt.py index 915213e51..ace05090d 100644 --- a/src/pipecat/services/aws/stt.py +++ b/src/pipecat/services/aws/stt.py @@ -10,12 +10,12 @@ This module provides a WebSocket-based connection to AWS Transcribe for real-tim speech-to-text transcription with support for multiple languages and audio formats. """ -import asyncio import json import os import random import string -from typing import AsyncGenerator, Optional +from dataclasses import dataclass +from typing import Any, AsyncGenerator, Optional from loguru import logger @@ -29,6 +29,8 @@ from pipecat.frames.frames import ( TranscriptionFrame, ) from pipecat.services.aws.utils import build_event_message, decode_event, get_presigned_url +from pipecat.services.settings import STTSettings +from pipecat.services.stt_latency import AWS_TRANSCRIBE_TTFS_P99 from pipecat.services.stt_service import WebsocketSTTService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.time import time_now_iso8601 @@ -43,6 +45,13 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class AWSTranscribeSTTSettings(STTSettings): + """Settings for AWSTranscribeSTTService.""" + + pass + + class AWSTranscribeSTTService(WebsocketSTTService): """AWS Transcribe Speech-to-Text service using WebSocket streaming. @@ -51,6 +60,9 @@ class AWSTranscribeSTTService(WebsocketSTTService): final transcription results. """ + Settings = AWSTranscribeSTTSettings + _settings: Settings + def __init__( self, *, @@ -58,8 +70,10 @@ class AWSTranscribeSTTService(WebsocketSTTService): aws_access_key_id: Optional[str] = None, aws_session_token: Optional[str] = None, region: Optional[str] = None, - sample_rate: int = 16000, - language: Language = Language.EN, + sample_rate: Optional[int] = None, + language: Optional[Language] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = AWS_TRANSCRIBE_TTFS_P99, **kwargs, ): """Initialize the AWS Transcribe STT service. @@ -69,27 +83,49 @@ class AWSTranscribeSTTService(WebsocketSTTService): aws_access_key_id: AWS access key ID. If None, uses AWS_ACCESS_KEY_ID environment variable. aws_session_token: AWS session token for temporary credentials. If None, uses AWS_SESSION_TOKEN environment variable. region: AWS region for the service. - sample_rate: Audio sample rate in Hz. Must be 8000 or 16000. Defaults to 16000. - language: Language for transcription. Defaults to English. + sample_rate: Audio sample rate in Hz. If None, uses the pipeline sample rate. + AWS Transcribe only supports 8000 or 16000 Hz; other values are + clamped to 16000 Hz at connect time. + language: Language for transcription. + + .. deprecated:: 0.0.105 + Use ``settings=AWSTranscribeSTTService.Settings(language=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to parent STTService class. """ - super().__init__(**kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + language=Language.EN, + ) - self._settings = { - "sample_rate": sample_rate, - "language": language, - "media_encoding": "linear16", # AWS expects raw PCM - "number_of_channels": 1, - "show_speaker_label": False, - "enable_channel_identification": False, - } + # 2. Apply direct init arg overrides (deprecated) + if language is not None: + self._warn_init_param_moved_to_settings("language", "language") + default_settings.language = language - # Validate sample rate - AWS Transcribe only supports 8000 Hz or 16000 Hz - if sample_rate not in [8000, 16000]: - logger.warning( - f"AWS Transcribe only supports 8000 Hz or 16000 Hz sample rates. Converting from {sample_rate} Hz to 16000 Hz." - ) - self._settings["sample_rate"] = 16000 + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) + + # Init-only connection config (not runtime-updatable). + self._media_encoding = "linear16" + self._number_of_channels = 1 + self._show_speaker_label = False + self._enable_channel_identification = False self._credentials = { "aws_access_key_id": aws_access_key_id or os.getenv("AWS_ACCESS_KEY_ID"), @@ -100,6 +136,14 @@ class AWSTranscribeSTTService(WebsocketSTTService): self._receive_task = None + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as AWS Transcribe STT supports metrics generation. + """ + return True + def get_service_encoding(self, encoding: str) -> str: """Convert internal encoding format to AWS Transcribe format. @@ -114,6 +158,16 @@ class AWSTranscribeSTTService(WebsocketSTTService): } return encoding_map.get(encoding, encoding) + async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: + """Apply a settings delta and reconnect if anything changed.""" + changed = await super()._update_settings(delta) + + if changed and self._websocket: + await self._disconnect() + await self._connect() + + return changed + async def start(self, frame: StartFrame): """Initialize the connection when the service starts. @@ -159,7 +213,6 @@ class AWSTranscribeSTTService(WebsocketSTTService): await self._websocket.send(event_message) # Start metrics after first chunk sent await self.start_processing_metrics() - await self.start_ttfb_metrics() except Exception as e: yield ErrorFrame(error=f"Error sending audio: {e}") @@ -170,6 +223,8 @@ class AWSTranscribeSTTService(WebsocketSTTService): Establishes websocket connection and starts receive task. """ + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: @@ -180,6 +235,8 @@ class AWSTranscribeSTTService(WebsocketSTTService): Sends end-stream message and cleans up. """ + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None @@ -202,9 +259,18 @@ class AWSTranscribeSTTService(WebsocketSTTService): logger.debug("Connecting to AWS Transcribe WebSocket") - language_code = self.language_to_service_language(Language(self._settings["language"])) + language_code = self._settings.language if not language_code: - raise ValueError(f"Unsupported language: {self._settings['language']}") + raise ValueError(f"Unsupported language: {language_code}") + + # Validate sample rate — AWS Transcribe only supports 8000 or 16000 Hz + connect_sample_rate = self.sample_rate + if connect_sample_rate not in (8000, 16000): + logger.warning( + f"AWS Transcribe only supports 8000 Hz or 16000 Hz sample rates. " + f"Converting from {connect_sample_rate} Hz to 16000 Hz." + ) + connect_sample_rate = 16000 # Generate random websocket key websocket_key = "".join( @@ -231,14 +297,14 @@ class AWSTranscribeSTTService(WebsocketSTTService): }, language_code=language_code, media_encoding=self.get_service_encoding( - self._settings["media_encoding"] + self._media_encoding ), # Convert to AWS format - sample_rate=self._settings["sample_rate"], - number_of_channels=self._settings["number_of_channels"], + sample_rate=connect_sample_rate, + number_of_channels=self._number_of_channels, enable_partial_results_stabilization=True, partial_results_stability="high", - show_speaker_label=self._settings["show_speaker_label"], - enable_channel_identification=self._settings["enable_channel_identification"], + show_speaker_label=self._show_speaker_label, + enable_channel_identification=self._enable_channel_identification, ) logger.debug(f"{self} Connecting to WebSocket with URL: {presigned_url[:100]}...") @@ -467,21 +533,20 @@ class AWSTranscribeSTTService(WebsocketSTTService): is_final = not result.get("IsPartial", True) if transcript: - await self.stop_ttfb_metrics() if is_final: await self.push_frame( TranscriptionFrame( transcript, self._user_id, time_now_iso8601(), - self._settings["language"], + self._settings.language, result=result, ) ) await self._handle_transcription( transcript, is_final, - self._settings["language"], + self._settings.language, ) await self.stop_processing_metrics() else: @@ -490,7 +555,7 @@ class AWSTranscribeSTTService(WebsocketSTTService): transcript, self._user_id, time_now_iso8601(), - self._settings["language"], + self._settings.language, result=result, ) ) diff --git a/src/pipecat/services/aws/tts.py b/src/pipecat/services/aws/tts.py index 0df2dabd9..32266886e 100644 --- a/src/pipecat/services/aws/tts.py +++ b/src/pipecat/services/aws/tts.py @@ -10,8 +10,8 @@ This module provides integration with Amazon Polly for text-to-speech synthesis, supporting multiple languages, voices, and SSML features. """ -import asyncio import os +from dataclasses import dataclass, field from typing import AsyncGenerator, List, Optional from loguru import logger @@ -22,9 +22,8 @@ from pipecat.frames.frames import ( ErrorFrame, Frame, TTSAudioRawFrame, - TTSStartedFrame, - TTSStoppedFrame, ) +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven from pipecat.services.tts_service import TTSService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.tracing.service_decorators import traced_tts @@ -122,6 +121,25 @@ def language_to_aws_language(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=False) +@dataclass +class AWSPollyTTSSettings(TTSSettings): + """Settings for AWSPollyTTSService. + + Parameters: + engine: TTS engine to use ('standard', 'neural', etc.). + pitch: Voice pitch adjustment (for standard engine only). + rate: Speech rate adjustment. + volume: Voice volume adjustment. + lexicon_names: List of pronunciation lexicons to apply. + """ + + engine: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + pitch: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + rate: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + volume: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + lexicon_names: List[str] | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class AWSPollyTTSService(TTSService): """AWS Polly text-to-speech service. @@ -130,9 +148,15 @@ class AWSPollyTTSService(TTSService): options including prosody controls. """ + Settings = AWSPollyTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for AWS Polly TTS configuration. + .. deprecated:: 0.0.105 + Use ``AWSPollyTTSService.Settings`` directly via the ``settings`` parameter instead. + Parameters: engine: TTS engine to use ('standard', 'neural', etc.). language: Language for synthesis. Defaults to English. @@ -156,9 +180,10 @@ class AWSPollyTTSService(TTSService): aws_access_key_id: Optional[str] = None, aws_session_token: Optional[str] = None, region: Optional[str] = None, - voice_id: str = "Joanna", + voice_id: Optional[str] = None, sample_rate: Optional[int] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initializes the AWS Polly TTS service. @@ -169,13 +194,59 @@ class AWSPollyTTSService(TTSService): aws_session_token: AWS session token for temporary credentials. region: AWS region for Polly service. Defaults to 'us-east-1'. voice_id: Voice ID to use for synthesis. Defaults to 'Joanna'. + + .. deprecated:: 0.0.105 + Use ``settings=AWSPollyTTSService.Settings(voice=...)`` instead. + sample_rate: Audio sample rate. If None, uses service default. params: Additional input parameters for voice customization. + + .. deprecated:: 0.0.105 + Use ``settings=AWSPollyTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService class. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice="Joanna", + language="en-US", + engine=None, + pitch=None, + rate=None, + volume=None, + lexicon_names=None, + ) - params = params or AWSPollyTTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.engine = params.engine + default_settings.language = params.language if params.language else "en-US" + default_settings.pitch = params.pitch + default_settings.rate = params.rate + default_settings.volume = params.volume + default_settings.lexicon_names = params.lexicon_names + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) # Get credentials from environment variables if not provided self._aws_params = { @@ -186,21 +257,9 @@ class AWSPollyTTSService(TTSService): } self._aws_session = aioboto3.Session() - self._settings = { - "engine": params.engine, - "language": self.language_to_service_language(params.language) - if params.language - else "en-US", - "pitch": params.pitch, - "rate": params.rate, - "volume": params.volume, - "lexicon_names": params.lexicon_names, - } self._resampler = create_stream_resampler() - self.set_voice(voice_id) - def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -223,19 +282,19 @@ class AWSPollyTTSService(TTSService): def _construct_ssml(self, text: str) -> str: ssml = "" - language = self._settings["language"] + language = self._settings.language ssml += f"" prosody_attrs = [] # Prosody tags are only supported for standard and neural engines - if self._settings["engine"] == "standard": - if self._settings["pitch"]: - prosody_attrs.append(f"pitch='{self._settings['pitch']}'") + if self._settings.engine == "standard": + if self._settings.pitch: + prosody_attrs.append(f"pitch='{self._settings.pitch}'") - if self._settings["rate"]: - prosody_attrs.append(f"rate='{self._settings['rate']}'") - if self._settings["volume"]: - prosody_attrs.append(f"volume='{self._settings['volume']}'") + if self._settings.rate: + prosody_attrs.append(f"rate='{self._settings.rate}'") + if self._settings.volume: + prosody_attrs.append(f"volume='{self._settings.volume}'") if prosody_attrs: ssml += f"" @@ -254,11 +313,12 @@ class AWSPollyTTSService(TTSService): return ssml @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using AWS Polly. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -266,8 +326,6 @@ class AWSPollyTTSService(TTSService): logger.debug(f"{self}: Generating TTS [{text}]") try: - await self.start_ttfb_metrics() - # Construct the parameters dictionary ssml = self._construct_ssml(text) @@ -275,11 +333,11 @@ class AWSPollyTTSService(TTSService): "Text": ssml, "TextType": "ssml", "OutputFormat": "pcm", - "VoiceId": self._voice_id, - "Engine": self._settings["engine"], + "VoiceId": self._settings.voice, + "Engine": self._settings.engine, # AWS only supports 8000 and 16000 for PCM. We select 16000. "SampleRate": "16000", - "LexiconNames": self._settings["lexicon_names"], + "LexiconNames": self._settings.lexicon_names, } # Filter out None values @@ -299,25 +357,19 @@ class AWSPollyTTSService(TTSService): await self.start_tts_usage_metrics(text) - yield TTSStartedFrame() - CHUNK_SIZE = self.chunk_size for i in range(0, len(audio_data), CHUNK_SIZE): chunk = audio_data[i : i + CHUNK_SIZE] if len(chunk) > 0: await self.stop_ttfb_metrics() - frame = TTSAudioRawFrame(chunk, self.sample_rate, 1) + frame = TTSAudioRawFrame(chunk, self.sample_rate, 1, context_id=context_id) yield frame - yield TTSStoppedFrame() except (BotoCoreError, ClientError) as error: error_message = f"AWS Polly TTS error: {str(error)}" yield ErrorFrame(error=error_message) - finally: - yield TTSStoppedFrame() - class PollyTTSService(AWSPollyTTSService): """Deprecated alias for AWSPollyTTSService. @@ -327,6 +379,8 @@ class PollyTTSService(AWSPollyTTSService): """ + Settings = AWSPollyTTSSettings + def __init__(self, **kwargs): """Initialize the deprecated PollyTTSService. diff --git a/src/pipecat/services/aws_nova_sonic/__init__.py b/src/pipecat/services/aws_nova_sonic/__init__.py index 8348e9ffe..a198094cb 100644 --- a/src/pipecat/services/aws_nova_sonic/__init__.py +++ b/src/pipecat/services/aws_nova_sonic/__init__.py @@ -17,3 +17,8 @@ with warnings.catch_warnings(): DeprecationWarning, stacklevel=2, ) + +__all__ = [ + "AWSNovaSonicLLMService", + "Params", +] diff --git a/src/pipecat/services/azure/common.py b/src/pipecat/services/azure/common.py index f867d4e5d..dc7aaa359 100644 --- a/src/pipecat/services/azure/common.py +++ b/src/pipecat/services/azure/common.py @@ -8,8 +8,6 @@ from typing import Optional -from loguru import logger - from pipecat.transcriptions.language import Language, resolve_language diff --git a/src/pipecat/services/azure/image.py b/src/pipecat/services/azure/image.py index b33d8cc7d..fc50d710a 100644 --- a/src/pipecat/services/azure/image.py +++ b/src/pipecat/services/azure/image.py @@ -12,14 +12,27 @@ using REST endpoints for creating images from text prompts. import asyncio import io -from typing import AsyncGenerator +from dataclasses import dataclass, field +from typing import AsyncGenerator, Optional import aiohttp -from loguru import logger from PIL import Image from pipecat.frames.frames import ErrorFrame, Frame, URLImageRawFrame from pipecat.services.image_service import ImageGenService +from pipecat.services.settings import NOT_GIVEN, ImageGenSettings, _NotGiven + + +@dataclass +class AzureImageGenSettings(ImageGenSettings): + """Settings for the Azure image generation service. + + Parameters: + model: Azure image generation model identifier. + image_size: Target size for generated images. + """ + + image_size: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) class AzureImageGenServiceREST(ImageGenService): @@ -30,33 +43,64 @@ class AzureImageGenServiceREST(ImageGenService): and automatic image download and processing. """ + Settings = AzureImageGenSettings + _settings: Settings + def __init__( self, *, - image_size: str, + image_size: Optional[str] = None, api_key: str, endpoint: str, - model: str, + model: Optional[str] = None, aiohttp_session: aiohttp.ClientSession, api_version="2023-06-01-preview", + settings: Optional[Settings] = None, ): """Initialize the AzureImageGenServiceREST. Args: image_size: Size specification for generated images (e.g., "1024x1024"). + + .. deprecated:: 0.0.105 + Use ``settings=AzureImageGenServiceREST.Settings(image_size=...)`` instead. + api_key: Azure OpenAI API key for authentication. endpoint: Azure OpenAI endpoint URL. model: The image generation model to use. + + .. deprecated:: 0.0.105 + Use ``settings=AzureImageGenServiceREST.Settings(model=...)`` instead. + aiohttp_session: Shared aiohttp session for HTTP requests. api_version: Azure API version string. Defaults to "2023-06-01-preview". + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. """ - super().__init__() + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + image_size=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + if image_size is not None: + self._warn_init_param_moved_to_settings("image_size", "image_size") + default_settings.image_size = image_size + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(settings=default_settings) self._api_key = api_key self._azure_endpoint = endpoint self._api_version = api_version - self.set_model_name(model) - self._image_size = image_size self._aiohttp_session = aiohttp_session async def run_image_gen(self, prompt: str) -> AsyncGenerator[Frame, None]: @@ -74,12 +118,13 @@ class AzureImageGenServiceREST(ImageGenService): headers = {"api-key": self._api_key, "Content-Type": "application/json"} body = { - # Enter your prompt text here "prompt": prompt, - "size": self._image_size, "n": 1, } + if self._settings.image_size is not None: + body["size"] = self._settings.image_size + async with self._aiohttp_session.post(url, headers=headers, json=body) as submission: # We never get past this line, because this header isn't # defined on a 429 response, but something is eating our diff --git a/src/pipecat/services/azure/llm.py b/src/pipecat/services/azure/llm.py index b1807ad13..8b5050e5b 100644 --- a/src/pipecat/services/azure/llm.py +++ b/src/pipecat/services/azure/llm.py @@ -6,12 +6,23 @@ """Azure OpenAI service implementation for the Pipecat AI framework.""" +from dataclasses import dataclass +from typing import Optional + from loguru import logger from openai import AsyncAzureOpenAI +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class AzureLLMSettings(BaseOpenAILLMService.Settings): + """Settings for AzureLLMService.""" + + pass + + class AzureLLMService(OpenAILLMService): """A service for interacting with Azure OpenAI using the OpenAI-compatible interface. @@ -19,13 +30,16 @@ class AzureLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. """ + Settings = AzureLLMSettings + def __init__( self, *, api_key: str, endpoint: str, - model: str, + model: Optional[str] = None, api_version: str = "2024-09-01-preview", + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Azure LLM service. @@ -33,15 +47,35 @@ class AzureLLMService(OpenAILLMService): Args: api_key: The API key for accessing Azure OpenAI. endpoint: The Azure endpoint URL. - model: The model identifier to use. + model: The model identifier to use. Defaults to "gpt-4.1". + + .. deprecated:: 0.0.105 + Use ``settings=AzureLLMService.Settings(model=...)`` instead. + api_version: Azure API version. Defaults to "2024-09-01-preview". + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="gpt-4.1") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + # Initialize variables before calling parent __init__() because that # will call create_client() and we need those values there. self._endpoint = endpoint self._api_version = api_version - super().__init__(api_key=api_key, model=model, **kwargs) + super().__init__(api_key=api_key, settings=default_settings, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): """Create OpenAI-compatible client for Azure OpenAI endpoint. diff --git a/src/pipecat/services/azure/realtime/llm.py b/src/pipecat/services/azure/realtime/llm.py index 39c9bd707..e6bc05478 100644 --- a/src/pipecat/services/azure/realtime/llm.py +++ b/src/pipecat/services/azure/realtime/llm.py @@ -6,6 +6,8 @@ """Azure OpenAI Realtime LLM service implementation.""" +from dataclasses import dataclass + from loguru import logger from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService @@ -18,6 +20,13 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class AzureRealtimeLLMSettings(OpenAIRealtimeLLMService.Settings): + """Settings for AzureRealtimeLLMService.""" + + pass + + class AzureRealtimeLLMService(OpenAIRealtimeLLMService): """Azure OpenAI Realtime LLM service with Azure-specific authentication. @@ -26,6 +35,9 @@ class AzureRealtimeLLMService(OpenAIRealtimeLLMService): real-time audio and text communication capabilities as the base OpenAI service. """ + Settings = AzureRealtimeLLMSettings + _settings: Settings + def __init__( self, *, diff --git a/src/pipecat/services/azure/stt.py b/src/pipecat/services/azure/stt.py index aaa1e2da4..57306e06a 100644 --- a/src/pipecat/services/azure/stt.py +++ b/src/pipecat/services/azure/stt.py @@ -11,7 +11,8 @@ Speech SDK for real-time audio transcription. """ import asyncio -from typing import AsyncGenerator, Optional +from dataclasses import dataclass +from typing import Any, AsyncGenerator, Optional from loguru import logger @@ -25,6 +26,8 @@ from pipecat.frames.frames import ( TranscriptionFrame, ) from pipecat.services.azure.common import language_to_azure_language +from pipecat.services.settings import STTSettings +from pipecat.services.stt_latency import AZURE_TTFS_P99 from pipecat.services.stt_service import STTService from pipecat.transcriptions.language import Language from pipecat.utils.time import time_now_iso8601 @@ -32,6 +35,7 @@ from pipecat.utils.tracing.service_decorators import traced_stt try: from azure.cognitiveservices.speech import ( + CancellationReason, ResultReason, SpeechConfig, SpeechRecognizer, @@ -47,6 +51,13 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class AzureSTTSettings(STTSettings): + """Settings for AzureSTTService.""" + + pass + + class AzureSTTService(STTService): """Azure Speech-to-Text service for real-time audio transcription. @@ -55,14 +66,20 @@ class AzureSTTService(STTService): provides real-time transcription results with timing information. """ + Settings = AzureSTTSettings + _settings: Settings + def __init__( self, *, api_key: str, - region: str, - language: Language = Language.EN_US, + region: Optional[str] = None, + language: Optional[Language] = Language.EN_US, sample_rate: Optional[int] = None, + private_endpoint: Optional[str] = None, endpoint_id: Optional[str] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = AZURE_TTFS_P99, **kwargs, ): """Initialize the Azure STT service. @@ -70,29 +87,75 @@ class AzureSTTService(STTService): Args: api_key: Azure Cognitive Services subscription key. region: Azure region for the Speech service (e.g., 'eastus'). + Required unless ``private_endpoint`` is provided. language: Language for speech recognition. Defaults to English (US). + + .. deprecated:: 0.0.105 + Use ``settings=AzureSTTService.Settings(language=...)`` instead. + sample_rate: Audio sample rate in Hz. If None, uses service default. + private_endpoint: Private endpoint for STT behind firewall. + See https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-services-private-link?tabs=portal endpoint_id: Custom model endpoint id. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to parent STTService. """ - super().__init__(sample_rate=sample_rate, **kwargs) - - self._speech_config = SpeechConfig( - subscription=api_key, - region=region, - speech_recognition_language=language_to_azure_language(language), + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + language=Language.EN_US, ) + # 2. Apply direct init arg overrides (deprecated) + if language is not None and language != Language.EN_US: + self._warn_init_param_moved_to_settings("language", "language") + default_settings.language = language + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) + + recognition_language = default_settings.language or language_to_azure_language( + Language.EN_US + ) + + if not region and not private_endpoint: + raise ValueError("Either 'region' or 'private_endpoint' must be provided.") + + if private_endpoint: + if region: + logger.warning( + "Both 'region' and 'private_endpoint' provided; 'region' will be ignored." + ) + self._speech_config = SpeechConfig( + subscription=api_key, + endpoint=private_endpoint, + speech_recognition_language=recognition_language, + ) + else: + self._speech_config = SpeechConfig( + subscription=api_key, + region=region, + speech_recognition_language=recognition_language, + ) + if endpoint_id: self._speech_config.endpoint_id = endpoint_id self._audio_stream = None self._speech_recognizer = None - self._settings = { - "region": region, - "language": language_to_azure_language(language), - "sample_rate": sample_rate, - } def can_generate_metrics(self) -> bool: """Check if this service can generate performance metrics. @@ -102,6 +165,31 @@ class AzureSTTService(STTService): """ return True + def language_to_service_language(self, language: Language) -> Optional[str]: + """Convert a Language enum to Azure service-specific language code. + + Args: + language: The language to convert. + + Returns: + The Azure-specific language identifier, or None if not supported. + """ + return language_to_azure_language(language) + + async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: + """Apply a settings delta and reconnect if language changed.""" + changed = await super()._update_settings(delta) + + if "language" in changed: + self._speech_config.speech_recognition_language = ( + self._settings.language or language_to_azure_language(Language.EN_US) + ) + if self._audio_stream: + await self._disconnect() + await self._connect() + + return changed + async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: """Process audio data for speech-to-text conversion. @@ -116,7 +204,6 @@ class AzureSTTService(STTService): """ try: await self.start_processing_metrics() - await self.start_ttfb_metrics() if self._audio_stream: self._audio_stream.write(audio) yield None @@ -126,14 +213,32 @@ class AzureSTTService(STTService): async def start(self, frame: StartFrame): """Start the speech recognition service. - Initializes the Azure speech recognizer with audio stream configuration - and begins continuous speech recognition. - Args: frame: Frame indicating the start of processing. """ await super().start(frame) + await self._connect() + async def stop(self, frame: EndFrame): + """Stop the speech recognition service. + + Args: + frame: Frame indicating the end of processing. + """ + await super().stop(frame) + await self._disconnect() + + async def cancel(self, frame: CancelFrame): + """Cancel the speech recognition service. + + Args: + frame: Frame indicating cancellation. + """ + await super().cancel(frame) + await self._disconnect() + + async def _connect(self): + """Initialize the Azure speech recognizer and begin continuous recognition.""" if self._audio_stream: return @@ -148,55 +253,33 @@ class AzureSTTService(STTService): ) self._speech_recognizer.recognizing.connect(self._on_handle_recognizing) self._speech_recognizer.recognized.connect(self._on_handle_recognized) + self._speech_recognizer.canceled.connect(self._on_handle_canceled) self._speech_recognizer.start_continuous_recognition_async() except Exception as e: await self.push_error( error_msg=f"Uncaught exception during initialization: {e}", exception=e ) - async def stop(self, frame: EndFrame): - """Stop the speech recognition service. - - Cleanly shuts down the Azure speech recognizer and closes audio streams. - - Args: - frame: Frame indicating the end of processing. - """ - await super().stop(frame) - - if self._speech_recognizer: - self._speech_recognizer.stop_continuous_recognition_async() - - if self._audio_stream: - self._audio_stream.close() - - async def cancel(self, frame: CancelFrame): - """Cancel the speech recognition service. - - Immediately stops recognition and closes resources. - - Args: - frame: Frame indicating cancellation. - """ - await super().cancel(frame) - + async def _disconnect(self): + """Stop recognition and close audio streams.""" if self._speech_recognizer: self._speech_recognizer.stop_continuous_recognition_async() + self._speech_recognizer = None if self._audio_stream: self._audio_stream.close() + self._audio_stream = None @traced_stt async def _handle_transcription( self, transcript: str, is_final: bool, language: Optional[Language] = None ): """Handle a transcription result with tracing.""" - await self.stop_ttfb_metrics() await self.stop_processing_metrics() def _on_handle_recognized(self, event): if event.result.reason == ResultReason.RecognizedSpeech and len(event.result.text) > 0: - language = getattr(event.result, "language", None) or self._settings.get("language") + language = getattr(event.result, "language", None) or self._settings.language frame = TranscriptionFrame( event.result.text, self._user_id, @@ -211,7 +294,7 @@ class AzureSTTService(STTService): def _on_handle_recognizing(self, event): if event.result.reason == ResultReason.RecognizingSpeech and len(event.result.text) > 0: - language = getattr(event.result, "language", None) or self._settings.get("language") + language = getattr(event.result, "language", None) or self._settings.language frame = InterimTranscriptionFrame( event.result.text, self._user_id, @@ -220,3 +303,13 @@ class AzureSTTService(STTService): result=event, ) asyncio.run_coroutine_threadsafe(self.push_frame(frame), self.get_event_loop()) + + def _on_handle_canceled(self, event): + details = event.result.cancellation_details + if details.reason == CancellationReason.Error: + error_msg = f"Azure STT recognition canceled: {details.reason}" + if details.error_details: + error_msg += f" - {details.error_details}" + asyncio.run_coroutine_threadsafe( + self.push_error(error_msg=error_msg), self.get_event_loop() + ) diff --git a/src/pipecat/services/azure/tts.py b/src/pipecat/services/azure/tts.py index 751320c19..a9491e9aa 100644 --- a/src/pipecat/services/azure/tts.py +++ b/src/pipecat/services/azure/tts.py @@ -7,6 +7,7 @@ """Azure Cognitive Services Text-to-Speech service implementations.""" import asyncio +from dataclasses import dataclass, field from typing import AsyncGenerator, Optional from loguru import logger @@ -20,12 +21,12 @@ from pipecat.frames.frames import ( InterruptionFrame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, TTSStoppedFrame, ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.azure.common import language_to_azure_language -from pipecat.services.tts_service import TTSService, WordTTSService +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven +from pipecat.services.tts_service import TextAggregationMode, TTSService from pipecat.transcriptions.language import Language from pipecat.utils.tracing.service_decorators import traced_tts @@ -65,6 +66,29 @@ def sample_rate_to_output_format(sample_rate: int) -> SpeechSynthesisOutputForma return sample_rate_map.get(sample_rate, SpeechSynthesisOutputFormat.Raw24Khz16BitMonoPcm) +@dataclass +class AzureTTSSettings(TTSSettings): + """Settings for AzureTTSService and AzureHttpTTSService. + + Parameters: + emphasis: Emphasis level for speech ("strong", "moderate", "reduced"). + pitch: Voice pitch adjustment (e.g., "+10%", "-5Hz", "high"). + rate: Speech rate adjustment (e.g., "1.0", "1.25", "slow", "fast"). + role: Voice role for expression (e.g., "YoungAdultFemale"). + style: Speaking style (e.g., "cheerful", "sad", "excited"). + style_degree: Intensity of the speaking style (0.01 to 2.0). + volume: Volume level (e.g., "+20%", "loud", "x-soft"). + """ + + emphasis: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + pitch: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + rate: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + role: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + style: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + style_degree: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + volume: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class AzureBaseTTSService: """Base mixin class for Azure Cognitive Services text-to-speech implementations. @@ -73,6 +97,9 @@ class AzureBaseTTSService: This is a mixin class and should be used alongside TTSService or its subclasses. """ + Settings = AzureTTSSettings + _settings: Settings + # Define SSML escape mappings based on SSML reserved characters # See - https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-synthesis-markup-structure SSML_ESCAPE_CHARS = { @@ -86,11 +113,14 @@ class AzureBaseTTSService: class InputParams(BaseModel): """Input parameters for Azure TTS voice configuration. + .. deprecated:: 0.0.105 + Use ``settings=AzureBaseTTSService.Settings(...)`` instead. + Parameters: emphasis: Emphasis level for speech ("strong", "moderate", "reduced"). language: Language for synthesis. Defaults to English (US). pitch: Voice pitch adjustment (e.g., "+10%", "-5Hz", "high"). - rate: Speech rate multiplier. Defaults to "1.05". + rate: Speech rate adjustment (e.g., "1.0", "1.25", "slow", "fast"). role: Voice role for expression (e.g., "YoungAdultFemale"). style: Speaking style (e.g., "cheerful", "sad", "excited"). style_degree: Intensity of the speaking style (0.01 to 2.0). @@ -100,7 +130,7 @@ class AzureBaseTTSService: emphasis: Optional[str] = None language: Optional[Language] = Language.EN_US pitch: Optional[str] = None - rate: Optional[str] = "1.05" + rate: Optional[str] = None role: Optional[str] = None style: Optional[str] = None style_degree: Optional[str] = None @@ -111,8 +141,6 @@ class AzureBaseTTSService: *, api_key: str, region: str, - voice: str = "en-US-SaraNeural", - params: Optional[InputParams] = None, ): """Initialize Azure-specific configuration. @@ -121,27 +149,9 @@ class AzureBaseTTSService: Args: api_key: Azure Cognitive Services subscription key. region: Azure region identifier (e.g., "eastus", "westus2"). - voice: Voice name to use for synthesis. Defaults to "en-US-SaraNeural". - params: Voice and synthesis parameters configuration. """ - params = params or AzureBaseTTSService.InputParams() - - self._settings = { - "emphasis": params.emphasis, - "language": self.language_to_service_language(params.language) - if params.language - else "en-US", - "pitch": params.pitch, - "rate": params.rate, - "role": params.role, - "style": params.style, - "style_degree": params.style_degree, - "volume": params.volume, - } - self._api_key = api_key self._region = region - self._voice_id = voice self._speech_synthesizer = None def language_to_service_language(self, language: Language) -> Optional[str]: @@ -156,7 +166,7 @@ class AzureBaseTTSService: return language_to_azure_language(language) def _construct_ssml(self, text: str) -> str: - language = self._settings["language"] + language = self._settings.language # Escape special characters escaped_text = self._escape_text(text) @@ -165,39 +175,42 @@ class AzureBaseTTSService: f"" - f"" + f"" "" ) - if self._settings["style"]: - ssml += f"" + # Only wrap in prosody tag if there are prosody attributes + if prosody_attrs: + ssml += f"" - if self._settings["emphasis"]: - ssml += f"" + if self._settings.emphasis: + ssml += f"" ssml += escaped_text - if self._settings["emphasis"]: + if self._settings.emphasis: ssml += "" - ssml += "" + if prosody_attrs: + ssml += "" - if self._settings["style"]: + if self._settings.style: ssml += "" ssml += "" @@ -226,7 +239,7 @@ class AzureBaseTTSService: return escaped_text -class AzureTTSService(WordTTSService, AzureBaseTTSService): +class AzureTTSService(TTSService, AzureBaseTTSService): """Azure Cognitive Services streaming TTS service with word timestamps. Provides real-time text-to-speech synthesis using Azure's WebSocket-based @@ -234,15 +247,19 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): available for lower latency playback and accurate word-level synchronization. """ + Settings = AzureTTSSettings + def __init__( self, *, api_key: str, region: str, - voice: str = "en-US-SaraNeural", + voice: Optional[str] = None, sample_rate: Optional[int] = None, params: Optional[AzureBaseTTSService.InputParams] = None, - aggregate_sentences: bool = True, + settings: Optional[Settings] = None, + aggregate_sentences: Optional[bool] = None, + text_aggregation_mode: Optional[TextAggregationMode] = None, **kwargs, ): """Initialize the Azure streaming TTS service. @@ -250,33 +267,94 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): Args: api_key: Azure Cognitive Services subscription key. region: Azure region identifier (e.g., "eastus", "westus2"). - voice: Voice name to use for synthesis. Defaults to "en-US-SaraNeural". + voice: Voice name to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=AzureTTSService.Settings(voice=...)`` instead. + sample_rate: Audio sample rate in Hz. If None, uses service default. params: Voice and synthesis parameters configuration. - aggregate_sentences: Whether to aggregate sentences before synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=AzureTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + aggregate_sentences: Deprecated. Use text_aggregation_mode instead. + + .. deprecated:: 0.0.104 + Use ``text_aggregation_mode`` instead. + + text_aggregation_mode: How to aggregate text before synthesis. **kwargs: Additional arguments passed to parent WordTTSService. """ - # Initialize WordTTSService first to set up word timestamp tracking + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice="en-US-SaraNeural", + language="en-US", + emphasis=None, + pitch=None, + rate=None, + role=None, + style=None, + style_degree=None, + volume=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice is not None: + self._warn_init_param_moved_to_settings("voice", "voice") + default_settings.voice = voice + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.emphasis = params.emphasis + default_settings.language = params.language if params.language else "en-US" + default_settings.pitch = params.pitch + default_settings.rate = params.rate + default_settings.role = params.role + default_settings.style = params.style + default_settings.style_degree = params.style_degree + default_settings.volume = params.volume + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( aggregate_sentences=aggregate_sentences, + text_aggregation_mode=text_aggregation_mode, push_text_frames=False, # We'll push text frames based on word timestamps push_stop_frames=True, + push_start_frame=True, pause_frame_processing=True, sample_rate=sample_rate, + settings=default_settings, **kwargs, ) # Initialize Azure-specific functionality from mixin - self._init_azure_base(api_key=api_key, region=region, voice=voice, params=params) + self._init_azure_base(api_key=api_key, region=region) self._speech_config = None self._speech_synthesizer = None self._audio_queue = asyncio.Queue() self._word_boundary_queue = asyncio.Queue() self._word_processor_task = None - self._started = False - self._first_chunk = True self._cumulative_audio_offset: float = 0.0 # Cumulative audio duration in seconds + self._current_sentence_base_offset: float = 0.0 # Base offset for current sentence + self._current_sentence_duration: float = 0.0 # Duration from Azure callback + self._current_sentence_max_word_offset: float = ( + 0.0 # Max word boundary offset seen in current sentence (for 8kHz workaround) + ) + self._last_word: Optional[str] = None # Track last word for punctuation merging + self._last_timestamp: Optional[float] = None # Track last timestamp + self._current_context_id: Optional[str] = ( + None # Track current context_id for word timestamps + ) def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -302,7 +380,7 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): subscription=self._api_key, region=self._region, ) - self._speech_config.speech_synthesis_language = self._settings["language"] + self._speech_config.speech_synthesis_language = self._settings.language self._speech_config.set_speech_synthesis_output_format( sample_rate_to_output_format(self.sample_rate) ) @@ -346,9 +424,34 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): await self.cancel_task(self._word_processor_task) self._word_processor_task = None + def _is_cjk_language(self) -> bool: + """Check if the configured language is CJK (Chinese, Japanese, Korean). + + Returns: + True if the language is CJK, False otherwise. + """ + language = (self._settings.language if self._settings.language else "").lower() + # Check if language starts with CJK language codes + return language.startswith(("zh", "ja", "ko", "cmn", "yue", "wuu")) + + def _is_punctuation_only(self, text: str) -> bool: + """Check if text consists only of punctuation and whitespace. + + Args: + text: Text to check. + + Returns: + True if text is only punctuation/whitespace, False otherwise. + """ + return text and all(not c.isalnum() for c in text) + def _handle_word_boundary(self, evt): """Handle word boundary events from Azure SDK. + Azure sends punctuation as separate word boundaries, and breaks CJK text + into individual characters/particles. This method routes to language-specific + handlers to properly merge and emit word boundaries. + Args: evt: SpeechSynthesisWordBoundaryEventArgs from Azure Speech SDK containing word text and audio offset timing. @@ -359,23 +462,94 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): word = evt.text sentence_relative_seconds = evt.audio_offset / 10_000_000.0 - # Add cumulative offset to get absolute timestamp across sentences - absolute_seconds = self._cumulative_audio_offset + sentence_relative_seconds + # Use base offset captured at start of run_tts to avoid race conditions + # with callbacks from overlapping TTS requests + absolute_seconds = self._current_sentence_base_offset + sentence_relative_seconds - # Queue word timestamp for async processing - # Use thread-safe queue since this is called from Azure SDK thread - if word: - logger.trace(f"{self}: Word boundary - '{word}' at {absolute_seconds:.2f}s") - # Put in temporary queue - will be processed by async task - # Store as (word, timestamp_in_seconds) tuple - self._word_boundary_queue.put_nowait((word, absolute_seconds)) + # Track max word offset for accurate cumulative timing + # (audio_duration from Azure doesn't always match word boundary offsets at 8kHz) + if sentence_relative_seconds > self._current_sentence_max_word_offset: + self._current_sentence_max_word_offset = sentence_relative_seconds + + if not word: + return + + # Route to language-specific handler + if self._is_cjk_language(): + self._handle_cjk_word_boundary(word, absolute_seconds) + else: + self._handle_non_cjk_word_boundary(word, absolute_seconds) + + def _emit_pending_word(self): + """Emit the currently buffered word if one exists.""" + if self._last_word is not None: + self._word_boundary_queue.put_nowait((self._last_word, self._last_timestamp)) + self._last_word = None + self._last_timestamp = None + + def _handle_cjk_word_boundary(self, word: str, timestamp: float): + """Handle word boundaries for CJK languages (Chinese, Japanese, Korean). + + CJK languages don't use spaces between words, so we merge characters together + and only emit at natural break points (punctuation or whitespace boundaries). + Without this logic, we don't get word output for CJK languages. + + Args: + word: The word/character from Azure. + timestamp: Timestamp in seconds. + """ + # First word: just store it + if self._last_word is None: + self._last_word = word + self._last_timestamp = timestamp + return + + # Punctuation: merge and emit (natural break) + if self._is_punctuation_only(word): + self._last_word += word + self._emit_pending_word() + return + + # Whitespace: emit before boundary, start new segment + if word.strip() != word: + self._emit_pending_word() + self._last_word = word + self._last_timestamp = timestamp + return + + # Default: continue merging CJK characters + self._last_word += word + + def _handle_non_cjk_word_boundary(self, word: str, timestamp: float): + """Handle word boundaries for non-CJK languages. + + Non-CJK languages use spaces between words, so we emit each word separately + after merging any trailing punctuation. + + Args: + word: The word from Azure. + timestamp: Timestamp in seconds. + """ + # Punctuation: merge with previous word (don't emit yet) + if self._is_punctuation_only(word) and self._last_word is not None: + self._last_word += word + return + + # Regular word: emit previous, store current + if self._last_word is not None: + self._word_boundary_queue.put_nowait((self._last_word, self._last_timestamp)) + self._last_word = word + self._last_timestamp = timestamp async def _word_processor_task_handler(self): """Process word timestamps from the queue and call add_word_timestamps.""" while True: try: word, timestamp_seconds = await self._word_boundary_queue.get() - await self.add_word_timestamps([(word, timestamp_seconds)]) + if self._current_context_id: + await self.add_word_timestamps( + [(word, timestamp_seconds)], self._current_context_id + ) self._word_boundary_queue.task_done() except asyncio.CancelledError: break @@ -397,9 +571,15 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): Args: evt: Completion event from Azure Speech SDK. """ - # Update cumulative audio offset for next sentence + # Flush any pending word before completing + if self._last_word is not None: + self._word_boundary_queue.put_nowait((self._last_word, self._last_timestamp)) + self._last_word = None + self._last_timestamp = None + + # Store duration for cumulative offset calculation if evt.result and evt.result.audio_duration: - self._cumulative_audio_offset += evt.result.audio_duration.total_seconds() + self._current_sentence_duration = evt.result.audio_duration.total_seconds() self._audio_queue.put_nowait(None) # Signal completion @@ -413,9 +593,13 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): # User cancellation (from interruption) is expected, not an error if reason == CancellationReason.CancelledByUser: logger.debug(f"{self}: Speech synthesis canceled by user (interruption)") + self._audio_queue.put_nowait(None) else: - logger.warning(f"{self}: Speech synthesis canceled: {reason}") - self._audio_queue.put_nowait(None) + details = evt.result.cancellation_details + error_msg = f"Azure TTS synthesis canceled: {reason}" + if details.error_details: + error_msg += f" - {details.error_details}" + self._audio_queue.put_nowait(Exception(error_msg)) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): """Push a frame and handle state changes. @@ -427,16 +611,20 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): await super().push_frame(frame, direction) if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)): self._reset_state() - if isinstance(frame, TTSStoppedFrame): - await self.add_word_timestamps([("Reset", 0)]) + if isinstance(frame, TTSStoppedFrame) and self._current_context_id: + await self.add_word_timestamps([("Reset", 0)], self._current_context_id) def _reset_state(self): """Reset TTS state between turns.""" - self._started = False - self._first_chunk = True self._cumulative_audio_offset = 0.0 + self._current_sentence_base_offset = 0.0 + self._current_sentence_duration = 0.0 + self._current_sentence_max_word_offset = 0.0 + self._last_word = None + self._last_timestamp = None + self._current_context_id = None - async def flush_audio(self): + async def flush_audio(self, context_id: Optional[str] = None): """Flush any pending audio data.""" logger.trace(f"{self}: flushing audio") @@ -478,11 +666,12 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): break @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Azure's streaming synthesis. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing synthesized speech data. @@ -501,11 +690,13 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): return try: - if not self._started: - await self.start_ttfb_metrics() - yield TTSStartedFrame() - self._started = True - self._first_chunk = True + self._current_context_id = context_id + + # Capture base offset BEFORE starting synthesis to avoid race conditions + # Word boundary callbacks will use this value + self._current_sentence_base_offset = self._cumulative_audio_offset + self._current_sentence_duration = 0.0 + self._current_sentence_max_word_offset = 0.0 ssml = self._construct_ssml(text) self._speech_synthesizer.speak_ssml_async(ssml) @@ -517,22 +708,31 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): chunk = await self._audio_queue.get() if chunk is None: # End of stream break - - if self._first_chunk: - await self.stop_ttfb_metrics() - await self.start_word_timestamps() - self._first_chunk = False + if isinstance(chunk, Exception): # Error from _handle_canceled + yield ErrorFrame(error=str(chunk)) + break frame = TTSAudioRawFrame( audio=chunk, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) yield frame + # Update cumulative offset for next sentence + # At 8kHz, Azure's audio_duration doesn't match word boundary offsets, + # so we use max_word_offset as a workaround. At other sample rates, + # audio_duration is accurate. + # TODO: Remove after Azure fixes word boundary timing at 8kHz + if self.sample_rate == 8000: + self._cumulative_audio_offset += self._current_sentence_max_word_offset + else: + self._cumulative_audio_offset += self._current_sentence_duration + except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() + yield TTSStoppedFrame(context_id=context_id) self._reset_state() return @@ -548,14 +748,17 @@ class AzureHttpTTSService(TTSService, AzureBaseTTSService): required and simpler integration is preferred. """ + Settings = AzureTTSSettings + def __init__( self, *, api_key: str, region: str, - voice: str = "en-US-SaraNeural", + voice: Optional[str] = None, sample_rate: Optional[int] = None, params: Optional[AzureBaseTTSService.InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Azure HTTP TTS service. @@ -563,15 +766,67 @@ class AzureHttpTTSService(TTSService, AzureBaseTTSService): Args: api_key: Azure Cognitive Services subscription key. region: Azure region identifier (e.g., "eastus", "westus2"). - voice: Voice name to use for synthesis. Defaults to "en-US-SaraNeural". + voice: Voice name to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=AzureHttpTTSService.Settings(voice=...)`` instead. + sample_rate: Audio sample rate in Hz. If None, uses service default. params: Voice and synthesis parameters configuration. + + .. deprecated:: 0.0.105 + Use ``settings=AzureHttpTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice="en-US-SaraNeural", + language="en-US", + emphasis=None, + pitch=None, + rate=None, + role=None, + style=None, + style_degree=None, + volume=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice is not None: + self._warn_init_param_moved_to_settings("voice", "voice") + default_settings.voice = voice + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.emphasis = params.emphasis + default_settings.language = params.language if params.language else "en-US" + default_settings.pitch = params.pitch + default_settings.rate = params.rate + default_settings.role = params.role + default_settings.style = params.style + default_settings.style_degree = params.style_degree + default_settings.volume = params.volume + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) # Initialize Azure-specific functionality from mixin - self._init_azure_base(api_key=api_key, region=region, voice=voice, params=params) + self._init_azure_base(api_key=api_key, region=region) self._speech_config = None self._speech_synthesizer = None @@ -599,7 +854,7 @@ class AzureHttpTTSService(TTSService, AzureBaseTTSService): subscription=self._api_key, region=self._region, ) - self._speech_config.speech_synthesis_language = self._settings["language"] + self._speech_config.speech_synthesis_language = self._settings.language self._speech_config.set_speech_synthesis_output_format( sample_rate_to_output_format(self.sample_rate) ) @@ -608,19 +863,18 @@ class AzureHttpTTSService(TTSService, AzureBaseTTSService): ) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Azure's HTTP synthesis API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the complete synthesized speech. """ logger.debug(f"{self}: Generating TTS [{text}]") - await self.start_ttfb_metrics() - ssml = self._construct_ssml(text) result = await asyncio.to_thread(self._speech_synthesizer.speak_ssml, ssml) @@ -628,14 +882,13 @@ class AzureHttpTTSService(TTSService, AzureBaseTTSService): if result.reason == ResultReason.SynthesizingAudioCompleted: await self.start_tts_usage_metrics(text) await self.stop_ttfb_metrics() - yield TTSStartedFrame() # Azure always sends a 44-byte header. Strip it off. yield TTSAudioRawFrame( audio=result.audio_data[44:], sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) - yield TTSStoppedFrame() elif result.reason == ResultReason.Canceled: cancellation_details = result.cancellation_details logger.warning(f"Speech synthesis canceled: {cancellation_details.reason}") diff --git a/src/pipecat/services/camb/__init__.py b/src/pipecat/services/camb/__init__.py new file mode 100644 index 000000000..f28a0edd6 --- /dev/null +++ b/src/pipecat/services/camb/__init__.py @@ -0,0 +1,5 @@ +# +# Copyright (c) 2024–2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# diff --git a/src/pipecat/services/camb/tts.py b/src/pipecat/services/camb/tts.py new file mode 100644 index 000000000..b6b83a928 --- /dev/null +++ b/src/pipecat/services/camb/tts.py @@ -0,0 +1,392 @@ +# +# Copyright (c) 2024–2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Camb.ai MARS text-to-speech service implementation. + +This module provides TTS functionality using Camb.ai's MARS model family, +offering high-quality text-to-speech synthesis with streaming support. + +Features: + - MARS models: mars-flash (fast), mars-pro (high quality) + - 140+ languages supported + - Real-time streaming via official SDK + - Model-specific sample rates: mars-pro (48kHz), mars-flash (22.05kHz) +""" + +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Dict, Optional + +from camb import StreamTtsOutputConfiguration +from camb.client import AsyncCambAI +from loguru import logger +from pydantic import BaseModel, Field + +from pipecat.frames.frames import ( + ErrorFrame, + Frame, + StartFrame, + TTSAudioRawFrame, +) +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven +from pipecat.services.tts_service import TTSService +from pipecat.transcriptions.language import Language, resolve_language +from pipecat.utils.tracing.service_decorators import traced_tts + +# Model-specific sample rates +MODEL_SAMPLE_RATES: Dict[str, int] = { + "mars-flash": 22050, # 22.05kHz + "mars-pro": 48000, # 48kHz + "mars-instruct": 22050, # 22.05kHz +} + + +def language_to_camb_language(language: Language) -> Optional[str]: + """Convert a Pipecat Language enum to Camb.ai language code. + + Args: + language: The Language enum value to convert. + + Returns: + The corresponding Camb.ai language code (BCP-47 format), or None if not supported. + """ + LANGUAGE_MAP = { + Language.EN: "en-us", + Language.EN_US: "en-us", + Language.EN_GB: "en-gb", + Language.EN_AU: "en-au", + Language.ES: "es-es", + Language.ES_ES: "es-es", + Language.ES_MX: "es-mx", + Language.FR: "fr-fr", + Language.FR_FR: "fr-fr", + Language.FR_CA: "fr-ca", + Language.DE: "de-de", + Language.DE_DE: "de-de", + Language.IT: "it-it", + Language.PT: "pt-pt", + Language.PT_BR: "pt-br", + Language.PT_PT: "pt-pt", + Language.NL: "nl-nl", + Language.PL: "pl-pl", + Language.RU: "ru-ru", + Language.JA: "ja-jp", + Language.KO: "ko-kr", + Language.ZH: "zh-cn", + Language.ZH_CN: "zh-cn", + Language.ZH_TW: "zh-tw", + Language.AR: "ar-sa", + Language.HI: "hi-in", + Language.TR: "tr-tr", + Language.VI: "vi-vn", + Language.TH: "th-th", + Language.ID: "id-id", + Language.MS: "ms-my", + Language.SV: "sv-se", + Language.DA: "da-dk", + Language.NO: "no-no", + Language.FI: "fi-fi", + Language.CS: "cs-cz", + Language.EL: "el-gr", + Language.HE: "he-il", + Language.HU: "hu-hu", + Language.RO: "ro-ro", + Language.SK: "sk-sk", + Language.UK: "uk-ua", + Language.BG: "bg-bg", + Language.HR: "hr-hr", + Language.SR: "sr-rs", + Language.SL: "sl-si", + Language.CA: "ca-es", + Language.EU: "eu-es", + Language.GL: "gl-es", + Language.AF: "af-za", + Language.SW: "sw-ke", + Language.TA: "ta-in", + Language.TE: "te-in", + Language.BN: "bn-in", + Language.MR: "mr-in", + Language.GU: "gu-in", + Language.KN: "kn-in", + Language.ML: "ml-in", + Language.PA: "pa-in", + Language.UR: "ur-pk", + Language.FA: "fa-ir", + Language.TL: "tl-ph", + } + + return resolve_language(language, LANGUAGE_MAP, use_base_code=True) + + +def _get_aligned_audio(buffer: bytes) -> tuple[bytes, bytes]: + """Split buffer into aligned audio (2-byte samples) and remainder. + + Args: + buffer: Raw audio bytes to align. + + Returns: + Tuple of (aligned audio bytes, remaining bytes). + """ + aligned_size = (len(buffer) // 2) * 2 + return buffer[:aligned_size], buffer[aligned_size:] + + +@dataclass +class CambTTSSettings(TTSSettings): + """Settings for CambTTSService. + + Parameters: + voice: Camb.ai voice ID. Overrides ``TTSSettings.voice`` (str) because + Camb.ai uses integer voice IDs. + user_instructions: Custom instructions for mars-instruct model only. + Ignored for other models. Max 1000 characters. + """ + + voice: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + user_instructions: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + +class CambTTSService(TTSService): + """Camb.ai MARS text-to-speech service using the official SDK. + + Converts text to speech using Camb.ai's MARS TTS models with support for + multiple languages. + + Models: + - mars-flash: Fast inference, 22.05kHz output (default) + - mars-pro: High quality, 48kHz output + + Example:: + + # Basic usage with mars-flash (fast) + tts = CambTTSService( + api_key="your-api-key", + settings=CambTTSService.Settings( + model="mars-flash" + ) + ) + + # High quality with mars-pro + tts = CambTTSService( + api_key="your-api-key", + settings=CambTTSService.Settings( + voice=12345, + model="mars-pro", + ) + ) + """ + + Settings = CambTTSSettings + _settings: Settings + + class InputParams(BaseModel): + """Input parameters for Camb.ai TTS configuration. + + .. deprecated:: 0.0.105 + Use ``settings=CambTTSService.Settings(...)`` instead. + + Parameters: + language: Language for synthesis (BCP-47 format). Defaults to English. + user_instructions: Custom instructions for mars-instruct model only. + Ignored for other models. Max 1000 characters. + """ + + language: Optional[Language] = Language.EN + user_instructions: Optional[str] = Field( + default=None, + max_length=1000, + description="Custom instructions for mars-instruct model only. " + "Use to control tone, style, or pronunciation. Max 1000 characters.", + ) + + def __init__( + self, + *, + api_key: str, + voice_id: Optional[int] = None, + model: Optional[str] = None, + timeout: float = 60.0, + sample_rate: Optional[int] = None, + params: Optional[InputParams] = None, + settings: Optional[Settings] = None, + **kwargs, + ): + """Initialize the Camb.ai TTS service. + + Args: + api_key: Camb.ai API key for authentication. + voice_id: Voice ID to use. + + .. deprecated:: 0.0.105 + Use ``settings=CambTTSService.Settings(voice=...)`` instead. + + model: TTS model to use. Options: "mars-flash" (fast), "mars-pro" (high quality). + + .. deprecated:: 0.0.105 + Use ``settings=CambTTSService.Settings(model=...)`` instead. + + timeout: Request timeout in seconds. Defaults to 60.0 (minimum recommended + by Camb.ai). + sample_rate: Audio sample rate in Hz. If None, uses model-specific default. + params: Additional voice parameters. If None, uses defaults. + + .. deprecated:: 0.0.105 + Use ``settings=CambTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + **kwargs: Additional arguments passed to parent TTSService. + """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="mars-flash", + voice=147320, + language="en-us", + user_instructions=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.user_instructions is not None: + default_settings.user_instructions = params.user_instructions + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + # Warn if sample rate doesn't match model's supported rate + _model = default_settings.model + if sample_rate and sample_rate != MODEL_SAMPLE_RATES.get(_model): + logger.warning( + f"Camb.ai's {_model} model only supports {MODEL_SAMPLE_RATES.get(_model)}Hz " + f"sample rate. Current rate of {sample_rate}Hz may cause issues." + ) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) + + self._api_key = api_key + self._timeout = timeout + + self._client = None + + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as Camb.ai service supports metrics generation. + """ + return True + + def language_to_service_language(self, language: Language) -> Optional[str]: + """Convert a Language enum to Camb.ai language format. + + Args: + language: The language to convert. + + Returns: + The Camb.ai-specific language code, or None if not supported. + """ + return language_to_camb_language(language) + + async def start(self, frame: StartFrame): + """Start the Camb.ai TTS service. + + Args: + frame: The start frame containing initialization parameters. + """ + await super().start(frame) + + self._client = AsyncCambAI(api_key=self._api_key, timeout=self._timeout) + + # Use model-specific sample rate if not explicitly specified + if not self._init_sample_rate: + self._sample_rate = MODEL_SAMPLE_RATES.get(self._settings.model, 22050) + + @traced_tts + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: + """Generate speech from text using Camb.ai's TTS API. + + Args: + text: The text to synthesize into speech (max 3000 characters). + context_id: The context ID for tracking audio frames. + + Yields: + Frame: Audio frames containing the synthesized speech. + """ + logger.debug(f"{self}: Generating TTS [{text}]") + + # Validate text length + if len(text) > 3000: + logger.warning("Text too long for Camb.ai TTS (max 3000 chars), truncating") + text = text[:3000] + + try: + # Build SDK parameters + tts_kwargs: Dict[str, Any] = { + "text": text, + "voice_id": self._settings.voice, + "language": self._settings.language, + "speech_model": self._settings.model, + "output_configuration": StreamTtsOutputConfiguration(format="pcm_s16le"), + } + + # Add user instructions if using mars-instruct model + if self._settings.model == "mars-instruct" and self._settings.user_instructions: + tts_kwargs["user_instructions"] = self._settings.user_instructions + + await self.start_tts_usage_metrics(text) + + assert self._client is not None, "Camb.ai TTS service not initialized" + + # Buffer for aligning chunks to 2-byte boundaries (16-bit PCM) + audio_buffer = b"" + + # Stream audio chunks from SDK + async for chunk in self._client.text_to_speech.tts(**tts_kwargs): + if chunk: + await self.stop_ttfb_metrics() + audio_buffer += chunk + + # Only yield complete 16-bit samples (2 bytes per sample) + aligned_audio, audio_buffer = _get_aligned_audio(audio_buffer) + if aligned_audio: + yield TTSAudioRawFrame( + audio=aligned_audio, + sample_rate=self.sample_rate, + num_channels=1, + context_id=context_id, + ) + + # Yield any remaining complete samples + if len(audio_buffer) >= 2: + aligned_audio, _ = _get_aligned_audio(audio_buffer) + if aligned_audio: + yield TTSAudioRawFrame( + audio=aligned_audio, + sample_rate=self.sample_rate, + num_channels=1, + context_id=context_id, + ) + + except Exception as e: + yield ErrorFrame(error=f"Camb.ai TTS error: {e}") diff --git a/src/pipecat/services/cartesia/stt.py b/src/pipecat/services/cartesia/stt.py index 386d8cbbc..85d43bcd3 100644 --- a/src/pipecat/services/cartesia/stt.py +++ b/src/pipecat/services/cartesia/stt.py @@ -12,7 +12,8 @@ the Cartesia Live transcription API for real-time speech recognition. import json import urllib.parse -from typing import AsyncGenerator, Optional +from dataclasses import dataclass +from typing import Any, AsyncGenerator, Optional from loguru import logger @@ -27,6 +28,8 @@ from pipecat.frames.frames import ( VADUserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import STTSettings +from pipecat.services.stt_latency import CARTESIA_TTFS_P99 from pipecat.services.stt_service import WebsocketSTTService from pipecat.transcriptions.language import Language from pipecat.utils.time import time_now_iso8601 @@ -41,11 +44,19 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class CartesiaSTTSettings(STTSettings): + """Settings for CartesiaSTTService.""" + + pass + + class CartesiaLiveOptions: """Configuration options for Cartesia Live STT service. - Manages transcription parameters including model selection, language, - audio encoding format, and sample rate settings. + .. deprecated:: 0.0.105 + Use ``settings=CartesiaSTTService.Settings(...)`` for model/language and + direct ``__init__`` parameters for encoding/sample_rate instead. """ def __init__( @@ -128,15 +139,26 @@ class CartesiaSTTService(WebsocketSTTService): Provides real-time speech transcription through WebSocket connection to Cartesia's Live transcription service. Supports both interim and final transcriptions with configurable models and languages. + + Cartesia disconnects WebSocket connections after 3 minutes of inactivity. + The timeout resets with each message (audio data or text command) sent to + the server. Silence-based keepalive is enabled by default to prevent this. + See: https://docs.cartesia.ai/api-reference/stt/stt """ + Settings = CartesiaSTTSettings + _settings: Settings + def __init__( self, *, api_key: str, base_url: str = "", - sample_rate: int = 16000, + encoding: str = "pcm_s16le", + sample_rate: Optional[int] = None, live_options: Optional[CartesiaLiveOptions] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = CARTESIA_TTFS_P99, **kwargs, ): """Initialize CartesiaSTTService with API key and options. @@ -144,34 +166,61 @@ class CartesiaSTTService(WebsocketSTTService): Args: api_key: Authentication key for Cartesia API. base_url: Custom API endpoint URL. If empty, uses default. - sample_rate: Audio sample rate in Hz. Defaults to 16000. + encoding: Audio encoding format. Defaults to "pcm_s16le". + sample_rate: Audio sample rate in Hz. If None, uses the pipeline + sample rate. live_options: Configuration options for transcription service. + + .. deprecated:: 0.0.105 + Use ``settings=CartesiaSTTService.Settings(...)`` for model/language + and direct init parameters for encoding/sample_rate instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to parent STTService. """ - sample_rate = sample_rate or (live_options.sample_rate if live_options else None) - super().__init__(sample_rate=sample_rate, **kwargs) - - default_options = CartesiaLiveOptions( + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( model="ink-whisper", language=Language.EN.value, - encoding="pcm_s16le", - sample_rate=sample_rate, ) - merged_options = default_options.to_dict() - if live_options: - merged_options.update(live_options.to_dict()) - # Filter out "None" string values - merged_options = { - k: v for k, v in merged_options.items() if not isinstance(v, str) or v != "None" - } + # 2. Apply live_options overrides — only if settings not provided + if live_options is not None: + self._warn_init_param_moved_to_settings("live_options") + if not settings: + if live_options.sample_rate and sample_rate is None: + sample_rate = live_options.sample_rate + if live_options.encoding: + encoding = live_options.encoding + if live_options.model: + default_settings.model = live_options.model + if live_options.language: + lang = live_options.language + default_settings.language = lang.value if isinstance(lang, Language) else lang + + # 3. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + keepalive_timeout=120, + keepalive_interval=30, + settings=default_settings, + **kwargs, + ) - self._settings = merged_options - self.set_model_name(merged_options["model"]) self._api_key = api_key self._base_url = base_url or "api.cartesia.ai" self._receive_task = None + # Init-only audio config (not runtime-updatable). + self._encoding = encoding + def can_generate_metrics(self) -> bool: """Check if the service can generate processing metrics. @@ -207,9 +256,8 @@ class CartesiaSTTService(WebsocketSTTService): await super().cancel(frame) await self._disconnect() - async def start_metrics(self): + async def _start_metrics(self): """Start performance metrics collection for transcription processing.""" - await self.start_ttfb_metrics() await self.start_processing_metrics() async def process_frame(self, frame: Frame, direction: FrameDirection): @@ -222,7 +270,7 @@ class CartesiaSTTService(WebsocketSTTService): await super().process_frame(frame, direction) if isinstance(frame, VADUserStartedSpeakingFrame): - await self.start_metrics() + await self._start_metrics() elif isinstance(frame, VADUserStoppedSpeakingFrame): # Send finalize command to flush the transcription session if self._websocket and self._websocket.state is State.OPEN: @@ -247,23 +295,53 @@ class CartesiaSTTService(WebsocketSTTService): async def _connect(self): await self._connect_websocket() + await super()._connect() + if self._websocket and not self._receive_task: self._receive_task = self.create_task(self._receive_task_handler(self._report_error)) async def _disconnect(self): + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None await self._disconnect_websocket() + async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: + """Apply a settings delta. + + Args: + delta: A :class:`STTSettings` (or ``CartesiaSTTService.Settings``) delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + + # TODO: someday we could reconnect here to apply updated settings. + # Code might look something like the below: + # if changed: + # await self._disconnect() + # await self._connect() + + self._warn_unhandled_updated_settings(changed) + + return changed + async def _connect_websocket(self): try: if self._websocket and self._websocket.state is State.OPEN: return logger.debug("Connecting to Cartesia STT") - params = self._settings + params = { + "model": self._settings.model, + "language": self._settings.language, + "encoding": self._encoding, + "sample_rate": str(self.sample_rate), + } ws_url = f"wss://{self._base_url}/stt/websocket?{urllib.parse.urlencode(params)}" headers = {"Cartesia-Version": "2025-04-16", "X-API-Key": self._api_key} @@ -288,7 +366,7 @@ class CartesiaSTTService(WebsocketSTTService): return self._websocket raise Exception("Websocket not connected") - async def _process_messages(self): + async def _receive_messages(self): """Process incoming WebSocket messages.""" async for message in self._get_websocket(): try: @@ -299,14 +377,6 @@ class CartesiaSTTService(WebsocketSTTService): except Exception as e: logger.error(f"Error processing message: {e}") - async def _receive_messages(self): - while True: - await self._process_messages() - # Cartesia times out after 5 minutes of innactivity (no keepalive - # mechanism is available). So, we try to reconnect. - logger.debug(f"{self} Cartesia connection was disconnected (timeout?), reconnecting") - await self._connect_websocket() - async def _process_response(self, data): if "type" in data: if data["type"] == "transcript": @@ -338,7 +408,6 @@ class CartesiaSTTService(WebsocketSTTService): pass if len(transcript) > 0: - await self.stop_ttfb_metrics() if is_final: await self.push_frame( TranscriptionFrame( diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py index 3ed3ca556..b713a0d9a 100644 --- a/src/pipecat/services/cartesia/tts.py +++ b/src/pipecat/services/cartesia/tts.py @@ -8,39 +8,32 @@ import base64 import json -import uuid -import warnings +from dataclasses import dataclass, field from enum import Enum -from typing import AsyncGenerator, List, Literal, Optional +from typing import AsyncGenerator, List, Optional +import aiohttp from loguru import logger -from pydantic import BaseModel, Field +from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, EndFrame, ErrorFrame, Frame, - InterruptionFrame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, TTSStoppedFrame, ) -from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.tts_service import AudioContextWordTTSService, TTSService +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven +from pipecat.services.tts_service import TextAggregationMode, TTSService, WebsocketTTSService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.text.base_text_aggregator import BaseTextAggregator from pipecat.utils.text.skip_tags_aggregator import SkipTagsAggregator from pipecat.utils.tracing.service_decorators import traced_tts -# Suppress regex warnings from pydub (used by cartesia) -warnings.filterwarnings("ignore", message="invalid escape sequence", category=SyntaxWarning) - - # See .env.example for Cartesia configuration needed try: - from cartesia import AsyncCartesia from websockets.asyncio.client import connect as websocket_connect from websockets.protocol import State except ModuleNotFoundError as e: @@ -192,33 +185,45 @@ class CartesiaEmotion(str, Enum): DETERMINED = "determined" -class CartesiaTTSService(AudioContextWordTTSService): +@dataclass +class CartesiaTTSSettings(TTSSettings): + """Settings for CartesiaTTSService and CartesiaHttpTTSService. + + Parameters: + generation_config: Generation configuration for Sonic-3 models. Includes volume, + speed (numeric), and emotion (string) parameters. + pronunciation_dict_id: The ID of the pronunciation dictionary to use for + custom pronunciations. + """ + + generation_config: GenerationConfig | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + pronunciation_dict_id: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + +class CartesiaTTSService(WebsocketTTSService): """Cartesia TTS service with WebSocket streaming and word timestamps. Provides text-to-speech using Cartesia's streaming WebSocket API. Supports word-level timestamps, audio context management, and various voice - customization options including speed and emotion controls. + customization options including generation configuration. """ + Settings = CartesiaTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Cartesia TTS configuration. Parameters: language: Language to use for synthesis. - speed: Voice speed control for non-Sonic-3 models (literal values). - emotion: List of emotion controls for non-Sonic-3 models. - - .. deprecated:: 0.0.68 - The `emotion` parameter is deprecated and will be removed in a future version. - generation_config: Generation configuration for Sonic-3 models. Includes volume, speed (numeric), and emotion (string) parameters. pronunciation_dict_id: The ID of the pronunciation dictionary to use for custom pronunciations. """ language: Optional[Language] = Language.EN - speed: Optional[Literal["slow", "normal", "fast"]] = None - emotion: Optional[List[str]] = [] generation_config: Optional[GenerationConfig] = None pronunciation_dict_id: Optional[str] = None @@ -226,16 +231,18 @@ class CartesiaTTSService(AudioContextWordTTSService): self, *, api_key: str, - voice_id: str, + voice_id: Optional[str] = None, cartesia_version: str = "2025-04-16", url: str = "wss://api.cartesia.ai/tts/websocket", - model: str = "sonic-3", + model: Optional[str] = None, sample_rate: Optional[int] = None, encoding: str = "pcm_s16le", container: str = "raw", params: Optional[InputParams] = None, + settings: Optional[Settings] = None, text_aggregator: Optional[BaseTextAggregator] = None, - aggregate_sentences: Optional[bool] = True, + text_aggregation_mode: Optional[TextAggregationMode] = None, + aggregate_sentences: Optional[bool] = None, **kwargs, ): """Initialize the Cartesia TTS service. @@ -243,37 +250,95 @@ class CartesiaTTSService(AudioContextWordTTSService): Args: api_key: Cartesia API key for authentication. voice_id: ID of the voice to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=CartesiaTTSService.Settings(voice=...)`` instead. + cartesia_version: API version string for Cartesia service. url: WebSocket URL for Cartesia TTS API. model: TTS model to use (e.g., "sonic-3"). + + .. deprecated:: 0.0.105 + Use ``settings=CartesiaTTSService.Settings(model=...)`` instead. + sample_rate: Audio sample rate. If None, uses default. encoding: Audio encoding format. container: Audio container format. params: Additional input parameters for voice customization. + + .. deprecated:: 0.0.105 + Use ``settings=CartesiaTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. text_aggregator: Custom text aggregator for processing input text. .. deprecated:: 0.0.95 Use an LLMTextProcessor before the TTSService for custom text aggregation. + text_aggregation_mode: How to aggregate incoming text before synthesis. aggregate_sentences: Whether to aggregate sentences within the TTSService. + + .. deprecated:: 0.0.104 + Use ``text_aggregation_mode`` instead. + **kwargs: Additional arguments passed to the parent service. """ - # Aggregating sentences still gives cleaner-sounding results and fewer - # artifacts than streaming one word at a time. On average, waiting for a - # full sentence should only "cost" us 15ms or so with GPT-4o or a Llama - # 3 model, and it's worth it for the better audio quality. + # By default, we aggregate sentences before sending to TTS. This adds + # ~200-300ms of latency per sentence (waiting for the sentence-ending + # punctuation token from the LLM). Setting + # text_aggregation_mode=TextAggregationMode.TOKEN streams tokens + # directly, which reduces latency. Streaming quality is good but less + # tested than sentence aggregation. + # TODO: Consider making TOKEN the default for Cartesia in 1.0. # # We also don't want to automatically push LLM response text frames, # because the context aggregators will add them to the LLM context even # if we're interrupted. Cartesia gives us word-by-word timestamps. We # can use those to generate text frames ourselves aligned with the # playout timing of the audio! + + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="sonic-3", + voice=None, + language=Language.EN, + generation_config=None, + pronunciation_dict_id=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.generation_config is not None: + default_settings.generation_config = params.generation_config + if params.pronunciation_dict_id is not None: + default_settings.pronunciation_dict_id = params.pronunciation_dict_id + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( + text_aggregation_mode=text_aggregation_mode, aggregate_sentences=aggregate_sentences, push_text_frames=False, - pause_frame_processing=True, + pause_frame_processing=False, sample_rate=sample_rate, + push_start_frame=True, text_aggregator=text_aggregator, + settings=default_settings, **kwargs, ) @@ -283,31 +348,19 @@ class CartesiaTTSService(AudioContextWordTTSService): # The preferred way of taking advantage of Cartesia SSML Tags is # to use an LLMTextProcessor and/or a text_transformer to identify # and insert these tags for the purpose of the TTS service alone. - self._text_aggregator = SkipTagsAggregator([("", "")]) - - params = params or CartesiaTTSService.InputParams() + self._text_aggregator = SkipTagsAggregator( + [("", "")], aggregation_type=self._text_aggregation_mode + ) self._api_key = api_key self._cartesia_version = cartesia_version self._url = url - self._settings = { - "output_format": { - "container": container, - "encoding": encoding, - "sample_rate": 0, - }, - "language": self.language_to_service_language(params.language) - if params.language - else None, - "speed": params.speed, - "emotion": params.emotion, - "generation_config": params.generation_config, - "pronunciation_dict_id": params.pronunciation_dict_id, - } - self.set_model_name(model) - self.set_voice(voice_id) - self._context_id = None + # Audio output format — init-only, not runtime-updatable + self._output_container = container + self._output_encoding = encoding + self._output_sample_rate = 0 # Set in start() from self.sample_rate + self._receive_task = None def can_generate_metrics(self) -> bool: @@ -318,16 +371,6 @@ class CartesiaTTSService(AudioContextWordTTSService): """ return True - async def set_model(self, model: str): - """Set the TTS model. - - Args: - model: The model name to use for synthesis. - """ - self._model_id = model - await super().set_model(model) - logger.info(f"Switching TTS model to: [{model}]") - def language_to_service_language(self, language: Language) -> Optional[str]: """Convert a Language enum to Cartesia language format. @@ -392,7 +435,7 @@ class CartesiaTTSService(AudioContextWordTTSService): Returns: List of (word, start_time) tuples processed for the language. """ - current_language = self._settings.get("language") + current_language = self._settings.language # Check if this is a CJK language (if language is None, treat as non-CJK) if current_language and self._is_cjk_language(current_language): @@ -409,48 +452,41 @@ class CartesiaTTSService(AudioContextWordTTSService): return list(zip(words, starts)) def _build_msg( - self, text: str = "", continue_transcript: bool = True, add_timestamps: bool = True + self, + text: str = "", + continue_transcript: bool = True, + add_timestamps: bool = True, + context_id: str = "", ): voice_config = {} voice_config["mode"] = "id" - voice_config["id"] = self._voice_id - - if self._settings["emotion"]: - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "The 'emotion' parameter in __experimental_controls is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) - voice_config["__experimental_controls"] = {} - if self._settings["emotion"]: - voice_config["__experimental_controls"]["emotion"] = self._settings["emotion"] + voice_config["id"] = self._settings.voice msg = { "transcript": text, "continue": continue_transcript, - "context_id": self._context_id, - "model_id": self.model_name, + "context_id": context_id, + "model_id": self._settings.model, "voice": voice_config, - "output_format": self._settings["output_format"], + "output_format": { + "container": self._output_container, + "encoding": self._output_encoding, + "sample_rate": self._output_sample_rate, + }, "add_timestamps": add_timestamps, - "use_original_timestamps": False if self.model_name == "sonic" else True, + "use_original_timestamps": False if self._settings.model == "sonic" else True, } - if self._settings["language"]: - msg["language"] = self._settings["language"] + if self._settings.language: + msg["language"] = self._settings.language - if self._settings["speed"]: - msg["speed"] = self._settings["speed"] - - if self._settings["generation_config"]: - msg["generation_config"] = self._settings["generation_config"].model_dump( + if self._settings.generation_config: + msg["generation_config"] = self._settings.generation_config.model_dump( exclude_none=True ) - if self._settings["pronunciation_dict_id"]: - msg["pronunciation_dict_id"] = self._settings["pronunciation_dict_id"] + if self._settings.pronunciation_dict_id: + msg["pronunciation_dict_id"] = self._settings.pronunciation_dict_id return json.dumps(msg) @@ -461,7 +497,7 @@ class CartesiaTTSService(AudioContextWordTTSService): frame: The start frame containing initialization parameters. """ await super().start(frame) - self._settings["output_format"]["sample_rate"] = self.sample_rate + self._output_sample_rate = self.sample_rate await self._connect() async def stop(self, frame: EndFrame): @@ -483,12 +519,16 @@ class CartesiaTTSService(AudioContextWordTTSService): await self._disconnect() async def _connect(self): + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: self._receive_task = self.create_task(self._receive_task_handler(self._report_error)) async def _disconnect(self): + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None @@ -519,7 +559,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") @@ -528,52 +568,65 @@ class CartesiaTTSService(AudioContextWordTTSService): return self._websocket raise Exception("Websocket not connected") - async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): - await super()._handle_interruption(frame, direction) + async def on_audio_context_interrupted(self, context_id: str): + """Cancel the active Cartesia context when the bot is interrupted.""" 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: + async def on_audio_context_completed(self, context_id: str): + """Close the Cartesia context after all audio has been played. + + No close message is needed: the server already considers the context + done once it has sent its ``done`` message, which is handled in + ``_process_messages``. + """ + pass + + async def flush_audio(self, context_id: Optional[str] = None): + """Flush any pending audio and finalize the current context. + + Args: + context_id: The specific context to flush. If None, falls back to the + currently active context. + """ + flush_id = context_id or self.get_active_audio_context_id() + if not flush_id or not self._websocket: return logger.trace(f"{self}: flushing audio") - msg = self._build_msg(text="", continue_transcript=False) + msg = self._build_msg(text="", continue_transcript=False, context_id=flush_id) await self._websocket.send(msg) - self._context_id = None async def _process_messages(self): async for message in self._get_websocket(): msg = json.loads(message) if not msg or not self.audio_context_available(msg["context_id"]): continue + ctx_id = msg["context_id"] if msg["type"] == "done": await self.stop_ttfb_metrics() - await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)]) - await self.remove_audio_context(msg["context_id"]) + await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id) + await self.remove_audio_context(ctx_id) elif msg["type"] == "timestamps": # Process the timestamps based on language before adding them processed_timestamps = self._process_word_timestamps_for_language( msg["word_timestamps"]["words"], msg["word_timestamps"]["start"] ) - await self.add_word_timestamps(processed_timestamps) + await self.add_word_timestamps(processed_timestamps, ctx_id) elif msg["type"] == "chunk": - await self.stop_ttfb_metrics() - await self.start_word_timestamps() frame = TTSAudioRawFrame( audio=base64.b64decode(msg["data"]), sample_rate=self.sample_rate, num_channels=1, + context_id=ctx_id, ) - await self.append_to_audio_context(msg["context_id"], frame) + await self.append_to_audio_context(ctx_id, frame) 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}") - self._context_id = None + self.reset_active_audio_context() else: await self.push_error(error_msg=f"Error, unknown message type: {msg}") @@ -586,35 +639,33 @@ class CartesiaTTSService(AudioContextWordTTSService): await self._connect_websocket() @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Cartesia's streaming API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. """ - logger.debug(f"{self}: Generating TTS [{text}]") + if not self._is_streaming_tokens: + logger.debug(f"{self}: Generating TTS [{text}]") + else: + logger.trace(f"{self}: Generating TTS [{text}]") try: if not self._websocket or self._websocket.state is State.CLOSED: await self._connect() - if not self._context_id: - await self.start_ttfb_metrics() - yield TTSStartedFrame() - self._context_id = str(uuid.uuid4()) - await self.create_audio_context(self._context_id) - - msg = self._build_msg(text=text) + msg = self._build_msg(text=text, context_id=context_id) try: await self._get_websocket().send(msg) await self.start_tts_usage_metrics(text) except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() + yield TTSStoppedFrame(context_id=context_id) await self._disconnect() await self._connect() return @@ -631,25 +682,20 @@ class CartesiaHttpTTSService(TTSService): integration is preferred. """ + Settings = CartesiaTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Cartesia HTTP TTS configuration. Parameters: language: Language to use for synthesis. - speed: Voice speed control for non-Sonic-3 models (literal values). - emotion: List of emotion controls for non-Sonic-3 models. - - .. deprecated:: 0.0.68 - The `emotion` parameter is deprecated and will be removed in a future version. - generation_config: Generation configuration for Sonic-3 models. Includes volume, speed (numeric), and emotion (string) parameters. pronunciation_dict_id: The ID of the pronunciation dictionary to use for custom pronunciations. """ language: Optional[Language] = Language.EN - speed: Optional[Literal["slow", "normal", "fast"]] = None - emotion: Optional[List[str]] = Field(default_factory=list) generation_config: Optional[GenerationConfig] = None pronunciation_dict_id: Optional[str] = None @@ -657,14 +703,16 @@ class CartesiaHttpTTSService(TTSService): self, *, api_key: str, - voice_id: str, - model: str = "sonic-3", + voice_id: Optional[str] = None, + model: Optional[str] = None, base_url: str = "https://api.cartesia.ai", - cartesia_version: str = "2024-11-13", + cartesia_version: str = "2026-03-01", + aiohttp_session: Optional[aiohttp.ClientSession] = None, sample_rate: Optional[int] = None, encoding: str = "pcm_s16le", container: str = "raw", params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Cartesia HTTP TTS service. @@ -672,43 +720,82 @@ class CartesiaHttpTTSService(TTSService): Args: api_key: Cartesia API key for authentication. voice_id: ID of the voice to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=CartesiaHttpTTSService.Settings(voice=...)`` instead. + model: TTS model to use (e.g., "sonic-3"). + + .. deprecated:: 0.0.105 + Use ``settings=CartesiaHttpTTSService.Settings(model=...)`` instead. + base_url: Base URL for Cartesia HTTP API. cartesia_version: API version string for Cartesia service. + aiohttp_session: Optional aiohttp ClientSession for HTTP requests. + If not provided, a session will be created and managed internally. sample_rate: Audio sample rate. If None, uses default. encoding: Audio encoding format. container: Audio container format. params: Additional input parameters for voice customization. + + .. deprecated:: 0.0.105 + Use ``settings=CartesiaHttpTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to the parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="sonic-3", + voice=None, + language=Language.EN, + generation_config=None, + pronunciation_dict_id=None, + ) - params = params or CartesiaHttpTTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.generation_config is not None: + default_settings.generation_config = params.generation_config + if params.pronunciation_dict_id is not None: + default_settings.pronunciation_dict_id = params.pronunciation_dict_id + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._base_url = base_url self._cartesia_version = cartesia_version - self._settings = { - "output_format": { - "container": container, - "encoding": encoding, - "sample_rate": 0, - }, - "language": self.language_to_service_language(params.language) - if params.language - else None, - "speed": params.speed, - "emotion": params.emotion, - "generation_config": params.generation_config, - "pronunciation_dict_id": params.pronunciation_dict_id, - } - self.set_voice(voice_id) - self.set_model_name(model) - self._client = AsyncCartesia( - api_key=api_key, - base_url=base_url, - ) + # Audio output format — init-only, not runtime-updatable + self._output_container = container + self._output_encoding = encoding + self._output_sample_rate = 0 # Set in start() from self.sample_rate + + self._session: aiohttp.ClientSession | None = aiohttp_session + self._owns_session = aiohttp_session is None def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -736,7 +823,15 @@ class CartesiaHttpTTSService(TTSService): frame: The start frame containing initialization parameters. """ await super().start(frame) - self._settings["output_format"]["sample_rate"] = self.sample_rate + self._output_sample_rate = self.sample_rate + if self._owns_session: + self._session = aiohttp.ClientSession() + + async def _close_session(self): + """Close the HTTP session if we own it.""" + if self._owns_session and self._session: + await self._session.close() + self._session = None async def stop(self, frame: EndFrame): """Stop the Cartesia HTTP TTS service. @@ -745,7 +840,7 @@ class CartesiaHttpTTSService(TTSService): frame: The end frame. """ await super().stop(frame) - await self._client.close() + await self._close_session() async def cancel(self, frame: CancelFrame): """Cancel the Cartesia HTTP TTS service. @@ -754,14 +849,15 @@ class CartesiaHttpTTSService(TTSService): frame: The cancel frame. """ await super().cancel(frame) - await self._client.close() + await self._close_session() @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Cartesia's HTTP API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -769,44 +865,31 @@ class CartesiaHttpTTSService(TTSService): logger.debug(f"{self}: Generating TTS [{text}]") try: - voice_config = {"mode": "id", "id": self._voice_id} + voice_config = {"mode": "id", "id": self._settings.voice} - if self._settings["emotion"]: - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "The 'emotion' parameter in voice.__experimental_controls is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) - voice_config["__experimental_controls"] = {"emotion": self._settings["emotion"]} - - await self.start_ttfb_metrics() - - payload = { - "model_id": self._model_name, - "transcript": text, - "voice": voice_config, - "output_format": self._settings["output_format"], + output_format = { + "container": self._output_container, + "encoding": self._output_encoding, + "sample_rate": self._output_sample_rate, } - if self._settings["language"]: - payload["language"] = self._settings["language"] + payload = { + "model_id": self._settings.model, + "transcript": text, + "voice": voice_config, + "output_format": output_format, + } - if self._settings["speed"]: - payload["speed"] = self._settings["speed"] + if self._settings.language: + payload["language"] = self._settings.language - if self._settings["generation_config"]: - payload["generation_config"] = self._settings["generation_config"].model_dump( + if self._settings.generation_config: + payload["generation_config"] = self._settings.generation_config.model_dump( exclude_none=True ) - if self._settings["pronunciation_dict_id"]: - payload["pronunciation_dict_id"] = self._settings["pronunciation_dict_id"] - - yield TTSStartedFrame() - - session = await self._client._get_session() + if self._settings.pronunciation_dict_id: + payload["pronunciation_dict_id"] = self._settings.pronunciation_dict_id headers = { "Cartesia-Version": self._cartesia_version, @@ -816,7 +899,7 @@ class CartesiaHttpTTSService(TTSService): url = f"{self._base_url}/tts/bytes" - async with session.post(url, json=payload, headers=headers) as response: + async with self._session.post(url, json=payload, headers=headers) as response: if response.status != 200: error_text = await response.text() yield ErrorFrame(error=f"Cartesia API error: {error_text}") @@ -830,6 +913,7 @@ class CartesiaHttpTTSService(TTSService): audio=audio_data, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) yield frame @@ -838,4 +922,3 @@ class CartesiaHttpTTSService(TTSService): yield ErrorFrame(error=f"Unknown error occurred: {e}") finally: await self.stop_ttfb_metrics() - yield TTSStoppedFrame() diff --git a/src/pipecat/services/cerebras/llm.py b/src/pipecat/services/cerebras/llm.py index 9ceb48905..dfb62baf8 100644 --- a/src/pipecat/services/cerebras/llm.py +++ b/src/pipecat/services/cerebras/llm.py @@ -6,14 +6,23 @@ """Cerebras LLM service implementation using OpenAI-compatible interface.""" -from typing import List +from dataclasses import dataclass +from typing import Optional from loguru import logger from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class CerebrasLLMSettings(BaseOpenAILLMService.Settings): + """Settings for CerebrasLLMService.""" + + pass + + class CerebrasLLMService(OpenAILLMService): """A service for interacting with Cerebras's API using the OpenAI-compatible interface. @@ -21,12 +30,16 @@ class CerebrasLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. """ + Settings = CerebrasLLMSettings + _settings: Settings + def __init__( self, *, api_key: str, base_url: str = "https://api.cerebras.ai/v1", - model: str = "gpt-oss-120b", + model: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Cerebras LLM service. @@ -35,9 +48,29 @@ class CerebrasLLMService(OpenAILLMService): api_key: The API key for accessing Cerebras's API. base_url: The base URL for Cerebras API. Defaults to "https://api.cerebras.ai/v1". model: The model identifier to use. Defaults to "gpt-oss-120b". + + .. deprecated:: 0.0.105 + Use ``settings=CerebrasLLMService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="gpt-oss-120b") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): """Create OpenAI-compatible client for Cerebras API endpoint. @@ -68,16 +101,28 @@ class CerebrasLLMService(OpenAILLMService): Dictionary of parameters for the chat completion request. """ params = { - "model": self.model_name, + "model": self._settings.model, "stream": True, - "seed": self._settings["seed"], - "temperature": self._settings["temperature"], - "top_p": self._settings["top_p"], - "max_completion_tokens": self._settings["max_completion_tokens"], + "seed": self._settings.seed, + "temperature": self._settings.temperature, + "top_p": self._settings.top_p, + "max_completion_tokens": self._settings.max_completion_tokens, } # Messages, tools, tool_choice params.update(params_from_context) - params.update(self._settings["extra"]) + params.update(self._settings.extra) + + # Prepend system instruction if set + if self._settings.system_instruction: + messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) + params["messages"] = [ + {"role": "system", "content": self._settings.system_instruction} + ] + messages + return params diff --git a/src/pipecat/services/deepgram/__init__.py b/src/pipecat/services/deepgram/__init__.py index 4e1db3886..f67271abc 100644 --- a/src/pipecat/services/deepgram/__init__.py +++ b/src/pipecat/services/deepgram/__init__.py @@ -9,6 +9,7 @@ import sys from pipecat.services import DeprecatedModuleProxy from .flux import * +from .sagemaker import * from .stt import * from .tts import * diff --git a/src/pipecat/services/deepgram/flux/stt.py b/src/pipecat/services/deepgram/flux/stt.py index cc2df29e2..64d4e87b9 100644 --- a/src/pipecat/services/deepgram/flux/stt.py +++ b/src/pipecat/services/deepgram/flux/stt.py @@ -9,6 +9,7 @@ import asyncio import json import time +from dataclasses import dataclass, field from enum import Enum from typing import Any, AsyncGenerator, Dict, Optional from urllib.parse import urlencode @@ -27,7 +28,7 @@ from pipecat.frames.frames import ( UserStartedSpeakingFrame, UserStoppedSpeakingFrame, ) -from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven from pipecat.services.stt_service import WebsocketSTTService from pipecat.transcriptions.language import Language from pipecat.utils.time import time_now_iso8601 @@ -52,6 +53,8 @@ class FluxMessageType(str, Enum): RECEIVE_CONNECTED = "Connected" RECEIVE_FATAL_ERROR = "Error" TURN_INFO = "TurnInfo" + CONFIGURE_SUCCESS = "ConfigureSuccess" + CONFIGURE_FAILURE = "ConfigureFailure" class FluxEventType(str, Enum): @@ -68,19 +71,59 @@ class FluxEventType(str, Enum): UPDATE = "Update" +@dataclass +class DeepgramFluxSTTSettings(STTSettings): + """Settings for DeepgramFluxSTTService. + + Parameters: + eager_eot_threshold: EagerEndOfTurn/TurnResumed threshold. Off by default. + Lower values = more aggressive (faster response, more LLM calls). + Higher values = more conservative (slower response, fewer LLM calls). + eot_threshold: End-of-turn confidence required to finish a turn (default 0.7). + eot_timeout_ms: Time in ms after speech to finish a turn regardless of EOT + confidence (default 5000). + keyterm: Keyterms to boost recognition accuracy for specialized terminology. + min_confidence: Minimum confidence required to create a TranscriptionFrame. + """ + + eager_eot_threshold: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + eot_threshold: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + eot_timeout_ms: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + keyterm: list | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + min_confidence: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class DeepgramFluxSTTService(WebsocketSTTService): """Deepgram Flux speech-to-text service. 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 + _settings: Settings + _CONFIGURE_FIELDS = {"keyterm", "eot_threshold", "eager_eot_threshold", "eot_timeout_ms"} + class InputParams(BaseModel): """Configuration parameters for Deepgram Flux API. - This class defines all available connection parameters for the Deepgram Flux API - based on the official documentation. + .. deprecated:: 0.0.105 + Use ``settings=DeepgramFluxSTTService.Settings(...)`` instead. Parameters: eager_eot_threshold: Optional. EagerEndOfTurn/TurnResumed are off by default. @@ -113,10 +156,13 @@ class DeepgramFluxSTTService(WebsocketSTTService): api_key: str, url: str = "wss://api.deepgram.com/v2/listen", sample_rate: Optional[int] = None, - model: str = "flux-general-en", + mip_opt_out: Optional[bool] = None, + model: Optional[str] = None, flux_encoding: str = "linear16", + tag: Optional[list] = None, params: Optional[InputParams] = None, should_interrupt: bool = True, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Deepgram Flux STT service. @@ -124,13 +170,25 @@ class DeepgramFluxSTTService(WebsocketSTTService): Args: api_key: Deepgram API key for authentication. Required for API access. url: WebSocket URL for the Deepgram Flux API. Defaults to the preview endpoint. - sample_rate: Audio sample rate in Hz. If None, uses the rate from params or 16000. - model: Deepgram Flux model to use for transcription. Currently only supports "flux-general-en". + sample_rate: Audio sample rate in Hz. If None, uses the pipeline + sample rate. + mip_opt_out: Opt out of the Deepgram Model Improvement Program. + model: Deepgram Flux model to use for transcription. + + .. deprecated:: 0.0.105 + Use ``settings=DeepgramFluxSTTService.Settings(model=...)`` instead. + flux_encoding: Audio encoding format required by Flux API. Must be "linear16". Raw signed little-endian 16-bit PCM encoding. + tag: Tags to label requests for identification during usage reporting. params: InputParams instance containing detailed API configuration options. - If None, default parameters will be used. + + .. deprecated:: 0.0.105 + Use ``settings=DeepgramFluxSTTService.Settings(...)`` instead. + should_interrupt: Determine whether the bot should be interrupted when Flux detects that the user is speaking. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to the parent WebsocketSTTService class. Examples: @@ -140,16 +198,15 @@ class DeepgramFluxSTTService(WebsocketSTTService): Advanced usage with custom parameters:: - params = DeepgramFluxSTTService.InputParams( - eager_eot_threshold=0.5, - eot_threshold=0.8, - keyterm=["AI", "machine learning", "neural network"], - tag=["production", "voice-agent"] - ) stt = DeepgramFluxSTTService( api_key="your-api-key", - model="flux-general-en", - params=params + settings=DeepgramFluxSTTService.Settings( + model="flux-general-en", + eager_eot_threshold=0.5, + eot_threshold=0.8, + keyterm=["AI", "machine learning", "neural network"], + tag=["production", "voice-agent"], + ), ) """ # Note: For DeepgramFluxSTTService, differently from other processes, we need to create @@ -162,18 +219,56 @@ class DeepgramFluxSTTService(WebsocketSTTService): # was never destroyed. # So we can keep it here as false, because inside the method send_with_retry, it will # already try to reconnect if needed. - super().__init__(sample_rate=sample_rate, reconnect_on_error=False, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="flux-general-en", + language=Language.EN, + eager_eot_threshold=None, + eot_threshold=None, + eot_timeout_ms=None, + keyterm=[], + min_confidence=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.eager_eot_threshold = params.eager_eot_threshold + default_settings.eot_threshold = params.eot_threshold + default_settings.eot_timeout_ms = params.eot_timeout_ms + default_settings.keyterm = params.keyterm or [] + if params.tag and tag is None: + tag = params.tag + default_settings.min_confidence = params.min_confidence + if params.mip_opt_out is not None: + mip_opt_out = params.mip_opt_out + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + reconnect_on_error=False, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._url = url - self._model = model - self._params = params or DeepgramFluxSTTService.InputParams() self._should_interrupt = should_interrupt - self._flux_encoding = flux_encoding - # This is the currently only supported language - self._language = Language.EN + self._encoding = flux_encoding + self._mip_opt_out = mip_opt_out + self._tag = tag or [] self._websocket_url = None self._receive_task = None + # Flux event handlers self._register_event_handler("on_start_of_turn") self._register_event_handler("on_turn_resumed") @@ -194,6 +289,8 @@ class DeepgramFluxSTTService(WebsocketSTTService): Establishes the WebSocket connection to the Deepgram Flux API and starts the background task for receiving transcription results. """ + await super()._connect() + await self._connect_websocket() async def _disconnect(self): @@ -202,6 +299,8 @@ class DeepgramFluxSTTService(WebsocketSTTService): Gracefully disconnects from the Deepgram Flux API, cancels background tasks, and cleans up resources to prevent memory leaks. """ + await super()._disconnect() + try: await self._disconnect_websocket() except Exception as e: @@ -314,6 +413,33 @@ class DeepgramFluxSTTService(WebsocketSTTService): except Exception as e: await self.push_error(error_msg=f"Error sending closeStream: {e}", exception=e) + async def _send_configure(self, fields: set[str]): + """Send a Configure control message to update settings mid-stream. + + Builds a Configure JSON message containing only the fields that changed + and sends it over the existing WebSocket connection. + + Args: + fields: Set of changed field names to include in the message. + """ + message: dict[str, Any] = {"type": "Configure"} + + if "keyterm" in fields: + message["keyterms"] = self._settings.keyterm + + thresholds: dict[str, Any] = {} + if "eot_threshold" in fields: + thresholds["eot_threshold"] = self._settings.eot_threshold + if "eager_eot_threshold" in fields: + thresholds["eager_eot_threshold"] = self._settings.eager_eot_threshold + if "eot_timeout_ms" in fields: + thresholds["eot_timeout_ms"] = self._settings.eot_timeout_ms + if thresholds: + message["thresholds"] = thresholds + + logger.debug(f"{self}: sending Configure message: {message}") + await self._websocket.send(json.dumps(message)) + def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -322,6 +448,26 @@ class DeepgramFluxSTTService(WebsocketSTTService): """ return True + async def _update_settings(self, delta: Settings) -> dict[str, Any]: + """Apply a settings delta. + + Configure-able fields (keyterm, eot_threshold, eager_eot_threshold, + eot_timeout_ms) are sent to Deepgram via a Configure WebSocket message. + Other fields are stored but cannot be applied to the active connection. + """ + changed = await super()._update_settings(delta) + + if not changed: + return changed + + configure_fields = changed.keys() & self._CONFIGURE_FIELDS + if configure_fields and self._websocket and self._websocket.state is State.OPEN: + await self._send_configure(configure_fields) + + self._warn_unhandled_updated_settings(changed.keys() - self._CONFIGURE_FIELDS) + + return changed + async def start(self, frame: StartFrame): """Start the Deepgram Flux STT service. @@ -334,29 +480,29 @@ class DeepgramFluxSTTService(WebsocketSTTService): await super().start(frame) url_params = [ - f"model={self._model}", + f"model={self._settings.model}", f"sample_rate={self.sample_rate}", - f"encoding={self._flux_encoding}", + f"encoding={self._encoding}", ] - if self._params.eager_eot_threshold is not None: - url_params.append(f"eager_eot_threshold={self._params.eager_eot_threshold}") + if self._settings.eager_eot_threshold is not None: + url_params.append(f"eager_eot_threshold={self._settings.eager_eot_threshold}") - if self._params.eot_threshold is not None: - url_params.append(f"eot_threshold={self._params.eot_threshold}") + if self._settings.eot_threshold is not None: + url_params.append(f"eot_threshold={self._settings.eot_threshold}") - if self._params.eot_timeout_ms is not None: - url_params.append(f"eot_timeout_ms={self._params.eot_timeout_ms}") + if self._settings.eot_timeout_ms is not None: + url_params.append(f"eot_timeout_ms={self._settings.eot_timeout_ms}") - if self._params.mip_opt_out is not None: - url_params.append(f"mip_opt_out={str(self._params.mip_opt_out).lower()}") + if self._mip_opt_out is not None: + url_params.append(f"mip_opt_out={str(self._mip_opt_out).lower()}") # Add keyterm parameters (can have multiple) - for keyterm in self._params.keyterm: + for keyterm in self._settings.keyterm: url_params.append(urlencode({"keyterm": keyterm})) # Add tag parameters (can have multiple) - for tag_value in self._params.tag: + for tag_value in self._tag: url_params.append(urlencode({"tag": tag_value})) self._websocket_url = f"{self._url}?{'&'.join(url_params)}" @@ -515,6 +661,14 @@ class DeepgramFluxSTTService(WebsocketSTTService): await self._handle_fatal_error(data) case FluxMessageType.TURN_INFO: await self._handle_turn_info(data) + case FluxMessageType.CONFIGURE_SUCCESS: + logger.info(f"{self}: Configure accepted: {data}") + case FluxMessageType.CONFIGURE_FAILURE: + error_code = data.get("error_code", "unknown") + description = data.get("description", "no description") + error_msg = f"Configure rejected: [{error_code}] {description}" + logger.warning(f"{self}: {error_msg}") + await self.push_error(error_msg=error_msg) async def _handle_connection_established(self): """Handle successful connection establishment to Deepgram Flux. @@ -596,7 +750,7 @@ class DeepgramFluxSTTService(WebsocketSTTService): self._user_is_speaking = True await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self.start_metrics() await self._call_event_handler("on_start_of_turn", transcript) if transcript: @@ -655,14 +809,17 @@ class DeepgramFluxSTTService(WebsocketSTTService): # Compute the average confidence average_confidence = self._calculate_average_confidence(data) - if not self._params.min_confidence or average_confidence > self._params.min_confidence: + if not self._settings.min_confidence or average_confidence > self._settings.min_confidence: + # EndOfTurn means Flux has determined the turn is complete, + # so this TranscriptionFrame is always finalized await self.push_frame( TranscriptionFrame( transcript, self._user_id, time_now_iso8601(), - self._language, + self._settings.language, result=data, + finalized=True, ) ) else: @@ -670,10 +827,9 @@ class DeepgramFluxSTTService(WebsocketSTTService): f"Transcription confidence below min_confidence threshold: {average_confidence}" ) - await self._handle_transcription(transcript, True, self._language) + await self._handle_transcription(transcript, True, self._settings.language) await self.stop_processing_metrics() - await self.push_frame(UserStoppedSpeakingFrame(), FrameDirection.DOWNSTREAM) - await self.push_frame(UserStoppedSpeakingFrame(), FrameDirection.UPSTREAM) + await self.broadcast_frame(UserStoppedSpeakingFrame) await self._call_event_handler("on_end_of_turn", transcript) async def _handle_eager_end_of_turn(self, transcript: str, data: Dict[str, Any]): @@ -715,7 +871,7 @@ class DeepgramFluxSTTService(WebsocketSTTService): transcript, self._user_id, time_now_iso8601(), - self._language, + self._settings.language, result=data, ) ) diff --git a/src/pipecat/services/deepgram/sagemaker/__init__.py b/src/pipecat/services/deepgram/sagemaker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pipecat/services/deepgram/sagemaker/stt.py b/src/pipecat/services/deepgram/sagemaker/stt.py new file mode 100644 index 000000000..1087b124f --- /dev/null +++ b/src/pipecat/services/deepgram/sagemaker/stt.py @@ -0,0 +1,527 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Deepgram speech-to-text service for AWS SageMaker. + +This module provides a Pipecat STT service that connects to Deepgram models +deployed on AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for +low-latency real-time transcription with support for interim results, multiple +languages, and various Deepgram features. +""" + +import asyncio +import json +from dataclasses import dataclass, fields +from typing import Any, AsyncGenerator, Optional + +from loguru import logger + +from pipecat.frames.frames import ( + CancelFrame, + EndFrame, + ErrorFrame, + Frame, + InterimTranscriptionFrame, + StartFrame, + TranscriptionFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, +) +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient +from pipecat.services.deepgram.stt import DeepgramSTTService, LiveOptions +from pipecat.services.settings import STTSettings, is_given +from pipecat.services.stt_latency import DEEPGRAM_SAGEMAKER_TTFS_P99 +from pipecat.services.stt_service import STTService +from pipecat.transcriptions.language import Language +from pipecat.utils.time import time_now_iso8601 +from pipecat.utils.tracing.service_decorators import traced_stt + + +@dataclass +class DeepgramSageMakerSTTSettings(DeepgramSTTService.Settings): + """Settings for the Deepgram SageMaker STT service. + + Inherits all fields from :class:`DeepgramSTTService.Settings`. + """ + + pass + + +class DeepgramSageMakerSTTService(STTService): + """Deepgram speech-to-text service for AWS SageMaker. + + Provides real-time speech recognition using Deepgram models deployed on + AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for low-latency + transcription with support for interim results, speaker diarization, and + multiple languages. + + Requirements: + + - AWS credentials configured (via environment variables, AWS CLI, or instance metadata) + - A deployed SageMaker endpoint with Deepgram model: https://developers.deepgram.com/docs/deploy-amazon-sagemaker + + Example:: + + stt = DeepgramSageMakerSTTService( + endpoint_name="my-deepgram-endpoint", + region="us-east-2", + settings=DeepgramSageMakerSTTService.Settings( + model="nova-3", + language="en", + interim_results=True, + punctuate=True, + ), + ) + """ + + Settings = DeepgramSageMakerSTTSettings + _settings: Settings + + def __init__( + self, + *, + endpoint_name: str, + region: str, + encoding: str = "linear16", + channels: int = 1, + multichannel: bool = False, + sample_rate: Optional[int] = None, + mip_opt_out: Optional[bool] = None, + live_options: Optional[LiveOptions] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = DEEPGRAM_SAGEMAKER_TTFS_P99, + **kwargs, + ): + """Initialize the Deepgram SageMaker STT service. + + Args: + endpoint_name: Name of the SageMaker endpoint with Deepgram model + deployed (e.g., "my-deepgram-nova-3-endpoint"). + region: AWS region where the endpoint is deployed (e.g., "us-east-2"). + encoding: Audio encoding format. Defaults to "linear16". + channels: Number of audio channels. Defaults to 1. + multichannel: Transcribe each audio channel independently. + Defaults to False. + sample_rate: Audio sample rate in Hz. If None, uses the pipeline + sample rate. + mip_opt_out: Opt out of Deepgram model improvement program. + live_options: Legacy configuration options. + + .. deprecated:: 0.0.105 + Use ``settings=DeepgramSageMakerSTTService.Settings(...)`` for + runtime-updatable fields and direct init parameters for + connection-level config. + + settings: Runtime-updatable settings. When provided alongside + ``live_options``, ``settings`` values take precedence (applied + after the ``live_options`` merge). + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark + **kwargs: Additional arguments passed to the parent STTService. + """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="nova-3", + language=Language.EN, + detect_entities=False, + diarize=False, + dictation=False, + endpointing=None, + interim_results=True, + keyterm=None, + keywords=None, + numerals=False, + profanity_filter=True, + punctuate=True, + redact=None, + replace=None, + search=None, + smart_format=False, + utterance_end_ms=None, + vad_events=False, + ) + + # 2. Apply live_options overrides — only if settings not provided + if live_options is not None: + self._warn_init_param_moved_to_settings("live_options") + if not settings: + # Extract init-only fields from live_options + if live_options.sample_rate is not None and sample_rate is None: + sample_rate = live_options.sample_rate + if live_options.encoding is not None: + encoding = live_options.encoding + if live_options.channels is not None: + channels = live_options.channels + if live_options.multichannel is not None: + multichannel = live_options.multichannel + if live_options.mip_opt_out is not None: + mip_opt_out = live_options.mip_opt_out + + # Build settings delta from remaining fields + init_only = { + "sample_rate", + "encoding", + "channels", + "multichannel", + "mip_opt_out", + } + lo_dict = {k: v for k, v in live_options.to_dict().items() if k not in init_only} + delta = self.Settings.from_mapping(lo_dict) + default_settings.apply_update(delta) + + # 3. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + # Sync extra to top-level fields so self._settings is unambiguous + default_settings._sync_extra_to_fields() + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) + + self._endpoint_name = endpoint_name + self._region = region + + # Init-only connection config (not runtime-updatable). + self._encoding = encoding + self._channels = channels + self._multichannel = multichannel + self._mip_opt_out = mip_opt_out + + self._client: Optional[SageMakerBidiClient] = None + self._response_task: Optional[asyncio.Task] = None + self._keepalive_task: Optional[asyncio.Task] = None + + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as Deepgram SageMaker service supports metrics generation. + """ + return True + + async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: + """Apply a settings delta and warn about unhandled changes.""" + changed = await super()._update_settings(delta) + + if not changed: + return changed + + # Sync extra to fields after the update so self._settings stays unambiguous + if isinstance(self._settings, self.Settings): + self._settings._sync_extra_to_fields() + + # TODO: someday we could reconnect here to apply updated settings. + # Code might look something like the below: + # await self._disconnect() + # await self._connect() + + self._warn_unhandled_updated_settings(changed) + + return changed + + async def start(self, frame: StartFrame): + """Start the Deepgram SageMaker STT service. + + Args: + frame: The start frame containing initialization parameters. + """ + await super().start(frame) + await self._connect() + + async def stop(self, frame: EndFrame): + """Stop the Deepgram SageMaker STT service. + + Args: + frame: The end frame. + """ + await super().stop(frame) + await self._disconnect() + + async def cancel(self, frame: CancelFrame): + """Cancel the Deepgram SageMaker STT service. + + Args: + frame: The cancel frame. + """ + await super().cancel(frame) + await self._disconnect() + + async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: + """Send audio data to Deepgram for transcription. + + Args: + audio: Raw audio bytes to transcribe. + + Yields: + Frame: None (transcription results come via BiDi stream callbacks). + """ + if self._client and self._client.is_active: + try: + await self._client.send_audio_chunk(audio) + except Exception as e: + yield ErrorFrame(error=f"Unknown error occurred: {e}") + yield None + + def _build_query_string(self) -> str: + """Build query string from current settings and init-only connection config.""" + params = {} + s = self._settings + + # Declared Deepgram-specific fields from settings + for f in fields(s): + if f.name in ("model", "language", "extra") or f.name.startswith("_"): + continue + value = getattr(s, f.name) + if not is_given(value) or value is None: + continue + params[f.name] = str(value).lower() if isinstance(value, bool) else str(value) + + # model and language + if is_given(s.model) and s.model is not None: + params["model"] = str(s.model) + if is_given(s.language) and s.language is not None: + params["language"] = str(s.language) + + # Init-only connection config + params["encoding"] = self._encoding + params["channels"] = str(self._channels) + params["multichannel"] = str(self._multichannel).lower() + params["sample_rate"] = str(self.sample_rate) + + if self._mip_opt_out is not None: + params["mip_opt_out"] = str(self._mip_opt_out).lower() + + # Any remaining values in extra + if s.extra: + for key, value in s.extra.items(): + if value is not None: + params[key] = str(value).lower() if isinstance(value, bool) else str(value) + + return "&".join(f"{k}={v}" for k, v in params.items()) + + async def _connect(self): + """Connect to the SageMaker endpoint and start the BiDi session. + + Builds the Deepgram query string from settings, creates the BiDi client, + starts the streaming session, and launches background tasks for processing + responses and sending KeepAlive messages. + """ + logger.debug("Connecting to Deepgram on SageMaker...") + + query_string = self._build_query_string() + + # Create BiDi client + self._client = SageMakerBidiClient( + endpoint_name=self._endpoint_name, + region=self._region, + model_invocation_path="v1/listen", + model_query_string=query_string, + ) + + try: + # Start the session + await self._client.start_session() + + # Start processing responses in the background + self._response_task = self.create_task(self._process_responses()) + + # Start keepalive task to maintain connection + self._keepalive_task = self.create_task(self._send_keepalive()) + + logger.debug("Connected to Deepgram on SageMaker") + await self._call_event_handler("on_connected") + + except Exception as e: + await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + await self._call_event_handler("on_connection_error", str(e)) + + async def _disconnect(self): + """Disconnect from the SageMaker endpoint. + + Sends a CloseStream message to Deepgram, cancels background tasks + (KeepAlive and response processing), and closes the BiDi session. + Safe to call multiple times. + """ + if self._client and self._client.is_active: + logger.debug("Disconnecting from Deepgram on SageMaker...") + + # Send CloseStream message to Deepgram + try: + await self._client.send_json({"type": "CloseStream"}) + except Exception as e: + logger.warning(f"Failed to send CloseStream message: {e}") + + # Cancel keepalive task + if self._keepalive_task and not self._keepalive_task.done(): + await self.cancel_task(self._keepalive_task) + + # Cancel response processing task + if self._response_task and not self._response_task.done(): + await self.cancel_task(self._response_task) + + # Close the BiDi session + await self._client.close_session() + + logger.debug("Disconnected from Deepgram on SageMaker") + await self._call_event_handler("on_disconnected") + + async def _send_keepalive(self): + """Send periodic KeepAlive messages to maintain the connection. + + Sends a KeepAlive JSON message to Deepgram every 5 seconds while the + connection is active. This prevents the connection from timing out during + periods of silence. + """ + while self._client and self._client.is_active: + await asyncio.sleep(5) + if self._client and self._client.is_active: + try: + await self._client.send_json({"type": "KeepAlive"}) + except Exception as e: + logger.warning(f"Failed to send KeepAlive: {e}") + + async def _process_responses(self): + """Process streaming responses from Deepgram on SageMaker. + + Continuously receives responses from the BiDi stream, decodes the payload, + parses JSON responses from Deepgram, and processes transcription results. + Runs as a background task until the connection is closed or cancelled. + """ + try: + while self._client and self._client.is_active: + result = await self._client.receive_response() + + if result is None: + break + + # Check if this is a PayloadPart with bytes + if hasattr(result, "value") and hasattr(result.value, "bytes_"): + if result.value.bytes_: + response_data = result.value.bytes_.decode("utf-8") + + try: + # Parse JSON response from Deepgram + parsed = json.loads(response_data) + + # Extract and process transcript if available + if "channel" in parsed: + await self._handle_transcript_response(parsed) + + except json.JSONDecodeError: + logger.warning(f"Non-JSON response: {response_data}") + + except asyncio.CancelledError: + logger.debug("Response processor cancelled") + except Exception as e: + await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + finally: + logger.debug("Response processor stopped") + + async def _handle_transcript_response(self, parsed: dict): + """Handle a transcript response from Deepgram. + + Extracts the transcript text, determines if it's final or interim, extracts + language information, and pushes the appropriate frame (TranscriptionFrame + or InterimTranscriptionFrame) downstream. + + Args: + parsed: The parsed JSON response from Deepgram containing channel, + alternatives, transcript, and metadata. + """ + alternatives = parsed.get("channel", {}).get("alternatives", []) + if not alternatives or not alternatives[0].get("transcript"): + return + + transcript = alternatives[0]["transcript"] + if not transcript.strip(): + return + + is_final = parsed.get("is_final", False) + + # Extract language if available + language = None + if alternatives[0].get("languages"): + language = alternatives[0]["languages"][0] + language = Language(language) + + if is_final: + # Check if this response is from a finalize() call. + # Only mark as finalized when both we requested it AND Deepgram confirms it. + from_finalize = parsed.get("from_finalize", False) + if from_finalize: + self.confirm_finalize() + await self.push_frame( + TranscriptionFrame( + transcript, + self._user_id, + time_now_iso8601(), + language, + result=parsed, + ) + ) + await self._handle_transcription(transcript, is_final, language) + await self.stop_processing_metrics() + else: + # Interim transcription + await self.push_frame( + InterimTranscriptionFrame( + transcript, + self._user_id, + time_now_iso8601(), + language, + result=parsed, + ) + ) + + @traced_stt + async def _handle_transcription( + self, transcript: str, is_final: bool, language: Optional[Language] = None + ): + """Handle a transcription result with tracing. + + This method is decorated with @traced_stt for observability and tracing + integration. The actual transcription processing is handled by the parent + class and observers. + + Args: + transcript: The transcribed text. + is_final: Whether this is a final transcription result. + language: The detected language of the transcription, if available. + """ + pass + + async def _start_metrics(self): + """Start processing metrics collection.""" + await self.start_processing_metrics() + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames with Deepgram SageMaker-specific handling. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ + await super().process_frame(frame, direction) + + # Start metrics when user starts speaking (if VAD is not provided by Deepgram) + if isinstance(frame, VADUserStartedSpeakingFrame): + await self._start_metrics() + elif isinstance(frame, VADUserStoppedSpeakingFrame): + # https://developers.deepgram.com/docs/finalize + # Mark that we're awaiting a from_finalize response + self.request_finalize() + if self._client and self._client.is_active: + try: + await self._client.send_json({"type": "Finalize"}) + except Exception as e: + logger.warning(f"Error sending Finalize message: {e}") + logger.trace(f"Triggered finalize event on: {frame.name=}, {direction=}") diff --git a/src/pipecat/services/deepgram/sagemaker/tts.py b/src/pipecat/services/deepgram/sagemaker/tts.py new file mode 100644 index 000000000..36541b4be --- /dev/null +++ b/src/pipecat/services/deepgram/sagemaker/tts.py @@ -0,0 +1,342 @@ +# +# 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 +from typing import Any, AsyncGenerator, Optional + +from loguru import logger + +from pipecat.frames.frames import ( + CancelFrame, + EndFrame, + ErrorFrame, + Frame, + StartFrame, + TTSAudioRawFrame, +) +from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient +from pipecat.services.settings import TTSSettings +from pipecat.services.tts_service import TTSService +from pipecat.utils.tracing.service_decorators import traced_tts + + +@dataclass +class DeepgramSageMakerTTSSettings(TTSSettings): + """Settings for DeepgramSageMakerTTSService.""" + + pass + + +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", + settings=DeepgramSageMakerTTSService.Settings( + voice="aura-2-helena-en", + ) + ) + """ + + Settings = DeepgramSageMakerTTSSettings + _settings: Settings + + def __init__( + self, + *, + endpoint_name: str, + region: str, + voice: Optional[str] = None, + sample_rate: Optional[int] = None, + encoding: str = "linear16", + settings: Optional[Settings] = None, + **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". + + .. deprecated:: 0.0.105 + Use ``settings=DeepgramSageMakerTTSService.Settings(voice=...)`` instead. + + sample_rate: Audio sample rate in Hz. If None, uses the value from StartFrame. + encoding: Audio encoding format. Defaults to "linear16". + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + **kwargs: Additional arguments passed to the parent TTSService. + """ + if voice is not None: + self._warn_init_param_moved_to_settings("voice", "voice") + + voice = voice or "aura-2-helena-en" + + default_settings = self.Settings( + model=None, + voice=voice, + language=None, + ) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + pause_frame_processing=True, + append_trailing_space=True, + settings=default_settings, + **kwargs, + ) + + self._endpoint_name = endpoint_name + self._region = region + self._encoding = encoding + + self._client: Optional[SageMakerBidiClient] = None + self._response_task: Optional[asyncio.Task] = None + + 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 _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._encoding}&sample_rate={self.sample_rate}" + ) + + self._client = SageMakerBidiClient( + endpoint_name=self._endpoint_name, + region=self._region, + model_invocation_path="v1/speak", + model_query_string=query_string, + ) + + try: + await self._client.start_session() + + self._response_task = self.create_task(self._process_responses()) + + logger.debug("Connected to Deepgram TTS on SageMaker") + await self._call_event_handler("on_connected") + + except Exception as e: + await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + await self._call_event_handler("on_connection_error", str(e)) + + async def _disconnect(self): + """Disconnect from the SageMaker endpoint. + + Sends a Close message to Deepgram, cancels the response processing task, + and closes the BiDi session. Safe to call multiple times. + """ + if self._client and self._client.is_active: + logger.debug("Disconnecting from Deepgram TTS on SageMaker...") + + try: + await self._client.send_json({"type": "Close"}) + except Exception as e: + logger.warning(f"Failed to send Close message: {e}") + + if self._response_task and not self._response_task.done(): + await self.cancel_task(self._response_task) + + await self._client.close_session() + + logger.debug("Disconnected from Deepgram TTS on SageMaker") + await self._call_event_handler("on_disconnected") + + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta and reconnect if necessary. + + Since all settings are part of the SageMaker session query string, + any setting change requires reconnecting to apply the new values. + """ + changed = await super()._update_settings(delta) + + if not changed: + return changed + + # Deepgram uses voice as the model, so keep them in sync for metrics + if "voice" in changed: + self._settings.model = self._settings.voice + self._sync_model_name_to_metrics() + + # TODO: someday we could reconnect here to apply updated settings. + # Code might look something like the below: + # await self._disconnect() + # await self._connect() + + self._warn_unhandled_updated_settings(changed) + + return changed + + async def _process_responses(self): + """Process streaming responses from Deepgram TTS on SageMaker. + + Continuously receives responses from the BiDi stream. Attempts to decode + each payload as UTF-8 JSON for control messages (Flushed, Cleared, Metadata, + Warning). If decoding fails, treats the payload as raw audio bytes and pushes + a TTSAudioRawFrame downstream. + """ + try: + while self._client and self._client.is_active: + result = await self._client.receive_response() + + if result is None: + break + + if hasattr(result, "value") and hasattr(result.value, "bytes_"): + if result.value.bytes_: + payload = result.value.bytes_ + + # Try to decode as JSON control message first + try: + response_data = payload.decode("utf-8") + parsed = json.loads(response_data) + msg_type = parsed.get("type") + + if msg_type == "Metadata": + logger.trace(f"Received metadata: {parsed}") + elif msg_type == "Flushed": + logger.trace(f"Received Flushed: {parsed}") + elif msg_type == "Cleared": + logger.trace(f"Received Cleared: {parsed}") + elif msg_type == "Warning": + logger.warning( + f"{self} warning: " + f"{parsed.get('description', 'Unknown warning')}" + ) + else: + logger.debug(f"Received unknown message type: {parsed}") + + except (UnicodeDecodeError, json.JSONDecodeError): + # Not JSON — treat as raw audio bytes + await self.stop_ttfb_metrics() + context_id = self.get_active_audio_context_id() + frame = TTSAudioRawFrame( + payload, + self.sample_rate, + 1, + context_id=context_id, + ) + await self.append_to_audio_context(context_id, 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 on_audio_context_interrupted(self, context_id: str): + """Called when an audio context is cancelled due to an interruption. + + Args: + context_id: The ID of the audio context that was interrupted, or + ``None`` if no context was active at the time. + """ + 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, context_id: Optional[str] = None): + """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: + await self._client.send_json({"type": "Speak", "text": text}) + yield None + except Exception as e: + yield ErrorFrame(error=f"Unknown error occurred: {e}") diff --git a/src/pipecat/services/deepgram/stt.py b/src/pipecat/services/deepgram/stt.py index 2469b25a3..0fb891d61 100644 --- a/src/pipecat/services/deepgram/stt.py +++ b/src/pipecat/services/deepgram/stt.py @@ -6,7 +6,9 @@ """Deepgram speech-to-text service implementation.""" -from typing import AsyncGenerator, Dict, Optional +import asyncio +from dataclasses import dataclass, field, fields +from typing import Any, AsyncGenerator, Optional from loguru import logger @@ -23,20 +25,25 @@ from pipecat.frames.frames import ( VADUserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import ( + NOT_GIVEN, + STTSettings, + _NotGiven, + is_given, +) +from pipecat.services.stt_latency import DEEPGRAM_TTFS_P99 from pipecat.services.stt_service import STTService from pipecat.transcriptions.language import Language from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_stt try: - from deepgram import ( - AsyncListenWebSocketClient, - DeepgramClient, - DeepgramClientOptions, - ErrorResponse, - LiveOptions, - LiveResultResponse, - LiveTranscriptionEvents, + from deepgram import AsyncDeepgramClient + from deepgram.core.events import EventType + from deepgram.listen.v1.types import ( + ListenV1Results, + ListenV1SpeechStarted, + ListenV1UtteranceEnd, ) except ModuleNotFoundError as e: logger.error(f"Exception: {e}") @@ -44,23 +51,281 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +class LiveOptions: + """Deepgram live transcription options. + + Compatibility wrapper that mirrors the ``LiveOptions`` class removed in + deepgram-sdk v6. + + .. deprecated:: 0.0.105 + Use ``settings=DeepgramSTTService.Settings(...)`` for runtime-updatable fields + and direct ``__init__`` parameters for connection-level config instead. + """ + + def __init__( + self, + *, + callback: Optional[str] = None, + callback_method: Optional[str] = None, + channels: Optional[int] = None, + detect_entities: Optional[bool] = None, + diarize: Optional[bool] = None, + dictation: Optional[bool] = None, + encoding: Optional[str] = None, + endpointing: Optional[Any] = None, + extra: Optional[Any] = None, + interim_results: Optional[bool] = None, + keyterm: Optional[Any] = None, + keywords: Optional[Any] = None, + language: Optional[str] = None, + mip_opt_out: Optional[bool] = None, + model: Optional[str] = None, + multichannel: Optional[bool] = None, + numerals: Optional[bool] = None, + profanity_filter: Optional[bool] = None, + punctuate: Optional[bool] = None, + redact: Optional[Any] = None, + replace: Optional[Any] = None, + sample_rate: Optional[int] = None, + search: Optional[Any] = None, + smart_format: Optional[bool] = None, + tag: Optional[Any] = None, + utterance_end_ms: Optional[int] = None, + vad_events: Optional[bool] = None, + version: Optional[str] = None, + **kwargs, + ): + """Initialize live transcription options. + + Args: + callback: Callback URL for async transcription delivery. + callback_method: HTTP method to use for the callback (``"GET"`` or ``"POST"``). + channels: Number of audio channels. + detect_entities: Enable named entity detection. + diarize: Enable speaker diarization. + dictation: Enable dictation mode (converts commands to punctuation). + encoding: Audio encoding (e.g. ``"linear16"``). + endpointing: Endpointing sensitivity in ms, or ``False`` to disable. + extra: Additional key-value metadata to attach to the transcription (str or list). + interim_results: Whether to emit interim transcriptions. + keyterm: Keyterms to boost (str or list of str). + keywords: Keywords to boost (str or list of str). + language: BCP-47 language tag (e.g. ``"en-US"``). + mip_opt_out: Opt out of model improvement program. + model: Deepgram model name (e.g. ``"nova-3-general"``). + multichannel: Enable per-channel transcription for multi-channel audio. + numerals: Convert spoken numbers to numerals. + profanity_filter: Filter profanity from transcripts. + punctuate: Add punctuation to transcripts. + redact: Redact sensitive information (str or list of redaction types). + replace: Word replacement rules (str or list). + sample_rate: Audio sample rate in Hz. + search: Search terms to highlight (str or list of str). + smart_format: Apply smart formatting to transcripts. + tag: Custom billing tag (str or list of str). + utterance_end_ms: Silence duration in ms before an utterance-end event. + vad_events: Enable Deepgram VAD speech-started / utterance-end events. + version: Model version (e.g. ``"latest"``). + **kwargs: Any additional Deepgram query parameters. + """ + self.callback = callback + self.callback_method = callback_method + self.channels = channels + self.detect_entities = detect_entities + self.diarize = diarize + self.dictation = dictation + self.encoding = encoding + self.endpointing = endpointing + self.extra = extra + self.interim_results = interim_results + self.keyterm = keyterm + self.keywords = keywords + self.language = language + self.mip_opt_out = mip_opt_out + self.model = model + self.multichannel = multichannel + self.numerals = numerals + self.profanity_filter = profanity_filter + self.punctuate = punctuate + self.redact = redact + self.replace = replace + self.sample_rate = sample_rate + self.search = search + self.smart_format = smart_format + self.tag = tag + self.utterance_end_ms = utterance_end_ms + self.vad_events = vad_events + self.version = version + self._extra = kwargs + + def __getattr__(self, name: str): + # Fall back to _extra for any params passed as **kwargs. + # __getattr__ is only called when normal attribute lookup fails. + extra = self.__dict__.get("_extra", {}) + try: + return extra[name] + except KeyError: + raise AttributeError(f"'LiveOptions' object has no attribute '{name}'") + + def to_dict(self) -> dict: + """Return a dict of all non-None options.""" + result = {k: v for k, v in vars(self).items() if not k.startswith("_") and v is not None} + result.update({k: v for k, v in self._extra.items() if v is not None}) + return result + + +@dataclass +class DeepgramSTTSettings(STTSettings): + """Settings for DeepgramSTTService. + + ``model`` and ``language`` are inherited from ``STTSettings`` / + ``ServiceSettings``. Additional Deepgram connection params may + be passed in through ``extra`` (also inherited). + + Parameters: + detect_entities: Enable named entity detection. + diarize: Enable speaker diarization. + dictation: Enable dictation mode (converts commands to punctuation). + endpointing: Endpointing sensitivity in ms, or ``False`` to disable. + interim_results: Whether to emit interim transcriptions. + keyterm: Keyterms to boost (str or list of str). + keywords: Keywords to boost (str or list of str). + numerals: Convert spoken numbers to numerals. + profanity_filter: Filter profanity from transcripts. + punctuate: Add punctuation to transcripts. + redact: Redact sensitive information (str or list of redaction types). + replace: Word replacement rules (str or list). + search: Search terms to highlight (str or list of str). + smart_format: Apply smart formatting to transcripts. + utterance_end_ms: Silence duration in ms before an utterance-end event. + vad_events: Enable Deepgram VAD speech-started / utterance-end events. + """ + + detect_entities: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + diarize: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + dictation: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + endpointing: Any | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + interim_results: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + keyterm: Any | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + keywords: Any | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + numerals: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + profanity_filter: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + punctuate: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + redact: Any | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + replace: Any | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + search: Any | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + smart_format: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + utterance_end_ms: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + vad_events: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + def _sync_extra_to_fields(self) -> None: + """Sync values from extra dict to declared fields. + + If a key in extra matches a field name and the field is NOT_GIVEN, + promote the extra value to the field. This ensures self._settings + always reflects the "final truth" of values that will be used. + + Keys in extra that match declared fields are always removed from extra + to avoid confusion, even if the field was already set. + """ + if not self.extra: + return + + field_names = { + f.name + for f in fields(self) + if f.name not in ("extra", "model", "language") and not f.name.startswith("_") + } + + for key in list(self.extra.keys()): + if key in field_names: + current_value = getattr(self, key) + if not is_given(current_value): + # Promote extra value to the field + setattr(self, key, self.extra[key]) + # Always remove from extra to avoid ambiguity + del self.extra[key] + + +def _derive_deepgram_urls(base_url: str) -> tuple[str, str]: + """Derive paired WebSocket and HTTP URLs from a single base URL. + + The Deepgram SDK client requires both a WebSocket URL (for streaming) + and an HTTP URL (for REST calls). This helper lets developers provide + a single ``base_url`` and consistently derives both, preserving the + security level they chose. Useful for air-gapped or private deployments + where insecure schemes (ws:// / http://) are acceptable. + + Accepted inputs: + - ``wss://`` or ``https://`` — secure (paired as wss + https) + - ``ws://`` or ``http://`` — insecure (paired as ws + http) + - Bare hostname (no scheme) — defaults to secure + - Unrecognized scheme — logs a warning, defaults to secure + + Args: + base_url: Host with optional scheme, port, and path. + + Returns: + A (ws_url, http_url) tuple with consistent schemes. + """ + known_schemes = ("wss://", "https://", "ws://", "http://") + if "://" in base_url: + scheme, host = base_url.split("://", 1) + scheme += "://" + if scheme not in known_schemes: + logger.warning( + f"Unrecognized scheme in base_url '{base_url}', defaulting to wss:// / https://" + ) + else: + scheme = "" + host = base_url + + insecure = scheme in ("ws://", "http://") + ws_url = f"{'ws' if insecure else 'wss'}://{host}" + http_url = f"{'http' if insecure else 'https'}://{host}" + return ws_url, http_url + + class DeepgramSTTService(STTService): """Deepgram speech-to-text service. 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 + _settings: Settings + def __init__( self, *, api_key: str, url: str = "", base_url: str = "", + encoding: str = "linear16", + channels: int = 1, + multichannel: bool = False, sample_rate: Optional[int] = None, + callback: Optional[str] = None, + callback_method: Optional[str] = None, + tag: Optional[Any] = None, + mip_opt_out: Optional[bool] = None, live_options: Optional[LiveOptions] = None, - addons: Optional[Dict] = None, + addons: Optional[dict] = None, should_interrupt: bool = True, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = DEEPGRAM_TTFS_P99, **kwargs, ): """Initialize the Deepgram STT service. @@ -73,22 +338,39 @@ class DeepgramSTTService(STTService): Parameter `url` is deprecated, use `base_url` instead. base_url: Custom Deepgram API base URL. - sample_rate: Audio sample rate. If None, uses default or live_options value. - live_options: Deepgram LiveOptions for detailed configuration. + encoding: Audio encoding format. Defaults to "linear16". + channels: Number of audio channels. Defaults to 1. + multichannel: Transcribe each audio channel independently. + Defaults to False. + sample_rate: Audio sample rate in Hz. If None, uses the pipeline + sample rate. + callback: Callback URL for async transcription delivery. + callback_method: HTTP method for the callback (``"GET"`` or ``"POST"``). + tag: Custom billing tag. + mip_opt_out: Opt out of Deepgram model improvement program. + live_options: Legacy configuration options. + + .. deprecated:: 0.0.105 + Use ``settings=DeepgramSTTService.Settings(...)`` for runtime-updatable + fields and direct init parameters for connection-level config. + addons: Additional Deepgram features to enable. - should_interrupt: Determine whether the bot should be interrupted when Deepgram VAD events are enabled and the system detects that the user is speaking. + should_interrupt: Whether to interrupt the bot when Deepgram VAD + detects the user is speaking. .. deprecated:: 0.0.99 This parameter will be removed along with `vad_events` support. + settings: Runtime-updatable settings. When provided alongside + ``live_options``, ``settings`` values take precedence (applied + after the ``live_options`` merge). + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to the parent STTService. Note: The `vad_events` option in LiveOptions is deprecated as of version 0.0.99 and will be removed in a future version. Please use the Silero VAD instead. """ - sample_rate = sample_rate or (live_options.sample_rate if live_options else None) - super().__init__(sample_rate=sample_rate, **kwargs) - if url: import warnings @@ -100,36 +382,92 @@ class DeepgramSTTService(STTService): ) base_url = url - default_options = LiveOptions( - encoding="linear16", - language=Language.EN, + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( model="nova-3-general", - channels=1, + language=Language.EN, + detect_entities=False, + diarize=False, + dictation=False, + endpointing=None, interim_results=True, - smart_format=True, - punctuate=True, + keyterm=None, + keywords=None, + numerals=False, profanity_filter=True, + punctuate=True, + redact=None, + replace=None, + search=None, + smart_format=False, + utterance_end_ms=None, vad_events=False, ) - merged_options = default_options.to_dict() - if live_options: - default_model = default_options.model - merged_options.update(live_options.to_dict()) - # NOTE(aleix): Fixes an in deepgram-sdk where `model` is initialized - # to the string "None" instead of the value `None`. - if "model" in merged_options and merged_options["model"] == "None": - merged_options["model"] = default_model + # 2. (No step 2, as there are no deprecated direct args) - if "language" in merged_options and isinstance(merged_options["language"], Language): - merged_options["language"] = merged_options["language"].value + # 3. Apply live_options overrides — only if settings not provided + if live_options is not None: + self._warn_init_param_moved_to_settings("live_options") + if not settings: + # Extract init-only fields from live_options + if live_options.sample_rate is not None and sample_rate is None: + sample_rate = live_options.sample_rate + if live_options.encoding is not None: + encoding = live_options.encoding + if live_options.channels is not None: + channels = live_options.channels + if live_options.callback is not None: + callback = live_options.callback + if live_options.callback_method is not None: + callback_method = live_options.callback_method + if live_options.tag is not None: + tag = live_options.tag + if live_options.mip_opt_out is not None: + mip_opt_out = live_options.mip_opt_out + if live_options.multichannel is not None: + multichannel = live_options.multichannel + + # Build settings delta from remaining fields + init_only = { + "sample_rate", + "encoding", + "channels", + "multichannel", + "callback", + "callback_method", + "tag", + "mip_opt_out", + } + lo_dict = {k: v for k, v in live_options.to_dict().items() if k not in init_only} + delta = self.Settings.from_mapping(lo_dict) + default_settings.apply_update(delta) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + # Sync extra to top-level fields so self._settings is unambiguous + default_settings._sync_extra_to_fields() + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) - self.set_model_name(merged_options["model"]) - self._settings = merged_options self._addons = addons self._should_interrupt = should_interrupt + self._encoding = encoding + self._channels = channels + self._multichannel = multichannel + self._callback = callback + self._callback_method = callback_method + self._tag = tag + self._mip_opt_out = mip_opt_out - if merged_options.get("vad_events"): + if self._settings.vad_events: import warnings with warnings.catch_warnings(): @@ -141,13 +479,28 @@ class DeepgramSTTService(STTService): stacklevel=2, ) - self._client = DeepgramClient( - api_key, - config=DeepgramClientOptions( - url=base_url, - options={"keepalive": "true"}, # verbose=logging.DEBUG - ), - ) + # Build client - support optional custom base URL via DeepgramClientEnvironment + if base_url: + try: + from deepgram import DeepgramClientEnvironment + + ws_url, http_url = _derive_deepgram_urls(base_url) + environment = DeepgramClientEnvironment( + base=http_url, + production=ws_url, + agent=ws_url, + ) + self._client = AsyncDeepgramClient(api_key=api_key, environment=environment) + except Exception: + logger.warning( + f"{self}: Custom base_url configuration failed, falling back to default" + ) + self._client = AsyncDeepgramClient(api_key=api_key) + else: + self._client = AsyncDeepgramClient(api_key=api_key) + + self._connection = None + self._connection_task = None if self.vad_enabled: self._register_event_handler("on_speech_started") @@ -160,7 +513,7 @@ class DeepgramSTTService(STTService): Returns: True if VAD events are enabled in the current settings. """ - return self._settings["vad_events"] + return self._settings.vad_events def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -170,28 +523,22 @@ class DeepgramSTTService(STTService): """ return True - async def set_model(self, model: str): - """Set the Deepgram model and reconnect. + async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: + """Apply a settings delta and reconnect if anything changed.""" + changed = await super()._update_settings(delta) - Args: - model: The Deepgram model name to use. - """ - await super().set_model(model) - logger.info(f"Switching STT model to: [{model}]") - self._settings["model"] = model - await self._disconnect() - await self._connect() + if not changed: + return changed - async def set_language(self, language: Language): - """Set the recognition language and reconnect. + # Sync extra to fields after the update so self._settings stays unambiguous + if isinstance(self._settings, self.Settings): + self._settings._sync_extra_to_fields() - Args: - language: The language to use for speech recognition. - """ - logger.info(f"Switching STT language to: [{language}]") - self._settings["language"] = language - await self._disconnect() - await self._connect() + if self._connection: + await self._disconnect() + await self._connect() + + return changed async def start(self, frame: StartFrame): """Start the Deepgram STT service. @@ -200,7 +547,6 @@ class DeepgramSTTService(STTService): frame: The start frame containing initialization parameters. """ await super().start(frame) - self._settings["sample_rate"] = self.sample_rate await self._connect() async def stop(self, frame: EndFrame): @@ -230,76 +576,154 @@ class DeepgramSTTService(STTService): Yields: Frame: None (transcription results come via WebSocket callbacks). """ - await self._connection.send(audio) + if self._connection: + await self._connection.send_media(audio) yield None + def _build_connect_kwargs(self) -> dict: + """Build keyword arguments for ``client.listen.v1.connect()`` from current settings.""" + kwargs = {} + s = self._settings + + # Declared Deepgram-specific fields + for f in fields(s): + if f.name in ("model", "language", "extra") or f.name.startswith("_"): + continue + value = getattr(s, f.name) + if not is_given(value) or value is None: + continue + # Lists (e.g. keyterm, keywords, search, redact, replace) must be + # passed through as-is so the SDK's encode_query produces repeated + # query params (keyterm=a&keyterm=b) instead of a stringified list. + if isinstance(value, list): + kwargs[f.name] = value + elif isinstance(value, bool): + kwargs[f.name] = str(value).lower() + else: + kwargs[f.name] = str(value) + + # model and language + if is_given(s.model) and s.model is not None: + kwargs["model"] = str(s.model) + if is_given(s.language) and s.language is not None: + kwargs["language"] = str(s.language) + + # Init-only connection config + kwargs["encoding"] = self._encoding + kwargs["channels"] = str(self._channels) + kwargs["multichannel"] = str(self._multichannel).lower() + kwargs["sample_rate"] = str(self.sample_rate) + + if self._callback is not None: + kwargs["callback"] = self._callback + if self._callback_method is not None: + kwargs["callback_method"] = self._callback_method + if self._tag is not None: + kwargs["tag"] = str(self._tag) + if self._mip_opt_out is not None: + kwargs["mip_opt_out"] = str(self._mip_opt_out).lower() + + # Any remaining values in extra (that didn't map to declared fields) + for key, value in s.extra.items(): + if value is not None: + if isinstance(value, list): + kwargs[key] = value + elif isinstance(value, bool): + kwargs[key] = str(value).lower() + else: + kwargs[key] = str(value) + + if self._addons: + for key, value in self._addons.items(): + kwargs[key] = str(value) + + return kwargs + async def _connect(self): logger.debug("Connecting to Deepgram") - - self._connection: AsyncListenWebSocketClient = self._client.listen.asyncwebsocket.v("1") - - self._connection.on( - LiveTranscriptionEvents(LiveTranscriptionEvents.Transcript), self._on_message - ) - self._connection.on(LiveTranscriptionEvents(LiveTranscriptionEvents.Error), self._on_error) - - if self.vad_enabled: - self._connection.on( - LiveTranscriptionEvents(LiveTranscriptionEvents.SpeechStarted), - self._on_speech_started, - ) - self._connection.on( - LiveTranscriptionEvents(LiveTranscriptionEvents.UtteranceEnd), - self._on_utterance_end, - ) - - if not await self._connection.start(options=self._settings, addons=self._addons): - await self.push_error(error_msg=f"Unable to connect to Deepgram") - else: - headers = { - k: v - for k, v in self._connection._socket.response.headers.items() - if k.startswith("dg-") - } - logger.debug(f'{self}: Websocket connection initialized: {{"headers": {headers}}}') + self._connection_task = self.create_task(self._connection_handler()) async def _disconnect(self): - if await self._connection.is_connected(): - logger.debug("Disconnecting from Deepgram") - # Deepgram swallows asyncio.CancelledError internally which prevents - # proper cancellation propagation. This issue was found with - # parallel pipelines where `CancelFrame` was not awaited for to - # finish in all branches and it was pushed downstream reaching the - # end of the pipeline, which caused `cleanup()` to be called while - # Deepgram disconnection was still finishing and therefore - # preventing the task cancellation that occurs during `cleanup()`. - # GH issue: https://github.com/deepgram/deepgram-python-sdk/issues/570 - await self._connection.finish() + if not self._connection_task: + return - async def start_metrics(self): - """Start TTFB and processing metrics collection.""" - await self.start_ttfb_metrics() + logger.debug("Disconnecting from Deepgram") + # Clear self._connection first to prevent run_stt from sending audio + # during the close handshake, then close gracefully on the saved ref. + connection = self._connection + self._connection = None + + if connection: + await connection.send_close_stream() + + await self.cancel_task(self._connection_task) + self._connection_task = None + + async def _connection_handler(self): + """Manages the full WebSocket lifecycle inside a single async with block. + + Reconnects automatically after transient errors. Exits cleanly when + the task is cancelled (i.e. on stop/cancel). + """ + while True: + connect_kwargs = self._build_connect_kwargs() + try: + async with self._client.listen.v1.connect(**connect_kwargs) as connection: + self._connection = connection + connection.on(EventType.MESSAGE, self._on_message) + connection.on(EventType.ERROR, self._on_error) + + logger.debug(f"{self}: Websocket connection initialized") + + keepalive_task = self.create_task( + self._keepalive_handler(), f"{self}::keepalive" + ) + try: + await connection.start_listening() + finally: + await self.cancel_task(keepalive_task) + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"{self}: Connection lost, will retry: {e}") + finally: + self._connection = None + + async def _keepalive_handler(self): + """Periodically send KeepAlive frames to prevent server-side timeout. + + Deepgram closes inactive connections after 10 seconds (NET-0001 error). + Sending every 5 seconds stays within the recommended 3-5 second interval. + """ + while True: + await asyncio.sleep(5) + if self._connection: + try: + await self._connection.send_keep_alive() + logger.trace(f"{self}: Sent keepalive") + except Exception as e: + logger.warning(f"{self}: Keepalive failed: {e}") + + async def _start_metrics(self): + """Start processing metrics collection for this utterance.""" await self.start_processing_metrics() - async def _on_error(self, *args, **kwargs): - error: ErrorResponse = kwargs["error"] + async def _on_error(self, error): logger.warning(f"{self} connection error, will retry: {error}") await self.push_error(error_msg=f"{error}") await self.stop_all_metrics() - # NOTE(aleix): we don't disconnect (i.e. call finish on the connection) - # because this triggers more errors internally in the Deepgram SDK. So, - # we just forget about the previous connection and create a new one. - await self._connect() + # Reconnection is handled automatically by the retry loop in + # _connection_handler once start_listening() exits after the error. - async def _on_speech_started(self, *args, **kwargs): - await self.start_metrics() - await self._call_event_handler("on_speech_started", *args, **kwargs) + async def _on_speech_started(self, message): + await self._start_metrics() + await self._call_event_handler("on_speech_started", message) await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() - async def _on_utterance_end(self, *args, **kwargs): - await self._call_event_handler("on_utterance_end", *args, **kwargs) + async def _on_utterance_end(self, message): + await self._call_event_handler("on_utterance_end", message) await self.broadcast_frame(UserStoppedSpeakingFrame) @traced_stt @@ -309,41 +733,51 @@ class DeepgramSTTService(STTService): """Handle a transcription result with tracing.""" pass - async def _on_message(self, *args, **kwargs): - result: LiveResultResponse = kwargs["result"] - if len(result.channel.alternatives) == 0: - return - is_final = result.is_final - transcript = result.channel.alternatives[0].transcript - language = None - if result.channel.alternatives[0].languages: - language = result.channel.alternatives[0].languages[0] - language = Language(language) - if len(transcript) > 0: - await self.stop_ttfb_metrics() - if is_final: - await self.push_frame( - TranscriptionFrame( - transcript, - self._user_id, - time_now_iso8601(), - language, - result=result, + async def _on_message(self, message): + if isinstance(message, ListenV1SpeechStarted): + if self.vad_enabled: + await self._on_speech_started(message) + elif isinstance(message, ListenV1UtteranceEnd): + if self.vad_enabled: + await self._on_utterance_end(message) + elif isinstance(message, ListenV1Results): + if not message.channel or len(message.channel.alternatives) == 0: + return + is_final = message.is_final + transcript = message.channel.alternatives[0].transcript + language = None + if message.channel.alternatives[0].languages: + language = message.channel.alternatives[0].languages[0] + language = Language(language) + if len(transcript) > 0: + 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 = getattr(message, "from_finalize", False) or False + if from_finalize: + self.confirm_finalize() + await self.push_frame( + TranscriptionFrame( + transcript, + self._user_id, + time_now_iso8601(), + language, + result=message, + ) ) - ) - await self._handle_transcription(transcript, is_final, language) - await self.stop_processing_metrics() - else: - # For interim transcriptions, just push the frame without tracing - await self.push_frame( - InterimTranscriptionFrame( - transcript, - self._user_id, - time_now_iso8601(), - language, - result=result, + await self._handle_transcription(transcript, is_final, language) + await self.stop_processing_metrics() + else: + # For interim transcriptions, just push the frame without tracing + await self.push_frame( + InterimTranscriptionFrame( + transcript, + self._user_id, + time_now_iso8601(), + language, + result=message, + ) ) - ) async def process_frame(self, frame: Frame, direction: FrameDirection): """Process frames with Deepgram-specific handling. @@ -356,8 +790,11 @@ class DeepgramSTTService(STTService): if isinstance(frame, VADUserStartedSpeakingFrame) and not self.vad_enabled: # Start metrics if Deepgram VAD is disabled & pipeline VAD has detected speech - await self.start_metrics() + await self._start_metrics() elif isinstance(frame, VADUserStoppedSpeakingFrame): # https://developers.deepgram.com/docs/finalize - await self._connection.finalize() - logger.trace(f"Triggered finalize event on: {frame.name=}, {direction=}") + # Mark that we're awaiting a from_finalize response + if self._connection: + self.request_finalize() + await self._connection.send_finalize() + logger.trace(f"Triggered finalize event on: {frame.name=}, {direction=}") diff --git a/src/pipecat/services/deepgram/stt_sagemaker.py b/src/pipecat/services/deepgram/stt_sagemaker.py index 7eb6a072b..08cd0c5d3 100644 --- a/src/pipecat/services/deepgram/stt_sagemaker.py +++ b/src/pipecat/services/deepgram/stt_sagemaker.py @@ -4,441 +4,15 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Deepgram speech-to-text service for AWS SageMaker. +"""Deprecated: use ``pipecat.services.deepgram.sagemaker.stt`` instead.""" -This module provides a Pipecat STT service that connects to Deepgram models -deployed on AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for -low-latency real-time transcription with support for interim results, multiple -languages, and various Deepgram features. -""" +import warnings -import asyncio -import json -from typing import AsyncGenerator, Optional - -from loguru import logger - -from pipecat.frames.frames import ( - CancelFrame, - EndFrame, - ErrorFrame, - Frame, - InterimTranscriptionFrame, - StartFrame, - TranscriptionFrame, - VADUserStartedSpeakingFrame, - VADUserStoppedSpeakingFrame, +warnings.warn( + "Module `pipecat.services.deepgram.stt_sagemaker` is deprecated, " + "use `pipecat.services.deepgram.sagemaker.stt` instead.", + DeprecationWarning, + stacklevel=2, ) -from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient -from pipecat.services.stt_service import STTService -from pipecat.transcriptions.language import Language -from pipecat.utils.time import time_now_iso8601 -from pipecat.utils.tracing.service_decorators import traced_stt -try: - from deepgram import LiveOptions -except ModuleNotFoundError as e: - logger.error(f"Exception: {e}") - logger.error( - "In order to use DeepgramSageMakerSTTService, you need to `pip install pipecat-ai[deepgram,sagemaker]`." - ) - raise Exception(f"Missing module: {e}") - - -class DeepgramSageMakerSTTService(STTService): - """Deepgram speech-to-text service for AWS SageMaker. - - Provides real-time speech recognition using Deepgram models deployed on - AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for low-latency - transcription with support for interim results, speaker diarization, and - multiple languages. - - Requirements: - - - AWS credentials configured (via environment variables, AWS CLI, or instance metadata) - - A deployed SageMaker endpoint with Deepgram model: https://developers.deepgram.com/docs/deploy-amazon-sagemaker - - Deepgram SDK for LiveOptions configuration - - Example:: - - stt = DeepgramSageMakerSTTService( - endpoint_name="my-deepgram-endpoint", - region="us-east-2", - live_options=LiveOptions( - model="nova-3", - language="en", - interim_results=True, - punctuate=True, - ), - ) - """ - - def __init__( - self, - *, - endpoint_name: str, - region: str, - sample_rate: Optional[int] = None, - live_options: Optional[LiveOptions] = None, - **kwargs, - ): - """Initialize the Deepgram SageMaker STT service. - - Args: - endpoint_name: Name of the SageMaker endpoint with Deepgram model - deployed (e.g., "my-deepgram-nova-3-endpoint"). - region: AWS region where the endpoint is deployed (e.g., "us-east-2"). - sample_rate: Audio sample rate in Hz. If None, uses value from - live_options or defaults to the value from StartFrame. - live_options: Deepgram LiveOptions for detailed configuration. If None, - uses sensible defaults (nova-3 model, English, interim results enabled). - **kwargs: Additional arguments passed to the parent STTService. - """ - sample_rate = sample_rate or (live_options.sample_rate if live_options else None) - super().__init__(sample_rate=sample_rate, **kwargs) - - self._endpoint_name = endpoint_name - self._region = region - - # Create default options similar to DeepgramSTTService - default_options = LiveOptions( - encoding="linear16", - language=Language.EN, - model="nova-3", - channels=1, - interim_results=True, - punctuate=True, - ) - - # Merge with provided options - merged_options = default_options.to_dict() - if live_options: - default_model = default_options.model - merged_options.update(live_options.to_dict()) - # Handle the "None" string bug from deepgram-sdk - if "model" in merged_options and merged_options["model"] == "None": - merged_options["model"] = default_model - - # Convert Language enum to string if needed - if "language" in merged_options and isinstance(merged_options["language"], Language): - merged_options["language"] = merged_options["language"].value - - self.set_model_name(merged_options["model"]) - self._settings = merged_options - - self._client: Optional[SageMakerBidiClient] = None - self._response_task: Optional[asyncio.Task] = None - self._keepalive_task: Optional[asyncio.Task] = None - - def can_generate_metrics(self) -> bool: - """Check if this service can generate processing metrics. - - Returns: - True, as Deepgram SageMaker service supports metrics generation. - """ - return True - - async def set_model(self, model: str): - """Set the Deepgram model and reconnect. - - Disconnects from the current session, updates the model setting, and - establishes a new connection with the updated model. - - Args: - model: The Deepgram model name to use (e.g., "nova-3"). - """ - await super().set_model(model) - logger.info(f"Switching STT model to: [{model}]") - self._settings["model"] = model - await self._disconnect() - await self._connect() - - async def set_language(self, language: Language): - """Set the recognition language and reconnect. - - Disconnects from the current session, updates the language setting, and - establishes a new connection with the updated language. - - Args: - language: The language to use for speech recognition (e.g., Language.EN, - Language.ES). - """ - logger.info(f"Switching STT language to: [{language}]") - self._settings["language"] = language - await self._disconnect() - await self._connect() - - async def start(self, frame: StartFrame): - """Start the Deepgram SageMaker STT service. - - Args: - frame: The start frame containing initialization parameters. - """ - await super().start(frame) - self._settings["sample_rate"] = self.sample_rate - await self._connect() - - async def stop(self, frame: EndFrame): - """Stop the Deepgram SageMaker STT service. - - Args: - frame: The end frame. - """ - await super().stop(frame) - await self._disconnect() - - async def cancel(self, frame: CancelFrame): - """Cancel the Deepgram SageMaker STT service. - - Args: - frame: The cancel frame. - """ - await super().cancel(frame) - await self._disconnect() - - async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: - """Send audio data to Deepgram for transcription. - - Args: - audio: Raw audio bytes to transcribe. - - Yields: - Frame: None (transcription results come via BiDi stream callbacks). - """ - if self._client and self._client.is_active: - try: - await self._client.send_audio_chunk(audio) - except Exception as e: - yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield None - - async def _connect(self): - """Connect to the SageMaker endpoint and start the BiDi session. - - Builds the Deepgram query string from settings, creates the BiDi client, - starts the streaming session, and launches background tasks for processing - responses and sending KeepAlive messages. - """ - logger.debug("Connecting to Deepgram on SageMaker...") - - # Update sample rate in settings - self._settings["sample_rate"] = self.sample_rate - - # Build query string from settings, converting booleans to strings - query_params = {} - for key, value in self._settings.items(): - if value is not None: - # Convert boolean values to lowercase strings for Deepgram API - if isinstance(value, bool): - query_params[key] = str(value).lower() - else: - query_params[key] = str(value) - - query_string = "&".join(f"{k}={v}" for k, v in query_params.items()) - - # Create BiDi client - self._client = SageMakerBidiClient( - endpoint_name=self._endpoint_name, - region=self._region, - model_invocation_path="v1/listen", - model_query_string=query_string, - ) - - try: - # Start the session - await self._client.start_session() - - # Start processing responses in the background - self._response_task = self.create_task(self._process_responses()) - - # Start keepalive task to maintain connection - self._keepalive_task = self.create_task(self._send_keepalive()) - - logger.debug("Connected to Deepgram on SageMaker") - await self._call_event_handler("on_connected") - - except Exception as e: - await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) - await self._call_event_handler("on_connection_error", str(e)) - - async def _disconnect(self): - """Disconnect from the SageMaker endpoint. - - Sends a CloseStream message to Deepgram, cancels background tasks - (KeepAlive and response processing), and closes the BiDi session. - Safe to call multiple times. - """ - if self._client and self._client.is_active: - logger.debug("Disconnecting from Deepgram on SageMaker...") - - # Send CloseStream message to Deepgram - try: - await self._client.send_json({"type": "CloseStream"}) - except Exception as e: - logger.warning(f"Failed to send CloseStream message: {e}") - - # Cancel keepalive task - if self._keepalive_task and not self._keepalive_task.done(): - await self.cancel_task(self._keepalive_task) - - # Cancel response processing task - if self._response_task and not self._response_task.done(): - await self.cancel_task(self._response_task) - - # Close the BiDi session - await self._client.close_session() - - logger.debug("Disconnected from Deepgram on SageMaker") - await self._call_event_handler("on_disconnected") - - async def _send_keepalive(self): - """Send periodic KeepAlive messages to maintain the connection. - - Sends a KeepAlive JSON message to Deepgram every 5 seconds while the - connection is active. This prevents the connection from timing out during - periods of silence. - """ - while self._client and self._client.is_active: - await asyncio.sleep(5) - if self._client and self._client.is_active: - try: - await self._client.send_json({"type": "KeepAlive"}) - except Exception as e: - logger.warning(f"Failed to send KeepAlive: {e}") - - async def _process_responses(self): - """Process streaming responses from Deepgram on SageMaker. - - Continuously receives responses from the BiDi stream, decodes the payload, - parses JSON responses from Deepgram, and processes transcription results. - Runs as a background task until the connection is closed or cancelled. - """ - try: - while self._client and self._client.is_active: - result = await self._client.receive_response() - - if result is None: - break - - # Check if this is a PayloadPart with bytes - if hasattr(result, "value") and hasattr(result.value, "bytes_"): - if result.value.bytes_: - response_data = result.value.bytes_.decode("utf-8") - - try: - # Parse JSON response from Deepgram - parsed = json.loads(response_data) - - # Extract and process transcript if available - if "channel" in parsed: - await self._handle_transcript_response(parsed) - - except json.JSONDecodeError: - logger.warning(f"Non-JSON response: {response_data}") - - except asyncio.CancelledError: - logger.debug("Response processor cancelled") - except Exception as e: - await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) - finally: - logger.debug("Response processor stopped") - - async def _handle_transcript_response(self, parsed: dict): - """Handle a transcript response from Deepgram. - - Extracts the transcript text, determines if it's final or interim, extracts - language information, and pushes the appropriate frame (TranscriptionFrame - or InterimTranscriptionFrame) downstream. - - Args: - parsed: The parsed JSON response from Deepgram containing channel, - alternatives, transcript, and metadata. - """ - alternatives = parsed.get("channel", {}).get("alternatives", []) - if not alternatives or not alternatives[0].get("transcript"): - return - - transcript = alternatives[0]["transcript"] - if not transcript.strip(): - return - - # Stop TTFB metrics on first transcript - await self.stop_ttfb_metrics() - - is_final = parsed.get("is_final", False) - speech_final = parsed.get("speech_final", False) - - # Extract language if available - language = None - if alternatives[0].get("languages"): - language = alternatives[0]["languages"][0] - language = Language(language) - - if is_final and speech_final: - # Final transcription - await self.push_frame( - TranscriptionFrame( - transcript, - self._user_id, - time_now_iso8601(), - language, - result=parsed, - ) - ) - await self._handle_transcription(transcript, is_final, language) - await self.stop_processing_metrics() - else: - # Interim transcription - await self.push_frame( - InterimTranscriptionFrame( - transcript, - self._user_id, - time_now_iso8601(), - language, - result=parsed, - ) - ) - - @traced_stt - async def _handle_transcription( - self, transcript: str, is_final: bool, language: Optional[Language] = None - ): - """Handle a transcription result with tracing. - - This method is decorated with @traced_stt for observability and tracing - integration. The actual transcription processing is handled by the parent - class and observers. - - Args: - transcript: The transcribed text. - is_final: Whether this is a final transcription result. - language: The detected language of the transcription, if available. - """ - pass - - async def start_metrics(self): - """Start TTFB and processing metrics collection.""" - await self.start_ttfb_metrics() - await self.start_processing_metrics() - - async def process_frame(self, frame: Frame, direction: FrameDirection): - """Process frames with Deepgram SageMaker-specific handling. - - Args: - frame: The frame to process. - direction: The direction of frame processing. - """ - await super().process_frame(frame, direction) - - # Start metrics when user starts speaking (if VAD is not provided by Deepgram) - if isinstance(frame, VADUserStartedSpeakingFrame): - await self.start_metrics() - elif isinstance(frame, VADUserStoppedSpeakingFrame): - # Send finalize message to Deepgram when user stops speaking - # This tells Deepgram to flush any remaining audio and return final results - 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}") +from pipecat.services.deepgram.sagemaker.stt import * # noqa: E402, F401, F403 diff --git a/src/pipecat/services/deepgram/tts.py b/src/pipecat/services/deepgram/tts.py index e1688a90c..9f2dc3976 100644 --- a/src/pipecat/services/deepgram/tts.py +++ b/src/pipecat/services/deepgram/tts.py @@ -11,7 +11,8 @@ for generating speech from text using various voice models. """ import json -from typing import AsyncGenerator, Optional +from dataclasses import dataclass +from typing import Any, AsyncGenerator, Optional import aiohttp from loguru import logger @@ -21,14 +22,11 @@ from pipecat.frames.frames import ( EndFrame, ErrorFrame, Frame, - InterruptionFrame, - LLMFullResponseEndFrame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, TTSStoppedFrame, ) -from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import TTSSettings from pipecat.services.tts_service import TTSService, WebsocketTTSService from pipecat.utils.tracing.service_decorators import traced_tts @@ -43,6 +41,13 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class DeepgramTTSSettings(TTSSettings): + """Settings for DeepgramTTSService and DeepgramHttpTTSService.""" + + pass + + class DeepgramTTSService(WebsocketTTSService): """Deepgram WebSocket-based text-to-speech service. @@ -51,26 +56,36 @@ class DeepgramTTSService(WebsocketTTSService): message for conversational AI use cases. """ + Settings = DeepgramTTSSettings + _settings: Settings + SUPPORTED_ENCODINGS = ("linear16", "mulaw", "alaw") def __init__( self, *, api_key: str, - voice: str = "aura-2-helena-en", + voice: Optional[str] = None, base_url: str = "wss://api.deepgram.com", sample_rate: Optional[int] = None, encoding: str = "linear16", + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Deepgram WebSocket TTS service. Args: api_key: Deepgram API key for authentication. - voice: Voice model to use for synthesis. Defaults to "aura-2-helena-en". + voice: Voice model to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=DeepgramTTSService.Settings(voice=...)`` instead. + base_url: WebSocket base URL for Deepgram API. Defaults to "wss://api.deepgram.com". sample_rate: Audio sample rate in Hz. If None, uses service default. encoding: Audio encoding format. Defaults to "linear16". Must be one of SUPPORTED_ENCODINGS. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent InterruptibleTTSService class. Raises: @@ -81,19 +96,38 @@ class DeepgramTTSService(WebsocketTTSService): f"Unsupported encoding '{encoding}'. Must be one of {', '.join(self.SUPPORTED_ENCODINGS)} for WebSocket TTS." ) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice="aura-2-helena-en", + language=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice is not None: + self._warn_init_param_moved_to_settings("voice", "voice") + default_settings.model = voice + default_settings.voice = voice + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( sample_rate=sample_rate, pause_frame_processing=True, - push_stop_frames=True, + push_stop_frames=False, + push_start_frame=True, + append_trailing_space=True, + settings=default_settings, **kwargs, ) self._api_key = api_key self._base_url = base_url - self._settings = { - "encoding": encoding, - } - self.set_voice(voice) + self._encoding = encoding self._receive_task = None @@ -132,21 +166,10 @@ class DeepgramTTSService(WebsocketTTSService): 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) - - # When the LLM finishes responding, flush any remaining text in Deepgram's buffer - if isinstance(frame, (LLMFullResponseEndFrame, EndFrame)): - await self.flush_audio() - async def _connect(self): """Connect to Deepgram WebSocket and start receive task.""" + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: @@ -154,12 +177,36 @@ class DeepgramTTSService(WebsocketTTSService): async def _disconnect(self): """Disconnect from Deepgram WebSocket and clean up tasks.""" + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None await self._disconnect_websocket() + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta. + + Args: + delta: A :class:`TTSSettings` (or ``DeepgramTTSService.Settings``) delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + + # 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 _connect_websocket(self): """Connect to Deepgram WebSocket API with configured settings.""" try: @@ -170,8 +217,8 @@ class DeepgramTTSService(WebsocketTTSService): # Build WebSocket URL with query parameters params = [] - params.append(f"model={self._voice_id}") - params.append(f"encoding={self._settings['encoding']}") + params.append(f"model={self._settings.voice}") + params.append(f"encoding={self._encoding}") params.append(f"sample_rate={self.sample_rate}") url = f"{self._base_url}/v1/speak?{'&'.join(params)}" @@ -215,19 +262,19 @@ class DeepgramTTSService(WebsocketTTSService): return self._websocket raise Exception("Websocket not connected") - async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): - """Handle interruption by sending Clear message to Deepgram. + async def on_audio_context_interrupted(self, context_id: str): + """Send Clear message to Deepgram when an audio context is interrupted. 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) - # Send Clear message to stop current audio generation + Args: + context_id: The ID of the audio context that was interrupted. + """ + await self.stop_all_metrics() if self._websocket: try: - clear_msg = {"type": "Clear"} - await self._websocket.send(json.dumps(clear_msg)) + await self._websocket.send(json.dumps({"type": "Clear"})) except Exception as e: logger.error(f"{self} error sending Clear message: {e}") @@ -236,9 +283,9 @@ class DeepgramTTSService(WebsocketTTSService): async for message in self._get_websocket(): if isinstance(message, bytes): # Binary message contains audio data - await self.stop_ttfb_metrics() - frame = TTSAudioRawFrame(message, self.sample_rate, 1) - await self.push_frame(frame) + ctx_id = self.get_active_audio_context_id() + frame = TTSAudioRawFrame(message, self.sample_rate, 1, context_id=ctx_id) + await self.append_to_audio_context(ctx_id, frame) elif isinstance(message, str): # Text message contains metadata or control messages try: @@ -249,12 +296,15 @@ class DeepgramTTSService(WebsocketTTSService): logger.trace(f"Received metadata: {msg}") elif msg_type == "Flushed": logger.trace(f"Received Flushed: {msg}") - # Flushed indicates the end of audio generation for the current buffer - # This happens after flush_audio() is called + ctx_id = self.get_active_audio_context_id() + await self.append_to_audio_context( + ctx_id, TTSStoppedFrame(context_id=ctx_id) + ) + await self.remove_audio_context(ctx_id) elif msg_type == "Cleared": logger.trace(f"Received Cleared: {msg}") - # Buffer has been cleared after interruption - # TTSStoppedFrame will be sent by the interruption handler + # Buffer has been cleared after interruption. + # The on_audio_context_interrupted handler already cleaned up. elif msg_type == "Warning": logger.warning( f"{self} warning: {msg.get('description', 'Unknown warning')}" @@ -264,7 +314,7 @@ class DeepgramTTSService(WebsocketTTSService): except json.JSONDecodeError: logger.error(f"Invalid JSON message: {message}") - async def flush_audio(self): + async def flush_audio(self, context_id: Optional[str] = None): """Flush any pending audio synthesis by sending Flush command. This should be called when the LLM finishes a complete response to force @@ -278,33 +328,27 @@ class DeepgramTTSService(WebsocketTTSService): logger.error(f"{self} error sending Flush message: {e}") @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Deepgram's WebSocket TTS API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech, plus start/stop frames. """ - # Append trailing space to prevent TTS from vocalizing trailing periods as "dot" - text_with_trailing_space = text + " " - logger.debug(f"{self}: Generating TTS [{text_with_trailing_space}]") + logger.debug(f"{self}: Generating TTS [{text}]") try: # Reconnect if the websocket is closed if not self._websocket or self._websocket.state is State.CLOSED: await self._connect() - await self.start_ttfb_metrics() - await self.start_tts_usage_metrics(text_with_trailing_space) - - yield TTSStartedFrame() - # Send text message to Deepgram # Note: We don't send Flush here - that should only be sent when the # LLM finishes a complete response via flush_audio() - speak_msg = {"type": "Speak", "text": text_with_trailing_space} + speak_msg = {"type": "Speak", "text": text} await self._get_websocket().send(json.dumps(speak_msg)) # The audio frames will be handled in _receive_messages @@ -322,37 +366,69 @@ class DeepgramHttpTTSService(TTSService): configurable sample rates and quality settings. """ + Settings = DeepgramTTSSettings + _settings: Settings + def __init__( self, *, api_key: str, - voice: str = "aura-2-helena-en", + voice: Optional[str] = None, aiohttp_session: aiohttp.ClientSession, base_url: str = "https://api.deepgram.com", sample_rate: Optional[int] = None, encoding: str = "linear16", + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Deepgram TTS service. Args: api_key: Deepgram API key for authentication. - voice: Voice model to use for synthesis. Defaults to "aura-2-helena-en". + voice: Voice model to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=DeepgramHttpTTSService.Settings(voice=...)`` instead. + aiohttp_session: Shared aiohttp session for HTTP requests with connection pooling. base_url: Custom base URL for Deepgram API. Defaults to "https://api.deepgram.com". sample_rate: Audio sample rate in Hz. If None, uses service default. encoding: Audio encoding format. Defaults to "linear16". + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService class. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice="aura-2-helena-en", + language=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice is not None: + self._warn_init_param_moved_to_settings("voice", "voice") + default_settings.model = voice + default_settings.voice = voice + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._session = aiohttp_session self._base_url = base_url - self._settings = { - "encoding": encoding, - } - self.set_voice(voice) + self._encoding = encoding def can_generate_metrics(self) -> bool: """Check if the service can generate metrics. @@ -363,11 +439,12 @@ class DeepgramHttpTTSService(TTSService): return True @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Deepgram's TTS API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech, plus start/stop frames. @@ -380,8 +457,8 @@ class DeepgramHttpTTSService(TTSService): headers = {"Authorization": f"Token {self._api_key}", "Content-Type": "application/json"} params = { - "model": self._voice_id, - "encoding": self._settings["encoding"], + "model": self._settings.voice, + "encoding": self._encoding, "sample_rate": self.sample_rate, "container": "none", } @@ -401,7 +478,6 @@ class DeepgramHttpTTSService(TTSService): raise Exception(f"HTTP {response.status}: {error_text}") await self.start_tts_usage_metrics(text) - yield TTSStartedFrame() CHUNK_SIZE = self.chunk_size @@ -416,9 +492,8 @@ class DeepgramHttpTTSService(TTSService): audio=chunk, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) - yield TTSStoppedFrame() - except Exception as e: yield ErrorFrame(f"Error getting audio: {str(e)}") diff --git a/src/pipecat/services/deepgram/tts_sagemaker.py b/src/pipecat/services/deepgram/tts_sagemaker.py new file mode 100644 index 000000000..61ca2bceb --- /dev/null +++ b/src/pipecat/services/deepgram/tts_sagemaker.py @@ -0,0 +1,18 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Deprecated: use ``pipecat.services.deepgram.sagemaker.tts`` instead.""" + +import warnings + +warnings.warn( + "Module `pipecat.services.deepgram.tts_sagemaker` is deprecated, " + "use `pipecat.services.deepgram.sagemaker.tts` instead.", + DeprecationWarning, + stacklevel=2, +) + +from pipecat.services.deepgram.sagemaker.tts import * # noqa: E402, F401, F403 diff --git a/src/pipecat/services/deepseek/llm.py b/src/pipecat/services/deepseek/llm.py index 50bdebd3b..cfb69cb9a 100644 --- a/src/pipecat/services/deepseek/llm.py +++ b/src/pipecat/services/deepseek/llm.py @@ -6,14 +6,23 @@ """DeepSeek LLM service implementation using OpenAI-compatible interface.""" -from typing import List +from dataclasses import dataclass +from typing import Optional from loguru import logger from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class DeepSeekLLMSettings(BaseOpenAILLMService.Settings): + """Settings for DeepSeekLLMService.""" + + pass + + class DeepSeekLLMService(OpenAILLMService): """A service for interacting with DeepSeek's API using the OpenAI-compatible interface. @@ -21,12 +30,16 @@ class DeepSeekLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. """ + Settings = DeepSeekLLMSettings + _settings: Settings + def __init__( self, *, api_key: str, base_url: str = "https://api.deepseek.com/v1", - model: str = "deepseek-chat", + model: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the DeepSeek LLM service. @@ -35,9 +48,29 @@ class DeepSeekLLMService(OpenAILLMService): api_key: The API key for accessing DeepSeek's API. base_url: The base URL for DeepSeek API. Defaults to "https://api.deepseek.com/v1". model: The model identifier to use. Defaults to "deepseek-chat". + + .. deprecated:: 0.0.105 + Use ``settings=DeepSeekLLMService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="deepseek-chat") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): """Create OpenAI-compatible client for DeepSeek API endpoint. @@ -67,18 +100,18 @@ class DeepSeekLLMService(OpenAILLMService): Dictionary of parameters for the chat completion request. """ params = { - "model": self.model_name, + "model": self._settings.model, "stream": True, "stream_options": {"include_usage": True}, - "frequency_penalty": self._settings["frequency_penalty"], - "presence_penalty": self._settings["presence_penalty"], - "temperature": self._settings["temperature"], - "top_p": self._settings["top_p"], - "max_tokens": self._settings["max_tokens"], + "frequency_penalty": self._settings.frequency_penalty, + "presence_penalty": self._settings.presence_penalty, + "temperature": self._settings.temperature, + "top_p": self._settings.top_p, + "max_tokens": self._settings.max_tokens, } # Messages, tools, tool_choice params.update(params_from_context) - params.update(self._settings["extra"]) + params.update(self._settings.extra) return params diff --git a/src/pipecat/services/elevenlabs/stt.py b/src/pipecat/services/elevenlabs/stt.py index 4d26e2f81..aa7fd0659 100644 --- a/src/pipecat/services/elevenlabs/stt.py +++ b/src/pipecat/services/elevenlabs/stt.py @@ -11,11 +11,13 @@ using segmented audio processing. The service uploads audio files and receives transcription results directly. """ +import asyncio import base64 import io import json +from dataclasses import dataclass, field from enum import Enum -from typing import AsyncGenerator, Optional +from typing import Any, AsyncGenerator, Optional import aiohttp from loguru import logger @@ -33,6 +35,8 @@ from pipecat.frames.frames import ( VADUserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven +from pipecat.services.stt_latency import ELEVENLABS_REALTIME_TTFS_P99, ELEVENLABS_TTFS_P99 from pipecat.services.stt_service import SegmentedSTTService, WebsocketSTTService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.time import time_now_iso8601 @@ -166,6 +170,44 @@ def language_to_elevenlabs_language(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=False) +class CommitStrategy(str, Enum): + """Commit strategies for transcript segmentation.""" + + MANUAL = "manual" + VAD = "vad" + + +@dataclass +class ElevenLabsSTTSettings(STTSettings): + """Settings for ElevenLabsSTTService. + + Parameters: + tag_audio_events: Whether to include audio events like (laughter), + (coughing) in the transcription. + """ + + tag_audio_events: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + +@dataclass +class ElevenLabsRealtimeSTTSettings(STTSettings): + """Settings for ElevenLabsRealtimeSTTService. + + See ``ElevenLabsRealtimeSTTService.InputParams`` for detailed descriptions. + + Parameters: + vad_silence_threshold_secs: Seconds of silence before VAD commits (0.3-3.0). + vad_threshold: VAD sensitivity (0.1-0.9, lower is more sensitive). + min_speech_duration_ms: Minimum speech duration for VAD (50-2000ms). + min_silence_duration_ms: Minimum silence duration for VAD (50-2000ms). + """ + + vad_silence_threshold_secs: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + vad_threshold: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + min_speech_duration_ms: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + min_silence_duration_ms: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class ElevenLabsSTTService(SegmentedSTTService): """Speech-to-text service using ElevenLabs' file-based API. @@ -174,9 +216,15 @@ class ElevenLabsSTTService(SegmentedSTTService): The service uploads audio files to ElevenLabs and receives transcription results directly. """ + Settings = ElevenLabsSTTSettings + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for ElevenLabs STT API. + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsSTTService.Settings(...)`` instead. + Parameters: language: Target language for transcription. tag_audio_events: Whether to include audio events like (laughter), (coughing), in the transcription. @@ -191,9 +239,11 @@ class ElevenLabsSTTService(SegmentedSTTService): api_key: str, aiohttp_session: aiohttp.ClientSession, base_url: str = "https://api.elevenlabs.io", - model: str = "scribe_v1", + model: Optional[str] = None, sample_rate: Optional[int] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = ELEVENLABS_TTFS_P99, **kwargs, ): """Initialize the ElevenLabs STT service. @@ -202,29 +252,57 @@ class ElevenLabsSTTService(SegmentedSTTService): api_key: ElevenLabs API key for authentication. aiohttp_session: aiohttp ClientSession for HTTP requests. base_url: Base URL for ElevenLabs API. - model: Model ID for transcription. Defaults to "scribe_v1". + model: Model ID for transcription. + + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsSTTService.Settings(model=...)`` instead. + sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate. params: Configuration parameters for the STT service. + + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsSTTService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to SegmentedSTTService. """ - super().__init__( - sample_rate=sample_rate, - **kwargs, + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="scribe_v2", + language=Language.EN, + tag_audio_events=None, ) - params = params or ElevenLabsSTTService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + default_settings.tag_audio_events = params.tag_audio_events + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._base_url = base_url self._session = aiohttp_session - self._model_id = model - self._tag_audio_events = params.tag_audio_events - - self._settings = { - "language": self.language_to_service_language(params.language) - if params.language - else "eng", - } def can_generate_metrics(self) -> bool: """Check if the service can generate processing metrics. @@ -245,28 +323,6 @@ class ElevenLabsSTTService(SegmentedSTTService): """ return language_to_elevenlabs_language(language) - async def set_language(self, language: Language): - """Set the transcription language. - - Args: - language: The language to use for speech-to-text transcription. - """ - logger.info(f"Switching STT language to: [{language}]") - self._settings["language"] = self.language_to_service_language(language) - - async def set_model(self, model: str): - """Set the STT model. - - Args: - model: The model name to use for transcription. - - Note: - ElevenLabs STT API does not currently support model selection. - This method is provided for interface compatibility. - """ - await super().set_model(model) - logger.info(f"Model setting [{model}] noted, but ElevenLabs STT uses default model") - async def _transcribe_audio(self, audio_data: bytes) -> dict: """Upload audio data to ElevenLabs and get transcription result. @@ -291,10 +347,11 @@ class ElevenLabsSTTService(SegmentedSTTService): content_type="audio/x-wav", ) - # Add required model_id, language_code, and tag_audio_events - data.add_field("model_id", self._model_id) - data.add_field("language_code", self._settings["language"]) - data.add_field("tag_audio_events", str(self._tag_audio_events).lower()) + # Add required model_id and language_code + data.add_field("model_id", self._settings.model) + data.add_field("language_code", self._settings.language) + if self._settings.tag_audio_events is not None: + data.add_field("tag_audio_events", str(self._settings.tag_audio_events).lower()) async with self._session.post(url, data=data, headers=headers) as response: if response.status != 200: @@ -310,7 +367,6 @@ class ElevenLabsSTTService(SegmentedSTTService): self, transcript: str, is_final: bool, language: Optional[str] = None ): """Handle a transcription result with tracing.""" - await self.stop_ttfb_metrics() await self.stop_processing_metrics() async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: @@ -328,7 +384,6 @@ class ElevenLabsSTTService(SegmentedSTTService): """ try: await self.start_processing_metrics() - await self.start_ttfb_metrics() # Upload audio and get transcription result directly result = await self._transcribe_audio(audio) @@ -382,13 +437,6 @@ def audio_format_from_sample_rate(sample_rate: int) -> str: return "pcm_16000" -class CommitStrategy(str, Enum): - """Commit strategies for transcript segmentation.""" - - MANUAL = "manual" - VAD = "vad" - - class ElevenLabsRealtimeSTTService(WebsocketSTTService): """Speech-to-text service using ElevenLabs' Realtime WebSocket API. @@ -401,9 +449,15 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): commit transcript segments, providing consistency with other STT services. """ + Settings = ElevenLabsRealtimeSTTSettings + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for ElevenLabs Realtime STT API. + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsRealtimeSTTService.Settings(...)`` instead. + Parameters: language_code: ISO-639-1 or ISO-639-3 language code. Leave None for auto-detection. commit_strategy: How to segment speech - manual (Pipecat VAD) or vad (ElevenLabs VAD). @@ -435,9 +489,15 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): *, api_key: str, base_url: str = "api.elevenlabs.io", - model: str = "scribe_v2_realtime", + commit_strategy: CommitStrategy = CommitStrategy.MANUAL, + model: Optional[str] = None, sample_rate: Optional[int] = None, + include_timestamps: bool = False, + enable_logging: bool = False, + include_language_detection: bool = False, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = ELEVENLABS_REALTIME_TTFS_P99, **kwargs, ): """Initialize the ElevenLabs Realtime STT service. @@ -445,26 +505,85 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): Args: api_key: ElevenLabs API key for authentication. base_url: Base URL for ElevenLabs WebSocket API. - model: Model ID for transcription. Defaults to "scribe_v2_realtime". + commit_strategy: How to segment speech — ``CommitStrategy.MANUAL`` + (Pipecat VAD) or ``CommitStrategy.VAD`` (ElevenLabs VAD). + Defaults to ``CommitStrategy.MANUAL``. + model: Model ID for transcription. + + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsRealtimeSTTService.Settings(model=...)`` instead. + sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate. + include_timestamps: Whether to include word-level timestamps in transcripts. + enable_logging: Whether to enable logging on ElevenLabs' side. + include_language_detection: Whether to include language detection in transcripts. params: Configuration parameters for the STT service. + + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsRealtimeSTTService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to WebsocketSTTService. """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="scribe_v2_realtime", + language=None, + vad_silence_threshold_secs=None, + vad_threshold=None, + min_speech_duration_ms=None, + min_silence_duration_ms=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = params.language_code + if params.commit_strategy != CommitStrategy.MANUAL: + commit_strategy = params.commit_strategy + default_settings.vad_silence_threshold_secs = params.vad_silence_threshold_secs + default_settings.vad_threshold = params.vad_threshold + default_settings.min_speech_duration_ms = params.min_speech_duration_ms + default_settings.min_silence_duration_ms = params.min_silence_duration_ms + include_timestamps = params.include_timestamps + enable_logging = params.enable_logging + include_language_detection = params.include_language_detection + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + keepalive_timeout=10, + keepalive_interval=5, + settings=default_settings, **kwargs, ) - params = params or ElevenLabsRealtimeSTTService.InputParams() - self._api_key = api_key self._base_url = base_url - self._model_id = model - self._params = params self._audio_format = "" # initialized in start() self._receive_task = None - self._settings = {"language": params.language_code} + # Init-only config (not runtime-updatable). + self._commit_strategy = commit_strategy + self._include_timestamps = include_timestamps + self._enable_logging = enable_logging + self._include_language_detection = include_language_detection + + self._connected_event = asyncio.Event() + self._connected_event.set() def can_generate_metrics(self) -> bool: """Check if the service can generate processing metrics. @@ -474,42 +593,25 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): """ return True - async def set_language(self, language: Language): - """Set the transcription language. + async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: + """Apply a settings delta and reconnect if anything changed. Args: - language: The language to use for speech-to-text transcription. + delta: A :class:`STTSettings` (or ``ElevenLabsRealtimeSTTService.Settings``) delta. - Note: - Changing language requires reconnecting to the WebSocket. + Returns: + Dict mapping changed field names to their previous values. """ - logger.info(f"Switching STT language to: [{language}]") - new_language = ( - language_to_elevenlabs_language(language) - if isinstance(language, Language) - else language - ) - self._params.language_code = new_language - self._settings["language"] = new_language - # Reconnect with new settings - await self._disconnect() - await self._connect() + changed = await super()._update_settings(delta) - async def set_model(self, model: str): - """Set the STT model. + if not changed: + return changed - Args: - model: The model name to use for transcription. + if self._websocket: + await self._disconnect() + await self._connect() - Note: - Changing model requires reconnecting to the WebSocket. - """ - await super().set_model(model) - logger.info(f"Switching STT model to: [{model}]") - self._model_id = model - # Reconnect with new settings - await self._disconnect() - await self._connect() + return changed async def start(self, frame: StartFrame): """Start the STT service and establish WebSocket connection. @@ -539,9 +641,8 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): await super().cancel(frame) await self._disconnect() - async def start_metrics(self): + async def _start_metrics(self): """Start performance metrics collection for transcription processing.""" - await self.start_ttfb_metrics() await self.start_processing_metrics() async def process_frame(self, frame: Frame, direction: FrameDirection): @@ -555,10 +656,10 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): if isinstance(frame, VADUserStartedSpeakingFrame): # Start metrics when user starts speaking - await self.start_metrics() + await self._start_metrics() elif isinstance(frame, VADUserStoppedSpeakingFrame): # Send commit when user stops speaking (manual commit mode) - if self._params.commit_strategy == CommitStrategy.MANUAL: + if self._commit_strategy == CommitStrategy.MANUAL: if self._websocket and self._websocket.state is State.OPEN: try: commit_message = { @@ -581,6 +682,9 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): Yields: None - transcription results are handled via WebSocket responses. """ + # Wait for any in-flight _connect() to finish before checking state + await self._connected_event.wait() + # Reconnect if connection is closed if not self._websocket or self._websocket.state is State.CLOSED: await self._connect() @@ -605,19 +709,44 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): async def _connect(self): """Establish WebSocket connection to ElevenLabs Realtime STT.""" - await self._connect_websocket() + self._connected_event.clear() + try: + await self._connect_websocket() - if self._websocket and not self._receive_task: - self._receive_task = self.create_task(self._receive_task_handler(self._report_error)) + await super()._connect() + + if self._websocket and not self._receive_task: + self._receive_task = self.create_task( + self._receive_task_handler(self._report_error) + ) + finally: + self._connected_event.set() async def _disconnect(self): """Close WebSocket connection and cleanup tasks.""" + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None await self._disconnect_websocket() + async def _send_keepalive(self, silence: bytes): + """Send silent audio wrapped in ElevenLabs' JSON protocol. + + Args: + silence: Silent 16-bit mono PCM audio bytes. + """ + audio_base64 = base64.b64encode(silence).decode("utf-8") + message = { + "message_type": "input_audio_chunk", + "audio_base_64": audio_base64, + "commit": False, + "sample_rate": self.sample_rate, + } + await self._websocket.send(json.dumps(message)) + async def _connect_websocket(self): """Connect to ElevenLabs Realtime STT WebSocket endpoint.""" try: @@ -627,38 +756,40 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): logger.debug("Connecting to ElevenLabs Realtime STT") # Build query parameters - params = [f"model_id={self._model_id}"] + params = [f"model_id={self._settings.model}"] - if self._params.language_code: - params.append(f"language_code={self._params.language_code}") + if self._settings.language: + params.append(f"language_code={self._settings.language}") params.append(f"audio_format={self._audio_format}") - params.append(f"commit_strategy={self._params.commit_strategy.value}") + params.append(f"commit_strategy={self._commit_strategy.value}") # Add optional parameters - if self._params.include_timestamps: - params.append(f"include_timestamps={str(self._params.include_timestamps).lower()}") + if self._include_timestamps: + params.append(f"include_timestamps={str(self._include_timestamps).lower()}") - if self._params.enable_logging: - params.append(f"enable_logging={str(self._params.enable_logging).lower()}") + if self._enable_logging: + params.append(f"enable_logging={str(self._enable_logging).lower()}") - if self._params.include_language_detection: + if self._include_language_detection: params.append( - f"include_language_detection={str(self._params.include_language_detection).lower()}" + f"include_language_detection={str(self._include_language_detection).lower()}" ) # Add VAD parameters if using VAD commit strategy and values are specified - if self._params.commit_strategy == CommitStrategy.VAD: - if self._params.vad_silence_threshold_secs is not None: + if self._commit_strategy == CommitStrategy.VAD: + if self._settings.vad_silence_threshold_secs is not None: params.append( - f"vad_silence_threshold_secs={self._params.vad_silence_threshold_secs}" + f"vad_silence_threshold_secs={self._settings.vad_silence_threshold_secs}" + ) + if self._settings.vad_threshold is not None: + params.append(f"vad_threshold={self._settings.vad_threshold}") + if self._settings.min_speech_duration_ms is not None: + params.append(f"min_speech_duration_ms={self._settings.min_speech_duration_ms}") + if self._settings.min_silence_duration_ms is not None: + params.append( + f"min_silence_duration_ms={self._settings.min_silence_duration_ms}" ) - if self._params.vad_threshold is not None: - params.append(f"vad_threshold={self._params.vad_threshold}") - if self._params.min_speech_duration_ms is not None: - params.append(f"min_speech_duration_ms={self._params.min_speech_duration_ms}") - if self._params.min_silence_duration_ms is not None: - params.append(f"min_silence_duration_ms={self._params.min_silence_duration_ms}") ws_url = f"wss://{self._base_url}/v1/speech-to-text/realtime?{'&'.join(params)}" @@ -760,8 +891,6 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): if not text: return - await self.stop_ttfb_metrics() - # Get language if provided language = data.get("language_code") @@ -792,14 +921,13 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): """ # If timestamps are enabled, skip this message and wait for the # committed_transcript_with_timestamps message which contains all the data - if self._params.include_timestamps: + if self._include_timestamps: return text = data.get("text", "").strip() if not text: return - await self.stop_ttfb_metrics() await self.stop_processing_metrics() # Get language if provided @@ -809,6 +937,8 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): await self._handle_transcription(text, True, language) + finalized = self._commit_strategy == CommitStrategy.MANUAL + await self.push_frame( TranscriptionFrame( text, @@ -816,6 +946,7 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): time_now_iso8601(), language, result=data, + finalized=finalized, ) ) @@ -841,7 +972,6 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): if not text: return - await self.stop_ttfb_metrics() await self.stop_processing_metrics() # Get language if provided @@ -851,6 +981,8 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): await self._handle_transcription(text, True, language) + finalized = self._commit_strategy == CommitStrategy.MANUAL + # This message is sent after committed_transcript when include_timestamps=true. # It contains the full transcript data including text and word-level timestamps. await self.push_frame( @@ -860,5 +992,6 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService): time_now_iso8601(), language, result=data, + finalized=finalized, ) ) diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py index dca462ce4..866d0405f 100644 --- a/src/pipecat/services/elevenlabs/tts.py +++ b/src/pipecat/services/elevenlabs/tts.py @@ -13,8 +13,19 @@ with support for streaming audio, word timestamps, and voice customization. import asyncio import base64 import json -import uuid -from typing import Any, AsyncGenerator, Dict, List, Literal, Mapping, Optional, Tuple, Union +from dataclasses import dataclass, field +from typing import ( + Any, + AsyncGenerator, + ClassVar, + Dict, + List, + Literal, + Mapping, + Optional, + Tuple, + Union, +) import aiohttp from loguru import logger @@ -33,9 +44,11 @@ from pipecat.frames.frames import ( TTSStoppedFrame, ) from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven from pipecat.services.tts_service import ( - AudioContextWordTTSService, - WordTTSService, + TextAggregationMode, + TTSService, + WebsocketTTSService, ) from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.tracing.service_decorators import traced_tts @@ -137,12 +150,12 @@ def output_format_from_sample_rate(sample_rate: int) -> str: def build_elevenlabs_voice_settings( - settings: Dict[str, Any], + settings: Union[Dict[str, Any], "TTSSettings"], ) -> Optional[Dict[str, Union[float, bool]]]: """Build voice settings dictionary for ElevenLabs based on provided settings. Args: - settings: Dictionary containing voice settings parameters. + settings: Dictionary or settings containing voice settings parameters. Returns: Dictionary of voice settings or None if no valid settings are provided. @@ -151,8 +164,11 @@ def build_elevenlabs_voice_settings( voice_settings = {} for key in voice_setting_keys: - if key in settings and settings[key] is not None: - voice_settings[key] = settings[key] + val = ( + getattr(settings, key, None) if isinstance(settings, TTSSettings) else settings.get(key) + ) + if val is not None: + voice_settings[key] = val return voice_settings or None @@ -169,6 +185,69 @@ class PronunciationDictionaryLocator(BaseModel): version_id: str +@dataclass +class ElevenLabsTTSSettings(TTSSettings): + """Settings for ElevenLabsTTSService. + + Fields that appear in the WebSocket URL (``voice``, ``model``, + ``language``) require a full reconnect when changed. Fields that + affect the voice character (``stability``, ``similarity_boost``, + ``style``, ``use_speaker_boost``, ``speed``) can be applied by closing + the current audio context so a new one is opened with updated settings. + + Parameters: + stability: Voice stability control (0.0 to 1.0). + similarity_boost: Similarity boost control (0.0 to 1.0). + style: Style control for voice expression (0.0 to 1.0). + use_speaker_boost: Whether to use speaker boost enhancement. + speed: Voice speed control (0.7 to 1.2). + apply_text_normalization: Text normalization mode ("auto", "on", "off"). + """ + + stability: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + similarity_boost: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + style: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + use_speaker_boost: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + speed: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + apply_text_normalization: Literal["auto", "on", "off"] | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + + #: Fields in the WS URL — changing any of these requires a reconnect. + URL_FIELDS: ClassVar[frozenset[str]] = frozenset({"voice", "model", "language"}) + + #: Fields affecting voice character — changing these requires closing the + #: current audio context so the next one picks up new settings. + VOICE_SETTINGS_FIELDS: ClassVar[frozenset[str]] = frozenset( + {"stability", "similarity_boost", "style", "use_speaker_boost", "speed"} + ) + + +@dataclass +class ElevenLabsHttpTTSSettings(TTSSettings): + """Settings for ElevenLabsHttpTTSService. + + Parameters: + optimize_streaming_latency: Latency optimization level (0-4). + stability: Voice stability control (0.0 to 1.0). + similarity_boost: Similarity boost control (0.0 to 1.0). + style: Style control for voice expression (0.0 to 1.0). + use_speaker_boost: Whether to use speaker boost enhancement. + speed: Voice speed control (0.25 to 4.0). + apply_text_normalization: Text normalization mode ("auto", "on", "off"). + """ + + optimize_streaming_latency: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + stability: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + similarity_boost: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + style: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + use_speaker_boost: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + speed: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + apply_text_normalization: Literal["auto", "on", "off"] | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + + def calculate_word_times( alignment_info: Mapping[str, Any], cumulative_time: float, @@ -229,7 +308,7 @@ def calculate_word_times( return (word_times, new_partial_word, new_partial_word_start_time) -class ElevenLabsTTSService(AudioContextWordTTSService): +class ElevenLabsTTSService(WebsocketTTSService): """ElevenLabs WebSocket-based TTS service with word timestamps. Provides real-time text-to-speech using ElevenLabs' WebSocket streaming API. @@ -237,9 +316,15 @@ class ElevenLabsTTSService(AudioContextWordTTSService): customization options including stability, similarity boost, and speed controls. """ + Settings = ElevenLabsTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for ElevenLabs TTS configuration. + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsTTSService.Settings(...)`` instead. + Parameters: language: Language to use for synthesis. stability: Voice stability control (0.0 to 1.0). @@ -270,12 +355,18 @@ class ElevenLabsTTSService(AudioContextWordTTSService): self, *, api_key: str, - voice_id: str, - model: str = "eleven_turbo_v2_5", + voice_id: Optional[str] = None, + model: Optional[str] = None, url: str = "wss://api.elevenlabs.io", sample_rate: Optional[int] = None, + auto_mode: bool = True, + enable_ssml_parsing: Optional[bool] = None, + enable_logging: Optional[bool] = None, + pronunciation_dictionary_locators: Optional[List[PronunciationDictionaryLocator]] = None, params: Optional[InputParams] = None, - aggregate_sentences: Optional[bool] = True, + settings: Optional[Settings] = None, + text_aggregation_mode: Optional[TextAggregationMode] = None, + aggregate_sentences: Optional[bool] = None, **kwargs, ): """Initialize the ElevenLabs TTS service. @@ -283,17 +374,43 @@ class ElevenLabsTTSService(AudioContextWordTTSService): Args: api_key: ElevenLabs API key for authentication. voice_id: ID of the voice to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsTTSService.Settings(voice=...)`` instead. + model: TTS model to use (e.g., "eleven_turbo_v2_5"). + + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsTTSService.Settings(model=...)`` instead. + url: WebSocket URL for ElevenLabs TTS API. sample_rate: Audio sample rate. If None, uses default. + auto_mode: Whether to enable automatic mode optimization. + enable_ssml_parsing: Whether to parse SSML tags in text. + enable_logging: Whether to enable ElevenLabs server-side logging. + pronunciation_dictionary_locators: List of pronunciation dictionary + locators to use. params: Additional input parameters for voice customization. + + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + text_aggregation_mode: How to aggregate incoming text before synthesis. aggregate_sentences: Whether to aggregate sentences within the TTSService. + + .. deprecated:: 0.0.104 + Use ``text_aggregation_mode`` instead. + **kwargs: Additional arguments passed to the parent service. """ - # Aggregating sentences still gives cleaner-sounding results and fewer - # artifacts than streaming one word at a time. On average, waiting for a - # full sentence should only "cost" us 15ms or so with GPT-4o or a Llama - # 3 model, and it's worth it for the better audio quality. + # By default, we aggregate sentences before sending to TTS. This adds + # ~200-300ms of latency per sentence (waiting for the sentence-ending + # punctuation token from the LLM). Setting + # text_aggregation_mode=TextAggregationMode.TOKEN streams tokens + # directly. To use this mode, you must set auto_mode=False. This + # eliminates aggregation time, but slows down ElevenLabs. # # We also don't want to automatically push LLM response text frames, # because the context aggregators will add them to the LLM context even @@ -304,49 +421,89 @@ class ElevenLabsTTSService(AudioContextWordTTSService): # Finally, ElevenLabs doesn't provide information on when the bot stops # speaking for a while, so we want the parent class to send TTSStopFrame # after a short period not receiving any audio. + + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="eleven_turbo_v2_5", + voice=None, + language=None, + stability=None, + similarity_boost=None, + style=None, + use_speaker_boost=None, + speed=None, + apply_text_normalization=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + _pronunciation_dictionary_locators = pronunciation_dictionary_locators + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.stability is not None: + default_settings.stability = params.stability + if params.similarity_boost is not None: + default_settings.similarity_boost = params.similarity_boost + if params.style is not None: + default_settings.style = params.style + if params.use_speaker_boost is not None: + default_settings.use_speaker_boost = params.use_speaker_boost + if params.speed is not None: + default_settings.speed = params.speed + if params.auto_mode is not None: + auto_mode = params.auto_mode + if params.enable_ssml_parsing is not None: + enable_ssml_parsing = params.enable_ssml_parsing + if params.enable_logging is not None: + enable_logging = params.enable_logging + if params.apply_text_normalization is not None: + default_settings.apply_text_normalization = params.apply_text_normalization + if _pronunciation_dictionary_locators is None: + _pronunciation_dictionary_locators = params.pronunciation_dictionary_locators + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( + text_aggregation_mode=text_aggregation_mode, aggregate_sentences=aggregate_sentences, push_text_frames=False, push_stop_frames=True, pause_frame_processing=True, sample_rate=sample_rate, + settings=default_settings, **kwargs, ) - params = params or ElevenLabsTTSService.InputParams() - self._api_key = api_key self._url = url - self._settings = { - "language": self.language_to_service_language(params.language) - if params.language - else None, - "stability": params.stability, - "similarity_boost": params.similarity_boost, - "style": params.style, - "use_speaker_boost": params.use_speaker_boost, - "speed": params.speed, - "auto_mode": str(params.auto_mode).lower(), - "enable_ssml_parsing": params.enable_ssml_parsing, - "enable_logging": params.enable_logging, - "apply_text_normalization": params.apply_text_normalization, - } - self.set_model_name(model) - self.set_voice(voice_id) + + # Init-only WebSocket URL params (not runtime-updatable). + self._auto_mode = auto_mode + self._enable_ssml_parsing = enable_ssml_parsing + self._enable_logging = enable_logging + self._output_format = "" # initialized in start() self._voice_settings = self._set_voice_settings() - self._pronunciation_dictionary_locators = params.pronunciation_dictionary_locators + self._pronunciation_dictionary_locators = _pronunciation_dictionary_locators - # Indicates if we have sent TTSStartedFrame. It will reset to False when - # there's an interruption or TTSStoppedFrame. - self._started = False self._cumulative_time = 0 # Track partial words that span across alignment chunks self._partial_word = "" 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 @@ -372,61 +529,54 @@ class ElevenLabsTTSService(AudioContextWordTTSService): def _set_voice_settings(self): return build_elevenlabs_voice_settings(self._settings) - async def set_model(self, model: str): - """Set the TTS model and reconnect. + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta, reconnecting as needed. + + Uses the declarative ``URL_FIELDS`` and ``VOICE_SETTINGS_FIELDS`` + sets on :class:`ElevenLabsTTSService.Settings` to decide whether to + reconnect the WebSocket or close the current audio context. Args: - model: The model name to use for synthesis. + delta: A :class:`TTSSettings` (or ``ElevenLabsTTSService.Settings``) delta. + + Returns: + Dict mapping changed field names to their previous values. """ - await super().set_model(model) - logger.info(f"Switching TTS model to: [{model}]") - await self._disconnect() - await self._connect() + changed = await super()._update_settings(delta) - async def _update_settings(self, settings: Mapping[str, Any]): - """Update service settings and reconnect if voice, model, or language changed.""" - # Track previous values for settings that require reconnection - prev_voice = self._voice_id - prev_model = self.model_name - prev_language = self._settings.get("language") - # Create snapshot of current voice settings to detect changes after update - prev_voice_settings = self._voice_settings.copy() if self._voice_settings else None + if not changed: + return changed - await super()._update_settings(settings) - - # Update voice settings for the next context creation + # Rebuild voice settings for next context self._voice_settings = self._set_voice_settings() - # Check if URL-level settings changed (these require reconnection) - url_changed = ( - prev_voice != self._voice_id - or prev_model != self.model_name - or prev_language != self._settings.get("language") - ) - - # Check if only voice settings changed (speed, stability, etc.) - voice_settings_changed = prev_voice_settings != self._voice_settings + url_changed = bool(changed.keys() & self.Settings.URL_FIELDS) + voice_settings_changed = bool(changed.keys() & self.Settings.VOICE_SETTINGS_FIELDS) if url_changed: - # These settings are in the WebSocket URL, so we need to reconnect logger.debug( - f"URL-level setting changed (voice/model/language), reconnecting WebSocket" + f"URL-level setting changed ({changed.keys() & self.Settings.URL_FIELDS}), " + f"reconnecting WebSocket" ) await self._disconnect() await self._connect() - elif voice_settings_changed and self._context_id: - # Voice settings can be updated by closing current context - # so new one gets created with updated voice settings - logger.debug(f"Voice settings changed, closing current context to apply changes") - try: - if self._websocket: - await self._websocket.send( - json.dumps({"context_id": self._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._started = False + elif voice_settings_changed: + logger.debug( + f"Voice settings changed ({changed.keys() & self.Settings.VOICE_SETTINGS_FIELDS}), " + f"closing current context to apply changes" + ) + audio_contexts = self.get_audio_contexts() + if audio_contexts: + for ctx_id in audio_contexts: + await self._close_context(ctx_id) + + if not url_changed: + # Reconnect applies all settings; only warn about fields not handled + # by voice settings or URL changes. + handled = self.Settings.URL_FIELDS | self.Settings.VOICE_SETTINGS_FIELDS + self._warn_unhandled_updated_settings(changed.keys() - handled) + + return changed async def start(self, frame: StartFrame): """Start the ElevenLabs TTS service. @@ -456,12 +606,18 @@ class ElevenLabsTTSService(AudioContextWordTTSService): await super().cancel(frame) await self._disconnect() - async def flush_audio(self): - """Flush any pending audio and finalize the current context.""" - if not self._context_id or not self._websocket: + async def flush_audio(self, context_id: Optional[str] = None): + """Flush any pending audio and finalize the current context. + + Args: + context_id: The specific context to flush. If None, falls back to the + currently active context. + """ + flush_id = context_id or self.get_active_audio_context_id() + if not flush_id or not self._websocket: return logger.trace(f"{self}: flushing audio") - msg = {"context_id": self._context_id, "flush": True} + msg = {"context_id": flush_id, "flush": True} await self._websocket.send(json.dumps(msg)) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): @@ -473,11 +629,12 @@ class ElevenLabsTTSService(AudioContextWordTTSService): """ await super().push_frame(frame, direction) if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)): - self._started = False if isinstance(frame, TTSStoppedFrame): - await self.add_word_timestamps([("Reset", 0)]) + await self.add_word_timestamps([("Reset", 0)], self.get_active_audio_context_id()) async def _connect(self): + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: @@ -487,6 +644,8 @@ class ElevenLabsTTSService(AudioContextWordTTSService): self._keepalive_task = self.create_task(self._keepalive_task_handler()) async def _disconnect(self): + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None @@ -504,22 +663,22 @@ class ElevenLabsTTSService(AudioContextWordTTSService): logger.debug("Connecting to ElevenLabs") - voice_id = self._voice_id - model = self.model_name + voice_id = self._settings.voice + model = self._settings.model output_format = self._output_format - url = f"{self._url}/v1/text-to-speech/{voice_id}/multi-stream-input?model_id={model}&output_format={output_format}&auto_mode={self._settings['auto_mode']}" + url = f"{self._url}/v1/text-to-speech/{voice_id}/multi-stream-input?model_id={model}&output_format={output_format}&auto_mode={str(self._auto_mode).lower()}" - if self._settings["enable_ssml_parsing"]: - url += f"&enable_ssml_parsing={self._settings['enable_ssml_parsing']}" + if self._enable_ssml_parsing: + url += f"&enable_ssml_parsing={self._enable_ssml_parsing}" - if self._settings["enable_logging"]: - url += f"&enable_logging={self._settings['enable_logging']}" + if self._enable_logging: + url += f"&enable_logging={self._enable_logging}" - if self._settings["apply_text_normalization"] is not None: - url += f"&apply_text_normalization={self._settings['apply_text_normalization']}" + if self._settings.apply_text_normalization is not None: + url += f"&apply_text_normalization={self._settings.apply_text_normalization}" # Language can only be used with the ELEVENLABS_MULTILINGUAL_MODELS - language = self._settings["language"] + language = self._settings.language if model in ELEVENLABS_MULTILINGUAL_MODELS and language is not None: url += f"&language_code={language}" logger.debug(f"Using language code: {language}") @@ -545,16 +704,13 @@ class ElevenLabsTTSService(AudioContextWordTTSService): if self._websocket: logger.debug("Disconnecting from ElevenLabs") - # Close all contexts and the socket - if self._context_id: - await self._websocket.send(json.dumps({"close_socket": True})) + 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._started = False - self._context_id = None + await self.remove_active_audio_context() self._websocket = None await self._call_event_handler("on_disconnected") @@ -563,13 +719,11 @@ class ElevenLabsTTSService(AudioContextWordTTSService): return self._websocket raise Exception("Websocket not connected") - async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): - """Handle interruption by closing the current context.""" - 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") + async def _close_context(self, context_id: str): + # ElevenLabs requires that Pipecat explicitly closes contexts to free + # server-side resources, both on interruption and on normal completion. + if context_id and self._websocket: + logger.trace(f"{self}: Closing context {context_id}") try: # ElevenLabs requires that Pipecat manages the contexts and closes them # when they're not longer in use. Since an InterruptionFrame is pushed @@ -578,14 +732,26 @@ 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._started = False - self._partial_word = "" - self._partial_word_start_time = 0.0 + self._cumulative_time = 0.0 + self._partial_word = "" + self._partial_word_start_time = 0.0 + + async def on_audio_context_interrupted(self, context_id: str): + """Close the ElevenLabs context when the bot is interrupted.""" + await self._close_context(context_id) + + async def on_audio_context_completed(self, context_id: str): + """Close the ElevenLabs context after all audio has been played. + + ElevenLabs does not send a server-side signal when a context is + exhausted, so Pipecat must explicitly close it with + ``close_context: True`` to free server-side resources. + """ + await self._close_context(context_id) async def _receive_messages(self): """Handle incoming WebSocket messages from ElevenLabs.""" @@ -603,11 +769,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 @@ -616,11 +782,8 @@ class ElevenLabsTTSService(AudioContextWordTTSService): continue if msg.get("audio"): - await self.stop_ttfb_metrics() - await self.start_word_timestamps() - audio = base64.b64decode(msg["audio"]) - frame = TTSAudioRawFrame(audio, self.sample_rate, 1) + frame = TTSAudioRawFrame(audio, self.sample_rate, 1, context_id=received_ctx_id) await self.append_to_audio_context(received_ctx_id, frame) if msg.get("alignment"): @@ -635,7 +798,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService): ) if word_times: - await self.add_word_timestamps(word_times) + await self.add_word_timestamps(word_times, received_ctx_id) # Calculate the actual end time of this audio chunk char_start_times_ms = alignment.get("charStartTimesMs", []) @@ -660,13 +823,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 @@ -678,18 +842,19 @@ class ElevenLabsTTSService(AudioContextWordTTSService): logger.warning(f"{self} keepalive error: {e}") break - async def _send_text(self, text: str): + async def _send_text(self, text: str, context_id: str): """Send text to the WebSocket for synthesis.""" - if self._websocket and self._context_id: - msg = {"text": text, "context_id": self._context_id} + if self._websocket and context_id: + msg = {"text": text, "context_id": context_id} await self._websocket.send(json.dumps(msg)) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using ElevenLabs' streaming WebSocket API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -701,25 +866,16 @@ class ElevenLabsTTSService(AudioContextWordTTSService): await self._connect() try: - if not self._started: + if not self.audio_context_available(context_id): + await self.create_audio_context(context_id) await self.start_ttfb_metrics() - yield TTSStartedFrame() - self._started = True + 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, create a new one and - # register it. 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 = str(uuid.uuid4()) - if not self.audio_context_available(self._context_id): - await self.create_audio_context(self._context_id) # Initialize context with voice settings and pronunciation dictionaries - msg = {"text": " ", "context_id": self._context_id} + msg = {"text": " ", "context_id": context_id} if self._voice_settings: msg["voice_settings"] = self._voice_settings if self._pronunciation_dictionary_locators: @@ -728,21 +884,20 @@ class ElevenLabsTTSService(AudioContextWordTTSService): for locator in self._pronunciation_dictionary_locators ] await self._websocket.send(json.dumps(msg)) - logger.trace(f"Created new context {self._context_id}") + logger.trace(f"Created new context {context_id}") - await self._send_text(text) + await self._send_text(text, context_id) await self.start_tts_usage_metrics(text) except Exception as e: - yield TTSStoppedFrame() + yield TTSStoppedFrame(context_id=context_id) yield ErrorFrame(error=f"Unknown error occurred: {e}") - self._started = False return yield None except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") -class ElevenLabsHttpTTSService(WordTTSService): +class ElevenLabsHttpTTSService(TTSService): """ElevenLabs HTTP-based TTS service with word timestamps. Provides text-to-speech using ElevenLabs' HTTP streaming API for simpler, @@ -750,9 +905,15 @@ class ElevenLabsHttpTTSService(WordTTSService): connection is not required or desired. """ + Settings = ElevenLabsHttpTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for ElevenLabs HTTP TTS configuration. + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsHttpTTSService.Settings(...)`` instead. + Parameters: language: Language to use for synthesis. optimize_streaming_latency: Latency optimization level (0-4). @@ -779,13 +940,16 @@ class ElevenLabsHttpTTSService(WordTTSService): self, *, api_key: str, - voice_id: str, + voice_id: Optional[str] = None, aiohttp_session: aiohttp.ClientSession, - model: str = "eleven_turbo_v2_5", + model: Optional[str] = None, base_url: str = "https://api.elevenlabs.io", sample_rate: Optional[int] = None, + pronunciation_dictionary_locators: Optional[List[PronunciationDictionaryLocator]] = None, params: Optional[InputParams] = None, - aggregate_sentences: Optional[bool] = True, + settings: Optional[Settings] = None, + text_aggregation_mode: Optional[TextAggregationMode] = None, + aggregate_sentences: Optional[bool] = None, **kwargs, ): """Initialize the ElevenLabs HTTP TTS service. @@ -793,50 +957,106 @@ class ElevenLabsHttpTTSService(WordTTSService): Args: api_key: ElevenLabs API key for authentication. voice_id: ID of the voice to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsHttpTTSService.Settings(voice=...)`` instead. + aiohttp_session: aiohttp ClientSession for HTTP requests. model: TTS model to use (e.g., "eleven_turbo_v2_5"). + + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsHttpTTSService.Settings(model=...)`` instead. + base_url: Base URL for ElevenLabs HTTP API. sample_rate: Audio sample rate. If None, uses default. + pronunciation_dictionary_locators: List of pronunciation dictionary + locators to use. params: Additional input parameters for voice customization. + + .. deprecated:: 0.0.105 + Use ``settings=ElevenLabsHttpTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + text_aggregation_mode: How to aggregate incoming text before synthesis. aggregate_sentences: Whether to aggregate sentences within the TTSService. + + .. deprecated:: 0.0.104 + Use ``text_aggregation_mode`` instead. + **kwargs: Additional arguments passed to the parent service. """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="eleven_turbo_v2_5", + voice=None, + language=None, + optimize_streaming_latency=None, + stability=None, + similarity_boost=None, + style=None, + use_speaker_boost=None, + speed=None, + apply_text_normalization=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + _pronunciation_dictionary_locators = pronunciation_dictionary_locators + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.optimize_streaming_latency is not None: + default_settings.optimize_streaming_latency = params.optimize_streaming_latency + if params.stability is not None: + default_settings.stability = params.stability + if params.similarity_boost is not None: + default_settings.similarity_boost = params.similarity_boost + if params.style is not None: + default_settings.style = params.style + if params.use_speaker_boost is not None: + default_settings.use_speaker_boost = params.use_speaker_boost + if params.speed is not None: + default_settings.speed = params.speed + if params.apply_text_normalization is not None: + default_settings.apply_text_normalization = params.apply_text_normalization + if _pronunciation_dictionary_locators is None: + _pronunciation_dictionary_locators = params.pronunciation_dictionary_locators + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( + text_aggregation_mode=text_aggregation_mode, aggregate_sentences=aggregate_sentences, push_text_frames=False, push_stop_frames=True, + push_start_frame=True, sample_rate=sample_rate, + settings=default_settings, **kwargs, ) - params = params or ElevenLabsHttpTTSService.InputParams() - self._api_key = api_key self._base_url = base_url - self._params = params self._session = aiohttp_session - self._settings = { - "language": self.language_to_service_language(params.language) - if params.language - else None, - "optimize_streaming_latency": params.optimize_streaming_latency, - "stability": params.stability, - "similarity_boost": params.similarity_boost, - "style": params.style, - "use_speaker_boost": params.use_speaker_boost, - "speed": params.speed, - "apply_text_normalization": params.apply_text_normalization, - } - self.set_model_name(model) - self.set_voice(voice_id) self._output_format = "" # initialized in start() self._voice_settings = self._set_voice_settings() - self._pronunciation_dictionary_locators = params.pronunciation_dictionary_locators + self._pronunciation_dictionary_locators = _pronunciation_dictionary_locators # Track cumulative time to properly sequence word timestamps across utterances self._cumulative_time = 0 - self._started = False # Store previous text for context within a turn self._previous_text = "" @@ -867,15 +1087,23 @@ class ElevenLabsHttpTTSService(WordTTSService): def _set_voice_settings(self): return build_elevenlabs_voice_settings(self._settings) - async def _update_settings(self, settings: Mapping[str, Any]): - await super()._update_settings(settings) - # Update voice settings for the next context creation - self._voice_settings = self._set_voice_settings() + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta and rebuild voice settings. + + Args: + delta: A :class:`TTSSettings` (or ``ElevenLabsHttpTTSService.Settings``) delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + if changed: + self._voice_settings = self._set_voice_settings() + return changed def _reset_state(self): """Reset internal state variables.""" self._cumulative_time = 0 - self._started = False self._previous_text = "" self._partial_word = "" self._partial_word_start_time = 0.0 @@ -972,7 +1200,7 @@ class ElevenLabsHttpTTSService(WordTTSService): return word_times @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using ElevenLabs streaming API with timestamps. Makes a request to the ElevenLabs API to generate audio and timing data. @@ -981,6 +1209,7 @@ class ElevenLabsHttpTTSService(WordTTSService): Args: text: Text to convert to speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio and control frames containing the synthesized speech. @@ -988,11 +1217,11 @@ class ElevenLabsHttpTTSService(WordTTSService): logger.debug(f"{self}: Generating TTS [{text}]") # Use the with-timestamps endpoint - url = f"{self._base_url}/v1/text-to-speech/{self._voice_id}/stream/with-timestamps" + url = f"{self._base_url}/v1/text-to-speech/{self._settings.voice}/stream/with-timestamps" payload: Dict[str, Union[str, Dict[str, Union[float, bool]]]] = { "text": text, - "model_id": self._model_name, + "model_id": self._settings.model, } # Include previous text as context if available @@ -1007,11 +1236,11 @@ class ElevenLabsHttpTTSService(WordTTSService): locator.model_dump() for locator in self._pronunciation_dictionary_locators ] - if self._settings["apply_text_normalization"] is not None: - payload["apply_text_normalization"] = self._settings["apply_text_normalization"] + if self._settings.apply_text_normalization is not None: + payload["apply_text_normalization"] = self._settings.apply_text_normalization - language = self._settings["language"] - if self._model_name in ELEVENLABS_MULTILINGUAL_MODELS and language: + language = self._settings.language + if self._settings.model in ELEVENLABS_MULTILINGUAL_MODELS and language: payload["language_code"] = language logger.debug(f"Using language code: {language}") elif language: @@ -1028,12 +1257,10 @@ class ElevenLabsHttpTTSService(WordTTSService): params = { "output_format": self._output_format, } - if self._settings["optimize_streaming_latency"] is not None: - params["optimize_streaming_latency"] = self._settings["optimize_streaming_latency"] + if self._settings.optimize_streaming_latency is not None: + params["optimize_streaming_latency"] = self._settings.optimize_streaming_latency try: - await self.start_ttfb_metrics() - async with self._session.post( url, json=payload, headers=headers, params=params ) as response: @@ -1044,12 +1271,6 @@ class ElevenLabsHttpTTSService(WordTTSService): await self.start_tts_usage_metrics(text) - # Start TTS sequence if not already started - if not self._started: - await self.start_word_timestamps() - yield TTSStartedFrame() - self._started = True - # Track the duration of this utterance based on the last character's end time utterance_duration = 0 async for line in response.content: @@ -1065,7 +1286,9 @@ class ElevenLabsHttpTTSService(WordTTSService): if data and "audio_base64" in data: await self.stop_ttfb_metrics() audio = base64.b64decode(data["audio_base64"]) - yield TTSAudioRawFrame(audio, self.sample_rate, 1) + yield TTSAudioRawFrame( + audio, self.sample_rate, 1, context_id=context_id + ) # Process alignment if present if data and "alignment" in data: @@ -1081,7 +1304,7 @@ class ElevenLabsHttpTTSService(WordTTSService): # Calculate word timestamps word_times = self.calculate_word_times(alignment) if word_times: - await self.add_word_timestamps(word_times) + await self.add_word_timestamps(word_times, context_id) except json.JSONDecodeError as e: logger.warning(f"Failed to parse JSON from stream: {e}") continue @@ -1093,7 +1316,7 @@ class ElevenLabsHttpTTSService(WordTTSService): # since this is the end of the utterance if self._partial_word: final_word_time = [(self._partial_word, self._partial_word_start_time)] - await self.add_word_timestamps(final_word_time) + await self.add_word_timestamps(final_word_time, context_id) self._partial_word = "" self._partial_word_start_time = 0.0 @@ -1113,4 +1336,3 @@ class ElevenLabsHttpTTSService(WordTTSService): yield ErrorFrame(error=f"Unknown error occurred: {e}") finally: await self.stop_ttfb_metrics() - # Let the parent class handle TTSStoppedFrame diff --git a/src/pipecat/services/fal/image.py b/src/pipecat/services/fal/image.py index 412cedfbd..31af55440 100644 --- a/src/pipecat/services/fal/image.py +++ b/src/pipecat/services/fal/image.py @@ -13,7 +13,8 @@ for creating images from text prompts using various AI models. import asyncio import io import os -from typing import AsyncGenerator, Dict, Optional, Union +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Dict, Optional, Union import aiohttp from loguru import logger @@ -22,13 +23,44 @@ from pydantic import BaseModel from pipecat.frames.frames import ErrorFrame, Frame, URLImageRawFrame from pipecat.services.image_service import ImageGenService +from pipecat.services.settings import NOT_GIVEN, ImageGenSettings, _NotGiven -try: - import fal_client -except ModuleNotFoundError as e: - logger.error(f"Exception: {e}") - logger.error("In order to use Fal, you need to `pip install pipecat-ai[fal]`.") - raise Exception(f"Missing module: {e}") + +@dataclass +class FalImageGenSettings(ImageGenSettings): + """Settings for the Fal image generation service. + + Parameters: + model: Fal.ai model identifier. + seed: Random seed for reproducible generation. ``None`` uses a random seed. + num_inference_steps: Number of inference steps for generation. + num_images: Number of images to generate. + image_size: Image dimensions as a string preset or dict with width/height. + expand_prompt: Whether to automatically expand/enhance the prompt. + enable_safety_checker: Whether to enable content safety filtering. + format: Output image format. + """ + + seed: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + num_inference_steps: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + num_images: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + image_size: str | Dict[str, int] | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + expand_prompt: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_safety_checker: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + format: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + def to_api_arguments(self) -> Dict[str, Any]: + """Build the Fal API arguments dict from settings, excluding None values.""" + args: Dict[str, Any] = {} + if self.seed is not None: + args["seed"] = self.seed + args["num_inference_steps"] = self.num_inference_steps + args["num_images"] = self.num_images + args["image_size"] = self.image_size + args["expand_prompt"] = self.expand_prompt + args["enable_safety_checker"] = self.enable_safety_checker + args["format"] = self.format + return args class FalImageGenService(ImageGenService): @@ -38,9 +70,15 @@ class FalImageGenService(ImageGenService): parameters for image quality, safety, and format options. """ + Settings = FalImageGenSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Fal.ai image generation. + .. deprecated:: 0.0.105 + Use ``settings=FalImageGenService.Settings(...)`` instead. + Parameters: seed: Random seed for reproducible generation. If None, uses random seed. num_inference_steps: Number of inference steps for generation. Defaults to 8. @@ -59,28 +97,72 @@ class FalImageGenService(ImageGenService): enable_safety_checker: bool = True format: str = "png" + _settings: Settings + def __init__( self, *, - params: InputParams, + params: Optional[InputParams] = None, aiohttp_session: aiohttp.ClientSession, - model: str = "fal-ai/fast-sdxl", + model: Optional[str] = None, key: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the FalImageGenService. Args: params: Input parameters for image generation configuration. + + .. deprecated:: 0.0.105 + Use ``settings=FalImageGenService.Settings(...)`` instead. + aiohttp_session: HTTP client session for downloading generated images. model: The Fal.ai model to use for generation. Defaults to "fal-ai/fast-sdxl". + + .. deprecated:: 0.0.105 + Use ``settings=FalImageGenService.Settings(model=...)`` instead. + key: Optional API key for Fal.ai. If provided, sets FAL_KEY environment variable. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent ImageGenService. """ - super().__init__(**kwargs) - self.set_model_name(model) - self._params = params + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="fal-ai/fast-sdxl", + seed=None, + num_inference_steps=8, + num_images=1, + image_size="square_hd", + expand_prompt=False, + enable_safety_checker=True, + format="png", + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.seed = params.seed + default_settings.num_inference_steps = params.num_inference_steps + default_settings.num_images = params.num_images + default_settings.image_size = params.image_size + default_settings.expand_prompt = params.expand_prompt + default_settings.enable_safety_checker = params.enable_safety_checker + default_settings.format = params.format + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(settings=default_settings, **kwargs) self._aiohttp_session = aiohttp_session + self._api_key = key or os.getenv("FAL_KEY", "") if key: os.environ["FAL_KEY"] = key @@ -102,10 +184,22 @@ class FalImageGenService(ImageGenService): logger.debug(f"Generating image from prompt: {prompt}") - response = await fal_client.run_async( - self.model_name, - arguments={"prompt": prompt, **self._params.model_dump(exclude_none=True)}, - ) + headers = { + "Authorization": f"Key {self._api_key}", + "Content-Type": "application/json", + } + payload = {"prompt": prompt, **self._settings.to_api_arguments()} + + async with self._aiohttp_session.post( + f"https://fal.run/{self._settings.model}", + json=payload, + headers=headers, + ) as resp: + if resp.status != 200: + error_text = await resp.text() + yield ErrorFrame(error=f"Fal API error ({resp.status}): {error_text}") + return + response = await resp.json() image_url = response["images"][0]["url"] if response else None diff --git a/src/pipecat/services/fal/stt.py b/src/pipecat/services/fal/stt.py index a71915743..65df7e3ab 100644 --- a/src/pipecat/services/fal/stt.py +++ b/src/pipecat/services/fal/stt.py @@ -10,27 +10,23 @@ This module provides integration with Fal's Wizper API for speech-to-text transcription using segmented audio processing. """ +import base64 import os +from dataclasses import dataclass from typing import AsyncGenerator, Optional +import aiohttp from loguru import logger from pydantic import BaseModel from pipecat.frames.frames import ErrorFrame, Frame, TranscriptionFrame +from pipecat.services.settings import STTSettings +from pipecat.services.stt_latency import FAL_TTFS_P99 from pipecat.services.stt_service import SegmentedSTTService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_stt -try: - import fal_client -except ModuleNotFoundError as e: - logger.error(f"Exception: {e}") - logger.error( - "In order to use Fal, you need to `pip install pipecat-ai[fal]`. Also, set `FAL_KEY` environment variable." - ) - raise Exception(f"Missing module: {e}") - def language_to_fal_language(language: Language) -> Optional[str]: """Convert a Language enum to Fal's Wizper language code. @@ -145,6 +141,13 @@ def language_to_fal_language(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=True) +@dataclass +class FalSTTSettings(STTSettings): + """Settings for FalSTTService.""" + + pass + + class FalSTTService(SegmentedSTTService): """Speech-to-text service using Fal's Wizper API. @@ -152,9 +155,15 @@ class FalSTTService(SegmentedSTTService): segments. It inherits from SegmentedSTTService to handle audio buffering and speech detection. """ + Settings = FalSTTSettings + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for Fal's Wizper API. + .. deprecated:: 0.0.105 + Use ``settings=FalSTTService.Settings(...)`` instead. + Parameters: language: Language of the audio input. Defaults to English. task: Task to perform ('transcribe' or 'translate'). Defaults to 'transcribe'. @@ -171,41 +180,83 @@ class FalSTTService(SegmentedSTTService): self, *, api_key: Optional[str] = None, + aiohttp_session: Optional[aiohttp.ClientSession] = None, + task: str = "transcribe", + chunk_level: str = "segment", + version: str = "3", sample_rate: Optional[int] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = FAL_TTFS_P99, **kwargs, ): """Initialize the FalSTTService with API key and parameters. Args: api_key: Fal API key. If not provided, will check FAL_KEY environment variable. + aiohttp_session: Optional aiohttp ClientSession for HTTP requests. + If not provided, a session will be created and managed internally. + task: Task to perform (``"transcribe"`` or ``"translate"``). + Defaults to ``"transcribe"``. + chunk_level: Level of chunking (``"segment"``). Defaults to ``"segment"``. + version: Version of Wizper model to use. Defaults to ``"3"``. sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate. params: Configuration parameters for the Wizper API. + + .. deprecated:: 0.0.105 + Use ``settings=FalSTTService.Settings(...)`` for model/language and + direct init parameters for task/chunk_level/version instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to SegmentedSTTService. """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + language=Language.EN, + ) + + # 2. (no deprecated direct args for this service) + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.task != "transcribe": + task = params.task + if params.chunk_level != "segment": + chunk_level = params.chunk_level + if params.version != "3": + version = params.version + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, **kwargs, ) - params = params or FalSTTService.InputParams() + self._task = task + self._chunk_level = chunk_level + self._version = version - if api_key: - os.environ["FAL_KEY"] = api_key - elif "FAL_KEY" not in os.environ: + self._api_key = api_key or os.getenv("FAL_KEY", "") + if not self._api_key: raise ValueError( "FAL_KEY must be provided either through api_key parameter or environment variable" ) - self._fal_client = fal_client.AsyncClient(key=api_key or os.getenv("FAL_KEY")) - self._settings = { - "task": params.task, - "language": self.language_to_service_language(params.language) - if params.language - else "en", - "chunk_level": params.chunk_level, - "version": params.version, - } + self._session: aiohttp.ClientSession | None = aiohttp_session + self._owns_session = aiohttp_session is None def can_generate_metrics(self) -> bool: """Check if the service can generate processing metrics. @@ -226,30 +277,11 @@ class FalSTTService(SegmentedSTTService): """ return language_to_fal_language(language) - async def set_language(self, language: Language): - """Set the transcription language. - - Args: - language: The language to use for speech-to-text transcription. - """ - logger.info(f"Switching STT language to: [{language}]") - self._settings["language"] = self.language_to_service_language(language) - - async def set_model(self, model: str): - """Set the STT model. - - Args: - model: The model name to use for transcription. - """ - await super().set_model(model) - logger.info(f"Switching STT model to: [{model}]") - @traced_stt async def _handle_transcription( self, transcript: str, is_final: bool, language: Optional[str] = None ): """Handle a transcription result with tracing.""" - await self.stop_ttfb_metrics() await self.stop_processing_metrics() async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: @@ -267,25 +299,46 @@ class FalSTTService(SegmentedSTTService): """ try: await self.start_processing_metrics() - await self.start_ttfb_metrics() - # Send to Fal directly (audio is already in WAV format from base class) - data_uri = fal_client.encode(audio, "audio/x-wav") - response = await self._fal_client.run( - "fal-ai/wizper", - arguments={"audio_url": data_uri, **self._settings}, - ) + if not self._session: + self._session = aiohttp.ClientSession() + + data_uri = f"data:audio/x-wav;base64,{base64.b64encode(audio).decode()}" + payload: dict = {"audio_url": data_uri} + if self._settings.language is not None: + payload["language"] = self._settings.language + if self._task is not None: + payload["task"] = self._task + if self._chunk_level is not None: + payload["chunk_level"] = self._chunk_level + if self._version is not None: + payload["version"] = self._version + headers = { + "Authorization": f"Key {self._api_key}", + "Content-Type": "application/json", + } + + async with self._session.post( + "https://fal.run/fal-ai/wizper", + json=payload, + headers=headers, + ) as resp: + if resp.status != 200: + error_text = await resp.text() + yield ErrorFrame(error=f"Fal API error ({resp.status}): {error_text}") + return + response = await resp.json() if response and "text" in response: text = response["text"].strip() if text: # Only yield non-empty text - await self._handle_transcription(text, True, self._settings["language"]) + await self._handle_transcription(text, True, self._settings.language) logger.debug(f"Transcription: [{text}]") yield TranscriptionFrame( text, self._user_id, time_now_iso8601(), - Language(self._settings["language"]), + Language(self._settings.language), result=response, ) diff --git a/src/pipecat/services/fireworks/llm.py b/src/pipecat/services/fireworks/llm.py index 92467786f..5efa60793 100644 --- a/src/pipecat/services/fireworks/llm.py +++ b/src/pipecat/services/fireworks/llm.py @@ -6,14 +6,23 @@ """Fireworks AI service implementation using OpenAI-compatible interface.""" -from typing import List +from dataclasses import dataclass +from typing import Optional from loguru import logger from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class FireworksLLMSettings(BaseOpenAILLMService.Settings): + """Settings for FireworksLLMService.""" + + pass + + class FireworksLLMService(OpenAILLMService): """A service for interacting with Fireworks AI using the OpenAI-compatible interface. @@ -21,12 +30,16 @@ class FireworksLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. """ + Settings = FireworksLLMSettings + _settings: Settings + def __init__( self, *, api_key: str, - model: str = "accounts/fireworks/models/firefunction-v2", + model: Optional[str] = None, base_url: str = "https://api.fireworks.ai/inference/v1", + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Fireworks LLM service. @@ -34,10 +47,30 @@ class FireworksLLMService(OpenAILLMService): Args: api_key: The API key for accessing Fireworks AI. model: The model identifier to use. Defaults to "accounts/fireworks/models/firefunction-v2". + + .. deprecated:: 0.0.105 + Use ``settings=FireworksLLMService.Settings(model=...)`` instead. + base_url: The base URL for Fireworks API. Defaults to "https://api.fireworks.ai/inference/v1". + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="accounts/fireworks/models/firefunction-v2") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): """Create OpenAI-compatible client for Fireworks API endpoint. @@ -68,17 +101,29 @@ class FireworksLLMService(OpenAILLMService): Dictionary of parameters for the chat completion request. """ params = { - "model": self.model_name, + "model": self._settings.model, "stream": True, - "frequency_penalty": self._settings["frequency_penalty"], - "presence_penalty": self._settings["presence_penalty"], - "temperature": self._settings["temperature"], - "top_p": self._settings["top_p"], - "max_tokens": self._settings["max_tokens"], + "frequency_penalty": self._settings.frequency_penalty, + "presence_penalty": self._settings.presence_penalty, + "temperature": self._settings.temperature, + "top_p": self._settings.top_p, + "max_tokens": self._settings.max_tokens, } # Messages, tools, tool_choice params.update(params_from_context) - params.update(self._settings["extra"]) + params.update(self._settings.extra) + + # Prepend system instruction if set + if self._settings.system_instruction: + messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) + params["messages"] = [ + {"role": "system", "content": self._settings.system_instruction} + ] + messages + return params diff --git a/src/pipecat/services/fish/tts.py b/src/pipecat/services/fish/tts.py index dfa161066..ab57522d4 100644 --- a/src/pipecat/services/fish/tts.py +++ b/src/pipecat/services/fish/tts.py @@ -10,8 +10,8 @@ This module provides integration with Fish Audio's real-time TTS WebSocket API for streaming text-to-speech synthesis with customizable voice parameters. """ -import uuid -from typing import AsyncGenerator, Literal, Optional +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Literal, Mapping, Optional, Self from loguru import logger from pydantic import BaseModel @@ -21,13 +21,11 @@ from pipecat.frames.frames import ( EndFrame, ErrorFrame, Frame, - InterruptionFrame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, TTSStoppedFrame, ) -from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven from pipecat.services.tts_service import InterruptibleTTSService from pipecat.transcriptions.language import Language from pipecat.utils.tracing.service_decorators import traced_tts @@ -45,6 +43,37 @@ except ModuleNotFoundError as e: FishAudioOutputFormat = Literal["opus", "mp3", "pcm", "wav"] +@dataclass +class FishAudioTTSSettings(TTSSettings): + """Settings for FishAudioTTSService. + + Parameters: + latency: Latency mode ("normal" or "balanced"). Defaults to "balanced". + normalize: Whether to normalize audio output. Defaults to True. + temperature: Controls randomness in speech generation (0.0-1.0). + top_p: Controls diversity via nucleus sampling (0.0-1.0). + prosody_speed: Speech speed multiplier (0.5-2.0). Defaults to 1.0. + prosody_volume: Volume adjustment in dB (-20 to 20). Defaults to 0. + """ + + latency: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + normalize: bool | 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) + prosody_speed: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + prosody_volume: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + @classmethod + def from_mapping(cls, settings: Mapping[str, Any]) -> Self: + """Construct settings from a plain dict, destructuring legacy nested ``prosody``.""" + flat = dict(settings) + nested = flat.pop("prosody", None) + if isinstance(nested, dict): + flat.setdefault("prosody_speed", nested.get("speed")) + flat.setdefault("prosody_volume", nested.get("volume")) + return super().from_mapping(flat) + + class FishAudioTTSService(InterruptibleTTSService): """Fish Audio text-to-speech service with WebSocket streaming. @@ -53,9 +82,15 @@ class FishAudioTTSService(InterruptibleTTSService): audio generation with interruption handling. """ + Settings = FishAudioTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Fish Audio TTS configuration. + .. deprecated:: 0.0.105 + Use ``settings=FishAudioTTSService.Settings(...)`` instead. + Parameters: language: Language for synthesis. Defaults to English. latency: Latency mode ("normal" or "balanced"). Defaults to "normal". @@ -76,10 +111,11 @@ class FishAudioTTSService(InterruptibleTTSService): api_key: str, reference_id: Optional[str] = None, # This is the voice ID model: Optional[str] = None, # Deprecated - model_id: str = "s1", + model_id: Optional[str] = None, output_format: FishAudioOutputFormat = "pcm", sample_rate: Optional[int] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Fish Audio TTS service. @@ -87,36 +123,38 @@ class FishAudioTTSService(InterruptibleTTSService): Args: api_key: Fish Audio API key for authentication. reference_id: Reference ID of the voice model to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=FishAudioTTSService.Settings(voice=...)`` instead. + model: Deprecated. Reference ID of the voice model to use for synthesis. - .. deprecated:: 0.0.74 - The `model` parameter is deprecated and will be removed in version 0.1.0. - Use `reference_id` instead to specify the voice model. + .. deprecated:: 0.0.74 + The ``model`` parameter is deprecated and will be removed in version 0.1.0. + Use ``reference_id`` instead to specify the voice model. + + model_id: Specify which Fish Audio TTS model to use (e.g. "s1"). + + .. deprecated:: 0.0.105 + Use ``settings=FishAudioTTSService.Settings(model=...)`` instead. - model_id: Specify which Fish Audio TTS model to use (e.g. "s1") output_format: Audio output format. Defaults to "pcm". sample_rate: Audio sample rate. If None, uses default. params: Additional input parameters for voice customization. + + .. deprecated:: 0.0.105 + Use ``settings=FishAudioTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to the parent service. """ - super().__init__( - push_stop_frames=True, - pause_frame_processing=True, - sample_rate=sample_rate, - **kwargs, - ) - - params = params or FishAudioTTSService.InputParams() - # Validation for model and reference_id parameters if model and reference_id: raise ValueError( "Cannot specify both 'model' and 'reference_id'. Use 'reference_id' only." ) - if model is None and reference_id is None: - raise ValueError("Must specify 'reference_id' (or deprecated 'model') parameter.") - if model: import warnings @@ -130,26 +168,61 @@ class FishAudioTTSService(InterruptibleTTSService): ) reference_id = model + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="s2-pro", + voice=None, + language=None, + latency="balanced", + normalize=True, + temperature=None, + top_p=None, + prosody_speed=1.0, + prosody_volume=0, + ) + + # 2. Apply direct init arg overrides (deprecated) + if reference_id is not None: + self._warn_init_param_moved_to_settings("reference_id", "voice") + default_settings.voice = reference_id + if model_id is not None: + self._warn_init_param_moved_to_settings("model_id", "model") + default_settings.model = model_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.latency is not None: + default_settings.latency = params.latency + if params.normalize is not None: + default_settings.normalize = params.normalize + if params.prosody_speed is not None: + default_settings.prosody_speed = params.prosody_speed + if params.prosody_volume is not None: + default_settings.prosody_volume = params.prosody_volume + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + push_stop_frames=True, + push_start_frame=True, + pause_frame_processing=True, + sample_rate=sample_rate, + settings=default_settings, + **kwargs, + ) + self._api_key = api_key self._base_url = "wss://api.fish.audio/v1/tts/live" self._websocket = None self._receive_task = None - self._request_id = None - self._started = False - self._settings = { - "sample_rate": 0, - "latency": params.latency, - "format": output_format, - "normalize": params.normalize, - "prosody": { - "speed": params.prosody_speed, - "volume": params.prosody_volume, - }, - "reference_id": reference_id, - } - - self.set_model_name(model_id) + # Init-only audio format config (not runtime-updatable). + self._fish_sample_rate = 0 # Set in start() + self._output_format = output_format def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -159,16 +232,24 @@ class FishAudioTTSService(InterruptibleTTSService): """ return True - async def set_model(self, model: str): - """Set the TTS model and reconnect. + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta and reconnect if needed. + + Any change to voice or model triggers a WebSocket reconnect. Args: - model: The model name to use for synthesis. + delta: A :class:`TTSSettings` (or ``FishAudioTTSService.Settings``) delta. + + Returns: + Dict mapping changed field names to their previous values. """ - await super().set_model(model) - logger.info(f"Switching TTS model to: [{model}]") - await self._disconnect() - await self._connect() + changed = await super()._update_settings(delta) + + if changed: + await self._disconnect() + await self._connect() + + return changed async def start(self, frame: StartFrame): """Start the Fish Audio TTS service. @@ -177,7 +258,7 @@ class FishAudioTTSService(InterruptibleTTSService): frame: The start frame containing initialization parameters. """ await super().start(frame) - self._settings["sample_rate"] = self.sample_rate + self._fish_sample_rate = self.sample_rate await self._connect() async def stop(self, frame: EndFrame): @@ -199,12 +280,16 @@ class FishAudioTTSService(InterruptibleTTSService): await self._disconnect() async def _connect(self): + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: self._receive_task = self.create_task(self._receive_task_handler(self._report_error)) async def _disconnect(self): + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None @@ -218,11 +303,26 @@ class FishAudioTTSService(InterruptibleTTSService): logger.debug("Connecting to Fish Audio") headers = {"Authorization": f"Bearer {self._api_key}"} - headers["model"] = self.model_name + headers["model"] = self._settings.model self._websocket = await websocket_connect(self._base_url, additional_headers=headers) # Send initial start message with ormsgpack - start_message = {"event": "start", "request": {"text": "", **self._settings}} + request_settings = { + "sample_rate": self._fish_sample_rate, + "latency": self._settings.latency, + "format": self._output_format, + "normalize": self._settings.normalize, + "prosody": { + "speed": self._settings.prosody_speed, + "volume": self._settings.prosody_volume, + }, + "reference_id": self._settings.voice, + } + if self._settings.temperature is not None: + request_settings["temperature"] = self._settings.temperature + if self._settings.top_p is not None: + request_settings["top_p"] = self._settings.top_p + start_message = {"event": "start", "request": {"text": "", **request_settings}} await self._websocket.send(ormsgpack.packb(start_message)) logger.debug("Sent start message to Fish Audio") @@ -244,12 +344,10 @@ class FishAudioTTSService(InterruptibleTTSService): except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: - self._request_id = None - self._started = False self._websocket = None await self._call_event_handler("on_disconnected") - async def flush_audio(self): + async def flush_audio(self, context_id: Optional[str] = None): """Flush any buffered audio by sending a flush event to Fish Audio.""" logger.trace(f"{self}: Flushing audio buffers") if not self._websocket or self._websocket.state is State.CLOSED: @@ -262,10 +360,9 @@ class FishAudioTTSService(InterruptibleTTSService): return self._websocket raise Exception("Websocket not connected") - async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): - await super()._handle_interruption(frame, direction) + async def on_audio_context_interrupted(self, context_id: str): + """Stop all metrics when audio context is interrupted.""" await self.stop_all_metrics() - self._request_id = None async def _receive_messages(self): async for message in self._get_websocket(): @@ -278,20 +375,34 @@ class FishAudioTTSService(InterruptibleTTSService): audio_data = msg.get("audio") # Only process larger chunks to remove msgpack overhead if audio_data and len(audio_data) > 1024: - frame = TTSAudioRawFrame(audio_data, self.sample_rate, 1) - await self.push_frame(frame) + context_id = self.get_active_audio_context_id() + frame = TTSAudioRawFrame( + audio_data, + self.sample_rate, + 1, + context_id=context_id, + ) + await self.append_to_audio_context(context_id, frame) await self.stop_ttfb_metrics() - continue + elif event == "finish": + reason = msg.get("reason", "unknown") + if reason == "error": + await self.push_error( + error_msg="Fish Audio server error during synthesis" + ) + else: + logger.debug(f"Fish Audio session finished: {reason}") except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Fish Audio's streaming API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames and control frames for the synthesized speech. @@ -301,12 +412,6 @@ class FishAudioTTSService(InterruptibleTTSService): if not self._websocket or self._websocket.state is State.CLOSED: await self._connect() - if not self._request_id: - await self.start_ttfb_metrics() - await self.start_tts_usage_metrics(text) - yield TTSStartedFrame() - self._request_id = str(uuid.uuid4()) - # Send the text text_message = { "event": "text", @@ -321,7 +426,7 @@ class FishAudioTTSService(InterruptibleTTSService): await self._get_websocket().send(ormsgpack.packb(flush_message)) except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() + yield TTSStoppedFrame(context_id=context_id) await self._disconnect() await self._connect() diff --git a/src/pipecat/services/gemini_multimodal_live/__init__.py b/src/pipecat/services/gemini_multimodal_live/__init__.py index 513d9fd66..ac4524606 100644 --- a/src/pipecat/services/gemini_multimodal_live/__init__.py +++ b/src/pipecat/services/gemini_multimodal_live/__init__.py @@ -1,2 +1,7 @@ from .file_api import GeminiFileAPI from .gemini import GeminiMultimodalLiveLLMService + +__all__ = [ + "GeminiFileAPI", + "GeminiMultimodalLiveLLMService", +] diff --git a/src/pipecat/services/gladia/config.py b/src/pipecat/services/gladia/config.py index ed160a36e..c492a04a1 100644 --- a/src/pipecat/services/gladia/config.py +++ b/src/pipecat/services/gladia/config.py @@ -152,6 +152,10 @@ class MessagesConfig(BaseModel): class GladiaInputParams(BaseModel): """Configuration parameters for the Gladia STT service. + .. deprecated:: 0.0.105 + Use ``settings=GladiaSTTService.Settings(...)`` for runtime-updatable + fields and direct init parameters for encoding/bit_depth/channels. + Parameters: encoding: Audio encoding format bit_depth: Audio bit depth @@ -169,6 +173,9 @@ class GladiaInputParams(BaseModel): pre_processing: Audio pre-processing options realtime_processing: Real-time processing features messages_config: WebSocket message filtering options + enable_vad: Enable VAD to trigger end of utterance detection. This should be used + without any other VAD enabled in the agent and will emit the speaker started + and stopped frames. Defaults to False. """ encoding: Optional[str] = "wav/pcm" @@ -182,3 +189,4 @@ class GladiaInputParams(BaseModel): pre_processing: Optional[PreProcessingConfig] = None realtime_processing: Optional[RealtimeProcessingConfig] = None messages_config: Optional[MessagesConfig] = None + enable_vad: bool = False diff --git a/src/pipecat/services/gladia/stt.py b/src/pipecat/services/gladia/stt.py index 48334ef8c..2ce2a15b5 100644 --- a/src/pipecat/services/gladia/stt.py +++ b/src/pipecat/services/gladia/stt.py @@ -14,7 +14,8 @@ import asyncio import base64 import json import warnings -from typing import Any, AsyncGenerator, Dict, Literal, Optional +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Literal, Optional import aiohttp from loguru import logger @@ -28,8 +29,18 @@ from pipecat.frames.frames import ( StartFrame, TranscriptionFrame, TranslationFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, ) -from pipecat.services.gladia.config import GladiaInputParams +from pipecat.services.gladia.config import ( + GladiaInputParams, + LanguageConfig, + MessagesConfig, + PreProcessingConfig, + RealtimeProcessingConfig, +) +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven +from pipecat.services.stt_latency import GLADIA_TTFS_P99 from pipecat.services.stt_service import WebsocketSTTService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.time import time_now_iso8601 @@ -175,6 +186,37 @@ class _InputParamsDescriptor: return GladiaInputParams +@dataclass +class GladiaSTTSettings(STTSettings): + """Settings for GladiaSTTService. + + Parameters: + language_config: Language detection and handling configuration. + custom_metadata: Additional metadata to include with requests. + endpointing: Silence duration in seconds to mark end of speech. + maximum_duration_without_endpointing: Maximum utterance duration without silence. + pre_processing: Audio pre-processing options. + realtime_processing: Real-time processing features. + messages_config: WebSocket message filtering options. + enable_vad: Enable VAD to trigger end of utterance detection. + """ + + language_config: LanguageConfig | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + custom_metadata: dict[str, Any] | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + endpointing: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + maximum_duration_without_endpointing: int | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + pre_processing: PreProcessingConfig | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + realtime_processing: RealtimeProcessingConfig | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + messages_config: MessagesConfig | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_vad: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class GladiaSTTService(WebsocketSTTService): """Speech-to-Text service using Gladia's API. @@ -188,6 +230,9 @@ class GladiaSTTService(WebsocketSTTService): Use :class:`~pipecat.services.gladia.config.GladiaInputParams` directly instead. """ + Settings = GladiaSTTSettings + _settings: Settings + # Maintain backward compatibility InputParams = _InputParamsDescriptor() @@ -197,11 +242,17 @@ class GladiaSTTService(WebsocketSTTService): api_key: str, region: Literal["us-west", "eu-west"] | None = None, url: str = "https://api.gladia.io/v2/live", + encoding: str = "wav/pcm", + bit_depth: int = 16, + channels: int = 1, confidence: Optional[float] = None, sample_rate: Optional[int] = None, - model: str = "solaria-1", + model: Optional[str] = None, params: Optional[GladiaInputParams] = None, max_buffer_size: int = 1024 * 1024 * 20, # 20MB default buffer + should_interrupt: bool = True, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = GLADIA_TTFS_P99, **kwargs, ): """Initialize the Gladia STT service. @@ -210,6 +261,9 @@ class GladiaSTTService(WebsocketSTTService): api_key: Gladia API key for authentication. region: Region used to process audio. eu-west or us-west. Defaults to eu-west. url: Gladia API URL. Defaults to "https://api.gladia.io/v2/live". + encoding: Audio encoding format. Defaults to ``"wav/pcm"``. + bit_depth: Audio bit depth. Defaults to 16. + channels: Number of audio channels. Defaults to 1. confidence: Minimum confidence threshold for transcriptions (0.0-1.0). .. deprecated:: 0.0.86 @@ -217,25 +271,26 @@ class GladiaSTTService(WebsocketSTTService): No confidence threshold is applied. sample_rate: Audio sample rate in Hz. If None, uses service default. - model: Model to use for transcription. Defaults to "solaria-1". + model: Model to use for transcription. + + .. deprecated:: 0.0.105 + Use ``settings=GladiaSTTService.Settings(model=...)`` instead. + params: Additional configuration parameters for Gladia service. + + .. deprecated:: 0.0.105 + Use ``settings=GladiaSTTService.Settings(...)`` for runtime-updatable + fields and direct init parameters for encoding/bit_depth/channels. + max_buffer_size: Maximum size of audio buffer in bytes. Defaults to 20MB. + should_interrupt: Determine whether the bot should be interrupted when + Gladia VAD detects user speech. Defaults to True. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to the STTService parent class. """ - super().__init__(sample_rate=sample_rate, **kwargs) - - params = params or GladiaInputParams() - - if params.language is not None: - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "The 'language' parameter is deprecated and will be removed in a future version. " - "Use 'language_config' instead.", - DeprecationWarning, - stacklevel=2, - ) - if confidence: with warnings.catch_warnings(): warnings.simplefilter("always") @@ -246,14 +301,86 @@ class GladiaSTTService(WebsocketSTTService): stacklevel=2, ) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="solaria-1", + language=None, + language_config=None, + custom_metadata=None, + endpointing=None, + maximum_duration_without_endpointing=5, + pre_processing=None, + realtime_processing=None, + messages_config=None, + enable_vad=False, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if params.language is not None: + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "The 'language' parameter is deprecated and will be removed in a future " + "version. Use 'language_config' instead.", + DeprecationWarning, + stacklevel=2, + ) + if not settings: + # Extract init-only fields from params + if params.encoding is not None: + encoding = params.encoding + if params.bit_depth is not None: + bit_depth = params.bit_depth + if params.channels is not None: + channels = params.channels + default_settings.custom_metadata = params.custom_metadata + default_settings.endpointing = params.endpointing + default_settings.maximum_duration_without_endpointing = ( + params.maximum_duration_without_endpointing + ) + default_settings.pre_processing = params.pre_processing + default_settings.realtime_processing = params.realtime_processing + default_settings.messages_config = params.messages_config + default_settings.enable_vad = params.enable_vad + # Resolve deprecated language → language_config at init time + if params.language_config: + default_settings.language_config = params.language_config + elif params.language: + language_code = self.language_to_service_language(params.language) + if language_code: + default_settings.language_config = LanguageConfig( + languages=[language_code], code_switching=False + ) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + keepalive_timeout=20, + keepalive_interval=5, + settings=default_settings, + **kwargs, + ) + self._api_key = api_key self._region = region self._url = url - self.set_model_name(model) - self._params = params self._receive_task = None - self._keepalive_task = None - self._settings = {} + + # Init-only connection config + self._encoding = encoding + self._bit_depth = bit_depth + self._channels = channels # Session management self._session_url = None @@ -266,6 +393,10 @@ class GladiaSTTService(WebsocketSTTService): self._max_buffer_size = max_buffer_size self._buffer_lock = asyncio.Lock() + # VAD state tracking + self._is_speaking = False + self._should_interrupt = should_interrupt + def __str__(self): return f"{self.name} [{self._session_id}]" @@ -288,54 +419,44 @@ class GladiaSTTService(WebsocketSTTService): """ return language_to_gladia_language(language) - def _prepare_settings(self) -> Dict[str, Any]: + def _prepare_settings(self) -> dict[str, Any]: + s = self._settings + settings = { - "encoding": self._params.encoding or "wav/pcm", - "bit_depth": self._params.bit_depth or 16, + "encoding": self._encoding or "wav/pcm", + "bit_depth": self._bit_depth or 16, "sample_rate": self.sample_rate, - "channels": self._params.channels or 1, - "model": self._model_name, + "channels": self._channels or 1, + "model": s.model, } # Add custom_metadata if provided - settings["custom_metadata"] = dict(self._params.custom_metadata or {}) + settings["custom_metadata"] = dict(s.custom_metadata or {}) settings["custom_metadata"]["pipecat"] = pipecat_version() # Add endpointing parameters if provided - if self._params.endpointing is not None: - settings["endpointing"] = self._params.endpointing - if self._params.maximum_duration_without_endpointing is not None: + if s.endpointing is not None: + settings["endpointing"] = s.endpointing + if s.maximum_duration_without_endpointing is not None: settings["maximum_duration_without_endpointing"] = ( - self._params.maximum_duration_without_endpointing + s.maximum_duration_without_endpointing ) - # Add language configuration (prioritize language_config over deprecated language) - if self._params.language_config: - settings["language_config"] = self._params.language_config.model_dump(exclude_none=True) - elif self._params.language: # Backward compatibility for deprecated parameter - language_code = self.language_to_service_language(self._params.language) - if language_code: - settings["language_config"] = { - "languages": [language_code], - "code_switching": False, - } + # Add language configuration + if s.language_config: + settings["language_config"] = s.language_config.model_dump(exclude_none=True) # Add pre_processing configuration if provided - if self._params.pre_processing: - settings["pre_processing"] = self._params.pre_processing.model_dump(exclude_none=True) + if s.pre_processing: + settings["pre_processing"] = s.pre_processing.model_dump(exclude_none=True) # Add realtime_processing configuration if provided - if self._params.realtime_processing: - settings["realtime_processing"] = self._params.realtime_processing.model_dump( - exclude_none=True - ) + if s.realtime_processing: + settings["realtime_processing"] = s.realtime_processing.model_dump(exclude_none=True) # Add messages_config if provided - if self._params.messages_config: - settings["messages_config"] = self._params.messages_config.model_dump(exclude_none=True) - - # Store settings for tracing - self._settings = settings + if s.messages_config: + settings["messages_config"] = s.messages_config.model_dump(exclude_none=True) return settings @@ -348,6 +469,33 @@ class GladiaSTTService(WebsocketSTTService): await super().start(frame) await self._connect() + async def _update_settings(self, delta: Settings) -> dict[str, Any]: + """Apply settings delta. + + Settings are stored but not applied to the active session. + + Args: + delta: A settings delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + + if not changed: + return changed + + # TODO: someday we could reconnect here to apply updated settings. + # Code might look something like the below: + # self._session_url = None + # self._session_id = None + # await self._disconnect() + # await self._connect() + + self._warn_unhandled_updated_settings(changed) + + return changed + async def stop(self, frame: EndFrame): """Stop the Gladia STT websocket connection. @@ -376,7 +524,6 @@ class GladiaSTTService(WebsocketSTTService): Yields: None (processing is handled asynchronously via WebSocket). """ - await self.start_ttfb_metrics() await self.start_processing_metrics() # Add audio to buffer @@ -414,22 +561,19 @@ class GladiaSTTService(WebsocketSTTService): await self._connect_websocket() + await super()._connect() + if self._websocket and not self._receive_task: self._receive_task = self.create_task(self._receive_task_handler(self._report_error)) - if self._websocket and not self._keepalive_task: - self._keepalive_task = self.create_task(self._keepalive_task_handler()) - async def _disconnect(self): """Disconnect from the Gladia service. Cleans up tasks and closes websocket connection. """ - self._connection_active = False + await super()._disconnect() - if self._keepalive_task: - await self.cancel_task(self._keepalive_task) - self._keepalive_task = None + self._connection_active = False if self._receive_task: await self.cancel_task(self._receive_task) @@ -474,7 +618,7 @@ class GladiaSTTService(WebsocketSTTService): self._websocket = None await self._call_event_handler("on_disconnected") - async def _setup_gladia(self, settings: Dict[str, Any]): + async def _setup_gladia(self, settings: dict[str, Any]): async with aiohttp.ClientSession() as session: params = {} if self._region: @@ -500,9 +644,35 @@ class GladiaSTTService(WebsocketSTTService): async def _handle_transcription( self, transcript: str, is_final: bool, language: Optional[str] = None ): - await self.stop_ttfb_metrics() await self.stop_processing_metrics() + async def _on_speech_started(self): + """Handle speech start event from Gladia. + + Broadcasts UserStartedSpeakingFrame and optionally triggers interruption + when VAD is enabled. + """ + if not self._settings.enable_vad or self._is_speaking: + return + + logger.debug(f"{self} User started speaking") + self._is_speaking = True + + await self.broadcast_frame(UserStartedSpeakingFrame) + if self._should_interrupt: + await self.broadcast_interruption() + + async def _on_speech_ended(self): + """Handle speech end event from Gladia. + + Broadcasts UserStoppedSpeakingFrame when VAD is enabled. + """ + if not self._settings.enable_vad or not self._is_speaking: + return + self._is_speaking = False + await self.broadcast_frame(UserStoppedSpeakingFrame) + logger.debug(f"{self} User stopped speaking") + async def _send_audio(self, audio: bytes): """Send audio chunk with proper message format.""" if self._websocket and self._websocket.state is State.OPEN: @@ -595,24 +765,17 @@ class GladiaSTTService(WebsocketSTTService): translation, "", time_now_iso8601(), translated_language ) ) + elif content["type"] == "speech_start": + await self._on_speech_started() + elif content["type"] == "speech_end": + await self._on_speech_ended() except json.JSONDecodeError: logger.warning(f"{self} Received non-JSON message: {message}") - async def _keepalive_task_handler(self): - """Send periodic empty audio chunks to keep the connection alive.""" - try: - KEEPALIVE_SLEEP = 20 - while self._connection_active: - # Send keepalive (Gladia times out after 30 seconds) - await asyncio.sleep(KEEPALIVE_SLEEP) - if self._websocket and self._websocket.state is State.OPEN: - # Send an empty audio chunk as keepalive - empty_audio = b"" - await self._send_audio(empty_audio) - else: - logger.debug(f"{self} Websocket closed, stopping keepalive") - break - except websockets.exceptions.ConnectionClosed: - logger.debug(f"{self} Connection closed during keepalive") - except Exception as e: - await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + async def _send_keepalive(self, silence: bytes): + """Send an empty audio chunk to keep the Gladia connection alive. + + Args: + silence: Silent PCM audio bytes (unused, Gladia accepts empty chunks). + """ + await self._send_audio(b"") diff --git a/src/pipecat/services/google/__init__.py b/src/pipecat/services/google/__init__.py index 032cf0eb8..32b12e367 100644 --- a/src/pipecat/services/google/__init__.py +++ b/src/pipecat/services/google/__init__.py @@ -12,12 +12,12 @@ from .frames import * from .gemini_live import * from .image import * from .llm import * -from .llm_openai import * -from .llm_vertex import * +from .openai import * from .rtvi import * from .stt import * from .tts import * +from .vertex import * sys.modules[__name__] = DeprecatedModuleProxy( - globals(), "google", "google.[frames,image,llm,llm_openai,llm_vertex,rtvi,stt,tts]" + globals(), "google", "google.[frames,image,llm,openai,vertex,rtvi,stt,tts]" ) diff --git a/src/pipecat/services/google/gemini_live/__init__.py b/src/pipecat/services/google/gemini_live/__init__.py index 142ca2a83..4afeb99ce 100644 --- a/src/pipecat/services/google/gemini_live/__init__.py +++ b/src/pipecat/services/google/gemini_live/__init__.py @@ -1,3 +1,9 @@ from .file_api import GeminiFileAPI from .llm import GeminiLiveLLMService -from .llm_vertex import GeminiLiveVertexLLMService +from .vertex.llm import GeminiLiveVertexLLMService + +__all__ = [ + "GeminiFileAPI", + "GeminiLiveLLMService", + "GeminiLiveVertexLLMService", +] diff --git a/src/pipecat/services/google/gemini_live/llm.py b/src/pipecat/services/google/gemini_live/llm.py index 4b0be986d..5c9e5f8b3 100644 --- a/src/pipecat/services/google/gemini_live/llm.py +++ b/src/pipecat/services/google/gemini_live/llm.py @@ -17,7 +17,7 @@ import io import time import uuid import warnings -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from typing import Any, Dict, List, Optional, Union @@ -44,7 +44,9 @@ from pipecat.frames.frames import ( LLMMessagesAppendFrame, LLMSetToolsFrame, LLMTextFrame, - LLMUpdateSettingsFrame, + LLMThoughtEndFrame, + LLMThoughtStartFrame, + LLMThoughtTextFrame, StartFrame, TranscriptionFrame, TTSAudioRawFrame, @@ -74,6 +76,7 @@ from pipecat.services.openai.llm import ( OpenAIAssistantContextAggregator, OpenAIUserContextAggregator, ) +from pipecat.services.settings import NOT_GIVEN, LLMSettings, _NotGiven from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.string import match_endofsentence from pipecat.utils.time import time_now_iso8601 @@ -549,6 +552,9 @@ class ContextWindowCompressionParams(BaseModel): class InputParams(BaseModel): """Input parameters for Gemini Live generation. + .. deprecated:: 0.0.105 + Use ``GeminiLiveLLMService.Settings`` instead. + Parameters: frequency_penalty: Frequency penalty for generation (0.0-2.0). Defaults to None. max_tokens: Maximum tokens to generate. Must be >= 1. Defaults to 4096. @@ -599,6 +605,35 @@ class InputParams(BaseModel): extra: Optional[Dict[str, Any]] = Field(default_factory=dict) +@dataclass +class GeminiLiveLLMSettings(LLMSettings): + """Settings for GeminiLiveLLMService. + + Parameters: + voice: TTS voice identifier (e.g. ``"Charon"``). + modalities: Response modalities. + language: Language for generation. + media_resolution: Media resolution setting. + vad: Voice activity detection parameters. + context_window_compression: Context window compression configuration. + thinking: Thinking configuration. + enable_affective_dialog: Whether to enable affective dialog. + proactivity: Proactivity configuration. + """ + + voice: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + modalities: GeminiModalities | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + language: Language | str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + media_resolution: GeminiMediaResolution | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + vad: GeminiVADParams | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + context_window_compression: ContextWindowCompressionParams | dict | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + thinking: ThinkingConfig | dict | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_affective_dialog: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + proactivity: ProactivityConfig | dict | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class GeminiLiveLLMService(LLMService): """Provides access to Google's Gemini Live API. @@ -607,6 +642,9 @@ class GeminiLiveLLMService(LLMService): responses, and tool usage. """ + Settings = GeminiLiveLLMSettings + _settings: Settings + # Overriding the default adapter to use the Gemini one. adapter_class = GeminiLLMAdapter @@ -615,13 +653,14 @@ class GeminiLiveLLMService(LLMService): *, api_key: str, base_url: Optional[str] = None, - model="models/gemini-2.5-flash-native-audio-preview-12-2025", + model: Optional[str] = None, voice_id: str = "Charon", start_audio_paused: bool = False, start_video_paused: bool = False, system_instruction: Optional[str] = None, tools: Optional[Union[List[dict], ToolsSchema]] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, inference_on_context_initialization: bool = True, file_api_base_url: str = "https://generativelanguage.googleapis.com/v1beta/files", http_options: Optional[HttpOptions] = None, @@ -638,13 +677,26 @@ class GeminiLiveLLMService(LLMService): Please use `http_options` to customize requests made by the API client. - model: Model identifier to use. Defaults to "models/gemini-2.5-flash-native-audio-preview-12-2025". + model: Model identifier to use. + + .. deprecated:: 0.0.105 + Use ``settings=GeminiLiveLLMService.Settings(model=...)`` instead. + voice_id: TTS voice identifier. Defaults to "Charon". + + .. deprecated:: 0.0.105 + Use ``settings=GeminiLiveLLMService.Settings(voice=...)`` instead. start_audio_paused: Whether to start with audio input paused. Defaults to False. start_video_paused: Whether to start with video input paused. Defaults to False. system_instruction: System prompt for the model. Defaults to None. tools: Tools/functions available to the model. Defaults to None. - params: Configuration parameters for the model. Defaults to InputParams(). + params: Configuration parameters for the model. + + .. deprecated:: 0.0.105 + Use ``settings=GeminiLiveLLMService.Settings(...)`` instead. + + settings: Gemini Live LLM settings. If provided together with deprecated + top-level parameters, the ``settings`` values take precedence. inference_on_context_initialization: Whether to generate a response when context is first set. Defaults to True. file_api_base_url: Base URL for the Gemini File API. Defaults to the official endpoint. @@ -663,15 +715,78 @@ class GeminiLiveLLMService(LLMService): stacklevel=2, ) - super().__init__(base_url=base_url, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="models/gemini-2.5-flash-native-audio-preview-12-2025", + system_instruction=system_instruction, + voice="Charon", + frequency_penalty=None, + max_tokens=4096, + presence_penalty=None, + temperature=None, + top_k=None, + top_p=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + modalities=GeminiModalities.AUDIO, + language="en-US", + media_resolution=GeminiMediaResolution.UNSPECIFIED, + vad=None, + context_window_compression={}, + thinking={}, + enable_affective_dialog=False, + proactivity={}, + extra={}, + ) - params = params or InputParams() + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if voice_id != "Charon": + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.frequency_penalty = params.frequency_penalty + default_settings.max_tokens = params.max_tokens + default_settings.presence_penalty = params.presence_penalty + default_settings.temperature = params.temperature + default_settings.top_k = params.top_k + default_settings.top_p = params.top_p + default_settings.modalities = params.modalities + default_settings.language = ( + language_to_gemini_language(params.language) if params.language else "en-US" + ) + default_settings.media_resolution = params.media_resolution + default_settings.vad = params.vad + default_settings.context_window_compression = ( + params.context_window_compression.model_dump() + if params.context_window_compression + else {} + ) + default_settings.thinking = params.thinking or {} + default_settings.enable_affective_dialog = params.enable_affective_dialog or False + default_settings.proactivity = params.proactivity or {} + if isinstance(params.extra, dict): + default_settings.extra = params.extra + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + base_url=base_url, + settings=default_settings, + **kwargs, + ) self._last_sent_time = 0 self._base_url = base_url - self.set_model_name(model) - self._voice_id = voice_id - self._language_code = params.language self._system_instruction_from_init = system_instruction self._tools_from_init = tools @@ -701,36 +816,18 @@ class GeminiLiveLLMService(LLMService): self._sample_rate = 24000 - self._language = params.language + self._language = self._settings.language self._language_code = ( - language_to_gemini_language(params.language) if params.language else "en-US" + language_to_gemini_language(self._settings.language) + if self._settings.language + else "en-US" ) - self._vad_params = params.vad + self._vad_params = self._settings.vad # Reconnection tracking self._consecutive_failures = 0 self._connection_start_time = None - self._settings = { - "frequency_penalty": params.frequency_penalty, - "max_tokens": params.max_tokens, - "presence_penalty": params.presence_penalty, - "temperature": params.temperature, - "top_k": params.top_k, - "top_p": params.top_p, - "modalities": params.modalities, - "language": self._language_code, - "media_resolution": params.media_resolution, - "vad": params.vad, - "context_window_compression": params.context_window_compression.model_dump() - if params.context_window_compression - else {}, - "thinking": params.thinking or {}, - "enable_affective_dialog": params.enable_affective_dialog or False, - "proactivity": params.proactivity or {}, - "extra": params.extra if isinstance(params.extra, dict) else {}, - } - self._file_api_base_url = file_api_base_url self._file_api: Optional[GeminiFileAPI] = None @@ -773,6 +870,25 @@ class GeminiLiveLLMService(LLMService): """ return True + async def _update_settings(self, delta: LLMSettings) -> dict[str, Any]: + """Apply a settings delta. + + Settings are stored but not applied to the active connection. + """ + changed = await super()._update_settings(delta) + + if not changed: + return changed + + # TODO: someday we could reconnect here to apply updated settings. + # Code might look something like the below: + # await self._disconnect() + # await self._connect() + + self._warn_unhandled_updated_settings(changed) + + return changed + def set_audio_input_paused(self, paused: bool): """Set the audio input pause state. @@ -795,7 +911,7 @@ class GeminiLiveLLMService(LLMService): Args: modalities: The modalities to use for responses. """ - self._settings["modalities"] = modalities + self._settings.modalities = modalities def set_language(self, language: Language): """Set the language for generation. @@ -805,7 +921,7 @@ class GeminiLiveLLMService(LLMService): """ self._language = language self._language_code = language_to_gemini_language(language) or "en-US" - self._settings["language"] = self._language_code + self._settings.language = self._language_code logger.info(f"Set Gemini language to: {self._language_code}") async def set_context(self, context: OpenAILLMContext): @@ -863,7 +979,7 @@ class GeminiLiveLLMService(LLMService): async def _handle_interruption(self): if self._bot_is_responding: await self._set_bot_is_responding(False) - if self._settings.get("modalities") == GeminiModalities.AUDIO: + if self._settings.modalities == GeminiModalities.AUDIO: await self.push_frame(TTSStoppedFrame()) # Do not send LLMFullResponseEndFrame here - an interruption # already tells the assistant context aggregator that the response @@ -944,10 +1060,9 @@ class GeminiLiveLLMService(LLMService): # uses this frame *without* a user context aggregator still works # (we have an example that does just that, actually). await self._create_single_response(frame.messages) - elif isinstance(frame, LLMUpdateSettingsFrame): - await self._update_settings(frame.settings) elif isinstance(frame, LLMSetToolsFrame): - await self._update_settings() + # TODO: implement runtime tool updates for Gemini Live. + pass else: await self.push_frame(frame, direction) @@ -1071,20 +1186,20 @@ class GeminiLiveLLMService(LLMService): # Assemble basic configuration config = LiveConnectConfig( generation_config=GenerationConfig( - frequency_penalty=self._settings["frequency_penalty"], - max_output_tokens=self._settings["max_tokens"], - presence_penalty=self._settings["presence_penalty"], - temperature=self._settings["temperature"], - top_k=self._settings["top_k"], - top_p=self._settings["top_p"], - response_modalities=[Modality(self._settings["modalities"].value)], + frequency_penalty=self._settings.frequency_penalty, + max_output_tokens=self._settings.max_tokens, + presence_penalty=self._settings.presence_penalty, + temperature=self._settings.temperature, + top_k=self._settings.top_k, + top_p=self._settings.top_p, + response_modalities=[Modality(self._settings.modalities.value)], speech_config=SpeechConfig( voice_config=VoiceConfig( - prebuilt_voice_config={"voice_name": self._voice_id} + prebuilt_voice_config={"voice_name": self._settings.voice} ), - language_code=self._settings["language"], + language_code=self._settings.language, ), - media_resolution=MediaResolution(self._settings["media_resolution"].value), + media_resolution=MediaResolution(self._settings.media_resolution.value), ), input_audio_transcription=AudioTranscriptionConfig(), output_audio_transcription=AudioTranscriptionConfig(), @@ -1092,37 +1207,36 @@ class GeminiLiveLLMService(LLMService): ) # Add context window compression to configuration, if enabled - if self._settings.get("context_window_compression", {}).get("enabled", False): + cwc = self._settings.context_window_compression or {} + if cwc.get("enabled", False): compression_config = ContextWindowCompressionConfig() # Add sliding window (always true if compression is enabled) compression_config.sliding_window = SlidingWindow() # Add trigger_tokens if specified - trigger_tokens = self._settings.get("context_window_compression", {}).get( - "trigger_tokens" - ) + trigger_tokens = cwc.get("trigger_tokens") if trigger_tokens is not None: compression_config.trigger_tokens = trigger_tokens config.context_window_compression = compression_config # Add thinking configuration to configuration, if provided - if self._settings.get("thinking"): - config.thinking_config = self._settings["thinking"] + if self._settings.thinking: + config.thinking_config = self._settings.thinking # Add affective dialog setting, if provided - if self._settings.get("enable_affective_dialog", False): - config.enable_affective_dialog = self._settings["enable_affective_dialog"] + if self._settings.enable_affective_dialog: + config.enable_affective_dialog = self._settings.enable_affective_dialog # Add proactivity configuration to configuration, if provided - if self._settings.get("proactivity"): - config.proactivity = self._settings["proactivity"] + if self._settings.proactivity: + config.proactivity = self._settings.proactivity # Add VAD configuration to configuration, if provided - if self._settings.get("vad"): + if self._settings.vad: vad_config = AutomaticActivityDetection() - vad_params = self._settings["vad"] + vad_params = self._settings.vad has_vad_settings = False # Only add parameters that are explicitly set @@ -1180,7 +1294,9 @@ class GeminiLiveLLMService(LLMService): await self.push_error(error_msg=f"Initialization error: {e}", exception=e) async def _connection_task_handler(self, config: LiveConnectConfig): - async with self._client.aio.live.connect(model=self._model_name, config=config) as session: + async with self._client.aio.live.connect( + model=self._settings.model, config=config + ) as session: logger.info("Connected to Gemini service") # Mark connection start time @@ -1195,7 +1311,20 @@ class GeminiLiveLLMService(LLMService): # Reset failure counter if connection has been stable self._check_and_reset_failure_counter() - if message.server_content and message.server_content.model_turn: + if message.server_content and message.server_content.interrupted: + # NOTE: while the service triggers interruptions in + # the specific case of barge-ins, it does *not* + # emit UserStarted/StoppedSpeakingFrames, as the + # Gemini Live API does not give us broadly reliable + # signals to base those off of. Pipelines that + # require turn tracking (like those using context + # aggregators) still need an independent way to + # track turns, such as local Silero VAD in + # combination with the context aggregator default + # turn strategies. + logger.debug("Gemini VAD: interrupted signal received") + await self.broadcast_interruption() + elif message.server_content and message.server_content.model_turn: await self._handle_msg_model_turn(message) elif ( message.server_content @@ -1455,10 +1584,19 @@ class GeminiLiveLLMService(LLMService): await self._set_bot_is_responding(True) await self.push_frame(LLMFullResponseStartFrame()) - self._bot_text_buffer += text - self._search_result_buffer += text # Also accumulate for grounding - frame = LLMTextFrame(text=text) - await self.push_frame(frame) + # Check if this is a thought + if part.thought: + # Gemini Live emits fully-formed thoughts rather than chunks, + # so bracket each thought in start/end frames + await self.push_frame(LLMThoughtStartFrame()) + await self.push_frame(LLMThoughtTextFrame(text)) + await self.push_frame(LLMThoughtEndFrame()) + else: + # Regular text response + self._bot_text_buffer += text + self._search_result_buffer += text # Also accumulate for grounding + frame = LLMTextFrame(text=text) + await self.push_frame(frame) # Check for grounding metadata in server content if msg.server_content and msg.server_content.grounding_metadata: @@ -1579,7 +1717,7 @@ class GeminiLiveLLMService(LLMService): text: The transcription text to push result: Optional LiveServerMessage that triggered this transcription """ - await self._handle_user_transcription(text, True, self._settings["language"]) + await self._handle_user_transcription(text, True, self._settings.language) await self.push_frame( TranscriptionFrame( text=text, @@ -1664,6 +1802,8 @@ class GeminiLiveLLMService(LLMService): self._transcription_timeout_task = self.create_task( self._transcription_timeout_handler() ) + # Let the event loop schedule the taks before it gets cancelled. + await asyncio.sleep(0) async def _handle_msg_output_transcription(self, message: LiveServerMessage): """Handle the output transcription message.""" @@ -1698,11 +1838,26 @@ class GeminiLiveLLMService(LLMService): await self.push_frame(TTSStartedFrame()) await self.push_frame(LLMFullResponseStartFrame()) - frame = TTSTextFrame(text=text, aggregated_by=AggregationType.SENTENCE) - # Gemini Live text already includes any necessary inter-chunk spaces - frame.includes_inter_frame_spaces = True + await self._push_output_transcription_text_frames(text) - await self.push_frame(frame) + async def _push_output_transcription_text_frames(self, text: str): + # In a typical "cascade" LLM + TTS setup, LLMTextFrames would not + # proceed beyond the TTS service. Therefore, since a speech-to-speech + # service like Gemini Live combines both LLM and TTS functionality, you + # might think we wouldn't need to push LLMTextFrames at all. However, + # RTVI relies on LLMTextFrames being pushed to trigger its + # "bot-llm-text" event. So here we push an LLMTextFrame, too, but avoid + # appending it to context to avoid context message duplication. + + # Push LLMTextFrame + llm_text_frame = LLMTextFrame(text) + llm_text_frame.append_to_context = False + await self.push_frame(llm_text_frame) + + # Push TTSTextFrame + tts_text_frame = TTSTextFrame(text, aggregated_by=AggregationType.SENTENCE) + tts_text_frame.includes_inter_frame_spaces = True + await self.push_frame(tts_text_frame) async def _handle_msg_grounding_metadata(self, message: LiveServerMessage): """Handle dedicated grounding metadata messages.""" diff --git a/src/pipecat/services/google/gemini_live/llm_vertex.py b/src/pipecat/services/google/gemini_live/llm_vertex.py index bb61033b3..038d72e57 100644 --- a/src/pipecat/services/google/gemini_live/llm_vertex.py +++ b/src/pipecat/services/google/gemini_live/llm_vertex.py @@ -4,182 +4,15 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Service for accessing Gemini Live via Google Vertex AI. +"""Deprecated: use ``pipecat.services.google.gemini_live.vertex.llm`` instead.""" -This module provides integration with Google's Gemini Live model via -Vertex AI, supporting both text and audio modalities with voice transcription, -streaming responses, and tool usage. -""" +import warnings -import json -from typing import List, Optional, Union - -from loguru import logger - -from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.services.google.gemini_live.llm import ( - GeminiLiveLLMService, - HttpOptions, - InputParams, +warnings.warn( + "Module `pipecat.services.google.gemini_live.llm_vertex` is deprecated, " + "use `pipecat.services.google.gemini_live.vertex.llm` instead.", + DeprecationWarning, + stacklevel=2, ) -try: - from google.auth import default - from google.auth.exceptions import GoogleAuthError - from google.auth.transport.requests import Request - from google.genai import Client - from google.oauth2 import service_account - -except ModuleNotFoundError as e: - logger.error(f"Exception: {e}") - logger.error("In order to use Google Vertex AI, you need to `pip install pipecat-ai[google]`.") - raise Exception(f"Missing module: {e}") - - -class GeminiLiveVertexLLMService(GeminiLiveLLMService): - """Provides access to Google's Gemini Live model via Vertex AI. - - This service enables real-time conversations with Gemini, supporting both - text and audio modalities. It handles voice transcription, streaming audio - responses, and tool usage. - """ - - def __init__( - self, - *, - credentials: Optional[str] = None, - credentials_path: Optional[str] = None, - location: str, - project_id: str, - model="google/gemini-live-2.5-flash-native-audio", - voice_id: str = "Charon", - start_audio_paused: bool = False, - start_video_paused: bool = False, - system_instruction: Optional[str] = None, - tools: Optional[Union[List[dict], ToolsSchema]] = None, - params: Optional[InputParams] = None, - inference_on_context_initialization: bool = True, - file_api_base_url: str = "https://generativelanguage.googleapis.com/v1beta/files", - http_options: Optional[HttpOptions] = None, - **kwargs, - ): - """Initialize the service for accessing Gemini Live via Google Vertex AI. - - Args: - credentials: JSON string of service account credentials. - credentials_path: Path to the service account JSON file. - location: GCP region for Vertex AI endpoint (e.g., "us-east4"). - project_id: Google Cloud project ID. - model: Model identifier to use. Defaults to "models/gemini-live-2.5-flash-native-audio". - voice_id: TTS voice identifier. Defaults to "Charon". - start_audio_paused: Whether to start with audio input paused. Defaults to False. - start_video_paused: Whether to start with video input paused. Defaults to False. - system_instruction: System prompt for the model. Defaults to None. - tools: Tools/functions available to the model. Defaults to None. - params: Configuration parameters for the model along with Vertex AI - location and project ID. - inference_on_context_initialization: Whether to generate a response when context - is first set. Defaults to True. - file_api_base_url: Base URL for the Gemini File API. Defaults to the official endpoint. - http_options: HTTP options for the client. - **kwargs: Additional arguments passed to parent GeminiLiveLLMService. - """ - # Check if user incorrectly passed api_key, which is used by parent - # class but not here. - if "api_key" in kwargs: - logger.error( - "GeminiLiveVertexLLMService does not accept 'api_key' parameter. " - "Use 'credentials' or 'credentials_path' instead for Vertex AI authentication." - ) - raise ValueError( - "Invalid parameter 'api_key'. Use 'credentials' or 'credentials_path' for Vertex AI authentication." - ) - - # These need to be set before calling super().__init__() because - # super().__init__() invokes create_client(), which needs these. - self._credentials = self._get_credentials(credentials, credentials_path) - self._project_id = project_id - self._location = location - - # Call parent constructor with the obtained API key - super().__init__( - # api_key is required by parent class, but actually not used with - # Vertex - api_key="dummy", - model=model, - voice_id=voice_id, - start_audio_paused=start_audio_paused, - start_video_paused=start_video_paused, - system_instruction=system_instruction, - tools=tools, - params=params, - inference_on_context_initialization=inference_on_context_initialization, - file_api_base_url=file_api_base_url, - http_options=http_options, - **kwargs, - ) - - def create_client(self): - """Create the Gemini client instance.""" - self._client = Client( - vertexai=True, - credentials=self._credentials, - project=self._project_id, - location=self._location, - http_options=self._http_options, - ) - - @property - def file_api(self): - """Gemini File API is not supported with Vertex AI.""" - raise NotImplementedError( - "When using Vertex AI, the recommended approach is to use Google Cloud Storage for file handling. The Gemini File API is not directly supported in this context." - ) - - @staticmethod - def _get_credentials(credentials: Optional[str], credentials_path: Optional[str]) -> str: - """Retrieve Credentials using Google service account credentials JSON. - - Supports multiple authentication methods: - 1. Direct JSON credentials string - 2. Path to service account JSON file - 3. Default application credentials (ADC) - - Args: - credentials: JSON string of service account credentials. - credentials_path: Path to the service account JSON file. - - Returns: - OAuth token for API authentication. - - Raises: - ValueError: If no valid credentials are provided or found. - """ - creds: Optional[service_account.Credentials] = None - - if credentials: - # Parse and load credentials from JSON string - creds = service_account.Credentials.from_service_account_info( - json.loads(credentials), - scopes=["https://www.googleapis.com/auth/cloud-platform"], - ) - elif credentials_path: - # Load credentials from JSON file - creds = service_account.Credentials.from_service_account_file( - credentials_path, - scopes=["https://www.googleapis.com/auth/cloud-platform"], - ) - else: - try: - creds, project_id = default( - scopes=["https://www.googleapis.com/auth/cloud-platform"] - ) - except GoogleAuthError: - pass - - if not creds: - raise ValueError("No valid credentials provided.") - - creds.refresh(Request()) # Ensure token is up-to-date, lifetime is 1 hour. - - return creds +from pipecat.services.google.gemini_live.vertex.llm import * # noqa: E402, F401, F403 diff --git a/src/pipecat/services/google/gemini_live/vertex/__init__.py b/src/pipecat/services/google/gemini_live/vertex/__init__.py new file mode 100644 index 000000000..c4d243b97 --- /dev/null +++ b/src/pipecat/services/google/gemini_live/vertex/__init__.py @@ -0,0 +1,5 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# diff --git a/src/pipecat/services/google/gemini_live/vertex/llm.py b/src/pipecat/services/google/gemini_live/vertex/llm.py new file mode 100644 index 000000000..cb9d74c62 --- /dev/null +++ b/src/pipecat/services/google/gemini_live/vertex/llm.py @@ -0,0 +1,278 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Service for accessing Gemini Live via Google Vertex AI. + +This module provides integration with Google's Gemini Live model via +Vertex AI, supporting both text and audio modalities with voice transcription, +streaming responses, and tool usage. +""" + +import json +from dataclasses import dataclass +from typing import List, Optional, Union + +from loguru import logger + +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.services.google.gemini_live.llm import ( + GeminiLiveLLMService, + GeminiMediaResolution, + GeminiModalities, + HttpOptions, + InputParams, + language_to_gemini_language, +) + +try: + from google.auth import default + from google.auth.exceptions import GoogleAuthError + from google.auth.transport.requests import Request + from google.genai import Client + from google.oauth2 import service_account + +except ModuleNotFoundError as e: + logger.error(f"Exception: {e}") + logger.error("In order to use Google Vertex AI, you need to `pip install pipecat-ai[google]`.") + raise Exception(f"Missing module: {e}") + + +@dataclass +class GeminiLiveVertexLLMSettings(GeminiLiveLLMService.Settings): + """Settings for GeminiLiveVertexLLMService.""" + + pass + + +class GeminiLiveVertexLLMService(GeminiLiveLLMService): + """Provides access to Google's Gemini Live model via Vertex AI. + + This service enables real-time conversations with Gemini, supporting both + text and audio modalities. It handles voice transcription, streaming audio + responses, and tool usage. + """ + + Settings = GeminiLiveVertexLLMSettings + _settings: Settings + + def __init__( + self, + *, + credentials: Optional[str] = None, + credentials_path: Optional[str] = None, + location: str, + project_id: str, + model: Optional[str] = None, + voice_id: str = "Charon", + start_audio_paused: bool = False, + start_video_paused: bool = False, + system_instruction: Optional[str] = None, + tools: Optional[Union[List[dict], ToolsSchema]] = None, + params: Optional[InputParams] = None, + settings: Optional[Settings] = None, + inference_on_context_initialization: bool = True, + file_api_base_url: str = "https://generativelanguage.googleapis.com/v1beta/files", + http_options: Optional[HttpOptions] = None, + **kwargs, + ): + """Initialize the service for accessing Gemini Live via Google Vertex AI. + + Args: + credentials: JSON string of service account credentials. + credentials_path: Path to the service account JSON file. + location: GCP region for Vertex AI endpoint (e.g., "us-east4"). + project_id: Google Cloud project ID. + model: Model identifier to use. + + .. deprecated:: 0.0.105 + Use ``settings=GeminiLiveVertexLLMService.Settings(model=...)`` instead. + + voice_id: TTS voice identifier. Defaults to "Charon". + + .. deprecated:: 0.0.105 + Use ``settings=GeminiLiveVertexLLMService.Settings(voice=...)`` instead. + start_audio_paused: Whether to start with audio input paused. Defaults to False. + start_video_paused: Whether to start with video input paused. Defaults to False. + system_instruction: System prompt for the model. Defaults to None. + tools: Tools/functions available to the model. Defaults to None. + params: Configuration parameters for the model along with Vertex AI + location and project ID. + + .. deprecated:: 0.0.105 + Use ``settings=GeminiLiveVertexLLMService.Settings(...)`` instead. + + settings: Gemini Live LLM settings. If provided together with deprecated + top-level parameters, the ``settings`` values take precedence. + inference_on_context_initialization: Whether to generate a response when context + is first set. Defaults to True. + file_api_base_url: Base URL for the Gemini File API. Defaults to the official endpoint. + http_options: HTTP options for the client. + **kwargs: Additional arguments passed to parent GeminiLiveLLMService. + """ + # Check if user incorrectly passed api_key, which is used by parent + # class but not here. + if "api_key" in kwargs: + logger.error( + "GeminiLiveVertexLLMService does not accept 'api_key' parameter. " + "Use 'credentials' or 'credentials_path' instead for Vertex AI authentication." + ) + raise ValueError( + "Invalid parameter 'api_key'. Use 'credentials' or 'credentials_path' for Vertex AI authentication." + ) + + # These need to be set before calling super().__init__() because + # super().__init__() invokes create_client(), which needs these. + self._credentials = self._get_credentials(credentials, credentials_path) + self._project_id = project_id + self._location = location + + # Build default_settings from deprecated args, then apply settings delta. + # We pass settings= to super() instead of model=/params= to avoid + # double deprecation warnings from the parent. + + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="google/gemini-live-2.5-flash-native-audio", + voice="Charon", + frequency_penalty=None, + max_tokens=4096, + presence_penalty=None, + temperature=None, + top_k=None, + top_p=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + modalities=GeminiModalities.AUDIO, + language="en-US", + media_resolution=GeminiMediaResolution.UNSPECIFIED, + vad=None, + context_window_compression={}, + thinking={}, + enable_affective_dialog=False, + proactivity={}, + extra={}, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if voice_id != "Charon": + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.frequency_penalty = params.frequency_penalty + default_settings.max_tokens = params.max_tokens + default_settings.presence_penalty = params.presence_penalty + default_settings.temperature = params.temperature + default_settings.top_k = params.top_k + default_settings.top_p = params.top_p + default_settings.modalities = params.modalities + default_settings.language = ( + language_to_gemini_language(params.language) if params.language else "en-US" + ) + default_settings.media_resolution = params.media_resolution + default_settings.vad = params.vad + default_settings.context_window_compression = ( + params.context_window_compression.model_dump() + if params.context_window_compression + else {} + ) + default_settings.thinking = params.thinking or {} + default_settings.enable_affective_dialog = params.enable_affective_dialog or False + default_settings.proactivity = params.proactivity or {} + if isinstance(params.extra, dict): + default_settings.extra = params.extra + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + # Call parent constructor with the obtained settings + super().__init__( + # api_key is required by parent class, but actually not used with + # Vertex + api_key="dummy", + start_audio_paused=start_audio_paused, + start_video_paused=start_video_paused, + system_instruction=system_instruction, + tools=tools, + settings=default_settings, + inference_on_context_initialization=inference_on_context_initialization, + file_api_base_url=file_api_base_url, + http_options=http_options, + **kwargs, + ) + + def create_client(self): + """Create the Gemini client instance.""" + self._client = Client( + vertexai=True, + credentials=self._credentials, + project=self._project_id, + location=self._location, + http_options=self._http_options, + ) + + @property + def file_api(self): + """Gemini File API is not supported with Vertex AI.""" + raise NotImplementedError( + "When using Vertex AI, the recommended approach is to use Google Cloud Storage for file handling. The Gemini File API is not directly supported in this context." + ) + + @staticmethod + def _get_credentials(credentials: Optional[str], credentials_path: Optional[str]) -> str: + """Retrieve Credentials using Google service account credentials JSON. + + Supports multiple authentication methods: + 1. Direct JSON credentials string + 2. Path to service account JSON file + 3. Default application credentials (ADC) + + Args: + credentials: JSON string of service account credentials. + credentials_path: Path to the service account JSON file. + + Returns: + OAuth token for API authentication. + + Raises: + ValueError: If no valid credentials are provided or found. + """ + creds: Optional[service_account.Credentials] = None + + if credentials: + # Parse and load credentials from JSON string + creds = service_account.Credentials.from_service_account_info( + json.loads(credentials), + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + elif credentials_path: + # Load credentials from JSON file + creds = service_account.Credentials.from_service_account_file( + credentials_path, + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + else: + try: + creds, project_id = default( + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + except GoogleAuthError: + pass + + if not creds: + raise ValueError("No valid credentials provided.") + + creds.refresh(Request()) # Ensure token is up-to-date, lifetime is 1 hour. + + return creds diff --git a/src/pipecat/services/google/google.py b/src/pipecat/services/google/google.py index 3d1814cf0..b2fc88b23 100644 --- a/src/pipecat/services/google/google.py +++ b/src/pipecat/services/google/google.py @@ -13,12 +13,12 @@ from pipecat.services import DeprecatedModuleProxy from .frames import * from .image import * from .llm import * -from .llm_openai import * -from .llm_vertex import * +from .openai import * from .rtvi import * from .stt import * from .tts import * +from .vertex import * sys.modules[__name__] = DeprecatedModuleProxy( - globals(), "google", "google.[frames,image,llm,llm_openai,llm_vertex,rtvi,stt,tts]" + globals(), "google", "google.[frames,image,llm,openai,vertex,rtvi,stt,tts]" ) diff --git a/src/pipecat/services/google/image.py b/src/pipecat/services/google/image.py index fcc8e41d0..6a2919986 100644 --- a/src/pipecat/services/google/image.py +++ b/src/pipecat/services/google/image.py @@ -16,6 +16,7 @@ import os # Suppress gRPC fork warnings os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" +from dataclasses import dataclass, field from typing import Any, AsyncGenerator, Optional from loguru import logger @@ -25,6 +26,7 @@ from pydantic import BaseModel, Field from pipecat.frames.frames import ErrorFrame, Frame, URLImageRawFrame from pipecat.services.google.utils import update_google_client_http_options from pipecat.services.image_service import ImageGenService +from pipecat.services.settings import NOT_GIVEN, ImageGenSettings, _NotGiven try: from google import genai @@ -35,6 +37,20 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class GoogleImageGenSettings(ImageGenSettings): + """Settings for the Google image generation service. + + Parameters: + model: Google Imagen model identifier. + number_of_images: Number of images to generate per request. + negative_prompt: Text describing what not to include in generated images. + """ + + number_of_images: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + negative_prompt: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class GoogleImageGenService(ImageGenService): """Google AI image generation service using Imagen models. @@ -43,9 +59,15 @@ class GoogleImageGenService(ImageGenService): prompting for enhanced control over generated content. """ + Settings = GoogleImageGenSettings + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for Google image generation. + .. deprecated:: 0.0.105 + Use ``settings=GoogleImageGenService.Settings(...)`` instead. + Parameters: number_of_images: Number of images to generate (1-8). Defaults to 1. model: Google Imagen model to use. Defaults to "imagen-3.0-generate-002". @@ -62,24 +84,48 @@ class GoogleImageGenService(ImageGenService): api_key: str, params: Optional[InputParams] = None, http_options: Optional[Any] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the GoogleImageGenService with API key and parameters. Args: api_key: Google AI API key for authentication. - params: Configuration parameters for image generation. Defaults to InputParams(). + params: Configuration parameters for image generation. + + .. deprecated:: 0.0.105 + Use ``settings=GoogleImageGenService.Settings(...)`` instead. + http_options: HTTP options for the client. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to the parent ImageGenService. """ - super().__init__(**kwargs) - self._params = params or GoogleImageGenService.InputParams() + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="imagen-3.0-generate-002", + number_of_images=1, + negative_prompt=None, + ) + + # 2. Apply params overrides (deprecated) + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.model = params.model + default_settings.number_of_images = params.number_of_images + default_settings.negative_prompt = params.negative_prompt + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(settings=default_settings, **kwargs) # Add client header http_options = update_google_client_http_options(http_options) self._client = genai.Client(api_key=api_key, http_options=http_options) - self.set_model_name(self._params.model) def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -107,11 +153,11 @@ class GoogleImageGenService(ImageGenService): try: response = await self._client.aio.models.generate_images( - model=self._params.model, + model=self._settings.model, prompt=prompt, config=types.GenerateImagesConfig( - number_of_images=self._params.number_of_images, - negative_prompt=self._params.negative_prompt, + number_of_images=self._settings.number_of_images, + negative_prompt=self._settings.negative_prompt, ), ) await self.stop_ttfb_metrics() diff --git a/src/pipecat/services/google/llm.py b/src/pipecat/services/google/llm.py index f5f11b7e1..26ad46311 100644 --- a/src/pipecat/services/google/llm.py +++ b/src/pipecat/services/google/llm.py @@ -15,8 +15,8 @@ import io import json import os import uuid -from dataclasses import dataclass -from typing import Any, AsyncIterator, Dict, List, Literal, Optional +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Dict, List, Literal, Optional, Union from loguru import logger from PIL import Image @@ -35,13 +35,9 @@ from pipecat.frames.frames import ( LLMFullResponseStartFrame, LLMMessagesAppendFrame, LLMMessagesFrame, - LLMTextFrame, LLMThoughtEndFrame, LLMThoughtStartFrame, LLMThoughtTextFrame, - LLMUpdateSettingsFrame, - OutputImageRawFrame, - UserImageRawFrame, ) from pipecat.metrics.metrics import LLMTokenUsage from pipecat.processors.aggregators.llm_context import LLMContext @@ -61,6 +57,12 @@ from pipecat.services.openai.llm import ( OpenAIAssistantContextAggregator, OpenAIUserContextAggregator, ) +from pipecat.services.settings import ( + NOT_GIVEN, + LLMSettings, + _NotGiven, + is_given, +) from pipecat.utils.tracing.service_decorators import traced_llm # Suppress gRPC fork warnings @@ -198,23 +200,9 @@ class GoogleAssistantContextAggregator(OpenAIAssistantContextAggregator): if message.role == "user": for part in message.parts: if part.function_response and part.function_response.id == tool_call_id: - part.function_response.response = {"value": json.dumps(result)} - - async def handle_user_image_frame(self, frame: UserImageRawFrame): - """Handle user image frame. - - Args: - frame: Frame containing user image data and request context. - """ - await self._update_function_call_result( - frame.request.function_name, frame.request.tool_call_id, "COMPLETED" - ) - self._context.add_image_frame_message( - format=frame.format, - size=frame.size, - image=frame.image, - text=frame.request.context, - ) + part.function_response.response = { + "value": json.dumps(result, ensure_ascii=False) + } @dataclass @@ -691,6 +679,64 @@ class GoogleLLMContext(OpenAILLMContext): self._messages = [m for m in self._messages if m.parts] +class GoogleThinkingConfig(BaseModel): + """Configuration for controlling the model's internal "thinking" process used before generating a response. + + Gemini 2.5 and 3 series models have this thinking process. + + Parameters: + thinking_level: Thinking level for Gemini 3 models. + For Gemini 3 Pro, this can be "low" or "high". + For Gemini 3 Flash, this can be "minimal", "low", "medium", or "high". + If not provided, Gemini 3 models default to "high". + Note: Gemini 2.5 series must use thinking_budget instead. + thinking_budget: Token budget for thinking, for Gemini 2.5 series. + -1 for dynamic thinking (model decides), 0 to disable thinking, + or a specific token count (e.g., 128-32768 for 2.5 Pro). + If not provided, most models today default to dynamic thinking. + See https://ai.google.dev/gemini-api/docs/thinking#set-budget + for default values and allowed ranges. + Note: Gemini 3 models must use thinking_level instead. + include_thoughts: Whether to include thought summaries in the response. + Today's models default to not including thoughts (False). + """ + + thinking_budget: Optional[int] = Field(default=None) + + # Why `| str` here? To not break compatibility in case Google adds more + # levels in the future. + thinking_level: Optional[Literal["low", "high", "medium", "minimal"] | str] = Field( + default=None + ) + + include_thoughts: Optional[bool] = Field(default=None) + + +@dataclass +class GoogleLLMSettings(LLMSettings): + """Settings for GoogleLLMService. + + Parameters: + thinking: Thinking configuration. + """ + + thinking: Union["GoogleLLMService.ThinkingConfig", _NotGiven] = field( + default_factory=lambda: NOT_GIVEN + ) + + @classmethod + def from_mapping(cls, settings): + """Convert a plain dict to settings, coercing thinking dicts. + + For backward compatibility, a ``thinking`` value that is a plain dict + is converted to a :class:`GoogleLLMService.ThinkingConfig`. + """ + instance = super().from_mapping(settings) + if is_given(instance.thinking) and isinstance(instance.thinking, dict): + instance.thinking = GoogleLLMService.ThinkingConfig(**instance.thinking) + return instance + + class GoogleLLMService(LLMService): """Google AI (Gemini) LLM service implementation. @@ -699,40 +745,21 @@ class GoogleLLMService(LLMService): expected by the Google AI model. """ + Settings = GoogleLLMSettings + _settings: Settings + # Overriding the default adapter to use the Gemini one. adapter_class = GeminiLLMAdapter - class ThinkingConfig(BaseModel): - """Configuration for controlling the model's internal "thinking" process used before generating a response. - - Gemini 2.5 and 3 series models have this thinking process. - - Parameters: - thinking_level: Thinking level for Gemini 3 Pro. Can be "low" or "high". - If not provided, Gemini 3 Pro defaults to "high". - Note: Gemini 2.5 series should use thinking_budget instead. - thinking_budget: Token budget for thinking, for Gemini 2.5 series. - -1 for dynamic thinking (model decides), 0 to disable thinking, - or a specific token count (e.g., 128-32768 for 2.5 Pro). - If not provided, most models today default to dynamic thinking. - See https://ai.google.dev/gemini-api/docs/thinking#set-budget - for default values and allowed ranges. - Note: Gemini 3 Pro should use thinking_level instead. - include_thoughts: Whether to include thought summaries in the response. - Today's models default to not including thoughts (False). - """ - - thinking_budget: Optional[int] = Field(default=None) - - # Why `| str` here? To not break compatibility in case Google adds more - # levels in the future. - thinking_level: Optional[Literal["low", "high"] | str] = Field(default=None) - - include_thoughts: Optional[bool] = Field(default=None) + # Backward compatibility: ThinkingConfig used to be defined inline here. + ThinkingConfig = GoogleThinkingConfig class InputParams(BaseModel): """Input parameters for Google AI models. + .. deprecated:: 0.0.105 + Use ``settings=GoogleLLMService.Settings(...)`` instead. + Parameters: max_tokens: Maximum number of tokens to generate. temperature: Sampling temperature between 0.0 and 2.0. @@ -758,8 +785,9 @@ class GoogleLLMService(LLMService): self, *, api_key: str, - model: str = "gemini-2.5-flash", + model: Optional[str] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, system_instruction: Optional[str] = None, tools: Optional[List[Dict[str, Any]]] = None, tool_config: Optional[Dict[str, Any]] = None, @@ -770,31 +798,73 @@ class GoogleLLMService(LLMService): Args: api_key: Google AI API key for authentication. - model: Model name to use. Defaults to "gemini-2.0-flash". - params: Input parameters for the model. + model: Model name to use. + + .. deprecated:: 0.0.105 + Use ``settings=GoogleLLMService.Settings(model=...)`` instead. + + params: Optional model parameters for inference. + + .. deprecated:: 0.0.105 + Use ``settings=GoogleLLMService.Settings(...)`` instead. + + settings: Runtime-updatable settings for this service. When both + deprecated parameters and *settings* are provided, *settings* + values take precedence. system_instruction: System instruction/prompt for the model. + + .. deprecated:: 0.0.105 + Use ``settings=GoogleLLMService.Settings(system_instruction=...)`` instead. tools: List of available tools/functions. tool_config: Configuration for tool usage. http_options: HTTP options for the client. **kwargs: Additional arguments passed to parent class. """ - super().__init__(**kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="gemini-2.5-flash", + system_instruction=None, + max_tokens=4096, + temperature=None, + top_k=None, + top_p=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + thinking=None, + extra={}, + ) - params = params or GoogleLLMService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if system_instruction is not None: + self._warn_init_param_moved_to_settings("system_instruction", "system_instruction") + default_settings.system_instruction = system_instruction + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.max_tokens = params.max_tokens + default_settings.temperature = params.temperature + default_settings.top_k = params.top_k + default_settings.top_p = params.top_p + default_settings.thinking = params.thinking + if isinstance(params.extra, dict): + default_settings.extra = params.extra + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(settings=default_settings, **kwargs) - self.set_model_name(model) self._api_key = api_key - self._system_instruction = system_instruction self._http_options = update_google_client_http_options(http_options) - - self._settings = { - "max_tokens": params.max_tokens, - "temperature": params.temperature, - "top_k": params.top_k, - "top_p": params.top_p, - "thinking": params.thinking, - "extra": params.extra if isinstance(params.extra, dict) else {}, - } self._tools = tools self._tool_config = tool_config @@ -813,11 +883,20 @@ class GoogleLLMService(LLMService): """Create the Gemini client instance. Subclasses can override this.""" self._client = genai.Client(api_key=self._api_key, http_options=self._http_options) - async def run_inference(self, context: LLMContext | OpenAILLMContext) -> Optional[str]: + async def run_inference( + self, + context: LLMContext | OpenAILLMContext, + max_tokens: Optional[int] = None, + system_instruction: Optional[str] = None, + ) -> Optional[str]: """Run a one-shot, out-of-band (i.e. out-of-pipeline) inference with the given LLM context. Args: context: The LLM context containing conversation history. + max_tokens: Optional maximum number of tokens to generate. If provided, + overrides the service's default max_tokens setting. + system_instruction: Optional system instruction to use for this inference. + If provided, overrides any system instruction in the context. Returns: The LLM's response as a string, or None if no response is generated. @@ -837,16 +916,29 @@ class GoogleLLMService(LLMService): system = getattr(context, "system_message", None) tools = context.tools or [] + # Override system instruction if provided + if system_instruction is not None: + if system: + logger.warning( + f"{self}: Both system_instruction and a system message in context are set." + " Using system_instruction." + ) + system = system_instruction + # Build generation config using the same method as streaming generation_params = self._build_generation_params( system_instruction=system, tools=tools if tools else None ) + # Override max_output_tokens if provided + if max_tokens is not None: + generation_params["max_output_tokens"] = max_tokens + generation_config = GenerateContentConfig(**generation_params) # Use the new google-genai client's async method response = await self._client.aio.models.generate_content( - model=self._model_name, + model=self._settings.model, contents=messages, config=generation_config, ) @@ -880,10 +972,10 @@ class GoogleLLMService(LLMService): k: v for k, v in { "system_instruction": system_instruction, - "temperature": self._settings["temperature"], - "top_p": self._settings["top_p"], - "top_k": self._settings["top_k"], - "max_output_tokens": self._settings["max_tokens"], + "temperature": self._settings.temperature, + "top_p": self._settings.top_p, + "top_k": self._settings.top_k, + "max_output_tokens": self._settings.max_tokens, "tools": tools, "tool_config": tool_config, }.items() @@ -891,13 +983,13 @@ class GoogleLLMService(LLMService): } # Add thinking parameters if configured - if self._settings["thinking"]: - generation_params["thinking_config"] = self._settings["thinking"].model_dump( + if self._settings.thinking: + generation_params["thinking_config"] = self._settings.thinking.model_dump( exclude_unset=True ) - if self._settings["extra"]: - generation_params.update(self._settings["extra"]) + if self._settings.extra: + generation_params.update(self._settings.extra) return generation_params @@ -906,10 +998,10 @@ class GoogleLLMService(LLMService): # There's no way to introspect on model capabilities, so # to check for models that we know default to thinkin on # and can be configured to turn it off. - if not self._model_name.startswith("gemini-2.5-flash"): + if not self._settings.model.startswith("gemini-2.5-flash"): return # If we have an image model, we don't use a budget either. - if "image" in self._model_name: + if "image" in self._settings.model: return # If thinking_config is already set, don't override it. if "thinking_config" in generation_params: @@ -922,12 +1014,16 @@ class GoogleLLMService(LLMService): self, params_from_context: GeminiLLMInvocationParams ) -> AsyncIterator[GenerateContentResponse]: messages = params_from_context["messages"] - if ( - params_from_context["system_instruction"] - and self._system_instruction != params_from_context["system_instruction"] - ): - logger.debug(f"System instruction changed: {params_from_context['system_instruction']}") - self._system_instruction = params_from_context["system_instruction"] + + # Constructor/settings system instruction takes priority over context. + if self._settings.system_instruction and params_from_context["system_instruction"]: + logger.warning( + f"{self}: Both system_instruction and a system message in context are" + " set. Using system_instruction." + ) + system_instruction = ( + self._settings.system_instruction or params_from_context["system_instruction"] + ) tools = [] if params_from_context["tools"]: @@ -940,7 +1036,9 @@ class GoogleLLMService(LLMService): # Build generation parameters generation_params = self._build_generation_params( - system_instruction=self._system_instruction, tools=tools, tool_config=tool_config + system_instruction=system_instruction, + tools=tools, + tool_config=tool_config, ) # possibly modify generation_params (in place) to set thinking to off by default @@ -950,7 +1048,7 @@ class GoogleLLMService(LLMService): await self.start_ttfb_metrics() return await self._client.aio.models.generate_content_stream( - model=self._model_name, + model=self._settings.model, contents=messages, config=generation_config, ) @@ -1037,7 +1135,7 @@ class GoogleLLMService(LLMService): await self.push_frame(LLMThoughtEndFrame()) else: accumulated_text += part.text - await self.push_frame(LLMTextFrame(part.text)) + await self._push_llm_text(part.text) elif part.function_call: function_call = part.function_call function_call_id = function_call.id or str(uuid.uuid4()) @@ -1196,8 +1294,6 @@ class GoogleLLMService(LLMService): # NOTE: LLMMessagesFrame is deprecated, so we don't support the newer universal # LLMContext with it context = GoogleLLMContext(frame.messages) - elif isinstance(frame, LLMUpdateSettingsFrame): - await self._update_settings(frame.settings) else: await self.push_frame(frame, direction) @@ -1221,14 +1317,6 @@ class GoogleLLMService(LLMService): # Do nothing - we're shutting down anyway pass - async def _update_settings(self, settings): - """Override to handle ThinkingConfig validation.""" - # Convert thinking dict to ThinkingConfig if needed - if "thinking" in settings and isinstance(settings["thinking"], dict): - settings = dict(settings) # Make a copy to avoid modifying the original - settings["thinking"] = self.ThinkingConfig(**settings["thinking"]) - await super()._update_settings(settings) - def create_context_aggregator( self, context: OpenAILLMContext, diff --git a/src/pipecat/services/google/llm_openai.py b/src/pipecat/services/google/llm_openai.py index c781fe0d3..f9d182e78 100644 --- a/src/pipecat/services/google/llm_openai.py +++ b/src/pipecat/services/google/llm_openai.py @@ -4,173 +4,15 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Google LLM service using OpenAI-compatible API format. +"""Deprecated: use ``pipecat.services.google.openai.llm`` instead.""" -This module provides integration with Google's AI LLM models using the OpenAI -API format through Google's Gemini API OpenAI compatibility layer. -""" +import warnings -import json -import os +warnings.warn( + "Module `pipecat.services.google.llm_openai` is deprecated, " + "use `pipecat.services.google.openai.llm` instead.", + DeprecationWarning, + stacklevel=2, +) -from openai import AsyncStream -from openai.types.chat import ChatCompletionChunk - -from pipecat.services.llm_service import FunctionCallFromLLM - -# Suppress gRPC fork warnings -os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" - -from loguru import logger - -from pipecat.frames.frames import LLMTextFrame -from pipecat.metrics.metrics import LLMTokenUsage -from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext -from pipecat.services.openai.llm import OpenAILLMService - - -class GoogleLLMOpenAIBetaService(OpenAILLMService): - """Google LLM service using OpenAI-compatible API format. - - This service provides access to Google's AI LLM models (like Gemini) through - the OpenAI API format. It handles streaming responses, function calls, and - tool usage while maintaining compatibility with OpenAI's interface. - - Note: This service includes a workaround for a Google API bug where function - call indices may be incorrectly set to None, resulting in empty function names. - - .. deprecated:: 0.0.82 - GoogleLLMOpenAIBetaService is deprecated and will be removed in a future version. - Use GoogleLLMService instead for better integration with Google's native API. - - Reference: - https://ai.google.dev/gemini-api/docs/openai - """ - - def __init__( - self, - *, - api_key: str, - base_url: str = "https://generativelanguage.googleapis.com/v1beta/openai/", - model: str = "gemini-2.0-flash", - **kwargs, - ): - """Initialize the Google LLM service. - - Args: - api_key: Google API key for authentication. - base_url: Base URL for Google's OpenAI-compatible API. - model: Google model name to use (e.g., "gemini-2.0-flash"). - **kwargs: Additional arguments passed to the parent OpenAILLMService. - """ - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "GoogleLLMOpenAIBetaService is deprecated and will be removed in a future version. " - "Use GoogleLLMService instead for better integration with Google's native API.", - DeprecationWarning, - stacklevel=2, - ) - - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) - - async def _process_context(self, context: OpenAILLMContext): - functions_list = [] - arguments_list = [] - tool_id_list = [] - func_idx = 0 - function_name = "" - arguments = "" - tool_call_id = "" - - await self.start_ttfb_metrics() - - chunk_stream: AsyncStream[ - ChatCompletionChunk - ] = await self._stream_chat_completions_specific_context(context) - - async for chunk in chunk_stream: - if chunk.usage: - tokens = LLMTokenUsage( - prompt_tokens=chunk.usage.prompt_tokens or 0, - completion_tokens=chunk.usage.completion_tokens or 0, - total_tokens=chunk.usage.total_tokens or 0, - ) - await self.start_llm_usage_metrics(tokens) - - if chunk.choices is None or len(chunk.choices) == 0: - continue - - await self.stop_ttfb_metrics() - - if not chunk.choices[0].delta: - continue - - if chunk.choices[0].delta.tool_calls: - # We're streaming the LLM response to enable the fastest response times. - # For text, we just yield each chunk as we receive it and count on consumers - # to do whatever coalescing they need (eg. to pass full sentences to TTS) - # - # If the LLM is a function call, we'll do some coalescing here. - # If the response contains a function name, we'll yield a frame to tell consumers - # that they can start preparing to call the function with that name. - # We accumulate all the arguments for the rest of the streamed response, then when - # the response is done, we package up all the arguments and the function name and - # yield a frame containing the function name and the arguments. - logger.debug(f"Tool call: {chunk.choices[0].delta.tool_calls}") - tool_call = chunk.choices[0].delta.tool_calls[0] - if tool_call.index != func_idx: - functions_list.append(function_name) - arguments_list.append(arguments) - tool_id_list.append(tool_call_id) - function_name = "" - arguments = "" - tool_call_id = "" - func_idx += 1 - if tool_call.function and tool_call.function.name: - function_name += tool_call.function.name - tool_call_id = tool_call.id - if tool_call.function and tool_call.function.arguments: - # Keep iterating through the response to collect all the argument fragments - arguments += tool_call.function.arguments - elif chunk.choices[0].delta.content: - await self.push_frame(LLMTextFrame(chunk.choices[0].delta.content)) - - # if we got a function name and arguments, check to see if it's a function with - # a registered handler. If so, run the registered callback, save the result to - # the context, and re-prompt to get a chat answer. If we don't have a registered - # handler, raise an exception. - if function_name and arguments: - # added to the list as last function name and arguments not added to the list - functions_list.append(function_name) - arguments_list.append(arguments) - tool_id_list.append(tool_call_id) - - logger.debug( - f"Function list: {functions_list}, Arguments list: {arguments_list}, Tool ID list: {tool_id_list}" - ) - - function_calls = [] - for function_name, arguments, tool_id in zip( - functions_list, arguments_list, tool_id_list - ): - if function_name == "": - # TODO: Remove the _process_context method once Google resolves the bug - # where the index is incorrectly set to None instead of returning the actual index, - # which currently results in an empty function name(''). - continue - - arguments = json.loads(arguments) - - function_calls.append( - FunctionCallFromLLM( - context=context, - tool_call_id=tool_id, - function_name=function_name, - arguments=arguments, - ) - ) - - await self.run_function_calls(function_calls) +from pipecat.services.google.openai.llm import * # noqa: E402, F401, F403 diff --git a/src/pipecat/services/google/llm_vertex.py b/src/pipecat/services/google/llm_vertex.py index ef222c97e..54d338ad7 100644 --- a/src/pipecat/services/google/llm_vertex.py +++ b/src/pipecat/services/google/llm_vertex.py @@ -4,239 +4,15 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Google Vertex AI LLM service implementation. +"""Deprecated: use ``pipecat.services.google.vertex.llm`` instead.""" -This module provides integration with Google's AI models via Vertex AI, -extending the GoogleLLMService with Vertex AI authentication. -""" +import warnings -import json -import os +warnings.warn( + "Module `pipecat.services.google.llm_vertex` is deprecated, " + "use `pipecat.services.google.vertex.llm` instead.", + DeprecationWarning, + stacklevel=2, +) -# Suppress gRPC fork warnings -os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" - -from typing import Optional - -from loguru import logger - -from pipecat.services.google.llm import GoogleLLMService - -try: - from google.auth import default - from google.auth.exceptions import GoogleAuthError - from google.auth.transport.requests import Request - from google.genai import Client - from google.genai.types import HttpOptions - from google.oauth2 import service_account - -except ModuleNotFoundError as e: - logger.error(f"Exception: {e}") - logger.error( - "In order to use Google AI, you need to `pip install pipecat-ai[google]`. Also, set `GOOGLE_APPLICATION_CREDENTIALS` environment variable." - ) - raise Exception(f"Missing module: {e}") - - -class GoogleVertexLLMService(GoogleLLMService): - """Google Vertex AI LLM service extending GoogleLLMService. - - Provides access to Google's AI models via Vertex AI while using the same - Google AI client and message format as GoogleLLMService. Handles authentication - using Google service account credentials and configures the client for - Vertex AI endpoints. - - Reference: - https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference - """ - - class InputParams(GoogleLLMService.InputParams): - """Input parameters specific to Vertex AI. - - Parameters: - location: GCP region for Vertex AI endpoint (e.g., "us-east4"). - - .. deprecated:: 0.0.90 - Use `location` as a direct argument to - `GoogleVertexLLMService.__init__()` instead. - - project_id: Google Cloud project ID. - - .. deprecated:: 0.0.90 - Use `project_id` as a direct argument to - `GoogleVertexLLMService.__init__()` instead. - """ - - # https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations - location: Optional[str] = None - project_id: Optional[str] = None - - def __init__(self, **kwargs): - """Initializes the InputParams.""" - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - if "location" in kwargs and kwargs["location"] is not None: - warnings.warn( - "GoogleVertexLLMService.InputParams.location is deprecated. " - "Please provide 'location' as a direct argument to GoogleVertexLLMService.__init__() instead.", - DeprecationWarning, - stacklevel=2, - ) - - if "project_id" in kwargs and kwargs["project_id"] is not None: - warnings.warn( - "GoogleVertexLLMService.InputParams.project_id is deprecated. " - "Please provide 'project_id' as a direct argument to GoogleVertexLLMService.__init__() instead.", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(**kwargs) - - def __init__( - self, - *, - credentials: Optional[str] = None, - credentials_path: Optional[str] = None, - model: str = "gemini-2.5-flash", - location: Optional[str] = None, - project_id: Optional[str] = None, - params: Optional[GoogleLLMService.InputParams] = None, - system_instruction: Optional[str] = None, - tools: Optional[list] = None, - tool_config: Optional[dict] = None, - http_options: Optional[HttpOptions] = None, - **kwargs, - ): - """Initializes the VertexLLMService. - - Args: - credentials: JSON string of service account credentials. - credentials_path: Path to the service account JSON file. - model: Model identifier (e.g., "gemini-2.5-flash"). - location: GCP region for Vertex AI endpoint (e.g., "us-east4"). - project_id: Google Cloud project ID. - params: Input parameters for the model. - system_instruction: System instruction/prompt for the model. - tools: List of available tools/functions. - tool_config: Configuration for tool usage. - http_options: HTTP options for the client. - **kwargs: Additional arguments passed to GoogleLLMService. - """ - # Check if user incorrectly passed api_key, which is used by parent - # class but not here. - if "api_key" in kwargs: - logger.error( - "GoogleVertexLLMService does not accept 'api_key' parameter. " - "Use 'credentials' or 'credentials_path' instead for Vertex AI authentication." - ) - raise ValueError( - "Invalid parameter 'api_key'. Use 'credentials' or 'credentials_path' for Vertex AI authentication." - ) - - # Handle deprecated InputParams fields - if params and isinstance(params, GoogleVertexLLMService.InputParams): - # Extract location and project_id from params if not provided - # directly, for backward compatibility - if project_id is None: - project_id = params.project_id - if location is None: - location = params.location - # Convert to base InputParams - params = GoogleLLMService.InputParams( - **params.model_dump(exclude={"location", "project_id"}, exclude_unset=True) - ) - - # Validate project_id and location parameters - # NOTE: once we remove Vertex-specific InputParams class, we can update - # __init__() signature as follows: - # - location: str = "us-east4", - # - project_id: str, - # But for now, we need them as-is to maintain proper backward - # compatibility. - if project_id is None: - raise ValueError("project_id is required") - if location is None: - # If location is not provided, default to "us-east4". - # Note: this is legacy behavior; ideally location would be - # required. - logger.warning("location is not provided. Defaulting to 'us-east4'.") - location = "us-east4" # Default location if not provided - - # These need to be set before calling super().__init__() because - # super().__init__() invokes _create_client(), which needs these. - self._credentials = self._get_credentials(credentials, credentials_path) - self._project_id = project_id - self._location = location - - # Call parent constructor with dummy api_key - # (api_key is required by parent class, but not actually used with Vertex) - super().__init__( - api_key="dummy", - model=model, - params=params, - system_instruction=system_instruction, - tools=tools, - tool_config=tool_config, - http_options=http_options, - **kwargs, - ) - - def create_client(self): - """Create the Gemini client instance configured for Vertex AI.""" - self._client = Client( - vertexai=True, - credentials=self._credentials, - project=self._project_id, - location=self._location, - http_options=self._http_options, - ) - - @staticmethod - def _get_credentials(credentials: Optional[str], credentials_path: Optional[str]): - """Retrieve Credentials using Google service account credentials. - - Supports multiple authentication methods: - 1. Direct JSON credentials string - 2. Path to service account JSON file - 3. Default application credentials (ADC) - - Args: - credentials: JSON string of service account credentials. - credentials_path: Path to the service account JSON file. - - Returns: - Google credentials object for API authentication. - - Raises: - ValueError: If no valid credentials are provided or found. - """ - creds: Optional[service_account.Credentials] = None - - if credentials: - # Parse and load credentials from JSON string - creds = service_account.Credentials.from_service_account_info( - json.loads(credentials), - scopes=["https://www.googleapis.com/auth/cloud-platform"], - ) - elif credentials_path: - # Load credentials from JSON file - creds = service_account.Credentials.from_service_account_file( - credentials_path, - scopes=["https://www.googleapis.com/auth/cloud-platform"], - ) - else: - try: - creds, project_id = default( - scopes=["https://www.googleapis.com/auth/cloud-platform"] - ) - except GoogleAuthError: - pass - - if not creds: - raise ValueError("No valid credentials provided.") - - creds.refresh(Request()) # Ensure token is up-to-date, lifetime is 1 hour. - - return creds +from pipecat.services.google.vertex.llm import * # noqa: E402, F401, F403 diff --git a/src/pipecat/services/google/openai/__init__.py b/src/pipecat/services/google/openai/__init__.py new file mode 100644 index 000000000..c4d243b97 --- /dev/null +++ b/src/pipecat/services/google/openai/__init__.py @@ -0,0 +1,5 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# diff --git a/src/pipecat/services/google/openai/llm.py b/src/pipecat/services/google/openai/llm.py new file mode 100644 index 000000000..da5d1be7a --- /dev/null +++ b/src/pipecat/services/google/openai/llm.py @@ -0,0 +1,213 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Google LLM service using OpenAI-compatible API format. + +This module provides integration with Google's AI LLM models using the OpenAI +API format through Google's Gemini API OpenAI compatibility layer. +""" + +import json +import os +from dataclasses import dataclass +from typing import Optional + +from openai import AsyncStream +from openai.types.chat import ChatCompletionChunk + +from pipecat.services.llm_service import FunctionCallFromLLM + +# Suppress gRPC fork warnings +os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" + +from loguru import logger + +from pipecat.frames.frames import LLMTextFrame +from pipecat.metrics.metrics import LLMTokenUsage +from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext +from pipecat.services.openai.base_llm import BaseOpenAILLMService +from pipecat.services.openai.llm import OpenAILLMService + + +@dataclass +class GoogleOpenAILLMSettings(BaseOpenAILLMService.Settings): + """Settings for GoogleLLMOpenAIBetaService.""" + + pass + + +class GoogleLLMOpenAIBetaService(OpenAILLMService): + """Google LLM service using OpenAI-compatible API format. + + This service provides access to Google's AI LLM models (like Gemini) through + the OpenAI API format. It handles streaming responses, function calls, and + tool usage while maintaining compatibility with OpenAI's interface. + + Note: This service includes a workaround for a Google API bug where function + call indices may be incorrectly set to None, resulting in empty function names. + + .. deprecated:: 0.0.82 + GoogleLLMOpenAIBetaService is deprecated and will be removed in a future version. + Use GoogleLLMService instead for better integration with Google's native API. + + Reference: + https://ai.google.dev/gemini-api/docs/openai + """ + + Settings = GoogleOpenAILLMSettings + _settings: Settings + + def __init__( + self, + *, + api_key: str, + base_url: str = "https://generativelanguage.googleapis.com/v1beta/openai/", + model: Optional[str] = None, + settings: Optional[Settings] = None, + **kwargs, + ): + """Initialize the Google LLM service. + + Args: + api_key: Google API key for authentication. + base_url: Base URL for Google's OpenAI-compatible API. + model: Google model name to use (e.g., "gemini-2.0-flash"). + + .. deprecated:: 0.0.105 + Use ``settings=GoogleLLMOpenAIBetaService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + **kwargs: Additional arguments passed to the parent OpenAILLMService. + """ + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "GoogleLLMOpenAIBetaService is deprecated and will be removed in a future version. " + "Use GoogleLLMService instead for better integration with Google's native API.", + DeprecationWarning, + stacklevel=2, + ) + + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="gemini-2.0-flash") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) + + async def _process_context(self, context: OpenAILLMContext): + functions_list = [] + arguments_list = [] + tool_id_list = [] + func_idx = 0 + function_name = "" + arguments = "" + tool_call_id = "" + + await self.start_ttfb_metrics() + + chunk_stream: AsyncStream[ + ChatCompletionChunk + ] = await self._stream_chat_completions_specific_context(context) + + # Use context manager to ensure stream is closed on cancellation/exception. + # Without this, CancelledError during iteration leaves the underlying socket open. + async with chunk_stream: + async for chunk in chunk_stream: + if chunk.usage: + tokens = LLMTokenUsage( + prompt_tokens=chunk.usage.prompt_tokens or 0, + completion_tokens=chunk.usage.completion_tokens or 0, + total_tokens=chunk.usage.total_tokens or 0, + ) + await self.start_llm_usage_metrics(tokens) + + if chunk.choices is None or len(chunk.choices) == 0: + continue + + await self.stop_ttfb_metrics() + + if not chunk.choices[0].delta: + continue + + if chunk.choices[0].delta.tool_calls: + # We're streaming the LLM response to enable the fastest response times. + # For text, we just yield each chunk as we receive it and count on consumers + # to do whatever coalescing they need (eg. to pass full sentences to TTS) + # + # If the LLM is a function call, we'll do some coalescing here. + # If the response contains a function name, we'll yield a frame to tell consumers + # that they can start preparing to call the function with that name. + # We accumulate all the arguments for the rest of the streamed response, then when + # the response is done, we package up all the arguments and the function name and + # yield a frame containing the function name and the arguments. + logger.debug(f"Tool call: {chunk.choices[0].delta.tool_calls}") + tool_call = chunk.choices[0].delta.tool_calls[0] + if tool_call.index != func_idx: + functions_list.append(function_name) + arguments_list.append(arguments) + tool_id_list.append(tool_call_id) + function_name = "" + arguments = "" + tool_call_id = "" + func_idx += 1 + if tool_call.function and tool_call.function.name: + function_name += tool_call.function.name + tool_call_id = tool_call.id + if tool_call.function and tool_call.function.arguments: + # Keep iterating through the response to collect all the argument fragments + arguments += tool_call.function.arguments + elif chunk.choices[0].delta.content: + await self.push_frame(LLMTextFrame(chunk.choices[0].delta.content)) + + # if we got a function name and arguments, check to see if it's a function with + # a registered handler. If so, run the registered callback, save the result to + # the context, and re-prompt to get a chat answer. If we don't have a registered + # handler, raise an exception. + if function_name and arguments: + # added to the list as last function name and arguments not added to the list + functions_list.append(function_name) + arguments_list.append(arguments) + tool_id_list.append(tool_call_id) + + logger.debug( + f"Function list: {functions_list}, Arguments list: {arguments_list}, Tool ID list: {tool_id_list}" + ) + + function_calls = [] + for function_name, arguments, tool_id in zip( + functions_list, arguments_list, tool_id_list + ): + if function_name == "": + # TODO: Remove the _process_context method once Google resolves the bug + # where the index is incorrectly set to None instead of returning the actual index, + # which currently results in an empty function name(''). + continue + + arguments = json.loads(arguments) + + function_calls.append( + FunctionCallFromLLM( + context=context, + tool_call_id=tool_id, + function_name=function_name, + arguments=arguments, + ) + ) + + await self.run_function_calls(function_calls) diff --git a/src/pipecat/services/google/rtvi.py b/src/pipecat/services/google/rtvi.py index 1cc68f5e4..738b0ab9d 100644 --- a/src/pipecat/services/google/rtvi.py +++ b/src/pipecat/services/google/rtvi.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Google RTVI integration models and observer implementation. +"""Google RTVI processor and observer implementation. This module provides integration with Google's services through the RTVI framework, including models for search responses and an observer for handling Google-specific @@ -15,10 +15,8 @@ from typing import List, Literal, Optional from pydantic import BaseModel -from pipecat.frames.frames import Frame from pipecat.observers.base_observer import FramePushed -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pipecat.processors.frameworks.rtvi import RTVIObserver, RTVIProcessor +from pipecat.processors.frameworks.rtvi import RTVIObserver, RTVIObserverParams, RTVIProcessor from pipecat.services.google.frames import LLMSearchOrigin, LLMSearchResponseFrame @@ -88,4 +86,23 @@ class GoogleRTVIObserver(RTVIObserver): rendered_content=frame.rendered_content, ) ) - await self.push_transport_message_urgent(message) + await self.send_rtvi_message(message) + + +class GoogleRTVIProcessor(RTVIProcessor): + """RTVI processor for Google service integration. + + Creates a specific Google RTVI Observer. + """ + + def create_rtvi_observer(self, *, params: Optional[RTVIObserverParams] = None, **kwargs): + """Creates a new RTVI Observer. + + Args: + params: Settings to enable/disable specific messages. + **kwargs: Additional arguments passed to the observer. + + Returns: + A new RTVI observer. + """ + return GoogleRTVIObserver(self) diff --git a/src/pipecat/services/google/stt.py b/src/pipecat/services/google/stt.py index ac77f0450..173b201fe 100644 --- a/src/pipecat/services/google/stt.py +++ b/src/pipecat/services/google/stt.py @@ -15,13 +15,15 @@ import asyncio import json import os import time +import warnings +from dataclasses import dataclass, field from pipecat.utils.tracing.service_decorators import traced_stt # Suppress gRPC fork warnings os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" -from typing import AsyncGenerator, List, Optional, Union +from typing import Any, AsyncGenerator, List, Optional, Union from loguru import logger from pydantic import BaseModel, Field, field_validator @@ -29,12 +31,13 @@ from pydantic import BaseModel, Field, field_validator from pipecat.frames.frames import ( CancelFrame, EndFrame, - ErrorFrame, Frame, InterimTranscriptionFrame, StartFrame, TranscriptionFrame, ) +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven +from pipecat.services.stt_latency import GOOGLE_TTFS_P99 from pipecat.services.stt_service import STTService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.time import time_now_iso8601 @@ -355,6 +358,46 @@ def language_to_google_stt_language(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=False) +@dataclass +class GoogleSTTSettings(STTSettings): + """Settings for GoogleSTTService. + + Parameters: + languages: List of ``Language`` enums for recognition + (e.g. ``[Language.EN_US]``). Preferred over ``language_codes``. + language_codes: List of Google STT language code strings + (e.g. ``["en-US"]``). + + .. deprecated:: 0.0.104 + Use ``languages`` instead. If both are provided, ``languages`` + takes precedence. This field is here just for backward + compatibility with dict-based settings updates. + use_separate_recognition_per_channel: Process each audio channel separately. + enable_automatic_punctuation: Add punctuation to transcripts. + enable_spoken_punctuation: Include spoken punctuation in transcript. + enable_spoken_emojis: Include spoken emojis in transcript. + profanity_filter: Filter profanity from transcript. + enable_word_time_offsets: Include timing information for each word. + enable_word_confidence: Include confidence scores for each word. + enable_interim_results: Stream partial recognition results. + enable_voice_activity_events: Detect voice activity in audio. + """ + + languages: List[Language] | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + language_codes: List[str] | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + use_separate_recognition_per_channel: bool | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + enable_automatic_punctuation: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_spoken_punctuation: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_spoken_emojis: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + profanity_filter: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_word_time_offsets: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_word_confidence: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_interim_results: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_voice_activity_events: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class GoogleSTTService(STTService): """Google Cloud Speech-to-Text V2 service implementation. @@ -371,6 +414,9 @@ class GoogleSTTService(STTService): ValueError: If project ID is not found in credentials. """ + Settings = GoogleSTTSettings + _settings: Settings + # Google Cloud's STT service has a connection time limit of 5 minutes per stream. # They've shared an "endless streaming" example that guided this implementation: # https://cloud.google.com/speech-to-text/docs/transcribe-streaming-audio#endless-streaming @@ -380,6 +426,9 @@ class GoogleSTTService(STTService): class InputParams(BaseModel): """Configuration parameters for Google Speech-to-Text. + .. deprecated:: 0.0.105 + Use ``settings=GoogleSTTService.Settings(...)`` instead. + Parameters: languages: Single language or list of recognition languages. First language is primary. model: Speech recognition model to use. @@ -439,6 +488,8 @@ class GoogleSTTService(STTService): location: str = "global", sample_rate: Optional[int] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = GOOGLE_TTFS_P99, **kwargs, ): """Initialize the Google STT service. @@ -449,11 +500,63 @@ class GoogleSTTService(STTService): location: Google Cloud location (e.g., "global", "us-central1"). sample_rate: Audio sample rate in Hertz. params: Configuration parameters for the service. + + .. deprecated:: 0.0.105 + Use ``settings=GoogleSTTService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + ``params``, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to STTService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + language=None, + languages=[Language.EN_US], + language_codes=None, + model="latest_long", + use_separate_recognition_per_channel=False, + enable_automatic_punctuation=True, + enable_spoken_punctuation=False, + enable_spoken_emojis=False, + profanity_filter=False, + enable_word_time_offsets=False, + enable_word_confidence=False, + enable_interim_results=True, + enable_voice_activity_events=False, + ) - params = params or GoogleSTTService.InputParams() + # 2. No direct init arg overrides + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.languages = list(params.language_list) + default_settings.model = params.model + default_settings.use_separate_recognition_per_channel = ( + params.use_separate_recognition_per_channel + ) + default_settings.enable_automatic_punctuation = params.enable_automatic_punctuation + default_settings.enable_spoken_punctuation = params.enable_spoken_punctuation + default_settings.enable_spoken_emojis = params.enable_spoken_emojis + default_settings.profanity_filter = params.profanity_filter + default_settings.enable_word_time_offsets = params.enable_word_time_offsets + default_settings.enable_word_confidence = params.enable_word_confidence + default_settings.enable_interim_results = params.enable_interim_results + default_settings.enable_voice_activity_events = params.enable_voice_activity_events + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) self._location = location self._stream = None @@ -505,22 +608,6 @@ class GoogleSTTService(STTService): self._client = speech_v2.SpeechAsyncClient(credentials=creds, client_options=client_options) - self._settings = { - "language_codes": [ - self.language_to_service_language(lang) for lang in params.language_list - ], - "model": params.model, - "use_separate_recognition_per_channel": params.use_separate_recognition_per_channel, - "enable_automatic_punctuation": params.enable_automatic_punctuation, - "enable_spoken_punctuation": params.enable_spoken_punctuation, - "enable_spoken_emojis": params.enable_spoken_emojis, - "profanity_filter": params.profanity_filter, - "enable_word_time_offsets": params.enable_word_time_offsets, - "enable_word_confidence": params.enable_word_confidence, - "enable_interim_results": params.enable_interim_results, - "enable_voice_activity_events": params.enable_voice_activity_events, - } - def can_generate_metrics(self) -> bool: """Check if the service can generate metrics. @@ -542,6 +629,21 @@ class GoogleSTTService(STTService): return [language_to_google_stt_language(lang) or "en-US" for lang in language] return language_to_google_stt_language(language) or "en-US" + def _get_language_codes(self) -> List[str]: + """Resolve the current language settings to Google STT language code strings. + + Prefers ``languages`` (``Language`` enums) over the deprecated + ``language_codes`` (raw strings). Falls back to ``["en-US"]``. + + Returns: + List[str]: Google STT language code strings. + """ + if self._settings.languages: + return [self.language_to_service_language(lang) for lang in self._settings.languages] + if self._settings.language_codes: + return list(self._settings.language_codes) + return ["en-US"] + async def _reconnect_if_needed(self): """Reconnect the stream if it's currently active.""" if self._streaming_task: @@ -549,41 +651,65 @@ class GoogleSTTService(STTService): await self._disconnect() await self._connect() - async def set_language(self, language: Language): - """Update the service's recognition language. - - A convenience method for setting a single language. - - Args: - language: New language for recognition. - """ - logger.debug(f"Switching STT language to: {language}") - await self.set_languages([language]) - async def set_languages(self, languages: List[Language]): """Update the service's recognition languages. + .. deprecated:: 0.0.104 + Use ``STTUpdateSettingsFrame`` with ``GoogleSTTService.Settings(languages=...)`` + instead. + Args: languages: List of languages for recognition. First language is primary. """ + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "set_languages() is deprecated. Use STTUpdateSettingsFrame with " + "self.Settings(languages=...) instead.", + DeprecationWarning, + ) logger.debug(f"Switching STT languages to: {languages}") - self._settings["language_codes"] = [ - self.language_to_service_language(lang) for lang in languages - ] - # Recreate stream with new languages - await self._reconnect_if_needed() + await self._update_settings(self.Settings(languages=list(languages))) - async def set_model(self, model: str): - """Update the service's recognition model. + async def _update_settings(self, delta: Settings) -> dict[str, Any]: + """Apply settings delta and reconnect if anything changed. + + Handles ``language`` from base ``set_language`` by converting it to + ``languages``. Emits a deprecation warning if ``language_codes`` is + used. All other fields (model, boolean flags) are applied directly. + Reconnects the stream on any change. Args: - model: The new recognition model to use. + delta: A settings delta. + + Returns: + Dict mapping changed field names to their previous values. """ - logger.debug(f"Switching STT model to: {model}") - await super().set_model(model) - self._settings["model"] = model - # Recreate stream with new model - await self._reconnect_if_needed() + from pipecat.services.settings import is_given + + # If base set_language sent a Language value, convert to languages list + if is_given(delta.language): + delta.languages = [delta.language] + # Clear language so the base class doesn't try to store it + delta.language = NOT_GIVEN + + # Warn on deprecated language_codes usage + if is_given(delta.language_codes): + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "self.Settings.language_codes is deprecated. " + "Use self.Settings.languages (List[Language]) instead.", + DeprecationWarning, + stacklevel=2, + ) + + changed = await super()._update_settings(delta) + + if changed: + await self._reconnect_if_needed() + + return changed async def start(self, frame: StartFrame): """Start the STT service and establish connection. @@ -629,6 +755,10 @@ class GoogleSTTService(STTService): ) -> None: """Update service options dynamically. + .. deprecated:: + Use ``STTUpdateSettingsFrame`` with ``GoogleSTTService.Settings(...)`` + instead. + Args: languages: New list of recognition languages. model: New recognition model. @@ -646,55 +776,42 @@ class GoogleSTTService(STTService): Changes that affect the streaming configuration will cause the stream to be reconnected. """ - # Update settings with new values + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "update_options() is deprecated. Use STTUpdateSettingsFrame with " + "self.Settings(...) instead.", + DeprecationWarning, + ) + # Build a settings delta from the provided options + delta = self.Settings() + if languages is not None: - logger.debug(f"Updating language to: {languages}") - self._settings["language_codes"] = [ - self.language_to_service_language(lang) for lang in languages - ] - + delta.languages = list(languages) if model is not None: - logger.debug(f"Updating model to: {model}") - self._settings["model"] = model - + delta.model = model if enable_automatic_punctuation is not None: - logger.debug(f"Updating automatic punctuation to: {enable_automatic_punctuation}") - self._settings["enable_automatic_punctuation"] = enable_automatic_punctuation - + delta.enable_automatic_punctuation = enable_automatic_punctuation if enable_spoken_punctuation is not None: - logger.debug(f"Updating spoken punctuation to: {enable_spoken_punctuation}") - self._settings["enable_spoken_punctuation"] = enable_spoken_punctuation - + delta.enable_spoken_punctuation = enable_spoken_punctuation if enable_spoken_emojis is not None: - logger.debug(f"Updating spoken emojis to: {enable_spoken_emojis}") - self._settings["enable_spoken_emojis"] = enable_spoken_emojis - + delta.enable_spoken_emojis = enable_spoken_emojis if profanity_filter is not None: - logger.debug(f"Updating profanity filter to: {profanity_filter}") - self._settings["profanity_filter"] = profanity_filter - + delta.profanity_filter = profanity_filter if enable_word_time_offsets is not None: - logger.debug(f"Updating word time offsets to: {enable_word_time_offsets}") - self._settings["enable_word_time_offsets"] = enable_word_time_offsets - + delta.enable_word_time_offsets = enable_word_time_offsets if enable_word_confidence is not None: - logger.debug(f"Updating word confidence to: {enable_word_confidence}") - self._settings["enable_word_confidence"] = enable_word_confidence - + delta.enable_word_confidence = enable_word_confidence if enable_interim_results is not None: - logger.debug(f"Updating interim results to: {enable_interim_results}") - self._settings["enable_interim_results"] = enable_interim_results - + delta.enable_interim_results = enable_interim_results if enable_voice_activity_events is not None: - logger.debug(f"Updating voice activity events to: {enable_voice_activity_events}") - self._settings["enable_voice_activity_events"] = enable_voice_activity_events + delta.enable_voice_activity_events = enable_voice_activity_events if location is not None: logger.debug(f"Updating location to: {location}") self._location = location - # Reconnect the stream for updates - await self._reconnect_if_needed() + await self._update_settings(delta) async def _connect(self): """Initialize streaming recognition config and stream.""" @@ -711,20 +828,20 @@ class GoogleSTTService(STTService): sample_rate_hertz=self.sample_rate, audio_channel_count=1, ), - language_codes=self._settings["language_codes"], - model=self._settings["model"], + language_codes=self._get_language_codes(), + model=self._settings.model, features=cloud_speech.RecognitionFeatures( - enable_automatic_punctuation=self._settings["enable_automatic_punctuation"], - enable_spoken_punctuation=self._settings["enable_spoken_punctuation"], - enable_spoken_emojis=self._settings["enable_spoken_emojis"], - profanity_filter=self._settings["profanity_filter"], - enable_word_time_offsets=self._settings["enable_word_time_offsets"], - enable_word_confidence=self._settings["enable_word_confidence"], + enable_automatic_punctuation=self._settings.enable_automatic_punctuation, + enable_spoken_punctuation=self._settings.enable_spoken_punctuation, + enable_spoken_emojis=self._settings.enable_spoken_emojis, + profanity_filter=self._settings.profanity_filter, + enable_word_time_offsets=self._settings.enable_word_time_offsets, + enable_word_confidence=self._settings.enable_word_confidence, ), ), streaming_features=cloud_speech.StreamingRecognitionFeatures( - enable_voice_activity_events=self._settings["enable_voice_activity_events"], - interim_results=self._settings["enable_interim_results"], + enable_voice_activity_events=self._settings.enable_voice_activity_events, + interim_results=self._settings.enable_interim_results, ), ) @@ -824,7 +941,6 @@ class GoogleSTTService(STTService): """ if self._streaming_task: # Queue the audio data - await self.start_ttfb_metrics() await self.start_processing_metrics() await self._request_queue.put(audio) yield None @@ -855,7 +971,7 @@ class GoogleSTTService(STTService): if not transcript: continue - primary_language = self._settings["language_codes"][0] + primary_language = self._get_language_codes()[0] if result.is_final: self._last_transcript_was_final = True @@ -876,7 +992,6 @@ class GoogleSTTService(STTService): ) else: self._last_transcript_was_final = False - await self.stop_ttfb_metrics() await self.push_frame( InterimTranscriptionFrame( transcript, diff --git a/src/pipecat/services/google/tts.py b/src/pipecat/services/google/tts.py index 52c0e7aec..93053cc94 100644 --- a/src/pipecat/services/google/tts.py +++ b/src/pipecat/services/google/tts.py @@ -23,7 +23,8 @@ from pipecat.utils.tracing.service_decorators import traced_tts # Suppress gRPC fork warnings os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" -from typing import Any, AsyncGenerator, List, Literal, Mapping, Optional +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, List, Literal, Optional from loguru import logger from pydantic import BaseModel @@ -33,13 +34,18 @@ from pipecat.frames.frames import ( Frame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, - TTSStoppedFrame, +) +from pipecat.services.settings import ( + NOT_GIVEN, + TTSSettings, + _NotGiven, + is_given, ) from pipecat.services.tts_service import TTSService from pipecat.transcriptions.language import Language, resolve_language try: + from google.api_core.client_options import ClientOptions from google.auth import default from google.auth.exceptions import GoogleAuthError from google.cloud import texttospeech_v1 @@ -473,6 +479,70 @@ def language_to_gemini_tts_language(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=False) +@dataclass +class GoogleHttpTTSSettings(TTSSettings): + """Settings for GoogleHttpTTSService. + + Parameters: + pitch: Voice pitch adjustment (e.g., "+2st", "-50%"). + rate: Speaking rate adjustment (e.g., "slow", "fast", "125%"). Used for + SSML prosody tags (non-Chirp voices). + speaking_rate: Speaking rate for AudioConfig (Chirp/Journey voices). + Range [0.25, 2.0]. + volume: Volume adjustment (e.g., "loud", "soft", "+6dB"). + emphasis: Emphasis level for the text. + gender: Voice gender preference. + google_style: Google-specific voice style. + """ + + pitch: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + rate: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + speaking_rate: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + volume: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + emphasis: Literal["strong", "moderate", "reduced", "none"] | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + gender: Literal["male", "female", "neutral"] | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + google_style: ( + Literal["apologetic", "calm", "empathetic", "firm", "lively"] | None | _NotGiven + ) = field(default_factory=lambda: NOT_GIVEN) + + +@dataclass +class GoogleTTSSettings(TTSSettings): + """Settings for GoogleTTSService. + + Parameters: + speaking_rate: The speaking rate, in the range [0.25, 2.0]. + """ + + speaking_rate: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + +#: .. deprecated:: 0.0.105 +#: Use ``GoogleTTSService.Settings`` instead. +GoogleStreamTTSSettings = GoogleTTSSettings + + +@dataclass +class GeminiTTSSettings(TTSSettings): + """Settings for GeminiTTSService. + + Parameters: + prompt: Optional style instructions for how to synthesize the content. + multi_speaker: Whether to enable multi-speaker support. + speaker_configs: List of speaker configurations for multi-speaker mode. + """ + + prompt: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + multi_speaker: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + speaker_configs: list[dict[str, Any]] | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + + class GoogleHttpTTSService(TTSService): """Google Cloud Text-to-Speech HTTP service with SSML support. @@ -487,9 +557,15 @@ class GoogleHttpTTSService(TTSService): Chirp and Journey voices don't support SSML and will use plain text input. """ + Settings = GoogleHttpTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Google HTTP TTS voice customization. + .. deprecated:: 0.0.105 + Use ``GoogleHttpTTSService.Settings`` directly via the ``settings`` parameter instead. + Parameters: pitch: Voice pitch adjustment (e.g., "+2st", "-50%"). rate: Speaking rate adjustment (e.g., "slow", "fast", "125%"). Used for SSML prosody tags (non-Chirp voices). @@ -515,9 +591,11 @@ class GoogleHttpTTSService(TTSService): *, credentials: Optional[str] = None, credentials_path: Optional[str] = None, - voice_id: str = "en-US-Chirp3-HD-Charon", + location: Optional[str] = None, + voice_id: Optional[str] = None, sample_rate: Optional[int] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initializes the Google HTTP TTS service. @@ -525,28 +603,75 @@ class GoogleHttpTTSService(TTSService): Args: credentials: JSON string containing Google Cloud service account credentials. credentials_path: Path to Google Cloud service account JSON file. + location: Google Cloud location for regional endpoint (e.g., "us-central1"). voice_id: Google TTS voice identifier (e.g., "en-US-Standard-A"). + + .. deprecated:: 0.0.105 + Use ``settings=GoogleHttpTTSService.Settings(voice=...)`` instead. + sample_rate: Audio sample rate in Hz. If None, uses default. params: Voice customization parameters including pitch, rate, volume, etc. + + .. deprecated:: 0.0.105 + Use ``settings=GoogleHttpTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice="en-US-Chirp3-HD-Charon", + language="en-US", + pitch=None, + rate=None, + speaking_rate=None, + volume=None, + emphasis=None, + gender=None, + google_style=None, + ) - params = params or GoogleHttpTTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id - self._settings = { - "pitch": params.pitch, - "rate": params.rate, - "speaking_rate": params.speaking_rate, - "volume": params.volume, - "emphasis": params.emphasis, - "language": self.language_to_service_language(params.language) - if params.language - else "en-US", - "gender": params.gender, - "google_style": params.google_style, - } - self.set_voice(voice_id) + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.pitch is not None: + default_settings.pitch = params.pitch + if params.rate is not None: + default_settings.rate = params.rate + if params.speaking_rate is not None: + default_settings.speaking_rate = params.speaking_rate + if params.volume is not None: + default_settings.volume = params.volume + if params.emphasis is not None: + default_settings.emphasis = params.emphasis + if params.language is not None: + default_settings.language = params.language + if params.gender is not None: + default_settings.gender = params.gender + if params.google_style is not None: + default_settings.google_style = params.google_style + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) + + self._location = location self._client: texttospeech_v1.TextToSpeechAsyncClient = self._create_client( credentials, credentials_path ) @@ -586,7 +711,15 @@ class GoogleHttpTTSService(TTSService): if not creds: raise ValueError("No valid credentials provided.") - return texttospeech_v1.TextToSpeechAsyncClient(credentials=creds) + client_options = None + if self._location: + client_options = ClientOptions( + api_endpoint=f"{self._location}-texttospeech.googleapis.com" + ) + + return texttospeech_v1.TextToSpeechAsyncClient( + credentials=creds, client_options=client_options + ) def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -607,61 +740,60 @@ class GoogleHttpTTSService(TTSService): """ return language_to_google_tts_language(language) - async def _update_settings(self, settings: Mapping[str, Any]): - """Override to handle speaking_rate updates for Chirp/Journey voices. + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Override to handle speaking_rate validation. Args: - settings: Dictionary of settings to update. Can include 'speaking_rate' (float) + delta: Settings delta. Can include 'speaking_rate' (float). """ - if "speaking_rate" in settings: - rate_value = float(settings["speaking_rate"]) - if 0.25 <= rate_value <= 2.0: - self._settings["speaking_rate"] = rate_value - else: + if isinstance(delta, self.Settings) and is_given(delta.speaking_rate): + rate_value = float(delta.speaking_rate) + if not (0.25 <= rate_value <= 2.0): logger.warning( f"Invalid speaking_rate value: {rate_value}. Must be between 0.25 and 2.0" ) - await super()._update_settings(settings) + delta.speaking_rate = NOT_GIVEN + return await super()._update_settings(delta) def _construct_ssml(self, text: str) -> str: ssml = "" # Voice tag - voice_attrs = [f"name='{self._voice_id}'"] + voice_attrs = [f"name='{self._settings.voice}'"] - language = self._settings["language"] + language = self._settings.language voice_attrs.append(f"language='{language}'") - if self._settings["gender"]: - voice_attrs.append(f"gender='{self._settings['gender']}'") + if self._settings.gender: + voice_attrs.append(f"gender='{self._settings.gender}'") ssml += f"" # Prosody tag prosody_attrs = [] - if self._settings["pitch"]: - prosody_attrs.append(f"pitch='{self._settings['pitch']}'") - if self._settings["rate"]: - prosody_attrs.append(f"rate='{self._settings['rate']}'") - if self._settings["volume"]: - prosody_attrs.append(f"volume='{self._settings['volume']}'") + if self._settings.pitch: + prosody_attrs.append(f"pitch='{self._settings.pitch}'") + if self._settings.rate: + prosody_attrs.append(f"rate='{self._settings.rate}'") + if self._settings.volume: + prosody_attrs.append(f"volume='{self._settings.volume}'") if prosody_attrs: ssml += f"" # Emphasis tag - if self._settings["emphasis"]: - ssml += f"" + if self._settings.emphasis: + ssml += f"" # Google style tag - if self._settings["google_style"]: - ssml += f"" + if self._settings.google_style: + ssml += f"" ssml += text # Close tags - if self._settings["google_style"]: + if self._settings.google_style: ssml += "" - if self._settings["emphasis"]: + if self._settings.emphasis: ssml += "" if prosody_attrs: ssml += "" @@ -670,11 +802,12 @@ class GoogleHttpTTSService(TTSService): return ssml @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Google's HTTP TTS API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -682,11 +815,9 @@ class GoogleHttpTTSService(TTSService): logger.debug(f"{self}: Generating TTS [{text}]") try: - await self.start_ttfb_metrics() - # Check if the voice is a Chirp voice (including Chirp 3) or Journey voice - is_chirp_voice = "chirp" in self._voice_id.lower() - is_journey_voice = "journey" in self._voice_id.lower() + is_chirp_voice = "chirp" in self._settings.voice.lower() + is_journey_voice = "journey" in self._settings.voice.lower() # Create synthesis input based on voice_id if is_chirp_voice or is_journey_voice: @@ -697,7 +828,7 @@ class GoogleHttpTTSService(TTSService): synthesis_input = texttospeech_v1.SynthesisInput(ssml=ssml) voice = texttospeech_v1.VoiceSelectionParams( - language_code=self._settings["language"], name=self._voice_id + language_code=self._settings.language, name=self._settings.voice ) # Build audio config with conditional speaking_rate audio_config_params = { @@ -706,8 +837,8 @@ class GoogleHttpTTSService(TTSService): } # For Chirp and Journey voices, include speaking_rate in AudioConfig - if (is_chirp_voice or is_journey_voice) and self._settings["speaking_rate"] is not None: - audio_config_params["speaking_rate"] = self._settings["speaking_rate"] + if (is_chirp_voice or is_journey_voice) and self._settings.speaking_rate is not None: + audio_config_params["speaking_rate"] = self._settings.speaking_rate audio_config = texttospeech_v1.AudioConfig(**audio_config_params) @@ -719,8 +850,6 @@ class GoogleHttpTTSService(TTSService): await self.start_tts_usage_metrics(text) - yield TTSStartedFrame() - # Skip the first 44 bytes to remove the WAV header audio_content = response.audio_content[44:] @@ -731,11 +860,9 @@ class GoogleHttpTTSService(TTSService): if not chunk: break await self.stop_ttfb_metrics() - frame = TTSAudioRawFrame(chunk, self.sample_rate, 1) + frame = TTSAudioRawFrame(chunk, self.sample_rate, 1, context_id=context_id) yield frame - yield TTSStoppedFrame() - except Exception as e: error_message = f"TTS generation error: {str(e)}" yield ErrorFrame(error=error_message) @@ -783,7 +910,15 @@ class GoogleBaseTTSService(TTSService): if not creds: raise ValueError("No valid credentials provided.") - return texttospeech_v1.TextToSpeechAsyncClient(credentials=creds) + client_options = None + if self._location: + client_options = ClientOptions( + api_endpoint=f"{self._location}-texttospeech.googleapis.com" + ) + + return texttospeech_v1.TextToSpeechAsyncClient( + credentials=creds, client_options=client_options + ) def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -808,6 +943,7 @@ class GoogleBaseTTSService(TTSService): self, streaming_config: texttospeech_v1.StreamingSynthesizeConfig, text: str, + context_id: str, prompt: Optional[str] = None, ) -> AsyncGenerator[Frame, None]: """Shared streaming synthesis logic. @@ -815,6 +951,7 @@ class GoogleBaseTTSService(TTSService): Args: streaming_config: The streaming configuration. text: The text to synthesize. + context_id: Unique identifier for this TTS context. prompt: Optional prompt for style instructions (Gemini only). Yields: @@ -836,8 +973,6 @@ class GoogleBaseTTSService(TTSService): streaming_responses = await self._client.streaming_synthesize(request_generator()) await self.start_tts_usage_metrics(text) - yield TTSStartedFrame() - audio_buffer = b"" first_chunk_for_ttfb = False @@ -856,12 +991,10 @@ class GoogleBaseTTSService(TTSService): while len(audio_buffer) >= CHUNK_SIZE: piece = audio_buffer[:CHUNK_SIZE] audio_buffer = audio_buffer[CHUNK_SIZE:] - yield TTSAudioRawFrame(piece, self.sample_rate, 1) + yield TTSAudioRawFrame(piece, self.sample_rate, 1, context_id=context_id) if audio_buffer: - yield TTSAudioRawFrame(audio_buffer, self.sample_rate, 1) - - yield TTSStoppedFrame() + yield TTSAudioRawFrame(audio_buffer, self.sample_rate, 1, context_id=context_id) class GoogleTTSService(GoogleBaseTTSService): @@ -880,16 +1013,22 @@ class GoogleTTSService(GoogleBaseTTSService): tts = GoogleTTSService( credentials_path="/path/to/service-account.json", - voice_id="en-US-Chirp3-HD-Charon", - params=GoogleTTSService.InputParams( + settings=GoogleTTSService.Settings( + voice="en-US-Chirp3-HD-Charon", language=Language.EN_US, ) ) """ + Settings = GoogleTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Google streaming TTS configuration. + .. deprecated:: 0.0.105 + Use ``GoogleTTSService.Settings`` directly via the ``settings`` parameter instead. + Parameters: language: Language for synthesis. Defaults to English. speaking_rate: The speaking rate, in the range [0.25, 2.0]. @@ -903,10 +1042,12 @@ class GoogleTTSService(GoogleBaseTTSService): *, credentials: Optional[str] = None, credentials_path: Optional[str] = None, - voice_id: str = "en-US-Chirp3-HD-Charon", + location: Optional[str] = None, + voice_id: Optional[str] = None, voice_cloning_key: Optional[str] = None, sample_rate: Optional[int] = None, - params: InputParams = InputParams(), + params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initializes the Google streaming TTS service. @@ -914,50 +1055,85 @@ class GoogleTTSService(GoogleBaseTTSService): Args: credentials: JSON string containing Google Cloud service account credentials. credentials_path: Path to Google Cloud service account JSON file. + location: Google Cloud location for regional endpoint (e.g., "us-central1"). voice_id: Google TTS voice identifier (e.g., "en-US-Chirp3-HD-Charon"). + + .. deprecated:: 0.0.105 + Use ``settings=GoogleTTSService.Settings(voice=...)`` instead. + voice_cloning_key: The voice cloning key for Chirp 3 custom voices. sample_rate: Audio sample rate in Hz. If None, uses default. params: Language configuration parameters. + + .. deprecated:: 0.0.105 + Use ``settings=GoogleTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice="en-US-Chirp3-HD-Charon", + language="en-US", + speaking_rate=None, + ) - params = params or GoogleTTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id - self._settings = { - "language": self.language_to_service_language(params.language) - if params.language - else "en-US", - "speaking_rate": params.speaking_rate, - } - self.set_voice(voice_id) + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.speaking_rate is not None: + default_settings.speaking_rate = params.speaking_rate + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) + + self._location = location self._voice_cloning_key = voice_cloning_key self._client: texttospeech_v1.TextToSpeechAsyncClient = self._create_client( credentials, credentials_path ) - async def _update_settings(self, settings: Mapping[str, Any]): - """Override to handle speaking_rate updates for streaming API. + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Override to handle speaking_rate validation. Args: - settings: Dictionary of settings to update. Can include 'speaking_rate' (float) + delta: Settings delta. Can include 'speaking_rate' (float). """ - if "speaking_rate" in settings: - rate_value = float(settings["speaking_rate"]) - if 0.25 <= rate_value <= 2.0: - self._settings["speaking_rate"] = rate_value - else: + if isinstance(delta, self.Settings) and is_given(delta.speaking_rate): + rate_value = float(delta.speaking_rate) + if not (0.25 <= rate_value <= 2.0): logger.warning( f"Invalid speaking_rate value: {rate_value}. Must be between 0.25 and 2.0" ) - await super()._update_settings(settings) + delta.speaking_rate = NOT_GIVEN + return await super()._update_settings(delta) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate streaming speech from text using Google's streaming API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech as it's generated. @@ -965,19 +1141,17 @@ class GoogleTTSService(GoogleBaseTTSService): logger.debug(f"{self}: Generating TTS [{text}]") try: - await self.start_ttfb_metrics() - # Build voice selection params if self._voice_cloning_key: voice_clone_params = texttospeech_v1.VoiceCloneParams( voice_cloning_key=self._voice_cloning_key ) voice = texttospeech_v1.VoiceSelectionParams( - language_code=self._settings["language"], voice_clone=voice_clone_params + language_code=self._settings.language, voice_clone=voice_clone_params ) else: voice = texttospeech_v1.VoiceSelectionParams( - language_code=self._settings["language"], name=self._voice_id + language_code=self._settings.language, name=self._settings.voice ) # Create streaming config @@ -986,12 +1160,12 @@ class GoogleTTSService(GoogleBaseTTSService): streaming_audio_config=texttospeech_v1.StreamingAudioConfig( audio_encoding=texttospeech_v1.AudioEncoding.PCM, sample_rate_hertz=self.sample_rate, - speaking_rate=self._settings["speaking_rate"], + speaking_rate=self._settings.speaking_rate, ), ) # Use base class streaming logic - async for frame in self._stream_tts(streaming_config, text): + async for frame in self._stream_tts(streaming_config, text, context_id): yield frame except Exception as e: @@ -1016,15 +1190,18 @@ class GeminiTTSService(GoogleBaseTTSService): tts = GeminiTTSService( credentials_path="/path/to/service-account.json", - model="gemini-2.5-flash-tts", - voice_id="Kore", - params=GeminiTTSService.InputParams( + settings=GeminiTTSService.Settings( + model="gemini-2.5-flash-tts", + voice="Kore", language=Language.EN_US, prompt="Say this in a friendly and helpful tone" ) ) """ + Settings = GeminiTTSSettings + _settings: Settings + GOOGLE_SAMPLE_RATE = 24000 # Google TTS always outputs at 24kHz # List of available Gemini TTS voices @@ -1064,6 +1241,9 @@ class GeminiTTSService(GoogleBaseTTSService): class InputParams(BaseModel): """Input parameters for Gemini TTS configuration. + .. deprecated:: 0.0.105 + Use ``GeminiTTSService.Settings`` directly via the ``settings`` parameter instead. + Parameters: language: Language for synthesis. Defaults to English. prompt: Optional style instructions for how to synthesize the content. @@ -1080,12 +1260,14 @@ class GeminiTTSService(GoogleBaseTTSService): self, *, api_key: Optional[str] = None, - model: str = "gemini-2.5-flash-tts", + model: Optional[str] = None, credentials: Optional[str] = None, credentials_path: Optional[str] = None, - voice_id: str = "Kore", + location: Optional[str] = None, + voice_id: Optional[str] = None, sample_rate: Optional[int] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initializes the Gemini TTS service. @@ -1099,11 +1281,26 @@ class GeminiTTSService(GoogleBaseTTSService): model: Gemini TTS model to use. Must be a TTS model like "gemini-2.5-flash-tts" or "gemini-2.5-pro-tts". + + .. deprecated:: 0.0.105 + Use ``settings=GeminiTTSService.Settings(model=...)`` instead. + credentials: JSON string containing Google Cloud service account credentials. credentials_path: Path to Google Cloud service account JSON file. + location: Google Cloud location for regional endpoint (e.g., "us-central1"). voice_id: Voice name from the available Gemini voices. + + .. deprecated:: 0.0.105 + Use ``settings=GeminiTTSService.Settings(voice=...)`` instead. + sample_rate: Audio sample rate in Hz. If None, uses Google's default 24kHz. params: TTS configuration parameters. + + .. deprecated:: 0.0.105 + Use ``settings=GeminiTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService. """ # Handle deprecated api_key parameter @@ -1120,24 +1317,56 @@ class GeminiTTSService(GoogleBaseTTSService): f"Google TTS only supports {self.GOOGLE_SAMPLE_RATE}Hz sample rate. " f"Current rate of {sample_rate}Hz may cause issues." ) - super().__init__(sample_rate=sample_rate, **kwargs) - params = params or GeminiTTSService.InputParams() + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="gemini-2.5-flash-tts", + voice="Kore", + language="en-US", + prompt=None, + multi_speaker=False, + speaker_configs=None, + ) - if voice_id not in self.AVAILABLE_VOICES: - logger.warning(f"Voice '{voice_id}' not in known voices list. Using anyway.") + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id - self._model = model - self._voice_id = voice_id - self._settings = { - "language": self.language_to_service_language(params.language) - if params.language - else "en-US", - "prompt": params.prompt, - "multi_speaker": params.multi_speaker, - "speaker_configs": params.speaker_configs, - } + if default_settings.voice not in self.AVAILABLE_VOICES: + logger.warning( + f"Voice '{default_settings.voice}' not in known voices list. Using anyway." + ) + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.prompt is not None: + default_settings.prompt = params.prompt + if params.multi_speaker is not None: + default_settings.multi_speaker = params.multi_speaker + if params.speaker_configs is not None: + default_settings.speaker_configs = params.speaker_configs + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) + + self._location = location self._client: texttospeech_v1.TextToSpeechAsyncClient = self._create_client( credentials, credentials_path ) @@ -1153,16 +1382,6 @@ class GeminiTTSService(GoogleBaseTTSService): """ return language_to_gemini_tts_language(language) - def set_voice(self, voice_id: str): - """Set the voice for TTS generation. - - Args: - voice_id: Name of the voice to use from AVAILABLE_VOICES. - """ - if voice_id not in self.AVAILABLE_VOICES: - logger.warning(f"Voice '{voice_id}' not in known voices list. Using anyway.") - self._voice_id = voice_id - async def start(self, frame: StartFrame): """Start the Gemini TTS service. @@ -1176,22 +1395,27 @@ class GeminiTTSService(GoogleBaseTTSService): f"Current rate of {self.sample_rate}Hz may cause issues." ) - async def _update_settings(self, settings: Mapping[str, Any]): - """Override to handle prompt updates. + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta with voice validation. Args: - settings: Dictionary of settings to update. Can include 'prompt' (str) + delta: Settings delta. Can include 'voice', 'prompt', etc. + + Returns: + Dict mapping changed field names to their previous values. """ - if "prompt" in settings: - self._settings["prompt"] = settings["prompt"] - await super()._update_settings(settings) + if is_given(delta.voice) and delta.voice not in self.AVAILABLE_VOICES: + logger.warning(f"Voice '{delta.voice}' not in known voices list. Using anyway.") + + return await super()._update_settings(delta) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate streaming speech from text using Gemini TTS models. Args: - text: The text to synthesize into speech. Can include markup tags + text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Can include markup tags like [sigh], [laughing], [whispering] for expressive control. Yields: @@ -1200,17 +1424,15 @@ class GeminiTTSService(GoogleBaseTTSService): logger.debug(f"{self}: Generating TTS [{text}]") try: - await self.start_ttfb_metrics() - # Build voice selection params - if self._settings["multi_speaker"] and self._settings["speaker_configs"]: + if self._settings.multi_speaker and self._settings.speaker_configs: # Multi-speaker mode speaker_voice_configs = [] - for speaker_config in self._settings["speaker_configs"]: + for speaker_config in self._settings.speaker_configs: speaker_voice_configs.append( texttospeech_v1.MultispeakerPrebuiltVoice( speaker_alias=speaker_config["speaker_alias"], - speaker_id=speaker_config.get("speaker_id", self._voice_id), + speaker_id=speaker_config.get("speaker_id", self._settings.voice), ) ) @@ -1219,16 +1441,16 @@ class GeminiTTSService(GoogleBaseTTSService): ) voice = texttospeech_v1.VoiceSelectionParams( - language_code=self._settings["language"], - model_name=self._model, + language_code=self._settings.language, + model_name=self._settings.model, multi_speaker_voice_config=multi_speaker_voice_config, ) else: # Single speaker mode voice = texttospeech_v1.VoiceSelectionParams( - language_code=self._settings["language"], - name=self._voice_id, - model_name=self._model, + language_code=self._settings.language, + name=self._settings.voice, + model_name=self._settings.model, ) # Create streaming config @@ -1241,7 +1463,9 @@ class GeminiTTSService(GoogleBaseTTSService): ) # Use base class streaming logic with prompt support - async for frame in self._stream_tts(streaming_config, text, self._settings["prompt"]): + async for frame in self._stream_tts( + streaming_config, text, context_id, self._settings.prompt + ): yield frame except Exception as e: diff --git a/src/pipecat/services/google/vertex/__init__.py b/src/pipecat/services/google/vertex/__init__.py new file mode 100644 index 000000000..c4d243b97 --- /dev/null +++ b/src/pipecat/services/google/vertex/__init__.py @@ -0,0 +1,5 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# diff --git a/src/pipecat/services/google/vertex/llm.py b/src/pipecat/services/google/vertex/llm.py new file mode 100644 index 000000000..946a8f1bb --- /dev/null +++ b/src/pipecat/services/google/vertex/llm.py @@ -0,0 +1,306 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Google Vertex AI LLM service implementation. + +This module provides integration with Google's AI models via Vertex AI, +extending the GoogleLLMService with Vertex AI authentication. +""" + +import json +import os +from dataclasses import dataclass + +# Suppress gRPC fork warnings +os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" + +from typing import Optional + +from loguru import logger + +from pipecat.services.google.llm import GoogleLLMService + +try: + from google.auth import default + from google.auth.exceptions import GoogleAuthError + from google.auth.transport.requests import Request + from google.genai import Client + from google.genai.types import HttpOptions + from google.oauth2 import service_account + +except ModuleNotFoundError as e: + logger.error(f"Exception: {e}") + logger.error( + "In order to use Google AI, you need to `pip install pipecat-ai[google]`. Also, set `GOOGLE_APPLICATION_CREDENTIALS` environment variable." + ) + raise Exception(f"Missing module: {e}") + + +@dataclass +class GoogleVertexLLMSettings(GoogleLLMService.Settings): + """Settings for GoogleVertexLLMService.""" + + pass + + +class GoogleVertexLLMService(GoogleLLMService): + """Google Vertex AI LLM service extending GoogleLLMService. + + Provides access to Google's AI models via Vertex AI while using the same + Google AI client and message format as GoogleLLMService. Handles authentication + using Google service account credentials and configures the client for + Vertex AI endpoints. + + Reference: + https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference + """ + + Settings = GoogleVertexLLMSettings + _settings: Settings + + class InputParams(GoogleLLMService.InputParams): + """Input parameters specific to Vertex AI. + + Parameters: + location: GCP region for Vertex AI endpoint (e.g., "us-east4"). + + .. deprecated:: 0.0.90 + Use `location` as a direct argument to + `GoogleVertexLLMService.__init__()` instead. + + project_id: Google Cloud project ID. + + .. deprecated:: 0.0.90 + Use `project_id` as a direct argument to + `GoogleVertexLLMService.__init__()` instead. + """ + + # https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations + location: Optional[str] = None + project_id: Optional[str] = None + + def __init__(self, **kwargs): + """Initializes the InputParams.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + if "location" in kwargs and kwargs["location"] is not None: + warnings.warn( + "GoogleVertexLLMService.InputParams.location is deprecated. " + "Please provide 'location' as a direct argument to GoogleVertexLLMService.__init__() instead.", + DeprecationWarning, + stacklevel=2, + ) + + if "project_id" in kwargs and kwargs["project_id"] is not None: + warnings.warn( + "GoogleVertexLLMService.InputParams.project_id is deprecated. " + "Please provide 'project_id' as a direct argument to GoogleVertexLLMService.__init__() instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) + + def __init__( + self, + *, + credentials: Optional[str] = None, + credentials_path: Optional[str] = None, + model: Optional[str] = None, + location: Optional[str] = None, + project_id: Optional[str] = None, + params: Optional[GoogleLLMService.InputParams] = None, + settings: Optional[Settings] = None, + system_instruction: Optional[str] = None, + tools: Optional[list] = None, + tool_config: Optional[dict] = None, + http_options: Optional[HttpOptions] = None, + **kwargs, + ): + """Initializes the VertexLLMService. + + Args: + credentials: JSON string of service account credentials. + credentials_path: Path to the service account JSON file. + model: Model identifier (e.g., "gemini-2.5-flash"). + + .. deprecated:: 0.0.105 + Use ``settings=GoogleVertexLLMService.Settings(model=...)`` instead. + + location: GCP region for Vertex AI endpoint (e.g., "us-east4"). + project_id: Google Cloud project ID. + params: Input parameters for the model. + + .. deprecated:: 0.0.105 + Use ``settings=GoogleVertexLLMService.Settings(...)`` instead. + + settings: Runtime-updatable settings for this service. When both + deprecated parameters and *settings* are provided, *settings* + values take precedence. + system_instruction: System instruction/prompt for the model. + + .. deprecated:: 0.0.105 + Use ``settings=GoogleVertexLLMService.Settings(system_instruction=...)`` instead. + tools: List of available tools/functions. + tool_config: Configuration for tool usage. + http_options: HTTP options for the client. + **kwargs: Additional arguments passed to GoogleLLMService. + """ + # Check if user incorrectly passed api_key, which is used by parent + # class but not here. + if "api_key" in kwargs: + logger.error( + "GoogleVertexLLMService does not accept 'api_key' parameter. " + "Use 'credentials' or 'credentials_path' instead for Vertex AI authentication." + ) + raise ValueError( + "Invalid parameter 'api_key'. Use 'credentials' or 'credentials_path' for Vertex AI authentication." + ) + + # Handle deprecated InputParams fields (location/project_id extraction + # must happen before validation, regardless of settings) + if params and isinstance(params, GoogleVertexLLMService.InputParams): + if project_id is None: + project_id = params.project_id + if location is None: + location = params.location + # Convert to base InputParams + params = GoogleLLMService.InputParams( + **params.model_dump(exclude={"location", "project_id"}, exclude_unset=True) + ) + + # Validate project_id and location parameters + # NOTE: once we remove Vertex-specific InputParams class, we can update + # __init__() signature as follows: + # - location: str = "us-east4", + # - project_id: str, + # But for now, we need them as-is to maintain proper backward + # compatibility. + if project_id is None: + raise ValueError("project_id is required") + if location is None: + # If location is not provided, default to "us-east4". + # Note: this is legacy behavior; ideally location would be + # required. + logger.warning("location is not provided. Defaulting to 'us-east4'.") + location = "us-east4" # Default location if not provided + + # These need to be set before calling super().__init__() because + # super().__init__() invokes _create_client(), which needs these. + self._credentials = self._get_credentials(credentials, credentials_path) + self._project_id = project_id + self._location = location + + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="gemini-2.5-flash", + system_instruction=None, + max_tokens=4096, + temperature=None, + top_k=None, + top_p=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + thinking=None, + extra={}, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if system_instruction is not None: + self._warn_init_param_moved_to_settings("system_instruction", "system_instruction") + default_settings.system_instruction = system_instruction + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.max_tokens = params.max_tokens + default_settings.temperature = params.temperature + default_settings.top_k = params.top_k + default_settings.top_p = params.top_p + default_settings.thinking = params.thinking + if isinstance(params.extra, dict): + default_settings.extra = params.extra + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + # Call parent constructor with dummy api_key + # (api_key is required by parent class, but not actually used with Vertex) + super().__init__( + api_key="dummy", + settings=default_settings, + tools=tools, + tool_config=tool_config, + http_options=http_options, + **kwargs, + ) + + def create_client(self): + """Create the Gemini client instance configured for Vertex AI.""" + self._client = Client( + vertexai=True, + credentials=self._credentials, + project=self._project_id, + location=self._location, + http_options=self._http_options, + ) + + @staticmethod + def _get_credentials(credentials: Optional[str], credentials_path: Optional[str]): + """Retrieve Credentials using Google service account credentials. + + Supports multiple authentication methods: + 1. Direct JSON credentials string + 2. Path to service account JSON file + 3. Default application credentials (ADC) + + Args: + credentials: JSON string of service account credentials. + credentials_path: Path to the service account JSON file. + + Returns: + Google credentials object for API authentication. + + Raises: + ValueError: If no valid credentials are provided or found. + """ + creds: Optional[service_account.Credentials] = None + + if credentials: + # Parse and load credentials from JSON string + creds = service_account.Credentials.from_service_account_info( + json.loads(credentials), + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + elif credentials_path: + # Load credentials from JSON file + creds = service_account.Credentials.from_service_account_file( + credentials_path, + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + else: + try: + creds, project_id = default( + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + except GoogleAuthError: + pass + + if not creds: + raise ValueError("No valid credentials provided.") + + creds.refresh(Request()) # Ensure token is up-to-date, lifetime is 1 hour. + + return creds diff --git a/src/pipecat/services/gradium/stt.py b/src/pipecat/services/gradium/stt.py index f869983d3..5dea2c824 100644 --- a/src/pipecat/services/gradium/stt.py +++ b/src/pipecat/services/gradium/stt.py @@ -10,21 +10,30 @@ This module provides integration with Gradium's real-time speech-to-text WebSocket API for streaming audio transcription. """ +import asyncio import base64 import json -from typing import AsyncGenerator +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Optional from loguru import logger +from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, EndFrame, Frame, + InterimTranscriptionFrame, StartFrame, TranscriptionFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, ) +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven +from pipecat.services.stt_latency import GRADIUM_TTFS_P99 from pipecat.services.stt_service import WebsocketSTTService -from pipecat.transcriptions.language import Language +from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_stt @@ -36,7 +45,71 @@ except ModuleNotFoundError as e: logger.error('In order to use Gradium, you need to `pip install "pipecat-ai[gradium]"`.') raise Exception(f"Missing module: {e}") -SAMPLE_RATE = 24000 +# Seconds to wait after a "flushed" message for trailing text tokens to arrive +# before finalizing the transcription. +TRANSCRIPT_AGGREGATION_DELAY = 0.1 + + +def _input_format_from_encoding(encoding: str, sample_rate: int) -> str: + """Build Gradium input_format from encoding type and sample rate. + + For PCM encoding, appends the sample rate (e.g., "pcm_16000"). + For other encodings (wav, opus), returns the encoding as-is. + + Args: + encoding: Base encoding type ("pcm", "wav", or "opus"). + sample_rate: Audio sample rate in Hz. + + Returns: + The full input_format string for the Gradium API. + """ + if encoding == "pcm": + match sample_rate: + case 8000: + return "pcm_8000" + case 16000: + return "pcm_16000" + case 24000: + return "pcm_24000" + logger.warning( + f"GradiumSTTService: unsupported sample rate {sample_rate} for PCM encoding, using pcm_16000" + ) + return "pcm_16000" + return encoding + + +def language_to_gradium_language(language: Language) -> Optional[str]: + """Convert a Language enum to Gradium's language code format. + + Args: + language: The Language enum value to convert. + + Returns: + The Gradium language code string or None if not supported. + """ + LANGUAGE_MAP = { + Language.DE: "de", + Language.EN: "en", + Language.ES: "es", + Language.FR: "fr", + Language.PT: "pt", + } + + return resolve_language(language, LANGUAGE_MAP, use_base_code=True) + + +@dataclass +class GradiumSTTSettings(STTSettings): + """Settings for GradiumSTTService. + + Parameters: + delay_in_frames: Delay in audio frames (80ms each) before text is + generated. Higher delays allow more context but increase latency. + Allowed values: 7, 8, 10, 12, 14, 16, 20, 24, 36, 48. + Default is 10 (800ms). Lower values like 7-8 give faster response. + """ + + delay_in_frames: Optional[int] | _NotGiven = field(default_factory=lambda: NOT_GIVEN) class GradiumSTTService(WebsocketSTTService): @@ -47,12 +120,39 @@ class GradiumSTTService(WebsocketSTTService): for audio processing and connection management. """ + Settings = GradiumSTTSettings + _settings: Settings + + class InputParams(BaseModel): + """Configuration parameters for Gradium STT API. + + .. deprecated:: 0.0.105 + Use ``settings=GradiumSTTService.Settings(...)`` instead. + + Parameters: + language: Expected language of the audio (e.g., "en", "es", "fr"). + This helps ground the model to a specific language and improve + transcription quality. + delay_in_frames: Delay in audio frames (80ms each) before text is + generated. Higher delays allow more context but increase latency. + Allowed values: 7, 8, 10, 12, 14, 16, 20, 24, 36, 48. + Default is 10 (800ms). Lower values like 7-8 give faster response. + """ + + language: Optional[Language] = None + delay_in_frames: Optional[int] = None + def __init__( self, *, api_key: str, api_endpoint_base_url: str = "wss://eu.api.gradium.ai/api/speech/asr", - json_config: str | None = None, + encoding: str = "pcm", + sample_rate: Optional[int] = None, + params: Optional[InputParams] = None, + json_config: Optional[str] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = GRADIUM_TTFS_P99, **kwargs, ): """Initialize the Gradium STT service. @@ -60,22 +160,87 @@ class GradiumSTTService(WebsocketSTTService): Args: api_key: Gradium API key for authentication. api_endpoint_base_url: WebSocket endpoint URL. Defaults to Gradium's streaming endpoint. + encoding: Base audio encoding type. One of "pcm", "wav", or "opus". + For PCM, the sample rate is appended automatically from the + pipeline's audio_in_sample_rate (e.g., "pcm" becomes "pcm_16000"). + Defaults to "pcm". + sample_rate: Audio sample rate in Hz. If None, uses the pipeline + sample rate. + params: Configuration parameters for language and delay settings. + + .. deprecated:: 0.0.105 + Use ``settings=GradiumSTTService.Settings(...)`` instead. + json_config: Optional JSON configuration string for additional model settings. + + .. deprecated:: 0.0.101 + Use `params` instead for type-safe configuration. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to parent STTService class. """ - super().__init__(sample_rate=SAMPLE_RATE, **kwargs) + if json_config is not None: + import warnings + + warnings.warn( + "Parameter 'json_config' is deprecated and will be removed in a future version, use 'params' instead.", + DeprecationWarning, + stacklevel=2, + ) + + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="default", + language=None, + delay_in_frames=None, + ) + + # 2. (No step 2, as there are no deprecated direct args) + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = params.language + if params.delay_in_frames is not None: + default_settings.delay_in_frames = params.delay_in_frames + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._api_endpoint_base_url = api_endpoint_base_url + self._encoding = encoding self._websocket = None self._json_config = json_config self._receive_task = None + self._input_format = "" + self._audio_buffer = bytearray() self._chunk_size_ms = 80 self._chunk_size_bytes = 0 + # Accumulates text fragments within a turn. Each "text" message + # appends to this list. On "flushed" a short aggregation delay + # allows trailing tokens to arrive before the full text is joined + # and pushed as a TranscriptionFrame. + self._accumulated_text: list[str] = [] + self._flush_counter = 0 + self._transcript_aggregation_task: Optional[asyncio.Task] = None + def can_generate_metrics(self) -> bool: """Check if the service can generate metrics. @@ -84,6 +249,24 @@ class GradiumSTTService(WebsocketSTTService): """ return True + async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: + """Apply a settings delta, sync params, and reconnect. + + Args: + delta: A :class:`STTSettings` (or ``GradiumSTTService.Settings``) delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + if not changed: + return changed + + if self._websocket: + await self._disconnect() + await self._connect() + return changed + async def start(self, frame: StartFrame): """Start the speech-to-text service. @@ -91,6 +274,7 @@ class GradiumSTTService(WebsocketSTTService): frame: Start frame to begin processing. """ await super().start(frame) + self._input_format = _input_format_from_encoding(self._encoding, self.sample_rate) self._chunk_size_bytes = int(self._chunk_size_ms * self.sample_rate * 2 / 1000) await self._connect() @@ -112,6 +296,42 @@ class GradiumSTTService(WebsocketSTTService): await super().cancel(frame) await self._disconnect() + async def _start_metrics(self): + """Start performance metrics collection for transcription processing.""" + await self.start_processing_metrics() + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process incoming frames and handle speech events. + + Args: + frame: The frame to process. + direction: Direction of frame flow in the pipeline. + """ + await super().process_frame(frame, direction) + + if isinstance(frame, VADUserStartedSpeakingFrame): + await self._start_metrics() + elif isinstance(frame, VADUserStoppedSpeakingFrame): + await self._send_flush() + + async def _send_flush(self): + """Send a flush request to process any buffered audio immediately. + + Sends a flush message to tell the server to process buffered audio. + The server responds with text fragments followed by a "flushed" + acknowledgment, which triggers finalization. + """ + if not self._websocket or self._websocket.state is not State.OPEN: + return + + self._flush_counter += 1 + flush_id = str(self._flush_counter) + msg = {"type": "flush", "flush_id": flush_id} + try: + await self._websocket.send(json.dumps(msg)) + except Exception as e: + logger.warning(f"Failed to send flush: {e}") + async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: """Process audio data for speech-to-text conversion. @@ -122,8 +342,6 @@ class GradiumSTTService(WebsocketSTTService): None (processing handled via WebSocket messages). """ self._audio_buffer.extend(audio) - await self.start_ttfb_metrics() - await self.start_processing_metrics() while len(self._audio_buffer) >= self._chunk_size_bytes: chunk = bytes(self._audio_buffer[: self._chunk_size_bytes]) @@ -141,6 +359,8 @@ class GradiumSTTService(WebsocketSTTService): pass async def _connect(self): + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: @@ -150,6 +370,9 @@ class GradiumSTTService(WebsocketSTTService): try: if self._websocket and self._websocket.state is State.OPEN: return + + logger.debug("Connecting to Gradium STT") + ws_url = self._api_endpoint_base_url headers = { "x-api-key": self._api_key, @@ -162,10 +385,21 @@ class GradiumSTTService(WebsocketSTTService): await self._call_event_handler("on_connected") setup_msg = { "type": "setup", - "input_format": "pcm", + "model_name": self._settings.model, + "input_format": self._input_format, } - if self._json_config is not None: - setup_msg["json_config"] = self._json_config + # Build json_config: start with deprecated json_config, then override with params + json_config = {} + if self._json_config: + json_config = json.loads(self._json_config) + if self._settings.language: + gradium_language = language_to_gradium_language(self._settings.language) + if gradium_language: + json_config["language"] = gradium_language + if self._settings.delay_in_frames: + json_config["delay_in_frames"] = self._settings.delay_in_frames + if json_config: + setup_msg["json_config"] = json_config await self._websocket.send(json.dumps(setup_msg)) ready_msg = await self._websocket.recv() ready_msg = json.loads(ready_msg) @@ -174,11 +408,22 @@ class GradiumSTTService(WebsocketSTTService): if ready_msg["type"] != "ready": raise Exception(f"unexpected first message type {ready_msg['type']}") + logger.debug("Connected to Gradium STT") + except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) raise async def _disconnect(self): + await super()._disconnect() + + if self._transcript_aggregation_task: + await self.cancel_task(self._transcript_aggregation_task) + self._transcript_aggregation_task = None + + self._accumulated_text.clear() + self._flush_counter = 0 + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None @@ -201,39 +446,75 @@ class GradiumSTTService(WebsocketSTTService): return self._websocket raise Exception("Websocket not connected") - async def _process_messages(self): + async def _receive_messages(self): async for message in self._get_websocket(): try: - data = json.loads(message) - await self._process_response(data) + msg = json.loads(message) except json.JSONDecodeError: logger.warning(f"Received non-JSON message: {message}") + continue - async def _receive_messages(self): - while True: - await self._process_messages() - logger.debug(f"{self} Gradium connection was disconnected (timeout?), reconnecting") - await self._connect_websocket() - - async def _process_response(self, msg): - type_ = msg.get("type", "") - if type_ == "text": - await self._handle_text(msg["text"]) - elif type_ == "end_of_stream": - await self._handle_end_of_stream() - elif type_ == "error": - await self.push_error(error_msg=f"Error: {msg}") - - async def _handle_end_of_stream(self): - """Handle termination message.""" - logger.debug("Received end_of_stream message from server") + type_ = msg.get("type", "") + if type_ == "text": + await self._handle_text(msg["text"]) + elif type_ == "flushed": + await self._handle_flushed() + elif type_ == "end_of_stream": + logger.debug("Received end_of_stream message from server") + elif type_ == "error": + await self.push_error(error_msg=f"Error: {msg}") async def _handle_text(self, text: str): - """Handle transcription results.""" + """Handle streaming transcription fragment. + + Accumulates text and pushes an InterimTranscriptionFrame with the + full accumulated text so far. + """ + self._accumulated_text.append(text) + accumulated = " ".join(self._accumulated_text) + await self.push_frame( + InterimTranscriptionFrame( + text=accumulated, + user_id=self._user_id, + timestamp=time_now_iso8601(), + language=self._settings.language, + ) + ) + await self.stop_processing_metrics() + + async def _handle_flushed(self): + """Handle flush completion by starting a transcript aggregation timer. + + The "flushed" message confirms that buffered audio has been processed, + but text tokens may still arrive after this point. A short timer allows + trailing tokens to accumulate before finalizing the transcription. + """ + if self._transcript_aggregation_task: + await self.cancel_task(self._transcript_aggregation_task) + self._transcript_aggregation_task = self.create_task( + self._transcript_aggregation_handler(), "transcript_aggregation" + ) + + async def _transcript_aggregation_handler(self): + """Wait for trailing tokens then finalize the accumulated transcription.""" + await asyncio.sleep(TRANSCRIPT_AGGREGATION_DELAY) + await self._finalize_accumulated_text() + + async def _finalize_accumulated_text(self): + """Join accumulated text, push TranscriptionFrame, and clear state.""" + if not self._accumulated_text: + return + self._transcript_aggregation_task = None + + text = " ".join(self._accumulated_text) + self._accumulated_text.clear() + logger.debug(f"Final transcription: [{text}]") await self.push_frame( TranscriptionFrame( text, self._user_id, time_now_iso8601(), + self._settings.language, ) ) + await self._trace_transcription(text, is_final=True, language=self._settings.language) diff --git a/src/pipecat/services/gradium/tts.py b/src/pipecat/services/gradium/tts.py index 3baaa887c..f63f931ad 100644 --- a/src/pipecat/services/gradium/tts.py +++ b/src/pipecat/services/gradium/tts.py @@ -6,8 +6,8 @@ import base64 import json -import uuid -from typing import Any, AsyncGenerator, Mapping, Optional +from dataclasses import dataclass +from typing import Any, AsyncGenerator, Optional from loguru import logger from pydantic import BaseModel @@ -19,11 +19,10 @@ from pipecat.frames.frames import ( Frame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, TTSStoppedFrame, ) -from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.tts_service import InterruptibleWordTTSService +from pipecat.services.settings import TTSSettings +from pipecat.services.tts_service import WebsocketTTSService from pipecat.utils.tracing.service_decorators import traced_tts try: @@ -38,12 +37,25 @@ except ModuleNotFoundError as e: SAMPLE_RATE = 48000 -class GradiumTTSService(InterruptibleWordTTSService): +@dataclass +class GradiumTTSSettings(TTSSettings): + """Settings for GradiumTTSService.""" + + pass + + +class GradiumTTSService(WebsocketTTSService): """Text-to-Speech service using Gradium's websocket API.""" + Settings = GradiumTTSSettings + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for Gradium TTS service. + .. deprecated:: 0.0.105 + Use ``GradiumTTSService.Settings`` directly via the ``settings`` parameter instead. + Parameters: temp: Temperature to be used for generation, defaults to 0.6. """ @@ -54,11 +66,12 @@ class GradiumTTSService(InterruptibleWordTTSService): self, *, api_key: str, - voice_id: str = "YTpq7expH9539ERJ", + voice_id: Optional[str] = None, url: str = "wss://eu.api.gradium.ai/api/speech/tts", - model: str = "default", + model: Optional[str] = None, json_config: Optional[str] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Gradium TTS service. @@ -66,33 +79,64 @@ class GradiumTTSService(InterruptibleWordTTSService): Args: api_key: Gradium API key for authentication. voice_id: the voice identifier. + + .. deprecated:: 0.0.105 + Use ``settings=GradiumTTSService.Settings(voice=...)`` instead. + url: Gradium websocket API endpoint. model: Model ID to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=GradiumTTSService.Settings(model=...)`` instead. + json_config: Optional JSON configuration string for additional model settings. params: Additional configuration parameters. + + .. deprecated:: 0.0.105 + Use ``settings=GradiumTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent class. """ - # Initialize with parent class settings for proper frame handling - super().__init__( - push_stop_frames=True, - pause_frame_processing=True, - sample_rate=SAMPLE_RATE, - **kwargs, + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="default", + voice="YTpq7expH9539ERJ", + language=None, ) - params = params or GradiumTTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + # Note: params.temp has no corresponding settings field + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + push_stop_frames=True, + push_start_frame=True, + push_text_frames=False, + pause_frame_processing=True, + sample_rate=SAMPLE_RATE, + settings=default_settings, + **kwargs, + ) # Store service configuration self._api_key = api_key self._url = url - self._voice_id = voice_id self._json_config = json_config - self._model = model - self._settings = { - "voice_id": voice_id, - "model_name": model, - "output_format": "pcm", - } # State tracking self._receive_task = None @@ -105,28 +149,27 @@ class GradiumTTSService(InterruptibleWordTTSService): """ return True - async def set_model(self, model: str): - """Update the TTS model. + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta and reconnect if voice changed. Args: - model: The model name to use for synthesis. - """ - self._model = model - await super().set_model(model) + delta: A :class:`TTSSettings` (or ``GradiumTTSService.Settings``) delta. - async def _update_settings(self, settings: Mapping[str, Any]): - """Update service settings and reconnect if voice changed.""" - prev_voice = self._voice_id - await super()._update_settings(settings) - if not prev_voice == self._voice_id: - self._settings["voice_id"] = self._voice_id - logger.info(f"Switching TTS voice to: [{self._voice_id}]") + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + if "voice" in changed: await self._disconnect() await self._connect() + else: + self._warn_unhandled_updated_settings(changed) + return changed - def _build_msg(self, text: str = "") -> dict: + def _build_msg(self, text: str = "", context_id: str = "") -> dict: """Build JSON message for Gradium API.""" - return {"text": text, "type": "text"} + msg = {"text": text, "type": "text", "client_req_id": context_id} + return msg async def start(self, frame: StartFrame): """Start the service and establish websocket connection. @@ -157,6 +200,8 @@ class GradiumTTSService(InterruptibleWordTTSService): async def _connect(self): """Establish websocket connection and start receive task.""" + await super()._connect() + logger.debug(f"{self}: connecting") # If the server disconnected, cancel the receive-task so that it can be reset below. @@ -173,6 +218,8 @@ class GradiumTTSService(InterruptibleWordTTSService): async def _disconnect(self): """Close websocket connection and clean up tasks.""" + await super()._disconnect() + logger.debug(f"{self}: disconnecting") if self._receive_task: await self.cancel_task(self._receive_task) @@ -192,7 +239,8 @@ class GradiumTTSService(InterruptibleWordTTSService): setup_msg = { "type": "setup", "output_format": "pcm", - "voice_id": self._voice_id, + "voice_id": self._settings.voice, + "close_ws_on_eos": False, } if self._json_config is not None: setup_msg["json_config"] = self._json_config @@ -219,6 +267,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") @@ -228,20 +277,39 @@ class GradiumTTSService(InterruptibleWordTTSService): return self._websocket raise Exception("Websocket not connected") - async def flush_audio(self): + async def flush_audio(self, context_id: Optional[str] = None): """Flush any pending audio synthesis.""" - if not self._websocket: + flush_id = context_id or self.get_active_audio_context_id() + if not flush_id or not self._websocket: return try: - msg = {"type": "end_of_stream"} + msg = {"type": "end_of_stream", "client_req_id": flush_id} await self._websocket.send(json.dumps(msg)) except ConnectionClosedOK: logger.debug(f"{self}: connection closed normally during flush") except Exception as e: logger.error(f"{self} exception: {e}") + async def on_audio_context_interrupted(self, context_id: str): + """Called when an audio context is cancelled due to an interruption. + + No WebSocket message is needed — audio from the interrupted + ``client_req_id`` will be silently dropped by the base class once the + audio context no longer exists. + """ + await self.stop_all_metrics() + + async def on_audio_context_completed(self, context_id: str): + """Called after an audio context has finished playing all of its audio. + + No close message is needed: Gradium signals completion with an + ``end_of_stream`` message (handled in ``_receive_messages``), after + which the server-side context is already closed. + """ + pass + 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. @@ -249,64 +317,58 @@ 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 - await self.stop_ttfb_metrics() - await self.start_word_timestamps() + if not ctx_id or not self.audio_context_available(ctx_id): + continue frame = TTSAudioRawFrame( audio=base64.b64decode(msg["audio"]), sample_rate=self.sample_rate, num_channels=1, + context_id=ctx_id, ) - await self.push_frame(frame) + await self.append_to_audio_context(ctx_id, frame) elif msg["type"] == "text": - await self.add_word_timestamps([(msg["text"], msg["start_s"])]) + 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) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Gradium's streaming API. Args: text: The text to convert to speech. + context_id: Unique identifier for this TTS context. 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: - yield TTSStartedFrame() - - msg = self._build_msg(text=text) + msg = self._build_msg(text=text, context_id=context_id) await self._get_websocket().send(json.dumps(msg)) await self.start_tts_usage_metrics(text) except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() + yield TTSStoppedFrame(context_id=context_id) await self._disconnect() await self._connect() return diff --git a/src/pipecat/services/grok/llm.py b/src/pipecat/services/grok/llm.py index fa905a92c..160ad3331 100644 --- a/src/pipecat/services/grok/llm.py +++ b/src/pipecat/services/grok/llm.py @@ -12,6 +12,7 @@ and context aggregation functionality. """ from dataclasses import dataclass +from typing import Optional from loguru import logger @@ -22,6 +23,7 @@ from pipecat.processors.aggregators.llm_response import ( LLMUserAggregatorParams, ) from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import ( OpenAIAssistantContextAggregator, OpenAILLMService, @@ -67,6 +69,13 @@ class GrokContextAggregatorPair: return self._assistant +@dataclass +class GrokLLMSettings(BaseOpenAILLMService.Settings): + """Settings for GrokLLMService.""" + + pass + + class GrokLLMService(OpenAILLMService): """A service for interacting with Grok's API using the OpenAI-compatible interface. @@ -76,12 +85,16 @@ class GrokLLMService(OpenAILLMService): processing and reports final totals. """ + Settings = GrokLLMSettings + _settings: Settings + def __init__( self, *, api_key: str, base_url: str = "https://api.x.ai/v1", - model: str = "grok-3-beta", + model: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the GrokLLMService with API key and model. @@ -90,9 +103,29 @@ class GrokLLMService(OpenAILLMService): api_key: The API key for accessing Grok's API. base_url: The base URL for Grok API. Defaults to "https://api.x.ai/v1". model: The model identifier to use. Defaults to "grok-3-beta". + + .. deprecated:: 0.0.105 + Use ``settings=GrokLLMService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="grok-3-beta") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) # Initialize counters for token usage metrics self._prompt_tokens = 0 self._completion_tokens = 0 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/grok/realtime/llm.py b/src/pipecat/services/grok/realtime/llm.py index cbf9b4d5c..6e37d21d2 100644 --- a/src/pipecat/services/grok/realtime/llm.py +++ b/src/pipecat/services/grok/realtime/llm.py @@ -13,8 +13,9 @@ https://docs.x.ai/docs/guides/voice/agent import base64 import json import time -from dataclasses import dataclass -from typing import Optional +from dataclasses import dataclass, field +from dataclasses import fields as dataclass_fields +from typing import Any, Dict, Mapping, Optional, Type from loguru import logger @@ -33,7 +34,7 @@ from pipecat.frames.frames import ( LLMFullResponseStartFrame, LLMMessagesAppendFrame, LLMSetToolsFrame, - LLMUpdateSettingsFrame, + LLMTextFrame, StartFrame, TranscriptionFrame, TTSAudioRawFrame, @@ -55,6 +56,12 @@ from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService +from pipecat.services.settings import ( + NOT_GIVEN, + LLMSettings, + _NotGiven, + is_given, +) from pipecat.utils.time import time_now_iso8601 from . import events @@ -84,6 +91,100 @@ class CurrentAudioResponse: total_size: int = 0 +@dataclass +class GrokRealtimeLLMSettings(LLMSettings): + """Settings for GrokRealtimeLLMService. + + Parameters: + session_properties: Grok Realtime session properties (voice, audio config, + tools, etc.). ``instructions`` is synced bidirectionally with the + top-level ``system_instruction`` field. + """ + + session_properties: events.SessionProperties | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + + # -- Bidirectional sync helpers ------------------------------------------ + + @staticmethod + def _sync_top_level_to_sp(settings: "GrokRealtimeLLMService.Settings"): + """Push top-level ``system_instruction`` into ``session_properties``.""" + if not is_given(settings.session_properties): + return + sp = settings.session_properties + if is_given(settings.system_instruction): + sp.instructions = settings.system_instruction + + # -- apply_update override ----------------------------------------------- + + def apply_update(self, delta: "GrokRealtimeLLMService.Settings") -> Dict[str, Any]: + """Merge a delta, keeping ``system_instruction`` in sync with SP. + + When the delta contains ``session_properties``, it **replaces** the + stored SP wholesale (matching legacy behaviour). Top-level field + values always take precedence over conflicting SP values. + """ + # 1. Let the base class handle all fields including session_properties + # (wholesale replacement when given). + changed = super().apply_update(delta) + + # 2. SP → top-level: if the SP was just replaced and carries + # instructions that the delta didn't set at top level, pull it up. + if "session_properties" in changed and is_given(self.session_properties): + sp = self.session_properties + if "system_instruction" not in changed and sp.instructions is not None: + old_si = self.system_instruction + self.system_instruction = sp.instructions + if old_si != self.system_instruction: + changed["system_instruction"] = old_si + + # 3. Top-level → SP: ensure SP mirrors the authoritative top-level + # values. Covers all cases: top-level-only delta, SP-only delta, + # and mixed deltas where top-level takes precedence. + self._sync_top_level_to_sp(self) + + return changed + + # -- from_mapping override ----------------------------------------------- + + @classmethod + def from_mapping( + cls: Type["GrokRealtimeLLMService.Settings"], settings: Mapping[str, Any] + ) -> "GrokRealtimeLLMService.Settings": + """Build a delta from a plain dict, routing SP keys into ``session_properties``. + + Keys that correspond to ``SessionProperties`` fields are collected into + a nested ``session_properties`` value. ``model`` is always routed to + the top-level field. Unknown keys go to ``extra``. + """ + # Determine which keys belong to our own dataclass fields. + own_field_names = {f.name for f in dataclass_fields(cls)} - {"extra"} + + top: Dict[str, Any] = {} + sp_dict: Dict[str, Any] = {} + extra: Dict[str, Any] = {} + + sp_keys = set(events.SessionProperties.model_fields.keys()) + + for key, value in settings.items(): + # Resolve aliases first + canonical = cls._aliases.get(key, key) + if canonical in own_field_names: + top[canonical] = value + elif canonical in sp_keys: + sp_dict[canonical] = value + else: + extra[key] = value + + if sp_dict: + top["session_properties"] = events.SessionProperties(**sp_dict) + + instance = cls(**top) + instance.extra = extra + return instance + + class GrokRealtimeLLMService(LLMService): """Grok Realtime Voice Agent LLM service providing real-time audio and text communication. @@ -100,6 +201,9 @@ class GrokRealtimeLLMService(LLMService): - Server-side VAD (Voice Activity Detection) """ + Settings = GrokRealtimeLLMSettings + _settings: Settings + # Use the Grok-specific adapter adapter_class = GrokRealtimeLLMAdapter @@ -109,6 +213,7 @@ class GrokRealtimeLLMService(LLMService): api_key: str, base_url: str = "wss://api.x.ai/v1/realtime", session_properties: Optional[events.SessionProperties] = None, + settings: Optional[Settings] = None, start_audio_paused: bool = False, **kwargs, ): @@ -120,24 +225,64 @@ class GrokRealtimeLLMService(LLMService): Defaults to "wss://api.x.ai/v1/realtime". session_properties: Configuration properties for the realtime session. If None, uses default SessionProperties with voice "Ara". + + .. deprecated:: 0.0.105 + Use ``settings=GrokRealtimeLLMService.Settings(session_properties=...)`` + instead. + To set a different voice, configure it in session_properties: session_properties = events.SessionProperties(voice="Rex") Available voices: Ara, Rex, Sal, Eve, Leo. + settings: Runtime-updatable settings for this service. start_audio_paused: Whether to start with audio input paused. Defaults to False. **kwargs: Additional arguments passed to parent LLMService. """ - super().__init__(base_url=base_url, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + system_instruction=None, + temperature=None, + max_tokens=None, + top_p=None, + top_k=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + session_properties=events.SessionProperties(), + ) + + # 2. Apply direct init arg overrides (deprecated) + if session_properties is not None: + _warn_deprecated_param( + "session_properties", + self.Settings, + "session_properties", + ) + default_settings.session_properties = session_properties + # Sync instructions from the deprecated SP arg to top-level + if session_properties.instructions is not None: + default_settings.system_instruction = session_properties.instructions + + # Sync top-level system_instruction back into session_properties + self.Settings._sync_top_level_to_sp(default_settings) + + # 3. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + base_url=base_url, + settings=default_settings, + **kwargs, + ) self.api_key = api_key self.base_url = base_url - # Initialize session_properties - self._session_properties: events.SessionProperties = ( - session_properties or events.SessionProperties() - ) - self._audio_input_paused = start_audio_paused self._websocket = None self._receive_task = None @@ -185,13 +330,13 @@ class GrokRealtimeLLMService(LLMService): Configured sample rate or None if not manually configured. For PCMU/PCMA formats, returns 8000 Hz (G.711 standard). """ - if not self._session_properties.audio: + if not self._settings.session_properties.audio: return None audio_config = ( - self._session_properties.audio.input + self._settings.session_properties.audio.input if direction == "input" - else self._session_properties.audio.output + else self._settings.session_properties.audio.output ) if audio_config and audio_config.format: @@ -221,8 +366,8 @@ class GrokRealtimeLLMService(LLMService): def _is_turn_detection_enabled(self) -> bool: """Check if server-side VAD is enabled.""" - if self._session_properties.turn_detection: - return self._session_properties.turn_detection.type == "server_vad" + if self._settings.session_properties.turn_detection: + return self._settings.session_properties.turn_detection.type == "server_vad" return False async def _handle_interruption(self): @@ -280,6 +425,27 @@ class GrokRealtimeLLMService(LLMService): # Standard AIService frame handling # + def _ensure_audio_config(self, input_sample_rate: int, output_sample_rate: int): + """Ensure session_properties.audio has input and output configs. + + Fills in any missing audio configuration using the given sample rates. + + Args: + input_sample_rate: Sample rate for audio input (Hz). + output_sample_rate: Sample rate for audio output (Hz). + """ + props = self._settings.session_properties + if not props.audio: + props.audio = events.AudioConfiguration() + if not props.audio.input: + props.audio.input = events.AudioInput( + format=events.PCMAudioFormat(rate=input_sample_rate) + ) + if not props.audio.output: + props.audio.output = events.AudioOutput( + format=events.PCMAudioFormat(rate=output_sample_rate) + ) + async def start(self, frame: StartFrame): """Start the service and establish WebSocket connection. @@ -287,23 +453,7 @@ class GrokRealtimeLLMService(LLMService): frame: The start frame triggering service initialization. """ await super().start(frame) - - # Ensure audio configuration exists with both input and output - if not self._session_properties.audio: - self._session_properties.audio = events.AudioConfiguration() - - # Fill in missing input configuration - if not self._session_properties.audio.input: - self._session_properties.audio.input = events.AudioInput( - format=events.PCMAudioFormat(rate=frame.audio_in_sample_rate) - ) - - # Fill in missing output configuration - if not self._session_properties.audio.output: - self._session_properties.audio.output = events.AudioOutput( - format=events.PCMAudioFormat(rate=frame.audio_out_sample_rate) - ) - + self._ensure_audio_config(frame.audio_in_sample_rate, frame.audio_out_sample_rate) await self._connect() async def stop(self, frame: EndFrame): @@ -354,11 +504,8 @@ class GrokRealtimeLLMService(LLMService): await self._handle_bot_stopped_speaking() elif isinstance(frame, LLMMessagesAppendFrame): await self._handle_messages_append(frame) - elif isinstance(frame, LLMUpdateSettingsFrame): - self._session_properties = events.SessionProperties(**frame.settings) - await self._update_settings() elif isinstance(frame, LLMSetToolsFrame): - await self._update_settings() + await self._send_session_update() await self.push_frame(frame, direction) @@ -435,9 +582,28 @@ class GrokRealtimeLLMService(LLMService): return await self.push_error(error_msg=f"Error sending client event: {e}", exception=e) - async def _update_settings(self): + async def _update_settings(self, delta): + """Apply a settings delta, sending a session update when needed.""" + # Capture audio config before the update — a wholesale SP replacement + # would lose it since the new SP likely has audio=None. + input_rate = self._get_configured_sample_rate("input") + output_rate = self._get_configured_sample_rate("output") + + changed = await super()._update_settings(delta) + + # Re-establish audio config if it was lost during SP replacement. + if "session_properties" in changed and input_rate and output_rate: + self._ensure_audio_config(input_rate, output_rate) + + handled = {"session_properties", "system_instruction"} + if changed.keys() & handled: + await self._send_session_update() + self._warn_unhandled_updated_settings(changed.keys() - handled) + return changed + + async def _send_session_update(self): """Update session settings on the server.""" - settings = self._session_properties + settings = self._settings.session_properties adapter: GrokRealtimeLLMAdapter = self.get_llm_adapter() if self._context: @@ -510,12 +676,18 @@ class GrokRealtimeLLMService(LLMService): elif evt.type == "response.function_call_arguments.done": await self._handle_evt_function_call_arguments_done(evt) elif evt.type == "error": - await self._handle_evt_error(evt) - return + if evt.error.code in ( + "response_cancel_not_active", + "conversation_already_has_active_response", + ): + logger.debug(f"{self} {evt.error.message}") + else: + await self._handle_evt_error(evt) + return async def _handle_evt_conversation_created(self, evt): """Handle conversation.created event - first event after connecting.""" - await self._update_settings() + await self._send_session_update() async def _handle_evt_response_created(self, evt): """Handle response.created event - response generation started.""" @@ -619,9 +791,26 @@ class GrokRealtimeLLMService(LLMService): async def _handle_evt_audio_transcript_delta(self, evt): """Handle audio transcript delta event.""" if evt.delta: - frame = TTSTextFrame(evt.delta, aggregated_by=AggregationType.SENTENCE) - frame.includes_inter_frame_spaces = True - await self.push_frame(frame) + await self._push_output_transcript_text_frames(evt.delta) + + async def _push_output_transcript_text_frames(self, text: str): + # In a typical "cascade" LLM + TTS setup, LLMTextFrames would not + # proceed beyond the TTS service. Therefore, since a speech-to-speech + # service like Grok Realtime combines both LLM and TTS functionality, + # you might think we wouldn't need to push LLMTextFrames at all. + # However, RTVI relies on LLMTextFrames being pushed to trigger its + # "bot-llm-text" event. So here we push an LLMTextFrame, too, but avoid + # appending it to context to avoid context message duplication. + + # Push LLMTextFrame + llm_text_frame = LLMTextFrame(text) + llm_text_frame.append_to_context = False + await self.push_frame(llm_text_frame) + + # Push TTSTextFrame + tts_text_frame = TTSTextFrame(text, aggregated_by=AggregationType.SENTENCE) + tts_text_frame.includes_inter_frame_spaces = True + await self.push_frame(tts_text_frame) async def _handle_evt_function_call_arguments_done(self, evt): """Handle function call arguments done event.""" @@ -653,13 +842,13 @@ class GrokRealtimeLLMService(LLMService): """Handle speech started event from VAD.""" await self._truncate_current_audio_response() await self.broadcast_frame(UserStartedSpeakingFrame) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_evt_speech_stopped(self, evt): """Handle speech stopped event from VAD.""" await self.start_ttfb_metrics() await self.start_processing_metrics() - await self.push_frame(UserStoppedSpeakingFrame()) + await self.broadcast_frame(UserStoppedSpeakingFrame) async def _handle_evt_error(self, evt): """Handle error event.""" @@ -701,7 +890,7 @@ class GrokRealtimeLLMService(LLMService): self._messages_added_manually[evt.item.id] = True await self.send_client_event(evt) - await self._update_settings() + await self._send_session_update() self._llm_needs_conversation_setup = False logger.debug("Creating Grok response") @@ -734,6 +923,14 @@ class GrokRealtimeLLMService(LLMService): async def _send_user_audio(self, frame): """Send user audio to Grok.""" + # Don't send audio if conversation setup is still pending, as it can + # lead to errors. For example: audio sent before conversation setup + # will be interpreted as having Grok's default sample rate (24000), + # and if that differs from the sample rate we eventually set through + # the conversation setup, Grok will error out. + if self._llm_needs_conversation_setup: + return + payload = base64.b64encode(frame.audio).decode("utf-8") await self.send_client_event(events.InputAudioBufferAppendEvent(audio=payload)) @@ -742,7 +939,7 @@ class GrokRealtimeLLMService(LLMService): item = events.ConversationItem( type="function_call_output", call_id=tool_call_id, - output=json.dumps(result), + output=json.dumps(result, ensure_ascii=False), ) await self.send_client_event(events.ConversationItemCreateEvent(item=item)) diff --git a/src/pipecat/services/groq/llm.py b/src/pipecat/services/groq/llm.py index 9dae88c31..d36b52ab8 100644 --- a/src/pipecat/services/groq/llm.py +++ b/src/pipecat/services/groq/llm.py @@ -6,11 +6,22 @@ """Groq LLM Service implementation using OpenAI-compatible interface.""" +from dataclasses import dataclass +from typing import Optional + from loguru import logger +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class GroqLLMSettings(BaseOpenAILLMService.Settings): + """Settings for GroqLLMService.""" + + pass + + class GroqLLMService(OpenAILLMService): """A service for interacting with Groq's API using the OpenAI-compatible interface. @@ -18,12 +29,16 @@ class GroqLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. """ + Settings = GroqLLMSettings + _settings: Settings + def __init__( self, *, api_key: str, base_url: str = "https://api.groq.com/openai/v1", - model: str = "llama-3.3-70b-versatile", + model: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize Groq LLM service. @@ -32,9 +47,29 @@ class GroqLLMService(OpenAILLMService): api_key: The API key for accessing Groq's API. base_url: The base URL for Groq API. Defaults to "https://api.groq.com/openai/v1". model: The model identifier to use. Defaults to "llama-3.3-70b-versatile". + + .. deprecated:: 0.0.105 + Use ``settings=GroqLLMService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="llama-3.3-70b-versatile") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): """Create OpenAI-compatible client for Groq API endpoint. diff --git a/src/pipecat/services/groq/stt.py b/src/pipecat/services/groq/stt.py index 3aadd0e73..3f6c23774 100644 --- a/src/pipecat/services/groq/stt.py +++ b/src/pipecat/services/groq/stt.py @@ -6,12 +6,28 @@ """Groq speech-to-text service implementation using Whisper models.""" +from dataclasses import dataclass from typing import Optional -from pipecat.services.whisper.base_stt import BaseWhisperSTTService, Transcription +from pipecat.services.stt_latency import GROQ_TTFS_P99 +from pipecat.services.whisper.base_stt import ( + BaseWhisperSTTService, + Transcription, +) from pipecat.transcriptions.language import Language +@dataclass +class GroqSTTSettings(BaseWhisperSTTService.Settings): + """Settings for the Groq STT service. + + Parameters: + prompt: Optional prompt text to guide transcription style. + """ + + pass + + class GroqSTTService(BaseWhisperSTTService): """Groq Whisper speech-to-text service. @@ -19,54 +35,105 @@ class GroqSTTService(BaseWhisperSTTService): set via the api_key parameter or GROQ_API_KEY environment variable. """ + Settings = GroqSTTSettings + _settings: Settings + def __init__( self, *, - model: str = "whisper-large-v3-turbo", + model: Optional[str] = None, api_key: Optional[str] = None, base_url: str = "https://api.groq.com/openai/v1", - language: Optional[Language] = Language.EN, + language: Optional[Language] = None, prompt: Optional[str] = None, temperature: Optional[float] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = GROQ_TTFS_P99, **kwargs, ): """Initialize Groq STT service. Args: - model: Whisper model to use. Defaults to "whisper-large-v3-turbo". + model: Whisper model to use. + + .. deprecated:: 0.0.105 + Use ``settings=GroqSTTService.Settings(model=...)`` instead. + api_key: Groq API key. Defaults to None. base_url: API base URL. Defaults to "https://api.groq.com/openai/v1". - language: Language of the audio input. Defaults to English. + language: Language of the audio input. + + .. deprecated:: 0.0.105 + Use ``settings=GroqSTTService.Settings(language=...)`` instead. + prompt: Optional text to guide the model's style or continue a previous segment. - temperature: Optional sampling temperature between 0 and 1. Defaults to 0.0. + + .. deprecated:: 0.0.105 + Use ``settings=GroqSTTService.Settings(prompt=...)`` instead. + + temperature: Optional sampling temperature between 0 and 1. + + .. deprecated:: 0.0.105 + Use ``settings=GroqSTTService.Settings(temperature=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to BaseWhisperSTTService. """ + # --- 1. Hardcoded defaults --- + default_settings = self.Settings( + model="whisper-large-v3-turbo", + language=Language.EN, + prompt=None, + temperature=None, + ) + + # --- 2. Deprecated direct-arg overrides --- + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if language is not None: + self._warn_init_param_moved_to_settings("language", "language") + default_settings.language = language + if prompt is not None: + self._warn_init_param_moved_to_settings("prompt", "prompt") + default_settings.prompt = prompt + if temperature is not None: + self._warn_init_param_moved_to_settings("temperature", "temperature") + default_settings.temperature = temperature + + # --- 3. (no params object for this service) --- + + # --- 4. Settings delta (canonical API, always wins) --- + if settings is not None: + default_settings.apply_update(settings) + super().__init__( - model=model, api_key=api_key, base_url=base_url, - language=language, - prompt=prompt, - temperature=temperature, + settings=default_settings, + ttfs_p99_latency=ttfs_p99_latency, **kwargs, ) async def _transcribe(self, audio: bytes) -> Transcription: - assert self._language is not None # Assigned in the BaseWhisperSTTService class + assert self._settings.language is not None # Build kwargs dict with only set parameters kwargs = { "file": ("audio.wav", audio, "audio/wav"), - "model": self.model_name, + "model": self._settings.model, # Use verbose_json to get probability metrics "response_format": "verbose_json" if self._include_prob_metrics else "json", - "language": self._language, + "language": self._settings.language, } - if self._prompt is not None: - kwargs["prompt"] = self._prompt + if self._settings.prompt is not None: + kwargs["prompt"] = self._settings.prompt - if self._temperature is not None: - kwargs["temperature"] = self._temperature + if self._settings.temperature is not None: + kwargs["temperature"] = self._settings.temperature return await self._client.audio.transcriptions.create(**kwargs) diff --git a/src/pipecat/services/groq/tts.py b/src/pipecat/services/groq/tts.py index e66dc07e7..00ff3ef84 100644 --- a/src/pipecat/services/groq/tts.py +++ b/src/pipecat/services/groq/tts.py @@ -8,6 +8,7 @@ import io import wave +from dataclasses import dataclass, field from typing import AsyncGenerator, Optional from loguru import logger @@ -17,9 +18,8 @@ from pipecat.frames.frames import ( ErrorFrame, Frame, TTSAudioRawFrame, - TTSStartedFrame, - TTSStoppedFrame, ) +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven from pipecat.services.tts_service import TTSService from pipecat.transcriptions.language import Language from pipecat.utils.tracing.service_decorators import traced_tts @@ -32,6 +32,17 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class GroqTTSSettings(TTSSettings): + """Settings for GroqTTSService. + + Parameters: + speed: Speech speed multiplier. Defaults to 1.0. + """ + + speed: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class GroqTTSService(TTSService): """Groq text-to-speech service implementation. @@ -40,9 +51,15 @@ class GroqTTSService(TTSService): and output formats. """ + Settings = GroqTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Groq TTS configuration. + .. deprecated:: 0.0.105 + Use ``settings=GroqTTSService.Settings(...)`` instead. + Parameters: language: Language for speech synthesis. Defaults to English. speed: Speech speed multiplier. Defaults to 1.0. @@ -59,9 +76,10 @@ class GroqTTSService(TTSService): api_key: str, output_format: str = "wav", params: Optional[InputParams] = None, - model_name: str = "canopylabs/orpheus-v1-english", - voice_id: str = "autumn", + model_name: Optional[str] = None, + voice_id: Optional[str] = None, sample_rate: Optional[int] = GROQ_SAMPLE_RATE, + settings: Optional[Settings] = None, **kwargs, ): """Initialize Groq TTS service. @@ -70,36 +88,66 @@ class GroqTTSService(TTSService): api_key: Groq API key for authentication. output_format: Audio output format. Defaults to "wav". params: Additional input parameters for voice customization. - model_name: TTS model to use. Defaults to "playai-tts". - voice_id: Voice identifier to use. Defaults to "Celeste-PlayAI". + + .. deprecated:: 0.0.105 + Use ``settings=GroqTTSService.Settings(...)`` instead. + + model_name: TTS model to use. + + .. deprecated:: 0.0.105 + Use ``settings=GroqTTSService.Settings(model=...)`` instead. + + voice_id: Voice identifier to use. + + .. deprecated:: 0.0.105 + Use ``settings=GroqTTSService.Settings(voice=...)`` instead. + sample_rate: Audio sample rate. Must be 48000 Hz for Groq TTS. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService class. """ if sample_rate != self.GROQ_SAMPLE_RATE: logger.warning(f"Groq TTS only supports {self.GROQ_SAMPLE_RATE}Hz sample rate. ") + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="canopylabs/orpheus-v1-english", + voice="autumn", + language="en", + speed=1.0, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model_name is not None: + self._warn_init_param_moved_to_settings("model_name", "model") + default_settings.model = model_name + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = str(params.language) if params.language else "en" + default_settings.speed = params.speed + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( pause_frame_processing=True, + push_start_frame=True, + push_stop_frames=True, sample_rate=sample_rate, + settings=default_settings, **kwargs, ) - params = params or GroqTTSService.InputParams() - self._api_key = api_key - self._model_name = model_name self._output_format = output_format - self._voice_id = voice_id - self._params = params - - self._settings = { - "model": model_name, - "voice_id": voice_id, - "output_format": output_format, - "language": str(params.language) if params.language else "en", - "speed": params.speed, - "sample_rate": sample_rate, - } self._client = AsyncGroq(api_key=self._api_key) @@ -112,25 +160,26 @@ class GroqTTSService(TTSService): return True @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Groq's TTS API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech data. """ logger.debug(f"{self}: Generating TTS [{text}]") measuring_ttfb = True - await self.start_ttfb_metrics() - yield TTSStartedFrame() - try: response = await self._client.audio.speech.create( - model=self._model_name, - voice=self._voice_id, + model=self._settings.model, + voice=self._settings.voice, response_format=self._output_format, + # Note: as of 2026-02-25, only a speed of 1.0 is supported, but + # here we pass it for completeness and future-proofing + speed=self._settings.speed, input=text, ) @@ -144,8 +193,6 @@ class GroqTTSService(TTSService): frame_rate = w.getframerate() num_frames = w.getnframes() bytes = w.readframes(num_frames) - yield TTSAudioRawFrame(bytes, frame_rate, channels) + yield TTSAudioRawFrame(bytes, frame_rate, channels, context_id=context_id) except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - - yield TTSStoppedFrame() diff --git a/src/pipecat/services/heygen/api_liveavatar.py b/src/pipecat/services/heygen/api_liveavatar.py index c9109062a..7b9119542 100644 --- a/src/pipecat/services/heygen/api_liveavatar.py +++ b/src/pipecat/services/heygen/api_liveavatar.py @@ -9,6 +9,7 @@ API to communicate with LiveAvatar Streaming API. """ +from enum import Enum from typing import Any, Dict, Optional import aiohttp @@ -46,17 +47,45 @@ class CustomSDKLiveKitConfig(BaseModel): livekit_client_token: str +class VideoEncoding(str, Enum): + """Enum representing the video encoding.""" + + H264 = "H264" + VP8 = "VP8" + + +class VideoQuality(str, Enum): + """Enum representing different avatar quality levels.""" + + low = "low" + medium = "medium" + high = "high" + very_high = "very_high" + + +class VideoSettings(BaseModel): + """Video encoding settings for session configuration.""" + + encoding: VideoEncoding + quality: VideoQuality = VideoQuality.high + + class LiveAvatarNewSessionRequest(BaseModel): """Request model for creating a LiveAvatar session token. Parameters: - mode (str): Session mode (default: "CUSTOM"). + mode (str): Session mode (default: "LITE"). avatar_id (str): Unique identifier for the avatar. + video_settings (VideoSettings): Video encoding settings. + is_sandbox (bool): Enable sandbox mode (default: False). avatar_persona (AvatarPersona): Avatar persona configuration. + livekit_config (CustomSDKLiveKitConfig): Custom LiveKit configuration. """ - mode: str = "CUSTOM" + mode: str = "LITE" avatar_id: str + video_settings: Optional[VideoSettings] = VideoSettings(encoding=VideoEncoding.VP8) + is_sandbox: Optional[bool] = False avatar_persona: Optional[AvatarPersona] = None livekit_config: Optional[CustomSDKLiveKitConfig] = None @@ -219,7 +248,7 @@ class LiveAvatarApi(BaseAvatarApi): Session token information. """ params: dict[str, Any] = { - "mode": request_data.mode, + "mode": request_data.mode if request_data.mode is not None else "LITE", "avatar_id": request_data.avatar_id, } @@ -234,6 +263,20 @@ class LiveAvatarApi(BaseAvatarApi): avatar_persona = {k: v for k, v in avatar_persona.items() if v is not None} params["avatar_persona"] = avatar_persona + if request_data.is_sandbox is not None: + params["is_sandbox"] = request_data.is_sandbox + + if request_data.video_settings is not None: + video_settings = { + "encoding": request_data.video_settings.encoding.value, + "quality": request_data.video_settings.quality.value, + } + params["video_settings"] = video_settings + else: + # Fall back to VP8 encoding if video_settings is not provided + params["video_settings"] = {"encoding": VideoEncoding.VP8.value} + + logger.debug(f"Creating LiveAvatar session token with params: {params}") response = await self._request("POST", "/sessions/token", params) logger.debug(f"LiveAvatar session token created") diff --git a/src/pipecat/services/heygen/client.py b/src/pipecat/services/heygen/client.py index 4018d3858..6d45d6114 100644 --- a/src/pipecat/services/heygen/client.py +++ b/src/pipecat/services/heygen/client.py @@ -62,10 +62,12 @@ class HeyGenCallbacks(BaseModel): """Callback handlers for HeyGen events. Parameters: - on_participant_connected: Called when a participant connects - on_participant_disconnected: Called when a participant disconnects + on_connected: Called when the bot connects to the LiveKit room. + on_participant_connected: Called when a participant connects. + on_participant_disconnected: Called when a participant disconnects. """ + on_connected: Callable[[], Awaitable[None]] on_participant_connected: Callable[[str], Awaitable[None]] on_participant_disconnected: Callable[[str], Awaitable[None]] @@ -251,6 +253,7 @@ class HeyGenClient: logger.debug(f"HeyGenClient send_interval: {self._send_interval}") await self._ws_connect() await self._livekit_connect() + self._call_event_callback(self._callbacks.on_connected) async def stop(self) -> None: """Stop the client and terminate all connections. diff --git a/src/pipecat/services/heygen/video.py b/src/pipecat/services/heygen/video.py index b97f4a5ed..9a20f35ef 100644 --- a/src/pipecat/services/heygen/video.py +++ b/src/pipecat/services/heygen/video.py @@ -12,6 +12,7 @@ audio/video streaming capabilities through the HeyGen API. """ import asyncio +from dataclasses import dataclass from typing import Optional, Union import aiohttp @@ -45,12 +46,20 @@ from pipecat.services.heygen.client import ( HeyGenClient, ServiceType, ) +from pipecat.services.settings import ServiceSettings from pipecat.transports.base_transport import TransportParams # Using the same values that we do in the BaseOutputTransport AVATAR_VAD_STOP_SECS = 0.35 +@dataclass +class HeyGenVideoSettings(ServiceSettings): + """Settings for the HeyGen video service.""" + + pass + + class HeyGenVideoService(AIService): """A service that integrates HeyGen's interactive avatar capabilities into the pipeline. @@ -73,6 +82,9 @@ class HeyGenVideoService(AIService): Defaults to using the "Shawn_Therapist_public" avatar with "v2" version. """ + Settings = HeyGenVideoSettings + _settings: Settings + def __init__( self, *, @@ -80,6 +92,7 @@ class HeyGenVideoService(AIService): session: aiohttp.ClientSession, session_request: Optional[Union[LiveAvatarNewSessionRequest, NewSessionRequest]] = None, service_type: Optional[ServiceType] = None, + settings: Optional[Settings] = None, **kwargs, ) -> None: """Initialize the HeyGen video service. @@ -89,9 +102,15 @@ class HeyGenVideoService(AIService): session: HTTP client session for API requests session_request: Configuration for the HeyGen session service_type: Service type for the avatar session + settings: Runtime-updatable settings. HeyGen has no model concept, so this + is primarily used for the ``extra`` dict. **kwargs: Additional arguments passed to parent AIService """ - super().__init__(**kwargs) + default_settings = ServiceSettings(model=None) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(settings=default_settings, **kwargs) self._api_key = api_key self._session = session self._client: Optional[HeyGenClient] = None @@ -128,6 +147,7 @@ class HeyGenVideoService(AIService): session_request=self._session_request, service_type=self._service_type, callbacks=HeyGenCallbacks( + on_connected=self._on_connected, on_participant_connected=self._on_participant_connected, on_participant_disconnected=self._on_participant_disconnected, ), @@ -144,6 +164,10 @@ class HeyGenVideoService(AIService): await self._client.cleanup() self._client = None + async def _on_connected(self): + """Handle bot connected to LiveKit room.""" + logger.info("HeyGen bot connected to LiveKit room") + async def _on_participant_connected(self, participant_id: str): """Handle participant connected events.""" logger.info(f"Participant connected {participant_id}") diff --git a/src/pipecat/services/hume/tts.py b/src/pipecat/services/hume/tts.py index c169cf4c0..e591ebec2 100644 --- a/src/pipecat/services/hume/tts.py +++ b/src/pipecat/services/hume/tts.py @@ -6,6 +6,8 @@ import base64 import os +import warnings +from dataclasses import dataclass, field from typing import Any, AsyncGenerator, Optional import httpx @@ -16,16 +18,15 @@ from pipecat import version as pipecat_version from pipecat.frames.frames import ( CancelFrame, EndFrame, - ErrorFrame, Frame, InterruptionFrame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, TTSStoppedFrame, ) from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.tts_service import WordTTSService +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 try: @@ -47,7 +48,22 @@ DEFAULT_HEADERS = { } -class HumeTTSService(WordTTSService): +@dataclass +class HumeTTSSettings(TTSSettings): + """Settings for HumeTTSService. + + Parameters: + description: Natural-language acting directions (up to 100 characters). + speed: Speaking-rate multiplier (0.5-2.0). + trailing_silence: Seconds of silence to append at the end (0-5). + """ + + description: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + speed: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + trailing_silence: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + +class HumeTTSService(TTSService): """Hume Octave Text-to-Speech service. Streams PCM audio via Hume's HTTP output streaming (JSON chunks) endpoint @@ -62,9 +78,15 @@ class HumeTTSService(WordTTSService): - Provides metrics for Time To First Byte (TTFB) and TTS usage. """ + Settings = HumeTTSSettings + _settings: Settings + class InputParams(BaseModel): """Optional synthesis parameters for Hume TTS. + .. deprecated:: 0.0.105 + Use ``settings=HumeTTSService.Settings(...)`` instead. + Parameters: description: Natural-language acting directions (up to 100 characters). speed: Speaking-rate multiplier (0.5-2.0). @@ -79,9 +101,10 @@ class HumeTTSService(WordTTSService): self, *, api_key: Optional[str] = None, - voice_id: str, + voice_id: Optional[str] = None, params: Optional[InputParams] = None, sample_rate: Optional[int] = HUME_SAMPLE_RATE, + settings: Optional[Settings] = None, **kwargs, ) -> None: """Initialize the HumeTTSService. @@ -89,8 +112,18 @@ class HumeTTSService(WordTTSService): Args: api_key: Hume API key. If omitted, reads the ``HUME_API_KEY`` environment variable. voice_id: ID of the voice to use. Only voice IDs are supported; voice names are not. + + .. deprecated:: 0.0.105 + Use ``settings=HumeTTSService.Settings(voice=...)`` instead. + params: Optional synthesis controls (acting instructions, speed, trailing silence). + + .. deprecated:: 0.0.105 + Use ``settings=HumeTTSService.Settings(...)`` instead. + sample_rate: Output sample rate for emitted PCM frames. Defaults to 48_000 (Hume). + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to the parent class. """ api_key = api_key or os.getenv("HUME_API_KEY") @@ -102,11 +135,39 @@ class HumeTTSService(WordTTSService): f"Hume TTS streams at {HUME_SAMPLE_RATE} Hz; configured sample_rate={sample_rate}" ) - # WordTTSService sets push_text_frames=False by default, which we want + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice=None, + language=None, # Not applicable here + description=None, + speed=None, + trailing_silence=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.description = params.description + default_settings.speed = params.speed + default_settings.trailing_silence = params.trailing_silence + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( sample_rate=sample_rate, push_text_frames=False, push_stop_frames=True, + push_start_frame=True, + settings=default_settings, **kwargs, ) @@ -115,16 +176,11 @@ class HumeTTSService(WordTTSService): self._http_client = httpx.AsyncClient(headers=DEFAULT_HEADERS) self._client = AsyncHumeClient(api_key=api_key, httpx_client=self._http_client) - self._params = params or HumeTTSService.InputParams() - - # Store voice in the base class (mirrors other services) - self.set_voice(voice_id) self._audio_bytes = b"" # Track cumulative time for word timestamps across utterances self._cumulative_time = 0.0 - self._started = False def can_generate_metrics(self) -> bool: """Can generate metrics. @@ -146,7 +202,6 @@ class HumeTTSService(WordTTSService): def _reset_state(self): """Reset internal state variables.""" self._cumulative_time = 0.0 - self._started = False async def stop(self, frame: EndFrame) -> None: """Stop the service and cleanup resources. @@ -184,7 +239,10 @@ class HumeTTSService(WordTTSService): await self.add_word_timestamps([("Reset", 0)]) async def update_setting(self, key: str, value: Any) -> None: - """Runtime updates via `TTSUpdateSettingsFrame`. + """Runtime updates via key/value pair. + + .. deprecated:: 0.0.104 + Use ``TTSUpdateSettingsFrame(delta=HumeTTSService.Settings(...))`` instead. Args: key: The name of the setting to update. Recognized keys are: @@ -194,27 +252,37 @@ class HumeTTSService(WordTTSService): - "trailing_silence" value: The new value for the setting. """ - key_l = (key or "").lower() + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "'update_setting' is deprecated, use " + "'TTSUpdateSettingsFrame(delta=self.Settings(...))' instead.", + DeprecationWarning, + stacklevel=2, + ) - if key_l == "voice_id": - self.set_voice(str(value)) - logger.debug(f"HumeTTSService voice_id set to: {self.voice}") - elif key_l == "description": - self._params.description = None if value is None else str(value) - elif key_l == "speed": - self._params.speed = None if value is None else float(value) - elif key_l == "trailing_silence": - self._params.trailing_silence = None if value is None else float(value) - else: - # Defer unknown keys to the base class - await super().update_setting(key, value) + key_l = (key or "").lower() + known_keys = {"voice_id", "voice", "description", "speed", "trailing_silence"} + + if key_l in known_keys: + kwargs: dict[str, Any] = {} + if key_l in ("voice_id", "voice"): + kwargs["voice"] = str(value) + elif key_l == "description": + kwargs["description"] = None if value is None else str(value) + elif key_l == "speed": + kwargs["speed"] = None if value is None else float(value) + elif key_l == "trailing_silence": + kwargs["trailing_silence"] = None if value is None else float(value) + await self._update_settings(self.Settings(**kwargs)) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Hume TTS with word timestamps. Args: text: The text to be synthesized. + context_id: Unique identifier for this TTS context. Returns: An async generator that yields `Frame` objects, including @@ -226,29 +294,22 @@ class HumeTTSService(WordTTSService): # Build the request payload utterance_kwargs: dict[str, Any] = { "text": text, - "voice": PostedUtteranceVoiceWithId(id=self._voice_id), + "voice": PostedUtteranceVoiceWithId(id=self._settings.voice), } - if self._params.description is not None: - utterance_kwargs["description"] = self._params.description - if self._params.speed is not None: - utterance_kwargs["speed"] = self._params.speed - if self._params.trailing_silence is not None: - utterance_kwargs["trailing_silence"] = self._params.trailing_silence + if self._settings.description is not None: + utterance_kwargs["description"] = self._settings.description + if self._settings.speed is not None: + utterance_kwargs["speed"] = self._settings.speed + if self._settings.trailing_silence is not None: + utterance_kwargs["trailing_silence"] = self._settings.trailing_silence utterance = PostedUtterance(**utterance_kwargs) # Request raw PCM chunks in the streaming JSON pcm_fmt = FormatPcm(type="pcm") - await self.start_ttfb_metrics() await self.start_tts_usage_metrics(text) - # Start TTS sequence if not already started - if not self._started: - await self.start_word_timestamps() - yield TTSStartedFrame() - self._started = True - try: # Instant mode is always enabled here (not user-configurable) # Hume emits mono PCM at 48 kHz; downstream can resample if needed. @@ -257,7 +318,7 @@ class HumeTTSService(WordTTSService): # Use version "2" by default if no description is provided # Version "1" is needed when description is used - version = "1" if self._params.description is not None else "2" + version = "1" if self._settings.description is not None else "2" # Track the duration of this utterance based on the last timestamp utterance_duration = 0.0 @@ -282,6 +343,7 @@ class HumeTTSService(WordTTSService): audio=self._audio_bytes, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) yield frame self._audio_bytes = b"" @@ -298,7 +360,9 @@ class HumeTTSService(WordTTSService): utterance_duration = max(utterance_duration, word_end_time) # Add word timestamp - await self.add_word_timestamps([(timestamp.text, word_start_time)]) + await self.add_word_timestamps( + [(timestamp.text, word_start_time)], context_id + ) # Flush any remaining audio bytes if self._audio_bytes: @@ -306,6 +370,7 @@ class HumeTTSService(WordTTSService): audio=self._audio_bytes, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) yield frame @@ -321,4 +386,3 @@ class HumeTTSService(WordTTSService): finally: # Ensure TTFB timer is stopped even on early failures await self.stop_ttfb_metrics() - # Let the parent class handle TTSStoppedFrame via push_stop_frames diff --git a/src/pipecat/services/image_service.py b/src/pipecat/services/image_service.py index 58ab58fa4..f99909444 100644 --- a/src/pipecat/services/image_service.py +++ b/src/pipecat/services/image_service.py @@ -11,11 +11,12 @@ text prompts into images. """ from abc import abstractmethod -from typing import AsyncGenerator +from typing import AsyncGenerator, Optional from pipecat.frames.frames import Frame, TextFrame from pipecat.processors.frame_processor import FrameDirection from pipecat.services.ai_service import AIService +from pipecat.services.settings import ImageGenSettings class ImageGenService(AIService): @@ -26,13 +27,20 @@ class ImageGenService(AIService): generation functionality using their specific AI service. """ - def __init__(self, **kwargs): + def __init__(self, *, settings: Optional[ImageGenSettings] = None, **kwargs): """Initialize the image generation service. Args: + settings: The runtime-updatable settings for the image generation service. **kwargs: Additional arguments passed to the parent AIService. """ - super().__init__(**kwargs) + super().__init__( + settings=settings + # Here in case subclass doesn't implement more specific settings + # (which hopefully should be rare) + or ImageGenSettings(), + **kwargs, + ) # Renders the image. Returns an Image object. @abstractmethod diff --git a/src/pipecat/services/inworld/tts.py b/src/pipecat/services/inworld/tts.py index fddb96602..334c3c617 100644 --- a/src/pipecat/services/inworld/tts.py +++ b/src/pipecat/services/inworld/tts.py @@ -13,15 +13,35 @@ Contains two TTS services: Inworld’s text-to-speech (TTS) models offer ultra-realistic, context-aware speech synthesis and precise voice cloning capabilities, enabling developers to build natural and engaging experiences with human-like speech quality at an accessible price point. """ +import asyncio import base64 import json import uuid -from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple +from dataclasses import dataclass, field +from typing import ( + Any, + AsyncGenerator, + ClassVar, + Dict, + List, + Literal, + Mapping, + Optional, + Self, + 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 + try: from websockets.asyncio.client import connect as websocket_connect from websockets.protocol import State @@ -42,39 +62,76 @@ from pipecat.frames.frames import ( TTSStoppedFrame, ) from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.tts_service import AudioContextWordTTSService, WordTTSService +from pipecat.services.tts_service import TextAggregationMode, TTSService, WebsocketTTSService from pipecat.utils.tracing.service_decorators import traced_tts -class InworldHttpTTSService(WordTTSService): +@dataclass +class InworldTTSSettings(TTSSettings): + """Settings for InworldTTSService and InworldHttpTTSService. + + Parameters: + speaking_rate: Speaking rate for speech synthesis. + temperature: Temperature for speech synthesis. + """ + + speaking_rate: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + temperature: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + _aliases: ClassVar[Dict[str, str]] = { + "voiceId": "voice", + "modelId": "model", + } + + @classmethod + def from_mapping(cls, settings: Mapping[str, Any]) -> Self: + """Construct settings from a plain dict, destructuring legacy nested ``audioConfig``.""" + flat = dict(settings) + nested = flat.pop("audioConfig", None) + if isinstance(nested, dict): + flat.setdefault("speaking_rate", nested.get("speakingRate")) + return super().from_mapping(flat) + + +class InworldHttpTTSService(TTSService): """Inworld AI HTTP-based TTS service. Supports both streaming and non-streaming modes via the `streaming` parameter. Outputs LINEAR16 audio at configurable sample rates with word-level timestamps. """ + Settings = InworldTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Inworld TTS configuration. + .. deprecated:: 0.0.105 + Use ``InworldHttpTTSService.Settings`` directly via the ``settings`` parameter instead. + 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, *, api_key: str, aiohttp_session: aiohttp.ClientSession, - voice_id: str = "Ashley", - model: str = "inworld-tts-1", + voice_id: Optional[str] = None, + model: Optional[str] = None, streaming: bool = True, sample_rate: Optional[int] = None, encoding: str = "LINEAR16", - params: InputParams = None, + timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = "ASYNC", + params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Inworld TTS service. @@ -83,22 +140,70 @@ class InworldHttpTTSService(WordTTSService): api_key: Inworld API key. aiohttp_session: aiohttp ClientSession for HTTP requests. voice_id: ID of the voice to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=InworldHttpTTSService.Settings(voice=...)`` instead. + model: ID of the model to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=InworldHttpTTSService.Settings(model=...)`` instead. + streaming: Whether to use streaming mode. sample_rate: Audio sample rate in Hz. encoding: Audio encoding format. + timestamp_transport_strategy: Strategy for timestamp transport + ("ASYNC" or "SYNC"). Defaults to "ASYNC". params: Input parameters for Inworld TTS configuration. + + .. deprecated:: 0.0.105 + Use ``settings=InworldHttpTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to the parent class. """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="inworld-tts-1.5-max", + voice="Ashley", + language=None, + speaking_rate=None, + temperature=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.speaking_rate is not None: + default_settings.speaking_rate = params.speaking_rate + if params.temperature is not None: + default_settings.temperature = params.temperature + if params.timestamp_transport_strategy is not None: + timestamp_transport_strategy = params.timestamp_transport_strategy + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( push_text_frames=False, push_stop_frames=True, + push_start_frame=True, sample_rate=sample_rate, + settings=default_settings, **kwargs, ) - params = params or InworldHttpTTSService.InputParams() - self._api_key = api_key self._session = aiohttp_session self._streaming = streaming @@ -109,25 +214,12 @@ class InworldHttpTTSService(WordTTSService): else: self._base_url = "https://api.inworld.ai/tts/v1/voice" - self._settings = { - "voiceId": voice_id, - "modelId": model, - "audioConfig": { - "audioEncoding": encoding, - "sampleRateHertz": 0, - }, - } - - if params.temperature is not None: - self._settings["temperature"] = params.temperature - if params.speaking_rate is not None: - self._settings["audioConfig"]["speakingRate"] = params.speaking_rate - - self._started = False self._cumulative_time = 0.0 - self.set_voice(voice_id) - self.set_model_name(model) + # Init-only config (not runtime-updatable). + self._audio_encoding = encoding + self._audio_sample_rate = 0 # Set in start() + self._timestamp_transport_strategy = timestamp_transport_strategy def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -144,23 +236,7 @@ class InworldHttpTTSService(WordTTSService): frame: The start frame. """ await super().start(frame) - self._settings["audioConfig"]["sampleRateHertz"] = self.sample_rate - - async def stop(self, frame: EndFrame): - """Stop the Inworld TTS service. - - Args: - frame: The end frame. - """ - await super().stop(frame) - - async def cancel(self, frame: CancelFrame): - """Cancel the Inworld TTS service. - - Args: - frame: The cancel frame. - """ - await super().cancel(frame) + self._audio_sample_rate = self.sample_rate async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): """Push a frame and handle state changes. @@ -171,7 +247,6 @@ class InworldHttpTTSService(WordTTSService): """ await super().push_frame(frame, direction) if isinstance(frame, (InterruptionFrame, TTSStoppedFrame)): - self._started = False self._cumulative_time = 0.0 if isinstance(frame, TTSStoppedFrame): await self.add_word_timestamps([("Reset", 0)]) @@ -212,57 +287,63 @@ class InworldHttpTTSService(WordTTSService): return (word_times, chunk_end_time) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate TTS audio for the given text. Args: text: The text to generate TTS audio for. + context_id: Unique identifier for this TTS context. Returns: An asynchronous generator of frames. """ logger.debug(f"{self}: Generating TTS [{text}] (streaming={self._streaming})") + audio_config = { + "audioEncoding": self._audio_encoding, + "sampleRateHertz": self._audio_sample_rate, + } + if self._settings.speaking_rate is not None: + audio_config["speakingRate"] = self._settings.speaking_rate + payload = { "text": text, - "voiceId": self._settings["voiceId"], - "modelId": self._settings["modelId"], - "audioConfig": self._settings["audioConfig"], + "voiceId": self._settings.voice, + "modelId": self._settings.model, + "audioConfig": audio_config, } - if "temperature" in self._settings: - payload["temperature"] = self._settings["temperature"] + if self._settings.temperature is not None: + payload["temperature"] = self._settings.temperature # Use WORD timestamps for simplicity and correct spacing/capitalization payload["timestampType"] = self._timestamp_type + if self._timestamp_transport_strategy is not None: + payload["timestampTransportStrategy"] = self._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: - await self.start_ttfb_metrics() - - if not self._started: - await self.start_word_timestamps() - yield TTSStartedFrame() - self._started = True - async with self._session.post( self._base_url, json=payload, headers=headers ) 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 if self._streaming: - async for frame in self._process_streaming_response(response): + async for frame in self._process_streaming_response(response, context_id): yield frame else: - async for frame in self._process_non_streaming_response(response): + async for frame in self._process_non_streaming_response(response, context_id): yield frame await self.start_tts_usage_metrics(text) @@ -274,12 +355,13 @@ class InworldHttpTTSService(WordTTSService): await self.stop_all_metrics() async def _process_streaming_response( - self, response: aiohttp.ClientResponse + self, response: aiohttp.ClientResponse, context_id: str ) -> AsyncGenerator[Frame, None]: """Process a streaming response from the Inworld API. Args: response: The response from the Inworld API. + context_id: Unique identifier for this TTS context. Yields: An asynchronous generator of frames. @@ -304,7 +386,7 @@ class InworldHttpTTSService(WordTTSService): if "result" in chunk_data and "audioContent" in chunk_data["result"]: await self.stop_ttfb_metrics() async for frame in self._process_audio_chunk( - base64.b64decode(chunk_data["result"]["audioContent"]) + base64.b64decode(chunk_data["result"]["audioContent"]), context_id ): yield frame @@ -312,7 +394,7 @@ class InworldHttpTTSService(WordTTSService): timestamp_info = chunk_data["result"]["timestampInfo"] word_times, chunk_end_time = self._calculate_word_times(timestamp_info) if word_times: - await self.add_word_timestamps(word_times) + await self.add_word_timestamps(word_times, context_id) # Track the maximum end time across all chunks utterance_duration = max(utterance_duration, chunk_end_time) @@ -325,12 +407,13 @@ class InworldHttpTTSService(WordTTSService): self._cumulative_time += utterance_duration async def _process_non_streaming_response( - self, response: aiohttp.ClientResponse + self, response: aiohttp.ClientResponse, context_id: str ) -> AsyncGenerator[Frame, None]: """Process a non-streaming response from the Inworld API. Args: response: The response from the Inworld API. + context_id: Unique identifier for this TTS context. Returns: An asynchronous generator of frames. @@ -347,7 +430,7 @@ class InworldHttpTTSService(WordTTSService): timestamp_info = response_data["timestampInfo"] word_times, chunk_end_time = self._calculate_word_times(timestamp_info) if word_times: - await self.add_word_timestamps(word_times) + await self.add_word_timestamps(word_times, context_id) utterance_duration = chunk_end_time audio_data = base64.b64decode(response_data["audioContent"]) @@ -361,20 +444,21 @@ class InworldHttpTTSService(WordTTSService): if chunk: await self.stop_ttfb_metrics() yield TTSAudioRawFrame( - audio=chunk, - sample_rate=self.sample_rate, - num_channels=1, + audio=chunk, sample_rate=self.sample_rate, num_channels=1, context_id=context_id ) # After processing all audio, add the utterance duration to cumulative time if utterance_duration > 0: self._cumulative_time += utterance_duration - async def _process_audio_chunk(self, audio_chunk: bytes) -> AsyncGenerator[Frame, None]: + async def _process_audio_chunk( + self, audio_chunk: bytes, context_id: str + ) -> AsyncGenerator[Frame, None]: """Process an audio chunk from the Inworld API. Args: audio_chunk: The audio chunk to process. + context_id: Unique identifier for this TTS context. Returns: An asynchronous generator of frames. @@ -392,10 +476,11 @@ class InworldHttpTTSService(WordTTSService): audio=audio_data, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) -class InworldTTSService(AudioContextWordTTSService): +class InworldTTSService(WebsocketTTSService): """Inworld AI WebSocket-based TTS service. Uses bidirectional WebSocket for lower latency streaming. Supports multiple @@ -403,15 +488,27 @@ class InworldTTSService(AudioContextWordTTSService): with word-level timestamps. """ + Settings = InworldTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Inworld WebSocket TTS configuration. + .. deprecated:: 0.0.105 + Use ``InworldTTSService.Settings`` directly via the ``settings`` parameter instead. + Parameters: temperature: Temperature for speech synthesis. speaking_rate: Speaking rate for speech synthesis. apply_text_normalization: Whether to apply text normalization. max_buffer_delay_ms: Maximum buffer delay in milliseconds. buffer_char_threshold: Buffer character threshold. + auto_mode: Whether to use auto mode. Recommended when texts are sent + in full sentences/phrases. When enabled, the server controls + 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 @@ -419,17 +516,26 @@ 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] = True + timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = "ASYNC" def __init__( self, *, api_key: str, - voice_id: str = "Ashley", - model: str = "inworld-tts-1", + voice_id: Optional[str] = None, + model: Optional[str] = None, url: str = "wss://api.inworld.ai/tts/v1/voice:streamBidirectional", sample_rate: Optional[int] = None, encoding: str = "LINEAR16", - params: InputParams = None, + auto_mode: Optional[bool] = None, + apply_text_normalization: Optional[str] = None, + timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = "ASYNC", + params: Optional[InputParams] = None, + settings: Optional[Settings] = None, + aggregate_sentences: Optional[bool] = None, + text_aggregation_mode: Optional[TextAggregationMode] = None, + append_trailing_space: bool = True, **kwargs: Any, ): """Initialize the Inworld WebSocket TTS service. @@ -437,53 +543,122 @@ class InworldTTSService(AudioContextWordTTSService): Args: api_key: Inworld API key. voice_id: ID of the voice to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=InworldTTSService.Settings(voice=...)`` instead. + model: ID of the model to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=InworldTTSService.Settings(model=...)`` instead. + url: URL of the Inworld WebSocket API. sample_rate: Audio sample rate in Hz. encoding: Audio encoding format. + auto_mode: Whether to use auto mode. When enabled, the server + controls flushing of buffered text. 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"). Defaults to "ASYNC". params: Input parameters for Inworld WebSocket TTS configuration. + + .. deprecated:: 0.0.105 + Use ``settings=InworldTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + aggregate_sentences: Deprecated. Use text_aggregation_mode instead. + + .. deprecated:: 0.0.104 + Use ``text_aggregation_mode`` instead. + + text_aggregation_mode: How to aggregate text before synthesis. + append_trailing_space: Whether to append a trailing space to text before sending to TTS. **kwargs: Additional arguments passed to the parent class. """ + # Derive auto_mode from aggregate_sentences if not explicitly set + if auto_mode is None: + auto_mode = True if aggregate_sentences is None else aggregate_sentences + + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="inworld-tts-1.5-max", + voice="Ashley", + language=None, + speaking_rate=None, + temperature=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + _buffer_max_delay_ms = None + _buffer_char_threshold = None + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.speaking_rate is not None: + default_settings.speaking_rate = params.speaking_rate + if params.temperature is not None: + default_settings.temperature = params.temperature + if params.apply_text_normalization is not None: + apply_text_normalization = params.apply_text_normalization + if params.timestamp_transport_strategy is not None: + timestamp_transport_strategy = params.timestamp_transport_strategy + if params.auto_mode is not None: + auto_mode = params.auto_mode + _buffer_max_delay_ms = params.max_buffer_delay_ms + _buffer_char_threshold = params.buffer_char_threshold + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( push_text_frames=False, push_stop_frames=True, pause_frame_processing=True, sample_rate=sample_rate, + aggregate_sentences=aggregate_sentences, + text_aggregation_mode=text_aggregation_mode, + append_trailing_space=append_trailing_space, + settings=default_settings, **kwargs, ) - params = params or InworldTTSService.InputParams() - self._api_key = api_key self._url = url - self._settings: Dict[str, Any] = { - "voiceId": voice_id, - "modelId": model, - "audioConfig": { - "audioEncoding": encoding, - "sampleRateHertz": 0, - }, - } self._timestamp_type = "WORD" - if params.temperature is not None: - self._settings["temperature"] = params.temperature - if params.speaking_rate is not None: - self._settings["audioConfig"]["speakingRate"] = params.speaking_rate - if params.apply_text_normalization is not None: - self._settings["applyTextNormalization"] = params.apply_text_normalization - self._buffer_settings = { - "maxBufferDelayMs": params.max_buffer_delay_ms, - "bufferCharThreshold": params.buffer_char_threshold, + "maxBufferDelayMs": _buffer_max_delay_ms, + "bufferCharThreshold": _buffer_char_threshold, } self._receive_task = None - self._context_id = None - self._started = False + self._keepalive_task = None - self.set_voice(voice_id) - self.set_model_name(model) + # Track cumulative time across generations for monotonic timestamps within a turn. + # When auto_mode is enabled, the server controls generations and timestamps reset + # to 0 after each generation, as indicated by a "flushCompleted" message. We + # add _cumulative_time to maintain monotonically increasing timestamps. + self._cumulative_time = 0.0 + # Track the end time of the last word in the current generation + self._generation_end_time = 0.0 + + # Init-only config (not runtime-updatable). + self._audio_encoding = encoding + self._audio_sample_rate = 0 # Set in start() + self._auto_mode = auto_mode + self._apply_text_normalization = apply_text_normalization + self._timestamp_transport_strategy = timestamp_transport_strategy def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -500,7 +675,7 @@ class InworldTTSService(AudioContextWordTTSService): frame: The start frame. """ await super().start(frame) - self._settings["audioConfig"]["sampleRateHertz"] = self.sample_rate + self._audio_sample_rate = self.sample_rate await self._connect() async def stop(self, frame: EndFrame): @@ -521,16 +696,17 @@ class InworldTTSService(AudioContextWordTTSService): await super().cancel(frame) await self._disconnect() - async def flush_audio(self): + async def flush_audio(self, context_id: Optional[str] = None): """Flush any pending audio without closing the context. This triggers synthesis of all accumulated text in the buffer while 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) + flush_id = context_id or self.get_active_audio_context_id() + if flush_id and self._websocket: + logger.trace(f"Flushing audio for context {flush_id}") + await self._send_flush(flush_id) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): """Push a frame and handle state changes. @@ -541,53 +717,70 @@ class InworldTTSService(AudioContextWordTTSService): """ await super().push_frame(frame, direction) if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)): - self._started = False + logger.trace( + f"{self}: Resetting timestamp tracking due to {type(frame).__name__} - " + f"cumulative_time was {self._cumulative_time}" + ) + self._cumulative_time = 0.0 + self._generation_end_time = 0.0 if isinstance(frame, TTSStoppedFrame): await self.add_word_timestamps([("Reset", 0)]) def _calculate_word_times(self, timestamp_info: Dict[str, Any]) -> List[Tuple[str, float]]: """Calculate word timestamps from Inworld WebSocket API response. + Adds cumulative time offset to maintain monotonically increasing timestamps + across multiple generations within an agent turn. Also tracks the generation + end time for updating cumulative time on flush. + Args: timestamp_info: The timestamp information from Inworld API. Returns: - A list of (word, timestamp) tuples. + List of (word, timestamp) tuples with cumulative offset applied. """ word_times: List[Tuple[str, float]] = [] alignment = timestamp_info.get("wordAlignment", {}) words = alignment.get("words", []) start_times = alignment.get("wordStartTimeSeconds", []) + end_times = alignment.get("wordEndTimeSeconds", []) if words and start_times and len(words) == len(start_times): for i, word in enumerate(words): - word_times.append((word, start_times[i])) + word_start = self._cumulative_time + start_times[i] + word_times.append((word, word_start)) + + # Track cumulative end time for this generation + if end_times and len(end_times) > 0: + self._generation_end_time = self._cumulative_time + end_times[-1] + + logger.trace( + f"{self}: Word timestamps - raw_start_times={start_times}, " + f"cumulative_offset={self._cumulative_time}, " + f"adjusted_times={[t for _, t in word_times]}, " + f"generation_end_time={self._generation_end_time}" + ) return word_times - async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): - """Handle an interruption from the Inworld WebSocket TTS service. - - Args: - frame: The interruption frame. - direction: The direction of the interruption. - """ - old_context_id = self._context_id - logger.trace(f"{self}: Handling interruption, old context: {old_context_id}") - - await super()._handle_interruption(frame, direction) - - if old_context_id and self._websocket: - logger.trace(f"{self}: Closing context {old_context_id} due to interruption") + async def _close_context(self, context_id: str): + if context_id and self._websocket: + logger.info(f"{self}: Closing context {context_id} due to interruption or completion") try: - await self._send_close_context(old_context_id) + await self._send_close_context(context_id) except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + self._cumulative_time = 0.0 + self._generation_end_time = 0.0 - self._context_id = None - self._started = False - logger.trace(f"{self}: Interruption handled, context reset to None") + async def on_audio_context_interrupted(self, context_id: str): + """Callback invoked when an audio context has been interrupted.""" + await self._close_context(context_id) + + async def on_audio_context_completed(self, context_id: str): + """Callback invoked when an audio context has been completed.""" + await self._close_context(context_id) def _get_websocket(self): """Get the websocket for the Inworld WebSocket TTS service. @@ -605,22 +798,49 @@ class InworldTTSService(AudioContextWordTTSService): Returns: The websocket. """ + await super()._connect() + await self._connect_websocket() + if self._websocket and not self._receive_task: self._receive_task = self.create_task(self._receive_task_handler(self._report_error)) + if self._websocket and not self._keepalive_task: + self._keepalive_task = self.create_task(self._keepalive_task_handler()) + async def _disconnect(self): """Disconnect from the Inworld WebSocket TTS service. Returns: The websocket. """ + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None + if self._keepalive_task: + await self.cancel_task(self._keepalive_task) + self._keepalive_task = None + await self._disconnect_websocket() + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta. + + Settings are stored but not applied to the active connection. + """ + changed = await super()._update_settings(delta) + + if not changed: + return changed + + await self._disconnect() + await self._connect() + + return changed + async def _connect_websocket(self): """Connect to the Inworld WebSocket TTS service. @@ -631,8 +851,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: @@ -651,19 +876,19 @@ class InworldTTSService(AudioContextWordTTSService): if self._websocket: logger.debug("Disconnecting from Inworld WebSocket TTS") - if self._context_id: - try: - await self._send_close_context(self._context_id) - except Exception: - pass + audio_contexts = self.get_audio_contexts() + if audio_contexts: + for ctx_id in audio_contexts: + await self._send_close_context(ctx_id) await self._websocket.close() logger.debug("Disconnected from Inworld WebSocket TTS") except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: - self._started = False - self._context_id = None + await self.remove_active_audio_context() self._websocket = None + self._cumulative_time = 0.0 + self._generation_end_time = 0.0 await self._call_event_handler("on_disconnected") async def _receive_messages(self): @@ -684,15 +909,21 @@ class InworldTTSService(AudioContextWordTTSService): for k in ["contextCreated", "audioChunk", "flushCompleted", "contextClosed"] if k in result ] - 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'}" - ) + logger.trace(f"{self}: Received message types={msg_types}, ctx_id={ctx_id}") # Check for errors status = result.get("status", {}) if status.get("code", 0) != 0: error_msg = status.get("message", "Unknown error") + error_code = status.get("code") + + # 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} not found.") + continue + + # For other errors, push error frame await self.push_error(error_msg=f"Inworld API error: {error_msg}") continue @@ -700,19 +931,10 @@ class InworldTTSService(AudioContextWordTTSService): await self.push_error(error_msg=str(msg["error"])) continue - # Check if this message belongs to an available context. - # If the context isn't available but matches our current context ID, - # recreate it (handles race conditions during interruption recovery). + # If the context isn't available 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) - else: - # This is a message from an old/closed context - skip it - logger.trace(f"{self}: Skipping message from unavailable context: {ctx_id}") - continue + logger.trace(f"{self}: Recreating audio context for current context: {ctx_id}") + await self.create_audio_context(ctx_id) # Process audio chunk audio_chunk = result.get("audioChunk", {}) @@ -720,42 +942,61 @@ class InworldTTSService(AudioContextWordTTSService): if audio_b64: logger.trace(f"{self}: Processing audio chunk for context {ctx_id}") - await self.stop_ttfb_metrics() - await self.start_word_timestamps() audio = base64.b64decode(audio_b64) if len(audio) > 44 and audio.startswith(b"RIFF"): audio = audio[44:] - frame = TTSAudioRawFrame(audio, self.sample_rate, 1) + frame = TTSAudioRawFrame(audio, self.sample_rate, 1, context_id=ctx_id) 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) + # 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: logger.trace(f"{self}: Context created on server: {ctx_id}") - # Handle flush completion - context is still valid, just acknowledge it + # Handle flush completion, which indicates the end of a generation if "flushCompleted" in result: - logger.trace(f"{self}: Flush completed for context {ctx_id}") + logger.trace( + f"{self}: Generation completed - updating cumulative_time: " + f"{self._cumulative_time} -> {self._generation_end_time}" + ) + self._cumulative_time = self._generation_end_time # Handle context closed - context no longer exists on server if "contextClosed" in result: 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 - self._started = False - 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)]) + await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id) + await self.remove_audio_context(ctx_id) + + async def _keepalive_task_handler(self): + """Send periodic keepalive messages to maintain WebSocket connection.""" + KEEPALIVE_SLEEP = 60 + while True: + await asyncio.sleep(KEEPALIVE_SLEEP) + try: + if self._websocket and self._websocket.state is State.OPEN: + context_id = self.get_active_audio_context_id() + if context_id: + keepalive_message = { + "send_text": {"text": ""}, + "contextId": context_id, + } + logger.trace(f"Sending keepalive for context {context_id}") + else: + keepalive_message = {"send_text": {"text": ""}} + logger.trace("Sending keepalive without context") + await self._websocket.send(json.dumps(keepalive_message)) + except websockets.ConnectionClosed as e: + logger.warning(f"{self} keepalive error: {e}") + break async def _send_context(self, context_id: str): """Send a context to the Inworld WebSocket TTS service. @@ -763,16 +1004,27 @@ class InworldTTSService(AudioContextWordTTSService): Args: context_id: The context ID. """ + audio_config = { + "audioEncoding": self._audio_encoding, + "sampleRateHertz": self._audio_sample_rate, + } + if self._settings.speaking_rate is not None: + audio_config["speakingRate"] = self._settings.speaking_rate + create_config: Dict[str, Any] = { - "voiceId": self._settings["voiceId"], - "modelId": self._settings["modelId"], - "audioConfig": self._settings["audioConfig"], + "voiceId": self._settings.voice, + "modelId": self._settings.model, + "audioConfig": audio_config, } - if "temperature" in self._settings: - create_config["temperature"] = self._settings["temperature"] - if "applyTextNormalization" in self._settings: - create_config["applyTextNormalization"] = self._settings["applyTextNormalization"] + if self._settings.temperature is not None: + create_config["temperature"] = self._settings.temperature + if self._apply_text_normalization is not None: + create_config["applyTextNormalization"] = self._apply_text_normalization + if self._auto_mode is not None: + create_config["autoMode"] = self._auto_mode + if self._timestamp_transport_strategy is not None: + create_config["timestampTransportStrategy"] = self._timestamp_transport_strategy # Set buffer settings for timely audio generation. # Use provided values or defaults that work well for streaming LLM output. @@ -814,11 +1066,12 @@ class InworldTTSService(AudioContextWordTTSService): await self.send_with_retry(json.dumps(msg), self._report_error) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate TTS audio for the given text using the Inworld WebSocket TTS service. Args: text: The text to generate TTS audio for. + context_id: Unique identifier for this TTS context. Returns: An asynchronous generator of frames. @@ -830,28 +1083,18 @@ class InworldTTSService(AudioContextWordTTSService): await self._connect() try: - if not self._started: + if not self.audio_context_available(context_id): + await self.create_audio_context(context_id) await self.start_ttfb_metrics() - yield TTSStartedFrame() - self._started = True + yield TTSStartedFrame(context_id=context_id) + await self._send_context(context_id) - if not self._context_id: - self._context_id = str(uuid.uuid4()) - 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: yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() - self._started = False + yield TTSStoppedFrame(context_id=context_id) return yield None except Exception as e: diff --git a/src/pipecat/services/kokoro/__init__.py b/src/pipecat/services/kokoro/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pipecat/services/kokoro/tts.py b/src/pipecat/services/kokoro/tts.py new file mode 100644 index 000000000..4756d4e74 --- /dev/null +++ b/src/pipecat/services/kokoro/tts.py @@ -0,0 +1,239 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Kokoro TTS service implementation using kokoro-onnx.""" + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import AsyncGenerator, Optional + +import numpy as np +from loguru import logger +from pydantic import BaseModel + +from pipecat.audio.utils import create_stream_resampler +from pipecat.frames.frames import ( + ErrorFrame, + Frame, + TTSAudioRawFrame, +) +from pipecat.services.settings import TTSSettings +from pipecat.services.tts_service import TTSService +from pipecat.transcriptions.language import Language, resolve_language +from pipecat.utils.tracing.service_decorators import traced_tts + +try: + import requests + from kokoro_onnx import Kokoro +except ModuleNotFoundError as e: + logger.error(f"Exception: {e}") + logger.error("In order to use Kokoro, you need to `pip install pipecat-ai[kokoro]`.") + raise Exception(f"Missing module: {e}") + +KOKORO_CACHE_DIR = Path(os.path.expanduser("~/.cache/kokoro-onnx")) +KOKORO_MODEL_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx" +KOKORO_VOICES_URL = ( + "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin" +) + + +def _download_file(url: str, dest: Path): + """Download a file from a URL to a destination path.""" + logger.debug(f"Downloading {url} to {dest}...") + dest.parent.mkdir(parents=True, exist_ok=True) + resp = requests.get(url, stream=True, timeout=300) + resp.raise_for_status() + with open(dest, "wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + f.write(chunk) + logger.debug(f"Downloaded {dest}") + + +def _ensure_model_files(model_path: Path, voices_path: Path): + """Download model files if they don't exist.""" + if not model_path.exists(): + _download_file(KOKORO_MODEL_URL, model_path) + if not voices_path.exists(): + _download_file(KOKORO_VOICES_URL, voices_path) + + +def language_to_kokoro_language(language: Language) -> str: + """Convert a Language enum to kokoro-onnx language code. + + Args: + language: The Language enum value to convert. + + Returns: + The corresponding kokoro-onnx locale code. + + """ + LANGUAGE_MAP = { + Language.EN: "en-us", + Language.EN_US: "en-us", + Language.EN_GB: "en-gb", + Language.ES: "es", + Language.FR: "fr", + Language.HI: "hi", + Language.IT: "it", + Language.JA: "ja", + Language.PT: "pt", + Language.ZH: "zh", + } + + return resolve_language(language, LANGUAGE_MAP, use_base_code=True) + + +@dataclass +class KokoroTTSSettings(TTSSettings): + """Settings for KokoroTTSService.""" + + pass + + +class KokoroTTSService(TTSService): + """Kokoro TTS service implementation. + + Provides local text-to-speech synthesis using kokoro-onnx. + Automatically downloads model files on first use. + """ + + Settings = KokoroTTSSettings + _settings: Settings + + class InputParams(BaseModel): + """Input parameters for Kokoro TTS configuration. + + .. deprecated:: 0.0.105 + Use ``KokoroTTSService.Settings`` directly via the ``settings`` parameter instead. + + Parameters: + language: Language to use for synthesis. + """ + + language: Language = Language.EN + + def __init__( + self, + *, + voice_id: Optional[str] = None, + model_path: Optional[str] = None, + voices_path: Optional[str] = None, + params: Optional[InputParams] = None, + settings: Optional[Settings] = None, + **kwargs, + ): + """Initialize the Kokoro TTS service. + + Args: + voice_id: Voice identifier to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=KokoroTTSService.Settings(voice=...)`` instead. + + model_path: Path to the kokoro ONNX model file. Defaults to auto-downloaded file. + voices_path: Path to the voices binary file. Defaults to auto-downloaded file. + params: Configuration parameters for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=KokoroTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + **kwargs: Additional arguments passed to parent `TTSService`. + + """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice=None, + language=Language.EN, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = params.language + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) + + model_file = Path(model_path) if model_path else KOKORO_CACHE_DIR / "kokoro-v1.0.onnx" + voices = Path(voices_path) if voices_path else KOKORO_CACHE_DIR / "voices-v1.0.bin" + + _ensure_model_files(model_file, voices) + + self._kokoro = Kokoro(str(model_file), str(voices)) + + self._resampler = create_stream_resampler() + + def can_generate_metrics(self) -> bool: + """Indicate that this service supports TTFB and usage metrics.""" + return True + + def language_to_service_language(self, language: Language) -> str: + """Convert a Language enum to kokoro-onnx language format. + + Args: + language: The language to convert. + + Returns: + The kokoro-onnx language code. + """ + return language_to_kokoro_language(language) + + @traced_tts + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: + """Synthesize speech from text using kokoro-onnx. + + Uses the async streaming API to generate audio frames. + + Args: + text: The text to synthesize. + context_id: Unique identifier for this TTS context. + + """ + logger.debug(f"{self}: Generating TTS [{text}]") + + try: + await self.start_tts_usage_metrics(text) + + stream = self._kokoro.create_stream( + text, voice=self._settings.voice, lang=self._settings.language, speed=1.0 + ) + + async for samples, sample_rate in stream: + await self.stop_ttfb_metrics() + + audio_int16 = (samples * 32767).astype(np.int16).tobytes() + audio_data = await self._resampler.resample( + audio_int16, sample_rate, self.sample_rate + ) + + yield TTSAudioRawFrame( + audio=audio_data, + sample_rate=self.sample_rate, + num_channels=1, + context_id=context_id, + ) + except Exception as e: + yield ErrorFrame(error=f"Unknown error occurred: {e}") + finally: + await self.stop_ttfb_metrics() diff --git a/src/pipecat/services/llm_service.py b/src/pipecat/services/llm_service.py index a6717b5cc..a479fcfc6 100644 --- a/src/pipecat/services/llm_service.py +++ b/src/pipecat/services/llm_service.py @@ -39,9 +39,12 @@ from pipecat.frames.frames import ( FunctionCallsStartedFrame, InterruptionFrame, LLMConfigureOutputFrame, + LLMContextSummaryRequestFrame, + LLMContextSummaryResultFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMTextFrame, + LLMUpdateSettingsFrame, StartFrame, UserImageRequestFrame, ) @@ -56,6 +59,12 @@ from pipecat.processors.aggregators.llm_response import ( from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.processors.frame_processor import FrameDirection from pipecat.services.ai_service import AIService +from pipecat.services.settings import LLMSettings +from pipecat.turns.user_turn_completion_mixin import UserTurnCompletionLLMServiceMixin +from pipecat.utils.context.llm_context_summarization import ( + DEFAULT_SUMMARIZATION_TIMEOUT, + LLMContextSummarizationUtil, +) # Type alias for a callable that handles LLM function calls. FunctionCallHandler = Callable[["FunctionCallParams"], Awaitable[None]] @@ -111,12 +120,15 @@ class FunctionCallRegistryItem: function_name: The name of the function (None for catch-all handler). handler: The handler for processing function call parameters. cancel_on_interruption: Whether to cancel the call on interruption. + timeout_secs: Optional per-tool timeout in seconds. Overrides the global + ``function_call_timeout_secs`` for this specific function. """ function_name: Optional[str] handler: FunctionCallHandler | "DirectFunctionWrapper" cancel_on_interruption: bool handler_deprecated: bool + timeout_secs: Optional[float] = None @dataclass @@ -142,7 +154,7 @@ class FunctionCallRunnerItem: run_llm: Optional[bool] = None -class LLMService(AIService): +class LLMService(UserTurnCompletionLLMServiceMixin, AIService): """Base class for all LLM services. Handles function calling registration and execution with support for both @@ -166,28 +178,48 @@ class LLMService(AIService): logger.info(f"Starting {len(function_calls)} function calls") """ + _settings: LLMSettings + # OpenAILLMAdapter is used as the default adapter since it aligns with most LLM implementations. # However, subclasses should override this with a more specific adapter when necessary. adapter_class: Type[BaseLLMAdapter] = OpenAILLMAdapter - def __init__(self, run_in_parallel: bool = True, **kwargs): + def __init__( + self, + run_in_parallel: bool = True, + function_call_timeout_secs: float = 10.0, + settings: Optional[LLMSettings] = None, + **kwargs, + ): """Initialize the LLM service. Args: run_in_parallel: Whether to run function calls in parallel or sequentially. Defaults to True. + function_call_timeout_secs: Timeout in seconds for deferred function calls. + Defaults to 10.0 seconds. + settings: The runtime-updatable settings for the LLM service. **kwargs: Additional arguments passed to the parent AIService. """ - super().__init__(**kwargs) + super().__init__( + settings=settings + # Here in case subclass doesn't implement more specific settings + # (which hopefully should be rare) + or LLMSettings(), + **kwargs, + ) self._run_in_parallel = run_in_parallel + self._function_call_timeout_secs = function_call_timeout_secs + self._filter_incomplete_user_turns: bool = False + self._base_system_instruction: Optional[str] = None self._start_callbacks = {} self._adapter = self.adapter_class() 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._register_event_handler("on_function_calls_started") self._register_event_handler("on_completion_timeout") @@ -211,13 +243,22 @@ class LLMService(AIService): """ return self.get_llm_adapter().create_llm_specific_message(message) - async def run_inference(self, context: LLMContext | OpenAILLMContext) -> Optional[str]: + async def run_inference( + self, + context: LLMContext | OpenAILLMContext, + max_tokens: Optional[int] = None, + system_instruction: Optional[str] = None, + ) -> Optional[str]: """Run a one-shot, out-of-band (i.e. out-of-pipeline) inference with the given LLM context. Must be implemented by subclasses. Args: context: The LLM context containing conversation history. + max_tokens: Optional maximum number of tokens to generate. If provided, + overrides the service's default max_tokens/max_completion_tokens setting. + system_instruction: Optional system instruction to use for this inference. + If provided, overrides any system instruction in the context. Returns: The LLM's response as a string, or None if no response is generated. @@ -268,7 +309,6 @@ class LLMService(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. @@ -279,6 +319,7 @@ class LLMService(AIService): await super().stop(frame) if not self._run_in_parallel: await self._cancel_sequential_runner_task() + await self._cancel_summary_task() async def cancel(self, frame: CancelFrame): """Cancel the LLM service. @@ -289,6 +330,64 @@ class LLMService(AIService): await super().cancel(frame) if not self._run_in_parallel: await self._cancel_sequential_runner_task() + await self._cancel_summary_task() + + def _compose_system_instruction(self): + """Compose system_instruction by appending turn completion instructions. + + Combines the base system instruction with turn completion instructions + and writes the result to ``self._settings.system_instruction``. + """ + base = self._base_system_instruction + completion_instructions = self._user_turn_completion_config.completion_instructions + if base: + self._settings.system_instruction = f"{base}\n\n{completion_instructions}" + else: + self._settings.system_instruction = completion_instructions + + async def _update_settings(self, delta: LLMSettings) -> dict[str, Any]: + """Apply a settings delta, handling turn-completion fields. + + Args: + delta: An LLM settings delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + + if "filter_incomplete_user_turns" in changed: + self._filter_incomplete_user_turns = ( + self._settings.filter_incomplete_user_turns or False + ) + logger.info( + f"{self}: Incomplete turn filtering " + f"{'enabled' if self._filter_incomplete_user_turns else 'disabled'}" + ) + if self._filter_incomplete_user_turns: + # Save the current system_instruction before composing + self._base_system_instruction = self._settings.system_instruction + self._compose_system_instruction() + else: + # Restore original system_instruction + self._settings.system_instruction = self._base_system_instruction + self._base_system_instruction = None + + if "user_turn_completion_config" in changed and self._filter_incomplete_user_turns: + self.set_user_turn_completion_config(self._settings.user_turn_completion_config) + self._compose_system_instruction() + + if ( + "system_instruction" in changed + and self._filter_incomplete_user_turns + and "filter_incomplete_user_turns" not in changed + ): + # system_instruction changed while turn completion is active. + # Treat the new value as the new base and recompose. + self._base_system_instruction = self._settings.system_instruction + self._compose_system_instruction() + + return changed async def process_frame(self, frame: Frame, direction: FrameDirection): """Process a frame. @@ -303,6 +402,25 @@ class LLMService(AIService): await self._handle_interruptions(frame) elif isinstance(frame, LLMConfigureOutputFrame): self._skip_tts = frame.skip_tts + elif isinstance(frame, LLMUpdateSettingsFrame): + if frame.service is not None and frame.service is not self: + await self.push_frame(frame, direction) + elif frame.delta is not None: + await self._update_settings(frame.delta) + elif frame.settings: + # Backward-compatible path: convert legacy dict to settings object. + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Passing a dict via LLMUpdateSettingsFrame(settings={...}) is deprecated " + "since 0.0.104, use LLMUpdateSettingsFrame(delta=LLMSettings(...)) instead.", + DeprecationWarning, + stacklevel=2, + ) + delta = type(self._settings).from_mapping(frame.settings) + await self._update_settings(delta) + elif isinstance(frame, LLMContextSummaryRequestFrame): + await self._handle_summary_request(frame) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): """Pushes a frame. @@ -317,11 +435,141 @@ class LLMService(AIService): await super().push_frame(frame, direction) + async def _push_llm_text(self, text: str): + """Push LLM text, using turn completion detection if enabled. + + This helper method simplifies text pushing in LLM implementations by + handling the conditional logic for turn completion internally. + + Args: + text: The text content from the LLM to push. + """ + if self._filter_incomplete_user_turns: + await self._push_turn_text(text) + else: + await self.push_frame(LLMTextFrame(text)) + async def _handle_interruptions(self, _: InterruptionFrame): for function_name, entry in self._functions.items(): if entry.cancel_on_interruption: await self._cancel_function_call(function_name) + async def _handle_summary_request(self, frame: LLMContextSummaryRequestFrame): + """Handle context summarization request from aggregator. + + Processes a summarization request by generating a compressed summary + of conversation history. Uses the adapter to format the summary + according to the provider's requirements. Broadcasts the result back + to the aggregator for context reconstruction. + + Args: + frame: The summary request frame containing context and parameters. + """ + logger.debug(f"{self}: Processing summarization request {frame.request_id}") + + # Create a background task to generate the summary without blocking + self._summary_task = self.create_task(self._generate_summary_task(frame)) + + async def _generate_summary_task(self, frame: LLMContextSummaryRequestFrame): + """Background task to generate summary without blocking the pipeline. + + Args: + frame: The summary request frame containing context and parameters. + """ + summary = "" + last_index = -1 + error = None + + timeout = frame.summarization_timeout or DEFAULT_SUMMARIZATION_TIMEOUT + + try: + summary, last_index = await asyncio.wait_for( + self._generate_summary(frame), + timeout=timeout, + ) + except asyncio.TimeoutError: + await self.push_error(error_msg=f"Context summarization timed out after {timeout}s") + except Exception as e: + error = f"Error generating context summary: {e}" + await self.push_error(error, exception=e) + + await self.broadcast_frame( + LLMContextSummaryResultFrame, + request_id=frame.request_id, + summary=summary, + last_summarized_index=last_index, + error=error, + ) + + self._summary_task = None + + async def _generate_summary(self, frame: LLMContextSummaryRequestFrame) -> tuple[str, int]: + """Generate a compressed summary of conversation context. + + Uses the message selection logic to identify which messages + to summarize, formats them as a transcript, and invokes the LLM to + generate a concise summary. The summary is formatted according to the + LLM provider's requirements using the adapter. + + Args: + frame: The summary request frame containing context and configuration. + + Returns: + Tuple of (formatted summary message, last_summarized_index). + + Raises: + RuntimeError: If there are no messages to summarize, the service doesn't + support run_inference(), or the LLM returns an empty summary. + + Note: + Requires the service to implement run_inference() method for + synchronous LLM calls. + """ + # Get messages to summarize using utility method + result = LLMContextSummarizationUtil.get_messages_to_summarize( + frame.context, frame.min_messages_to_keep + ) + + if not result.messages: + logger.debug(f"{self}: No messages to summarize") + raise RuntimeError("No messages to summarize") + + logger.debug( + f"{self}: Generating summary for {len(result.messages)} messages " + f"(index 0 to {result.last_summarized_index}), " + f"target_context_tokens={frame.target_context_tokens}" + ) + + # Create summary context + transcript = LLMContextSummarizationUtil.format_messages_for_summary(result.messages) + summary_context = LLMContext( + messages=[{"role": "user", "content": f"Conversation history:\n{transcript}"}] + ) + + # Generate summary using run_inference + # This will be overridden by each LLM service implementation + try: + summary_text = await self.run_inference( + summary_context, + max_tokens=frame.target_context_tokens, + system_instruction=frame.summarization_prompt, + ) + except NotImplementedError: + raise RuntimeError( + f"LLM service {self.__class__.__name__} does not implement run_inference" + ) + + if not summary_text: + raise RuntimeError("LLM returned empty summary") + + summary_text = summary_text.strip() + logger.info( + f"{self}: Generated summary of {len(summary_text)} characters " + f"for {len(result.messages)} messages" + ) + + return summary_text, result.last_summarized_index + def register_function( self, function_name: Optional[str], @@ -329,6 +577,7 @@ class LLMService(AIService): start_callback=None, *, cancel_on_interruption: bool = True, + timeout_secs: Optional[float] = None, ): """Register a function handler for LLM function calls. @@ -345,6 +594,9 @@ class LLMService(AIService): cancel_on_interruption: Whether to cancel this function call when an interruption occurs. Defaults to True. + timeout_secs: Optional per-tool timeout in seconds. Overrides the global + ``function_call_timeout_secs`` for this specific function. Defaults to + None, which uses the global timeout. """ signature = inspect.signature(handler) handler_deprecated = len(signature.parameters) > 1 @@ -363,6 +615,7 @@ class LLMService(AIService): handler=handler, cancel_on_interruption=cancel_on_interruption, handler_deprecated=handler_deprecated, + timeout_secs=timeout_secs, ) # Start callbacks are now deprecated. @@ -381,6 +634,7 @@ class LLMService(AIService): handler: DirectFunction, *, cancel_on_interruption: bool = True, + timeout_secs: Optional[float] = None, ): """Register a direct function handler for LLM function calls. @@ -392,6 +646,9 @@ class LLMService(AIService): handler: The direct function to register. Must follow DirectFunction protocol. cancel_on_interruption: Whether to cancel this function call when an interruption occurs. Defaults to True. + timeout_secs: Optional per-tool timeout in seconds. Overrides the global + ``function_call_timeout_secs`` for this specific function. Defaults to + None, which uses the global timeout. """ wrapper = DirectFunctionWrapper(handler) self._functions[wrapper.name] = FunctionCallRegistryItem( @@ -399,6 +656,7 @@ class LLMService(AIService): handler=wrapper, cancel_on_interruption=cancel_on_interruption, handler_deprecated=False, + timeout_secs=timeout_secs, ) def unregister_function(self, function_name: Optional[str]): @@ -519,9 +777,10 @@ class LLMService(AIService): UserImageRequestFrame( user_id=user_id, text=text_content, - # Deprecated fields below. + append_to_context=True, function_name=function_name, tool_call_id=tool_call_id, + # Deprecated fields below. context=text_content, ), FrameDirection.UPSTREAM, @@ -537,6 +796,11 @@ class LLMService(AIService): await self.cancel_task(self._sequential_runner_task) self._sequential_runner_task = None + async def _cancel_summary_task(self): + if self._summary_task: + await self.cancel_task(self._summary_task) + self._summary_task = None + async def _sequential_runner_handler(self): while True: runner_item = await self._sequential_runner_queue.get() @@ -595,14 +859,18 @@ class LLMService(AIService): cancel_on_interruption=item.cancel_on_interruption, ) - callback_executed = False + timeout_task: Optional[asyncio.Task] = None # Define a callback function that pushes a FunctionCallResultFrame upstream & downstream. async def function_call_result_callback( result: Any, *, properties: Optional[FunctionCallResultProperties] = None ): - nonlocal callback_executed - callback_executed = True + nonlocal timeout_task + + # Cancel timeout task if it exists + if timeout_task and not timeout_task.done(): + await self.cancel_task(timeout_task) + await self.broadcast_frame( FunctionCallResultFrame, function_name=runner_item.function_name, @@ -613,7 +881,31 @@ class LLMService(AIService): properties=properties, ) + # Start a timeout task for deferred function calls + async def timeout_handler(): + try: + effective_timeout = ( + item.timeout_secs + if item.timeout_secs is not None + else self._function_call_timeout_secs + ) + await asyncio.sleep(effective_timeout) + logger.warning( + f"{self} Function call [{runner_item.function_name}:{runner_item.tool_call_id}] timed out after {effective_timeout} seconds." + f" You can increase this timeout by passing `timeout_secs` to `register_function()`," + f" or set a global default via `function_call_timeout_secs` on the LLM constructor." + ) + await function_call_result_callback(None) + except asyncio.CancelledError: + raise + + timeout_task = self.create_task(timeout_handler()) + try: + # Yield to the event loop so the timeout task coroutine gets entered + # before it could be cancelled. Without this, cancelling the task before + # it starts would leave the coroutine in a "never awaited" state. + await asyncio.sleep(0) if isinstance(item.handler, DirectFunctionWrapper): # Handler is a DirectFunctionWrapper await item.handler.invoke( @@ -653,8 +945,8 @@ class LLMService(AIService): logger.error(f"{self} {error_message}") await self.push_error(error_msg=error_message, exception=e, fatal=False) finally: - if not callback_executed: - await function_call_result_callback(None) + if timeout_task and not timeout_task.done(): + await self.cancel_task(timeout_task) async def _cancel_function_call(self, function_name: Optional[str]): cancelled_tasks = set() @@ -673,8 +965,9 @@ class LLMService(AIService): await self.cancel_task(task) cancelled_tasks.add(task) - frame = FunctionCallCancelFrame(function_name=name, tool_call_id=tool_call_id) - await self.push_frame(frame) + await self.broadcast_frame( + FunctionCallCancelFrame, function_name=name, tool_call_id=tool_call_id + ) logger.debug(f"{self} Function call [{name}:{tool_call_id}] has been cancelled") diff --git a/src/pipecat/services/lmnt/tts.py b/src/pipecat/services/lmnt/tts.py index b6a50aa9a..0df70fd19 100644 --- a/src/pipecat/services/lmnt/tts.py +++ b/src/pipecat/services/lmnt/tts.py @@ -7,7 +7,8 @@ """LMNT text-to-speech service implementation.""" import json -from typing import AsyncGenerator, Optional +from dataclasses import dataclass +from typing import Any, AsyncGenerator, Optional from loguru import logger @@ -16,13 +17,11 @@ from pipecat.frames.frames import ( EndFrame, ErrorFrame, Frame, - InterruptionFrame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, TTSStoppedFrame, ) -from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import TTSSettings from pipecat.services.tts_service import InterruptibleTTSService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.tracing.service_decorators import traced_tts @@ -47,6 +46,7 @@ def language_to_lmnt_language(language: Language) -> Optional[str]: The corresponding LMNT language code, or None if not supported. """ LANGUAGE_MAP = { + Language.AR: "ar", Language.DE: "de", Language.EN: "en", Language.ES: "es", @@ -64,6 +64,7 @@ def language_to_lmnt_language(language: Language) -> Optional[str]: Language.TH: "th", Language.TR: "tr", Language.UK: "uk", + Language.UR: "ur", Language.VI: "vi", Language.ZH: "zh", } @@ -71,6 +72,13 @@ def language_to_lmnt_language(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=True) +@dataclass +class LmntTTSSettings(TTSSettings): + """Settings for LmntTTSService.""" + + pass + + class LmntTTSService(InterruptibleTTSService): """LMNT real-time text-to-speech service. @@ -79,14 +87,19 @@ class LmntTTSService(InterruptibleTTSService): language settings. """ + Settings = LmntTTSSettings + _settings: Settings + def __init__( self, *, api_key: str, - voice_id: str, + voice_id: Optional[str] = None, sample_rate: Optional[int] = None, language: Language = Language.EN, - model: str = "blizzard", + output_format: str = "pcm_s16le", + model: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the LMNT TTS service. @@ -94,26 +107,62 @@ class LmntTTSService(InterruptibleTTSService): Args: api_key: LMNT API key for authentication. voice_id: ID of the voice to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=LmntTTSService.Settings(voice=...)`` instead. + sample_rate: Audio sample rate. If None, uses default. language: Language for synthesis. Defaults to English. - model: TTS model to use. Defaults to "blizzard". + + .. deprecated:: 0.0.106 + Use ``settings=LmntTTSService.Settings(language=...)`` instead. + + output_format: Audio output format. One of "pcm_s16le", "pcm_f32le", + "mp3", "ulaw", "webm". Defaults to "pcm_s16le". + model: TTS model to use. + + .. deprecated:: 0.0.105 + Use ``settings=LmntTTSService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent InterruptibleTTSService. """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="aurora", + voice=None, + language=Language.EN, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if language is not None: + self._warn_init_param_moved_to_settings("language", "language") + default_settings.language = language + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( push_stop_frames=True, + push_start_frame=True, pause_frame_processing=True, sample_rate=sample_rate, + settings=default_settings, **kwargs, ) self._api_key = api_key - self.set_voice(voice_id) - self.set_model_name(model) - self._settings = { - "language": self.language_to_service_language(language), - "format": "raw", # Use raw format for direct PCM data - } - self._started = False + self._output_format = output_format self._receive_task = None def can_generate_metrics(self) -> bool: @@ -162,19 +211,10 @@ class LmntTTSService(InterruptibleTTSService): await super().cancel(frame) await self._disconnect() - async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): - """Push a frame downstream with special handling for stop conditions. - - Args: - frame: The frame to push. - direction: The direction to push the frame. - """ - await super().push_frame(frame, direction) - if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)): - self._started = False - async def _connect(self): """Connect to LMNT WebSocket and start receive task.""" + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: @@ -182,12 +222,31 @@ class LmntTTSService(InterruptibleTTSService): async def _disconnect(self): """Disconnect from LMNT WebSocket and clean up tasks.""" + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None await self._disconnect_websocket() + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta. + + Args: + delta: A :class:`TTSSettings` (or ``LmntTTSService.Settings``) delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + + if changed: + await self._disconnect() + await self._connect() + + return changed + async def _connect_websocket(self): """Connect to LMNT websocket.""" try: @@ -199,11 +258,11 @@ class LmntTTSService(InterruptibleTTSService): # Build initial connection message init_msg = { "X-API-Key": self._api_key, - "voice": self._voice_id, - "format": self._settings["format"], + "voice": self._settings.voice, + "format": self._output_format, "sample_rate": self.sample_rate, - "language": self._settings["language"], - "model": self.model_name, + "language": self._settings.language, + "model": self._settings.model, } # Connect to LMNT's websocket directly @@ -232,7 +291,6 @@ class LmntTTSService(InterruptibleTTSService): except Exception as e: await self.push_error(error_msg=f"Error disconnecting from LMNT: {e}", exception=e) finally: - self._started = False self._websocket = None await self._call_event_handler("on_disconnected") @@ -242,7 +300,7 @@ class LmntTTSService(InterruptibleTTSService): return self._websocket raise Exception("Websocket not connected") - async def flush_audio(self): + async def flush_audio(self, context_id: Optional[str] = None): """Flush any pending audio synthesis.""" if not self._websocket or self._websocket.state is State.CLOSED: return @@ -254,17 +312,22 @@ class LmntTTSService(InterruptibleTTSService): if isinstance(message, bytes): # Raw audio data await self.stop_ttfb_metrics() + context_id = self.get_active_audio_context_id() frame = TTSAudioRawFrame( audio=message, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) - await self.push_frame(frame) + await self.append_to_audio_context(context_id, frame) else: try: msg = json.loads(message) if "error" in msg: - await self.push_frame(TTSStoppedFrame()) + context_id = self.get_active_audio_context_id() + await self.append_to_audio_context( + context_id, TTSStoppedFrame(context_id=context_id) + ) await self.stop_all_metrics() await self.push_error(error_msg=f"Error: {msg['error']}") return @@ -272,11 +335,12 @@ class LmntTTSService(InterruptibleTTSService): logger.error(f"Invalid JSON message: {message}") @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate TTS audio from text using LMNT's streaming API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -288,11 +352,6 @@ class LmntTTSService(InterruptibleTTSService): await self._connect() try: - if not self._started: - await self.start_ttfb_metrics() - yield TTSStartedFrame() - self._started = True - # Send text to LMNT await self._get_websocket().send(json.dumps({"text": text})) # Force synthesis @@ -300,7 +359,7 @@ class LmntTTSService(InterruptibleTTSService): await self.start_tts_usage_metrics(text) except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() + yield TTSStoppedFrame(context_id=context_id) await self._disconnect() await self._connect() return diff --git a/src/pipecat/services/mcp_service.py b/src/pipecat/services/mcp_service.py index 936e210d2..d4f0807b8 100644 --- a/src/pipecat/services/mcp_service.py +++ b/src/pipecat/services/mcp_service.py @@ -296,10 +296,7 @@ class MCPClient(BaseObject): available_tools = await session.list_tools() tool_schemas: List[FunctionSchema] = [] - try: - logger.debug(f"Found {len(available_tools)} available tools") - except: - pass + logger.debug(f"Found {len(available_tools.tools)} available tools") for tool in available_tools.tools: tool_name = tool.name diff --git a/src/pipecat/services/mem0/memory.py b/src/pipecat/services/mem0/memory.py index afb38088e..c4dfe16de 100644 --- a/src/pipecat/services/mem0/memory.py +++ b/src/pipecat/services/mem0/memory.py @@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional from loguru import logger from pydantic import BaseModel, Field -from pipecat.frames.frames import ErrorFrame, Frame, LLMContextFrame, LLMMessagesFrame +from pipecat.frames.frames import Frame, LLMContextFrame, LLMMessagesFrame from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.openai_llm_context import ( OpenAILLMContext, @@ -121,7 +121,6 @@ class Mem0MemoryService(FrameProcessor): try: logger.debug(f"Storing {len(messages)} messages in Mem0") params = { - "async_mode": True, "messages": messages, "metadata": {"platform": "pipecat"}, "output_format": "v1.1", diff --git a/src/pipecat/services/minimax/tts.py b/src/pipecat/services/minimax/tts.py index c3e434361..46502409d 100644 --- a/src/pipecat/services/minimax/tts.py +++ b/src/pipecat/services/minimax/tts.py @@ -11,7 +11,8 @@ for streaming text-to-speech synthesis. """ import json -from typing import AsyncGenerator, Optional +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Mapping, Optional, Self import aiohttp from loguru import logger @@ -22,9 +23,8 @@ from pipecat.frames.frames import ( Frame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, - TTSStoppedFrame, ) +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven from pipecat.services.tts_service import TTSService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.tracing.service_decorators import traced_tts @@ -85,6 +85,50 @@ def language_to_minimax_language(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=False) +@dataclass +class MiniMaxTTSSettings(TTSSettings): + """Settings for MiniMaxHttpTTSService. + + Parameters: + speed: Speech speed (range: 0.5 to 2.0). + volume: Speech volume (range: 0 to 10). + pitch: Pitch adjustment (range: -12 to 12). + emotion: Emotional tone (options: "happy", "sad", "angry", "fearful", + "disgusted", "surprised", "calm", "fluent"). + text_normalization: Enable text normalization (Chinese/English). + latex_read: Enable LaTeX formula reading. + language_boost: Language boost string for multilingual support. + """ + + speed: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + volume: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + pitch: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + emotion: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + text_normalization: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + latex_read: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + language_boost: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + @classmethod + def from_mapping(cls, settings: Mapping[str, Any]) -> Self: + """Construct settings from a plain dict, destructuring legacy nested dicts. + + Handles ``voice_setting`` (with ``vol`` → ``volume`` rename) and + ``audio_setting`` (with prefixed field mapping). + """ + flat = dict(settings) + + voice = flat.pop("voice_setting", None) + if isinstance(voice, dict): + flat.setdefault("speed", voice.get("speed")) + flat.setdefault("volume", voice.get("vol")) + flat.setdefault("pitch", voice.get("pitch")) + flat.setdefault("emotion", voice.get("emotion")) + flat.setdefault("text_normalization", voice.get("text_normalization")) + flat.setdefault("latex_read", voice.get("latex_read")) + + return super().from_mapping(flat) + + class MiniMaxHttpTTSService(TTSService): """Text-to-speech service using MiniMax's T2A (Text-to-Audio) API. @@ -93,12 +137,18 @@ class MiniMaxHttpTTSService(TTSService): Supports real-time audio streaming with configurable voice parameters. Platform documentation: - https://www.minimax.io/platform/document/T2A%20V2?key=66719005a427f0c8a5701643 + https://platform.minimax.io/docs/api-reference/speech-t2a-http """ + Settings = MiniMaxTTSSettings + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for MiniMax TTS. + .. deprecated:: 0.0.105 + Use ``MiniMaxHttpTTSService.Settings`` directly via the ``settings`` parameter instead. + Parameters: language: Language for TTS generation. Supports 40 languages. Note: Filipino, Tamil, and Persian require speech-2.6-* models. @@ -134,11 +184,13 @@ class MiniMaxHttpTTSService(TTSService): api_key: str, base_url: str = "https://api.minimax.io/v1/t2a_v2", group_id: str, - model: str = "speech-02-turbo", - voice_id: str = "Calm_Woman", + model: Optional[str] = None, + voice_id: Optional[str] = None, aiohttp_session: aiohttp.ClientSession, sample_rate: Optional[int] = None, + stream: bool = True, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the MiniMax TTS service. @@ -154,87 +206,120 @@ class MiniMaxHttpTTSService(TTSService): "speech-2.6-hd", "speech-2.6-turbo" (latest, supports Filipino/Tamil/Persian), "speech-02-hd", "speech-02-turbo", "speech-01-hd", "speech-01-turbo". + + .. deprecated:: 0.0.105 + Use ``settings=MiniMaxHttpTTSService.Settings(model=...)`` instead. + voice_id: Voice identifier. Defaults to "Calm_Woman". + + .. deprecated:: 0.0.105 + Use ``settings=MiniMaxHttpTTSService.Settings(voice=...)`` instead. + aiohttp_session: aiohttp.ClientSession for API communication. sample_rate: Output audio sample rate in Hz. If None, uses pipeline default. + stream: Whether to use streaming mode. Defaults to True. params: Additional configuration parameters. + + .. deprecated:: 0.0.105 + Use ``settings=MiniMaxHttpTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="speech-02-turbo", + voice="Calm_Woman", + language=None, + speed=1.0, + volume=1.0, + pitch=0, + language_boost=None, + emotion=None, + text_normalization=None, + latex_read=None, + ) - params = params or MiniMaxHttpTTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.speed = params.speed + default_settings.volume = params.volume + default_settings.pitch = params.pitch + default_settings.latex_read = params.latex_read + + # Resolve language boost + if params.language: + service_lang = self.language_to_service_language(params.language) + if service_lang: + default_settings.language_boost = service_lang + + # Resolve emotion + if params.emotion: + supported_emotions = [ + "happy", + "sad", + "angry", + "fearful", + "disgusted", + "surprised", + "neutral", + "fluent", + ] + if params.emotion in supported_emotions: + default_settings.emotion = params.emotion + else: + logger.warning( + f"Unsupported emotion: {params.emotion}. Supported emotions: {supported_emotions}" + ) + + # Resolve text_normalization + if params.english_normalization is not None: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Parameter `english_normalization` is deprecated and will be removed in a future version. Use `text_normalization` instead.", + DeprecationWarning, + ) + default_settings.text_normalization = params.english_normalization + if params.text_normalization is not None: + default_settings.text_normalization = params.text_normalization + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._group_id = group_id + self._stream = stream self._base_url = f"{base_url}?GroupId={group_id}" self._session = aiohttp_session - self._model_name = model - self._voice_id = voice_id - # Create voice settings - self._settings = { - "stream": True, - "voice_setting": { - "speed": params.speed, - "vol": params.volume, - "pitch": params.pitch, - }, - "audio_setting": { - "bitrate": 128000, - "format": "pcm", - "channel": 1, - }, - } - - # Set voice and model - self.set_voice(voice_id) - self.set_model_name(model) - - # Add language boost if provided - if params.language: - service_lang = self.language_to_service_language(params.language) - if service_lang: - self._settings["language_boost"] = service_lang - - # Add optional emotion if provided - if params.emotion: - # Validate emotion is in the supported list - supported_emotions = [ - "happy", - "sad", - "angry", - "fearful", - "disgusted", - "surprised", - "neutral", - "fluent", - ] - if params.emotion in supported_emotions: - self._settings["voice_setting"]["emotion"] = params.emotion - else: - logger.warning( - f"Unsupported emotion: {params.emotion}. Supported emotions: {supported_emotions}" - ) - - # If `english_normalization`, add `text_normalization` and print warning - if params.english_normalization is not None: - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "Parameter `english_normalization` is deprecated and will be removed in a future version. Use `text_normalization` instead.", - DeprecationWarning, - ) - self._settings["voice_setting"]["text_normalization"] = params.english_normalization - - # Add text_normalization if provided (corrected parameter name) - if params.text_normalization is not None: - self._settings["voice_setting"]["text_normalization"] = params.text_normalization - - # Add latex_read if provided - if params.latex_read is not None: - self._settings["voice_setting"]["latex_read"] = params.latex_read + # Init-only audio format config + self._audio_bitrate = 128000 + self._audio_format = "pcm" + self._audio_channel = 1 + self._audio_sample_rate = 0 # Set in start() def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -255,24 +340,6 @@ class MiniMaxHttpTTSService(TTSService): """ return language_to_minimax_language(language) - def set_model_name(self, model: str): - """Set the TTS model to use. - - Args: - model: The model name to use for synthesis. - """ - self._model_name = model - - def set_voice(self, voice: str): - """Set the voice to use. - - Args: - voice: The voice identifier to use for synthesis. - """ - self._voice_id = voice - if "voice_setting" in self._settings: - self._settings["voice_setting"]["voice_id"] = voice - async def start(self, frame: StartFrame): """Start the MiniMax TTS service. @@ -280,15 +347,16 @@ class MiniMaxHttpTTSService(TTSService): frame: The start frame containing initialization parameters. """ await super().start(frame) - self._settings["audio_setting"]["sample_rate"] = self.sample_rate + self._audio_sample_rate = self.sample_rate logger.debug(f"MiniMax TTS initialized with sample_rate: {self.sample_rate}") @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate TTS audio from text using MiniMax's streaming API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -301,14 +369,40 @@ class MiniMaxHttpTTSService(TTSService): "Authorization": f"Bearer {self._api_key}", } + # Build voice_setting dict for API + voice_setting = { + "voice_id": self._settings.voice, + "speed": self._settings.speed, + "vol": self._settings.volume, + "pitch": self._settings.pitch, + } + if self._settings.emotion is not None: + voice_setting["emotion"] = self._settings.emotion + if self._settings.text_normalization is not None: + voice_setting["text_normalization"] = self._settings.text_normalization + if self._settings.latex_read is not None: + voice_setting["latex_read"] = self._settings.latex_read + + # Build audio_setting dict for API + audio_setting = { + "bitrate": self._audio_bitrate, + "format": self._audio_format, + "channel": self._audio_channel, + "sample_rate": self._audio_sample_rate, + } + # Create payload from settings - payload = self._settings.copy() - payload["model"] = self._model_name - payload["text"] = text + payload = { + "stream": self._stream, + "voice_setting": voice_setting, + "audio_setting": audio_setting, + "model": self._settings.model, + "text": text, + } + if self._settings.language_boost is not None: + payload["language_boost"] = self._settings.language_boost try: - await self.start_ttfb_metrics() - async with self._session.post( self._base_url, headers=headers, json=payload ) as response: @@ -318,7 +412,6 @@ class MiniMaxHttpTTSService(TTSService): return await self.start_tts_usage_metrics(text) - yield TTSStartedFrame() # Process the streaming response buffer = bytearray() @@ -377,6 +470,7 @@ class MiniMaxHttpTTSService(TTSService): audio=audio_chunk, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) except ValueError as e: logger.error( @@ -394,4 +488,3 @@ class MiniMaxHttpTTSService(TTSService): yield ErrorFrame(error=f"Unknown error occurred: {e}", exception=e) finally: await self.stop_ttfb_metrics() - yield TTSStoppedFrame() diff --git a/src/pipecat/services/mistral/llm.py b/src/pipecat/services/mistral/llm.py index b95c3d1ab..063dac3aa 100644 --- a/src/pipecat/services/mistral/llm.py +++ b/src/pipecat/services/mistral/llm.py @@ -6,18 +6,25 @@ """Mistral LLM service implementation using OpenAI-compatible interface.""" -from typing import List, Sequence +from dataclasses import dataclass +from typing import List, Optional, Sequence from loguru import logger -from openai import AsyncStream -from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam +from openai.types.chat import ChatCompletionMessageParam from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams from pipecat.frames.frames import FunctionCallFromLLM -from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class MistralLLMSettings(BaseOpenAILLMService.Settings): + """Settings for MistralLLMService.""" + + pass + + class MistralLLMService(OpenAILLMService): """A service for interacting with Mistral's API using the OpenAI-compatible interface. @@ -25,12 +32,16 @@ class MistralLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. """ + Settings = MistralLLMSettings + _settings: Settings + def __init__( self, *, api_key: str, base_url: str = "https://api.mistral.ai/v1", - model: str = "mistral-small-latest", + model: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Mistral LLM service. @@ -39,9 +50,29 @@ class MistralLLMService(OpenAILLMService): api_key: The API key for accessing Mistral's API. base_url: The base URL for Mistral API. Defaults to "https://api.mistral.ai/v1". model: The model identifier to use. Defaults to "mistral-small-latest". + + .. deprecated:: 0.0.105 + Use ``settings=MistralLLMService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="mistral-small-latest") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): """Create OpenAI-compatible client for Mistral API endpoint. @@ -182,24 +213,35 @@ class MistralLLMService(OpenAILLMService): fixed_messages = self._apply_mistral_fixups(params_from_context["messages"]) params = { - "model": self.model_name, + "model": self._settings.model, "stream": True, "messages": fixed_messages, "tools": params_from_context["tools"], "tool_choice": params_from_context["tool_choice"], - "frequency_penalty": self._settings["frequency_penalty"], - "presence_penalty": self._settings["presence_penalty"], - "temperature": self._settings["temperature"], - "top_p": self._settings["top_p"], - "max_tokens": self._settings["max_tokens"], + "frequency_penalty": self._settings.frequency_penalty, + "presence_penalty": self._settings.presence_penalty, + "temperature": self._settings.temperature, + "top_p": self._settings.top_p, + "max_tokens": self._settings.max_tokens, } # Handle Mistral-specific parameter mapping # Mistral uses "random_seed" instead of "seed" - if self._settings["seed"]: - params["random_seed"] = self._settings["seed"] + if self._settings.seed: + params["random_seed"] = self._settings.seed # Add any extra parameters - params.update(self._settings["extra"]) + params.update(self._settings.extra) + + # Prepend system instruction if set + if self._settings.system_instruction: + messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) + params["messages"] = [ + {"role": "system", "content": self._settings.system_instruction} + ] + messages return params diff --git a/src/pipecat/services/moondream/vision.py b/src/pipecat/services/moondream/vision.py index f0ae8dca2..6eeff19cd 100644 --- a/src/pipecat/services/moondream/vision.py +++ b/src/pipecat/services/moondream/vision.py @@ -11,6 +11,7 @@ for image analysis and description generation. """ import asyncio +from dataclasses import dataclass from typing import AsyncGenerator, Optional from loguru import logger @@ -24,6 +25,7 @@ from pipecat.frames.frames import ( VisionFullResponseStartFrame, VisionTextFrame, ) +from pipecat.services.settings import VisionSettings from pipecat.services.vision_service import VisionService try: @@ -46,7 +48,7 @@ def detect_device(): and dtype is the recommended torch data type for that device. """ try: - import intel_extension_for_pytorch + import intel_extension_for_pytorch # noqa: F401 if torch.xpu.is_available(): return torch.device("xpu"), torch.float32 @@ -60,6 +62,15 @@ def detect_device(): return torch.device("cpu"), torch.float32 +@dataclass +class MoondreamSettings(VisionSettings): + """Settings for the Moondream vision service. + + Parameters: + model: Moondream model identifier. + """ + + class MoondreamService(VisionService): """Moondream vision-language model service. @@ -68,20 +79,45 @@ class MoondreamService(VisionService): including CUDA, MPS, and Intel XPU. """ + Settings = MoondreamSettings + _settings: Settings + def __init__( - self, *, model="vikhyatk/moondream2", revision="2025-01-09", use_cpu=False, **kwargs + self, + *, + model: Optional[str] = None, + revision="2025-01-09", + use_cpu=False, + settings: Optional[Settings] = None, + **kwargs, ): """Initialize the Moondream service. Args: model: Hugging Face model identifier for the Moondream model. + + .. deprecated:: 0.0.105 + Use ``settings=MoondreamService.Settings(model=...)`` instead. + revision: Specific model revision to use. use_cpu: Whether to force CPU usage instead of hardware acceleration. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to the parent VisionService. """ - super().__init__(**kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="vikhyatk/moondream2") - self.set_model_name(model) + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(settings=default_settings, **kwargs) if not use_cpu: device, dtype = detect_device() @@ -92,7 +128,7 @@ class MoondreamService(VisionService): logger.debug("Loading Moondream model...") self._model = AutoModelForCausalLM.from_pretrained( - model, + self._settings.model, trust_remote_code=True, revision=revision, device_map={"": device}, diff --git a/src/pipecat/services/neuphonic/tts.py b/src/pipecat/services/neuphonic/tts.py index 44e00dd09..1fad281d8 100644 --- a/src/pipecat/services/neuphonic/tts.py +++ b/src/pipecat/services/neuphonic/tts.py @@ -13,28 +13,24 @@ text-to-speech API for real-time audio synthesis. import asyncio import base64 import json -from typing import Any, AsyncGenerator, Mapping, Optional +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Optional import aiohttp from loguru import logger from pydantic import BaseModel from pipecat.frames.frames import ( - BotStoppedSpeakingFrame, CancelFrame, EndFrame, ErrorFrame, Frame, - InterruptionFrame, - LLMFullResponseEndFrame, StartFrame, TTSAudioRawFrame, - TTSSpeakFrame, - TTSStartedFrame, TTSStoppedFrame, ) -from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.tts_service import InterruptibleTTSService, TTSService +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven +from pipecat.services.tts_service import InterruptibleTTSService, TextAggregationMode, TTSService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.tracing.service_decorators import traced_tts @@ -72,6 +68,17 @@ def language_to_neuphonic_lang_code(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=True) +@dataclass +class NeuphonicTTSSettings(TTSSettings): + """Settings for NeuphonicTTSService and NeuphonicHttpTTSService. + + Parameters: + speed: Speech speed multiplier. Defaults to 1.0. + """ + + speed: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class NeuphonicTTSService(InterruptibleTTSService): """Neuphonic real-time text-to-speech service using WebSocket streaming. @@ -80,9 +87,15 @@ class NeuphonicTTSService(InterruptibleTTSService): parameters for high-quality speech generation. """ + Settings = NeuphonicTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Neuphonic TTS configuration. + .. deprecated:: 0.0.105 + Use ``settings=NeuphonicTTSService.Settings(...)`` instead. + Parameters: language: Language for synthesis. Defaults to English. speed: Speech speed multiplier. Defaults to 1.0. @@ -100,7 +113,9 @@ class NeuphonicTTSService(InterruptibleTTSService): sample_rate: Optional[int] = 22050, encoding: str = "pcm_linear", params: Optional[InputParams] = None, - aggregate_sentences: Optional[bool] = True, + settings: Optional[Settings] = None, + aggregate_sentences: Optional[bool] = None, + text_aggregation_mode: Optional[TextAggregationMode] = None, **kwargs, ): """Initialize the Neuphonic TTS service. @@ -108,40 +123,72 @@ class NeuphonicTTSService(InterruptibleTTSService): Args: api_key: Neuphonic API key for authentication. voice_id: ID of the voice to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=NeuphonicTTSService.Settings(voice=...)`` instead. + url: WebSocket URL for the Neuphonic API. sample_rate: Audio sample rate in Hz. Defaults to 22050. encoding: Audio encoding format. Defaults to "pcm_linear". params: Additional input parameters for TTS configuration. - aggregate_sentences: Whether to aggregate sentences within the TTSService. + + .. deprecated:: 0.0.105 + Use ``settings=NeuphonicTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + aggregate_sentences: Deprecated. Use text_aggregation_mode instead. + + .. deprecated:: 0.0.104 + Use ``text_aggregation_mode`` instead. + + text_aggregation_mode: How to aggregate text before synthesis. **kwargs: Additional arguments passed to parent InterruptibleTTSService. """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice=None, + language=Language.EN, + speed=1.0, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.speed is not None: + default_settings.speed = params.speed + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( aggregate_sentences=aggregate_sentences, + text_aggregation_mode=text_aggregation_mode, push_stop_frames=True, + push_start_frame=True, + pause_frame_processing=True, stop_frame_timeout_s=2.0, sample_rate=sample_rate, + settings=default_settings, **kwargs, ) - params = params or NeuphonicTTSService.InputParams() - self._api_key = api_key self._url = url - self._settings = { - "lang_code": self.language_to_service_language(params.language), - "speed": params.speed, - "encoding": encoding, - "sampling_rate": sample_rate, - } - self.set_voice(voice_id) - - # Indicates if we have sent TTSStartedFrame. It will reset to False when - # there's an interruption or TTSStoppedFrame. - self._started = False - self._cumulative_time = 0 - self._receive_task = None self._keepalive_task = None + self._encoding = encoding + self._sampling_rate = sample_rate def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -162,15 +209,14 @@ class NeuphonicTTSService(InterruptibleTTSService): """ return language_to_neuphonic_lang_code(language) - async def _update_settings(self, settings: Mapping[str, Any]): - """Update service settings and reconnect with new configuration.""" - if "voice_id" in settings: - self.set_voice(settings["voice_id"]) - - await super()._update_settings(settings) - await self._disconnect() - await self._connect() - logger.info(f"Switching TTS to settings: [{self._settings}]") + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta and reconnect with new configuration.""" + changed = await super()._update_settings(delta) + if changed: + await self._disconnect() + await self._connect() + logger.info(f"Switching TTS to settings: [{self._settings}]") + return changed async def start(self, frame: StartFrame): """Start the Neuphonic TTS service. @@ -199,44 +245,16 @@ class NeuphonicTTSService(InterruptibleTTSService): await super().cancel(frame) await self._disconnect() - async def flush_audio(self): + async def flush_audio(self, context_id: Optional[str] = None): """Flush any pending audio synthesis by sending stop command.""" if self._websocket: msg = {"text": ""} await self._websocket.send(json.dumps(msg)) - async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): - """Push a frame downstream with special handling for stop conditions. - - Args: - frame: The frame to push. - direction: The direction to push the frame. - """ - await super().push_frame(frame, direction) - if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)): - self._started = False - - async def process_frame(self, frame: Frame, direction: FrameDirection): - """Process frames with special handling for speech control. - - Args: - frame: The frame to process. - direction: The direction of frame processing. - """ - await super().process_frame(frame, direction) - - # If we received a TTSSpeakFrame and the LLM response included text (it - # might be that it's only a function calling response) we pause - # processing more frames until we receive a BotStoppedSpeakingFrame. - if isinstance(frame, TTSSpeakFrame): - await self.pause_processing_frames() - elif isinstance(frame, LLMFullResponseEndFrame) and self._started: - await self.pause_processing_frames() - elif isinstance(frame, BotStoppedSpeakingFrame): - await self.resume_processing_frames() - async def _connect(self): """Connect to Neuphonic WebSocket and start background tasks.""" + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: @@ -247,6 +265,8 @@ class NeuphonicTTSService(InterruptibleTTSService): async def _disconnect(self): """Disconnect from Neuphonic WebSocket and clean up tasks.""" + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None @@ -266,8 +286,11 @@ class NeuphonicTTSService(InterruptibleTTSService): logger.debug("Connecting to Neuphonic") tts_config = { - **self._settings, - "voice_id": self._voice_id, + "lang_code": self._settings.language, + "speed": self._settings.speed, + "encoding": self._encoding, + "sampling_rate": self._sampling_rate, + "voice_id": self._settings.voice, } query_params = [] @@ -275,7 +298,7 @@ class NeuphonicTTSService(InterruptibleTTSService): if value is not None: query_params.append(f"{key}={value}") - url = f"{self._url}/speak/{self._settings['lang_code']}" + url = f"{self._url}/speak/{self._settings.language}" if query_params: url += f"?{'&'.join(query_params)}" @@ -300,7 +323,6 @@ class NeuphonicTTSService(InterruptibleTTSService): except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: - self._started = False self._websocket = None await self._call_event_handler("on_disconnected") @@ -313,8 +335,14 @@ class NeuphonicTTSService(InterruptibleTTSService): await self.stop_ttfb_metrics() audio = base64.b64decode(msg["data"]["audio"]) - frame = TTSAudioRawFrame(audio, self.sample_rate, 1) - await self.push_frame(frame) + context_id = self.get_active_audio_context_id() + frame = TTSAudioRawFrame( + audio, + self.sample_rate, + 1, + context_id=context_id, + ) + await self.append_to_audio_context(context_id, frame) async def _keepalive_task_handler(self): """Handle keepalive messages to maintain WebSocket connection.""" @@ -338,11 +366,12 @@ class NeuphonicTTSService(InterruptibleTTSService): await self._websocket.send(json.dumps(msg)) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Neuphonic's streaming API. Args: text: The text to synthesize into speech. + context_id: Unique identifier for this TTS context. Yields: Frame: Audio frames containing the synthesized speech. @@ -354,17 +383,11 @@ class NeuphonicTTSService(InterruptibleTTSService): await self._connect() try: - if not self._started: - await self.start_ttfb_metrics() - yield TTSStartedFrame() - self._started = True - self._cumulative_time = 0 - await self._send_text(text) await self.start_tts_usage_metrics(text) except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() + yield TTSStoppedFrame(context_id=context_id) await self._disconnect() await self._connect() return @@ -381,9 +404,15 @@ class NeuphonicHttpTTSService(TTSService): HTTP-based communication over WebSocket connections. """ + Settings = NeuphonicTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Neuphonic HTTP TTS configuration. + .. deprecated:: 0.0.105 + Use ``settings=NeuphonicHttpTTSService.Settings(...)`` instead. + Parameters: language: Language for synthesis. Defaults to English. speed: Speech speed multiplier. Defaults to 1.0. @@ -402,6 +431,7 @@ class NeuphonicHttpTTSService(TTSService): sample_rate: Optional[int] = 22050, encoding: Optional[str] = "pcm_linear", params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Neuphonic HTTP TTS service. @@ -409,24 +439,61 @@ class NeuphonicHttpTTSService(TTSService): Args: api_key: Neuphonic API key for authentication. voice_id: ID of the voice to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=NeuphonicHttpTTSService.Settings(voice=...)`` instead. + aiohttp_session: Shared aiohttp session for HTTP requests. url: Base URL for the Neuphonic HTTP API. sample_rate: Audio sample rate in Hz. Defaults to 22050. encoding: Audio encoding format. Defaults to "pcm_linear". params: Additional input parameters for TTS configuration. + + .. deprecated:: 0.0.105 + Use ``settings=NeuphonicHttpTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice=None, + language=Language.EN, + speed=1.0, + ) - params = params or NeuphonicHttpTTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.speed is not None: + default_settings.speed = params.speed + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_stop_frames=True, + push_start_frame=True, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._session = aiohttp_session self._base_url = url.rstrip("/") - self._lang_code = self.language_to_service_language(params.language) or "en" - self._speed = params.speed self._encoding = encoding - self.set_voice(voice_id) def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -455,7 +522,7 @@ class NeuphonicHttpTTSService(TTSService): """ await super().start(frame) - async def flush_audio(self): + async def flush_audio(self, context_id: Optional[str] = None): """Flush any pending audio synthesis. Note: @@ -498,18 +565,19 @@ class NeuphonicHttpTTSService(TTSService): return None @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Neuphonic streaming API. Args: text: The text to convert to speech. + context_id: Unique identifier for this TTS context. Yields: Frame: Audio frames containing the synthesized speech and status information. """ logger.debug(f"Generating TTS: [{text}]") - url = f"{self._base_url}/sse/speak/{self._lang_code}" + url = f"{self._base_url}/sse/speak/{self._settings.language}" headers = { "X-API-KEY": self._api_key, @@ -518,18 +586,16 @@ class NeuphonicHttpTTSService(TTSService): payload = { "text": text, - "lang_code": self._lang_code, + "lang_code": self._settings.language, "encoding": self._encoding, "sampling_rate": self.sample_rate, - "speed": self._speed, + "speed": self._settings.speed, } - if self._voice_id: - payload["voice_id"] = self._voice_id + if self._settings.voice: + payload["voice_id"] = self._settings.voice try: - await self.start_ttfb_metrics() - async with self._session.post(url, json=payload, headers=headers) as response: if response.status != 200: error_text = await response.text() @@ -538,7 +604,6 @@ class NeuphonicHttpTTSService(TTSService): return await self.start_tts_usage_metrics(text) - yield TTSStartedFrame() # Process SSE stream line by line async for line in response.content: @@ -560,7 +625,9 @@ class NeuphonicHttpTTSService(TTSService): audio_bytes = base64.b64decode(audio_b64) await self.stop_ttfb_metrics() - yield TTSAudioRawFrame(audio_bytes, self.sample_rate, 1) + yield TTSAudioRawFrame( + audio_bytes, self.sample_rate, 1, context_id=context_id + ) except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") @@ -574,4 +641,3 @@ class NeuphonicHttpTTSService(TTSService): yield ErrorFrame(error=f"Unknown error occurred: {e}") finally: await self.stop_ttfb_metrics() - yield TTSStoppedFrame() diff --git a/src/pipecat/services/nvidia/llm.py b/src/pipecat/services/nvidia/llm.py index c5db58f31..66bbd4402 100644 --- a/src/pipecat/services/nvidia/llm.py +++ b/src/pipecat/services/nvidia/llm.py @@ -10,12 +10,23 @@ This module provides a service for interacting with NVIDIA's NIM (NVIDIA Inferen Microservice) API while maintaining compatibility with the OpenAI-style interface. """ +from dataclasses import dataclass +from typing import Optional + from pipecat.metrics.metrics import LLMTokenUsage from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class NvidiaLLMSettings(BaseOpenAILLMService.Settings): + """Settings for NvidiaLLMService.""" + + pass + + class NvidiaLLMService(OpenAILLMService): """A service for interacting with NVIDIA's NIM (NVIDIA Inference Microservice) API. @@ -24,12 +35,16 @@ class NvidiaLLMService(OpenAILLMService): in token usage reporting between NIM (incremental) and OpenAI (final summary). """ + Settings = NvidiaLLMSettings + _settings: Settings + def __init__( self, *, api_key: str, base_url: str = "https://integrate.api.nvidia.com/v1", - model: str = "nvidia/llama-3.1-nemotron-70b-instruct", + model: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the NvidiaLLMService. @@ -37,10 +52,31 @@ class NvidiaLLMService(OpenAILLMService): Args: api_key: The API key for accessing NVIDIA's NIM API. base_url: The base URL for NIM API. Defaults to "https://integrate.api.nvidia.com/v1". - model: The model identifier to use. Defaults to "nvidia/llama-3.1-nemotron-70b-instruct". + model: The model identifier to use. Defaults to + "nvidia/llama-3.1-nemotron-70b-instruct". + + .. deprecated:: 0.0.105 + Use ``settings=NvidiaLLMService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="nvidia/llama-3.1-nemotron-70b-instruct") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) # Counters for accumulating token usage metrics self._prompt_tokens = 0 self._completion_tokens = 0 diff --git a/src/pipecat/services/nvidia/stt.py b/src/pipecat/services/nvidia/stt.py index 0d671571f..50a654191 100644 --- a/src/pipecat/services/nvidia/stt.py +++ b/src/pipecat/services/nvidia/stt.py @@ -8,7 +8,8 @@ import asyncio from concurrent.futures import CancelledError as FuturesCancelledError -from typing import AsyncGenerator, List, Mapping, Optional +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, List, Mapping, Optional from loguru import logger from pydantic import BaseModel @@ -22,6 +23,8 @@ from pipecat.frames.frames import ( StartFrame, TranscriptionFrame, ) +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven +from pipecat.services.stt_latency import NVIDIA_TTFS_P99 from pipecat.services.stt_service import SegmentedSTTService, STTService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.time import time_now_iso8601 @@ -88,6 +91,32 @@ def language_to_nvidia_riva_language(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=False) +@dataclass +class NvidiaSTTSettings(STTSettings): + """Settings for NvidiaSTTService.""" + + pass + + +@dataclass +class NvidiaSegmentedSTTSettings(STTSettings): + """Settings for NvidiaSegmentedSTTService. + + Parameters: + profanity_filter: Whether to filter profanity from results. + automatic_punctuation: Whether to add automatic punctuation. + verbatim_transcripts: Whether to return verbatim transcripts. + boosted_lm_words: List of words to boost in language model. + boosted_lm_score: Score boost for specified words. + """ + + profanity_filter: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + automatic_punctuation: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + verbatim_transcripts: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + boosted_lm_words: List[str] | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + boosted_lm_score: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class NvidiaSTTService(STTService): """Real-time speech-to-text service using NVIDIA Riva streaming ASR. @@ -96,9 +125,15 @@ class NvidiaSTTService(STTService): processing for low-latency applications. """ + Settings = NvidiaSTTSettings + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for NVIDIA Riva STT service. + .. deprecated:: 0.0.105 + Use ``settings=NvidiaSTTService.Settings(...)`` instead. + Parameters: language: Target language for transcription. Defaults to EN_US. """ @@ -117,6 +152,8 @@ class NvidiaSTTService(STTService): sample_rate: Optional[int] = None, params: Optional[InputParams] = None, use_ssl: bool = True, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = NVIDIA_TTFS_P99, **kwargs, ): """Initialize the NVIDIA Riva STT service. @@ -127,21 +164,45 @@ class NvidiaSTTService(STTService): model_function_map: Mapping containing 'function_id' and 'model_name' for the ASR model. sample_rate: Audio sample rate in Hz. If None, uses pipeline default. params: Additional configuration parameters for NVIDIA Riva. + + .. deprecated:: 0.0.105 + Use ``settings=NvidiaSTTService.Settings(...)`` instead. + use_ssl: Whether to use SSL for the NVIDIA Riva server. Defaults to True. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to STTService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=model_function_map.get("model_name"), + language=Language.EN_US, + ) - params = params or NvidiaSTTService.InputParams() + # 2. (no deprecated direct args for this service) + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = params.language + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) + + self._server = server self._api_key = api_key self._use_ssl = use_ssl - self._profanity_filter = False - self._automatic_punctuation = True - self._no_verbatim_transcripts = False - self._language_code = params.language - self._boosted_lm_words = None - self._boosted_lm_score = 4.0 self._start_history = -1 self._start_threshold = -1.0 self._stop_history = -1 @@ -151,84 +212,37 @@ class NvidiaSTTService(STTService): self._custom_configuration = "" self._function_id = model_function_map.get("function_id") - self._settings = { - "language": str(params.language), - "profanity_filter": self._profanity_filter, - "automatic_punctuation": self._automatic_punctuation, - "verbatim_transcripts": not self._no_verbatim_transcripts, - "boosted_lm_words": self._boosted_lm_words, - "boosted_lm_score": self._boosted_lm_score, - } - - self.set_model_name(model_function_map.get("model_name")) - - metadata = [ - ["function-id", self._function_id], - ["authorization", f"Bearer {api_key}"], - ] - auth = riva.client.Auth(None, self._use_ssl, server, metadata) - - self._asr_service = riva.client.ASRService(auth) - + self._asr_service = None self._queue = None self._config = None self._thread_task = None - self._response_task = None - def can_generate_metrics(self) -> bool: - """Check if this service can generate processing metrics. + def _initialize_client(self): + metadata = [ + ["function-id", self._function_id], + ["authorization", f"Bearer {self._api_key}"], + ] + auth = riva.client.Auth(None, self._use_ssl, self._server, metadata) - Returns: - False - this service does not support metrics generation. - """ - return False - - async def set_model(self, model: str): - """Set the ASR model for transcription. - - Args: - model: Model name to set. - - Note: - Model cannot be changed after initialization. Use model_function_map - parameter in constructor instead. - """ - logger.warning(f"Cannot set model after initialization. Set model and function id like so:") - example = {"function_id": "", "model_name": ""} - logger.warning( - f"{self.__class__.__name__}(api_key=, model_function_map={example})" - ) - - async def start(self, frame: StartFrame): - """Start the NVIDIA Riva STT service and initialize streaming configuration. - - Args: - frame: StartFrame indicating pipeline start. - """ - await super().start(frame) - - if self._config: - return + self._asr_service = riva.client.ASRService(auth) + def _create_recognition_config(self): + """Create the NVIDIA Riva ASR recognition configuration.""" config = riva.client.StreamingRecognitionConfig( config=riva.client.RecognitionConfig( encoding=riva.client.AudioEncoding.LINEAR_PCM, - language_code=self._language_code, + language_code=self._settings.language, model="", max_alternatives=1, - profanity_filter=self._profanity_filter, - enable_automatic_punctuation=self._automatic_punctuation, - verbatim_transcripts=not self._no_verbatim_transcripts, + profanity_filter=False, + enable_automatic_punctuation=True, + verbatim_transcripts=True, sample_rate_hertz=self.sample_rate, audio_channel_count=1, ), interim_results=True, ) - riva.client.add_word_boosting_to_config( - config, self._boosted_lm_words, self._boosted_lm_score - ) - riva.client.add_endpoint_parameters_to_config( config, self._start_history, @@ -240,15 +254,61 @@ class NvidiaSTTService(STTService): ) riva.client.add_custom_configuration_to_config(config, self._custom_configuration) - self._config = config + return config + + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + False - this service does not support metrics generation. + """ + return False + + async def set_model(self, model: str): + """Set the ASR model for transcription. + + .. deprecated:: 0.0.104 + Model cannot be changed after initialization for NVIDIA Riva streaming STT. + Set model and function id in the constructor instead, e.g.:: + + NvidiaSTTService( + api_key=..., + model_function_map={"function_id": "", "model_name": ""}, + ) + + Args: + model: Model name to set. + """ + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "'set_model' is deprecated. Model cannot be changed after initialization" + " for NVIDIA Riva streaming STT. Set model and function id in the" + " constructor instead, e.g.:" + " NvidiaSTTService(api_key=..., model_function_map=" + "{'function_id': '', 'model_name': ''})", + DeprecationWarning, + stacklevel=2, + ) + + async def start(self, frame: StartFrame): + """Start the NVIDIA Riva STT service and initialize streaming configuration. + + Args: + frame: StartFrame indicating pipeline start. + """ + await super().start(frame) + self._initialize_client() + self._config = self._create_recognition_config() + self._queue = asyncio.Queue() if not self._thread_task: self._thread_task = self.create_task(self._thread_task_handler()) - if not self._response_task: - self._response_queue = asyncio.Queue() - self._response_task = self.create_task(self._response_task_handler()) + logger.debug(f"Initialized NvidiaSTTService with model: {self._settings.model}") async def stop(self, frame: EndFrame): """Stop the NVIDIA Riva STT service and clean up resources. @@ -273,10 +333,6 @@ class NvidiaSTTService(STTService): await self.cancel_task(self._thread_task) self._thread_task = None - if self._response_task: - await self.cancel_task(self._response_task) - self._response_task = None - def _response_handler(self): responses = self._asr_service.streaming_response_generator( audio_chunks=self, @@ -285,9 +341,7 @@ class NvidiaSTTService(STTService): for response in responses: if not response.results: continue - asyncio.run_coroutine_threadsafe( - self._response_queue.put(response), self.get_event_loop() - ) + asyncio.run_coroutine_threadsafe(self._handle_response(response), self.get_event_loop()) async def _thread_task_handler(self): try: @@ -311,7 +365,6 @@ class NvidiaSTTService(STTService): transcript = result.alternatives[0].transcript if transcript and len(transcript) > 0: - await self.stop_ttfb_metrics() if result.is_final: await self.stop_processing_metrics() await self.push_frame( @@ -319,14 +372,14 @@ class NvidiaSTTService(STTService): transcript, self._user_id, time_now_iso8601(), - self._language_code, + self._settings.language, result=result, ) ) await self._handle_transcription( transcript=transcript, is_final=result.is_final, - language=self._language_code, + language=self._settings.language, ) else: await self.push_frame( @@ -334,17 +387,11 @@ class NvidiaSTTService(STTService): transcript, self._user_id, time_now_iso8601(), - self._language_code, + self._settings.language, result=result, ) ) - async def _response_task_handler(self): - while True: - response = await self._response_queue.get() - await self._handle_response(response) - self._response_queue.task_done() - async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: """Process audio data for speech-to-text transcription. @@ -354,7 +401,6 @@ class NvidiaSTTService(STTService): Yields: None - transcription results are pushed to the pipeline via frames. """ - await self.start_ttfb_metrics() await self.start_processing_metrics() await self._queue.put(audio) yield None @@ -394,9 +440,15 @@ class NvidiaSegmentedSTTService(SegmentedSTTService): audio buffering and speech detection. """ + Settings = NvidiaSegmentedSTTSettings + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for NVIDIA Riva segmented STT service. + .. deprecated:: 0.0.105 + Use ``settings=NvidiaSegmentedSTTService.Settings(...)`` instead. + Parameters: language: Target language for transcription. Defaults to EN_US. profanity_filter: Whether to filter profanity from results. @@ -425,6 +477,8 @@ class NvidiaSegmentedSTTService(SegmentedSTTService): sample_rate: Optional[int] = None, params: Optional[InputParams] = None, use_ssl: bool = True, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = NVIDIA_TTFS_P99, **kwargs, ): """Initialize the NVIDIA Riva segmented STT service. @@ -435,33 +489,57 @@ class NvidiaSegmentedSTTService(SegmentedSTTService): model_function_map: Mapping of model name and its corresponding NVIDIA Cloud Function ID sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate params: Additional configuration parameters for NVIDIA Riva + + .. deprecated:: 0.0.105 + Use ``settings=NvidiaSegmentedSTTService.Settings(...)`` instead. + use_ssl: Whether to use SSL for the NVIDIA Riva server. Defaults to True. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to SegmentedSTTService """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=model_function_map.get("model_name"), + language=Language.EN_US, + profanity_filter=False, + automatic_punctuation=True, + verbatim_transcripts=False, + boosted_lm_words=None, + boosted_lm_score=4.0, + ) - params = params or NvidiaSegmentedSTTService.InputParams() + # 2. (no deprecated direct args for this service) - # Set model name - self.set_model_name(model_function_map.get("model_name")) + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = params.language or Language.EN_US + default_settings.profanity_filter = params.profanity_filter + default_settings.automatic_punctuation = params.automatic_punctuation + default_settings.verbatim_transcripts = params.verbatim_transcripts + default_settings.boosted_lm_words = params.boosted_lm_words + default_settings.boosted_lm_score = params.boosted_lm_score + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) # Initialize NVIDIA Riva settings self._api_key = api_key self._server = server self._use_ssl = use_ssl self._function_id = model_function_map.get("function_id") - self._model_name = model_function_map.get("model_name") - - # Store the language as a Language enum and as a string - self._language_enum = params.language or Language.EN_US - self._language = self.language_to_service_language(self._language_enum) or "en-US" - - # Configure transcription parameters - self._profanity_filter = params.profanity_filter - self._automatic_punctuation = params.automatic_punctuation - self._verbatim_transcripts = params.verbatim_transcripts - self._boosted_lm_words = params.boosted_lm_words - self._boosted_lm_score = params.boosted_lm_score # Voice activity detection thresholds (use NVIDIA Riva defaults) self._start_history = -1 @@ -472,10 +550,8 @@ class NvidiaSegmentedSTTService(SegmentedSTTService): self._stop_threshold_eou = -1.0 self._custom_configuration = "" - # Create NVIDIA Riva client self._config = None self._asr_service = None - self._settings = {"language": self._language_enum} def language_to_service_language(self, language: Language) -> Optional[str]: """Convert pipecat Language enum to NVIDIA Riva's language code. @@ -503,24 +579,25 @@ class NvidiaSegmentedSTTService(SegmentedSTTService): auth = riva.client.Auth(None, self._use_ssl, self._server, metadata) self._asr_service = riva.client.ASRService(auth) - logger.info(f"Initialized NvidiaSegmentedSTTService with model: {self.model_name}") + def _get_language_code(self) -> str: + """Get the current NVIDIA Riva language code string.""" + return self._settings.language or "en-US" def _create_recognition_config(self): """Create the NVIDIA Riva ASR recognition configuration.""" # Create base configuration + s = self._settings config = riva.client.RecognitionConfig( - language_code=self._language, # Now using the string, not a tuple + language_code=self._get_language_code(), max_alternatives=1, - profanity_filter=self._profanity_filter, - enable_automatic_punctuation=self._automatic_punctuation, - verbatim_transcripts=self._verbatim_transcripts, + profanity_filter=s.profanity_filter, + enable_automatic_punctuation=s.automatic_punctuation, + verbatim_transcripts=s.verbatim_transcripts, ) # Add word boosting if specified - if self._boosted_lm_words: - riva.client.add_word_boosting_to_config( - config, self._boosted_lm_words, self._boosted_lm_score - ) + if s.boosted_lm_words: + riva.client.add_word_boosting_to_config(config, s.boosted_lm_words, s.boosted_lm_score) # Add voice activity detection parameters riva.client.add_endpoint_parameters_to_config( @@ -547,22 +624,6 @@ class NvidiaSegmentedSTTService(SegmentedSTTService): """ return True - async def set_model(self, model: str): - """Set the ASR model for transcription. - - Args: - model: Model name to set. - - Note: - Model cannot be changed after initialization. Use model_function_map - parameter in constructor instead. - """ - logger.warning(f"Cannot set model after initialization. Set model and function id like so:") - example = {"function_id": "", "model_name": ""} - logger.warning( - f"{self.__class__.__name__}(api_key=, model_function_map={example})" - ) - async def start(self, frame: StartFrame): """Initialize the service when the pipeline starts. @@ -572,21 +633,23 @@ class NvidiaSegmentedSTTService(SegmentedSTTService): await super().start(frame) self._initialize_client() self._config = self._create_recognition_config() + logger.debug(f"Initialized NvidiaSegmentedSTTService with model: {self._settings.model}") - async def set_language(self, language: Language): - """Set the language for the STT service. + async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: + """Apply a settings delta and sync internal state. Args: - language: Target language for transcription. - """ - logger.info(f"Switching STT language to: [{language}]") - self._language_enum = language - self._language = self.language_to_service_language(language) or "en-US" - self._settings["language"] = language + delta: A :class:`STTSettings` (or ``NvidiaSegmentedSTTService.Settings``) delta. - # Update configuration with new language - if self._config: - self._config.language_code = self._language + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + + if changed: + self._config = self._create_recognition_config() + + return changed @traced_stt async def _handle_transcription( @@ -605,65 +668,51 @@ class NvidiaSegmentedSTTService(SegmentedSTTService): Frame: TranscriptionFrame containing the transcribed text. """ try: - await self.start_processing_metrics() - await self.start_ttfb_metrics() - - # Make sure the client is initialized - if self._asr_service is None: - self._initialize_client() - - # Make sure the config is created - if self._config is None: - self._config = self._create_recognition_config() - - # Type assertion to satisfy the IDE assert self._asr_service is not None, "ASR service not initialized" assert self._config is not None, "Recognition config not created" + await self.start_processing_metrics() + # Process audio with NVIDIA Riva ASR - explicitly request non-future response raw_response = self._asr_service.offline_recognize(audio, self._config, future=False) - await self.stop_ttfb_metrics() await self.stop_processing_metrics() # Process the response - handle different possible return types - try: - # If it's a future-like object, get the result - if hasattr(raw_response, "result"): - response = raw_response.result() - else: - response = raw_response + # If it's a future-like object, get the result + if hasattr(raw_response, "result"): + response = raw_response.result() + else: + response = raw_response - # Process transcription results - transcription_found = False + # Process transcription results + transcription_found = False - # Now we can safely check results - # Type hint for the IDE - results = getattr(response, "results", []) + # Now we can safely check results + # Type hint for the IDE + results = getattr(response, "results", []) - for result in results: - alternatives = getattr(result, "alternatives", []) - if alternatives: - text = alternatives[0].transcript.strip() - if text: - logger.debug(f"Transcription: [{text}]") - yield TranscriptionFrame( - text, - self._user_id, - time_now_iso8601(), - self._language_enum, - ) - transcription_found = True + for result in results: + alternatives = getattr(result, "alternatives", []) + if alternatives: + text = alternatives[0].transcript.strip() + if text: + logger.debug(f"Transcription: [{text}]") + yield TranscriptionFrame( + text, + self._user_id, + time_now_iso8601(), + self._settings.language, + ) + transcription_found = True - await self._handle_transcription(text, True, self._language_enum) - - if not transcription_found: - logger.debug("No transcription results found in NVIDIA Riva response") - - except AttributeError as ae: - logger.error(f"Unexpected response structure from NVIDIA Riva: {ae}") - yield ErrorFrame(f"Unexpected NVIDIA Riva response format: {str(ae)}") + await self._handle_transcription(text, True, self._settings.language) + if not transcription_found: + logger.debug(f"{self}: No transcription results found in NVIDIA Riva response") + except AttributeError as ae: + logger.error(f"{self}: Unexpected response structure from NVIDIA Riva: {ae}") + yield ErrorFrame(f"{self}: Unexpected NVIDIA Riva response format: {str(ae)}") except Exception as e: logger.error(f"{self} exception: {e}") yield ErrorFrame(error=f"{self} error: {e}") diff --git a/src/pipecat/services/nvidia/tts.py b/src/pipecat/services/nvidia/tts.py index 70be24a8f..5e7a20a3c 100644 --- a/src/pipecat/services/nvidia/tts.py +++ b/src/pipecat/services/nvidia/tts.py @@ -12,7 +12,8 @@ gRPC API for high-quality speech synthesis. import asyncio import os -from typing import AsyncGenerator, Mapping, Optional +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, AsyncIterator, Generator, Mapping, Optional from pipecat.utils.tracing.service_decorators import traced_tts @@ -25,22 +26,31 @@ from pydantic import BaseModel from pipecat.frames.frames import ( ErrorFrame, Frame, + StartFrame, TTSAudioRawFrame, - TTSStartedFrame, - TTSStoppedFrame, ) +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven from pipecat.services.tts_service import TTSService from pipecat.transcriptions.language import Language try: import riva.client - + import riva.client.proto.riva_tts_pb2 as rtts except ModuleNotFoundError as e: logger.error(f"Exception: {e}") logger.error("In order to use NVIDIA Riva TTS, you need to `pip install pipecat-ai[nvidia]`.") raise Exception(f"Missing module: {e}") -NVIDIA_TTS_TIMEOUT_SECS = 5 + +@dataclass +class NvidiaTTSSettings(TTSSettings): + """Settings for NvidiaTTSService. + + Parameters: + quality: Audio quality setting (0-100). + """ + + quality: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN) class NvidiaTTSService(TTSService): @@ -51,9 +61,15 @@ class NvidiaTTSService(TTSService): configurable quality settings. """ + Settings = NvidiaTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Riva TTS configuration. + .. deprecated:: 0.0.105 + Use ``NvidiaTTSService.Settings`` directly via the ``settings`` parameter instead. + Parameters: language: Language code for synthesis. Defaults to US English. quality: Audio quality setting (0-100). Defaults to 20. @@ -67,13 +83,14 @@ class NvidiaTTSService(TTSService): *, api_key: str, server: str = "grpc.nvcf.nvidia.com:443", - voice_id: str = "Magpie-Multilingual.EN-US.Aria", + voice_id: Optional[str] = None, sample_rate: Optional[int] = None, model_function_map: Mapping[str, str] = { "function_id": "877104f7-e885-42b9-8de8-f6e4c6303969", "model_name": "magpie-tts-multilingual", }, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, use_ssl: bool = True, **kwargs, ): @@ -82,108 +99,197 @@ class NvidiaTTSService(TTSService): Args: api_key: NVIDIA API key for authentication. server: gRPC server endpoint. Defaults to NVIDIA's cloud endpoint. - voice_id: Voice model identifier. Defaults to multilingual Ray voice. + voice_id: Voice model identifier. Defaults to multilingual Aria voice. + + .. deprecated:: 0.0.105 + Use ``settings=NvidiaTTSService.Settings(voice=...)`` instead. + sample_rate: Audio sample rate. If None, uses service default. model_function_map: Dictionary containing function_id and model_name for the TTS model. params: Additional configuration parameters for TTS synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=NvidiaTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. use_ssl: Whether to use SSL for the NVIDIA Riva server. Defaults to True. **kwargs: Additional arguments passed to parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=model_function_map.get("model_name"), + voice="Magpie-Multilingual.EN-US.Aria", + language=Language.EN_US, + quality=20, + ) - params = params or NvidiaTTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.quality is not None: + default_settings.quality = params.quality + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) + + self._server = server self._api_key = api_key - self._voice_id = voice_id - self._language_code = params.language - self._quality = params.quality self._function_id = model_function_map.get("function_id") self._use_ssl = use_ssl - self.set_model_name(model_function_map.get("model_name")) - self.set_voice(voice_id) + + self._service = None + self._config = None + + async def set_model(self, model: str): + """Set the TTS model. + + .. deprecated:: 0.0.104 + Model cannot be changed after initialization for NVIDIA Riva TTS. + Set model and function id in the constructor instead, e.g.:: + + NvidiaTTSService( + api_key=..., + model_function_map={"function_id": "", "model_name": ""}, + ) + + Args: + model: The model name to set. + """ + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "'set_model' is deprecated. Model cannot be changed after initialization" + " for NVIDIA Riva TTS. Set model and function id in the constructor" + " instead, e.g.: NvidiaTTSService(api_key=..., model_function_map=" + "{'function_id': '', 'model_name': ''})", + DeprecationWarning, + stacklevel=2, + ) + + async def _update_settings(self, delta: Settings) -> dict[str, Any]: + """Apply a settings delta. + + Settings are stored but not applied to the active connection. + """ + changed = await super()._update_settings(delta) + if not changed: + return changed + # TODO: reconnect gRPC client to apply changed settings. + self._warn_unhandled_updated_settings(changed) + return changed + + def _initialize_client(self): + if self._service is not None: + return metadata = [ ["function-id", self._function_id], - ["authorization", f"Bearer {api_key}"], + ["authorization", f"Bearer {self._api_key}"], ] - auth = riva.client.Auth(None, self._use_ssl, server, metadata) + auth = riva.client.Auth(None, self._use_ssl, self._server, metadata) self._service = riva.client.SpeechSynthesisService(auth) + def _create_synthesis_config(self): + if not self._service: + return + # warm up the service - config_response = self._service.stub.GetRivaSynthesisConfig( + config = self._service.stub.GetRivaSynthesisConfig( riva.client.proto.riva_tts_pb2.RivaSynthesisConfigRequest() ) + return config - async def set_model(self, model: str): - """Attempt to set the TTS model. - - Note: Model cannot be changed after initialization for Riva service. + async def start(self, frame: StartFrame): + """Start the Cartesia TTS service. Args: - model: The model name to set (operation not supported). + frame: The start frame containing initialization parameters. """ - logger.warning(f"Cannot set model after initialization. Set model and function id like so:") - example = {"function_id": "", "model_name": ""} - logger.warning( - f"{self.__class__.__name__}(api_key=, model_function_map={example})" - ) + await super().start(frame) + self._initialize_client() + self._config = self._create_synthesis_config() + logger.debug(f"Initialized NvidiaTTSService with model: {self._settings.model}") @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using NVIDIA Riva TTS. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech data. """ - def read_audio_responses(queue: asyncio.Queue): - def add_response(r): - asyncio.run_coroutine_threadsafe(queue.put(r), self.get_event_loop()) + def read_audio_responses() -> Generator[rtts.SynthesizeSpeechResponse, None, None]: + responses = self._service.synthesize_online( + text, + self._settings.voice, + self._settings.language, + sample_rate_hz=self.sample_rate, + zero_shot_audio_prompt_file=None, + zero_shot_quality=self._settings.quality, + custom_dictionary={}, + ) + return responses + def async_next(it): try: - responses = self._service.synthesize_online( - text, - self._voice_id, - self._language_code, - sample_rate_hz=self.sample_rate, - zero_shot_audio_prompt_file=None, - zero_shot_quality=self._quality, - custom_dictionary={}, - ) - for r in responses: - add_response(r) - add_response(None) - except Exception as e: - logger.error(f"{self} exception: {e}") - add_response(None) + return next(it) + except StopIteration: + return None - await self.start_ttfb_metrics() - yield TTSStartedFrame() - - logger.debug(f"{self}: Generating TTS [{text}]") + async def async_iterator(iterator) -> AsyncIterator[rtts.SynthesizeSpeechResponse]: + while True: + item = await asyncio.to_thread(async_next, iterator) + if item is None: + return + yield item try: - queue = asyncio.Queue() - await asyncio.to_thread(read_audio_responses, queue) + assert self._service is not None, "TTS service not initialized" + assert self._config is not None, "Synthesis configuration not created" - # Wait for the thread to start. - resp = await asyncio.wait_for(queue.get(), timeout=NVIDIA_TTS_TIMEOUT_SECS) - while resp: + logger.debug(f"{self}: Generating TTS [{text}]") + + responses = await asyncio.to_thread(read_audio_responses) + + async for resp in async_iterator(responses): await self.stop_ttfb_metrics() frame = TTSAudioRawFrame( audio=resp.audio, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) yield frame - resp = await asyncio.wait_for(queue.get(), timeout=NVIDIA_TTS_TIMEOUT_SECS) - except asyncio.TimeoutError: + + await self.start_tts_usage_metrics(text) + except asyncio.TimeoutError as e: logger.error(f"{self} timeout waiting for audio response") yield ErrorFrame(error=f"{self} error: {e}") - - await self.start_tts_usage_metrics(text) - yield TTSStoppedFrame() + except Exception as e: + logger.error(f"{self} exception: {e}") + yield ErrorFrame(error=f"{self} error: {e}") diff --git a/src/pipecat/services/ollama/llm.py b/src/pipecat/services/ollama/llm.py index 04f22bbae..a24ebfcaf 100644 --- a/src/pipecat/services/ollama/llm.py +++ b/src/pipecat/services/ollama/llm.py @@ -6,11 +6,22 @@ """OLLama LLM service implementation for Pipecat AI framework.""" +from dataclasses import dataclass +from typing import Optional + from loguru import logger +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class OllamaLLMSettings(BaseOpenAILLMService.Settings): + """Settings for OLLamaLLMService.""" + + pass + + class OLLamaLLMService(OpenAILLMService): """OLLama LLM service that provides local language model capabilities. @@ -18,18 +29,46 @@ class OLLamaLLMService(OpenAILLMService): providing a compatible interface for running large language models locally. """ + Settings = OllamaLLMSettings + _settings: Settings + def __init__( - self, *, model: str = "llama2", base_url: str = "http://localhost:11434/v1", **kwargs + self, + *, + model: Optional[str] = None, + base_url: str = "http://localhost:11434/v1", + settings: Optional[Settings] = None, + **kwargs, ): """Initialize OLLama LLM service. Args: model: The OLLama model to use. Defaults to "llama2". + + .. deprecated:: 0.0.105 + Use ``settings=OLLamaLLMService.Settings(model=...)`` instead. + base_url: The base URL for the OLLama API endpoint. Defaults to "http://localhost:11434/v1". + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(model=model, base_url=base_url, api_key="ollama", **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="llama2") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(base_url=base_url, api_key="ollama", settings=default_settings, **kwargs) def create_client(self, base_url=None, **kwargs): """Create OpenAI-compatible client for Ollama. diff --git a/src/pipecat/services/openai/__init__.py b/src/pipecat/services/openai/__init__.py index e182264b1..3caa3c3cb 100644 --- a/src/pipecat/services/openai/__init__.py +++ b/src/pipecat/services/openai/__init__.py @@ -11,6 +11,7 @@ from pipecat.services import DeprecatedModuleProxy from .image import * from .llm import * from .realtime import * +from .responses.llm import * from .stt import * from .tts import * diff --git a/src/pipecat/services/openai/base_llm.py b/src/pipecat/services/openai/base_llm.py index 419d5db1e..eb8ce3cc6 100644 --- a/src/pipecat/services/openai/base_llm.py +++ b/src/pipecat/services/openai/base_llm.py @@ -9,6 +9,8 @@ import asyncio import base64 import json +from contextlib import asynccontextmanager +from dataclasses import dataclass, field from typing import Any, Dict, List, Mapping, Optional import httpx @@ -31,7 +33,6 @@ from pipecat.frames.frames import ( LLMFullResponseStartFrame, LLMMessagesFrame, LLMTextFrame, - LLMUpdateSettingsFrame, ) from pipecat.metrics.metrics import LLMTokenUsage from pipecat.processors.aggregators.llm_context import LLMContext @@ -41,9 +42,22 @@ from pipecat.processors.aggregators.openai_llm_context import ( ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService +from pipecat.services.settings import NOT_GIVEN as _NOT_GIVEN +from pipecat.services.settings import LLMSettings, _NotGiven from pipecat.utils.tracing.service_decorators import traced_llm +@dataclass +class OpenAILLMSettings(LLMSettings): + """Settings for BaseOpenAILLMService. + + Parameters: + max_completion_tokens: Maximum completion tokens to generate. + """ + + max_completion_tokens: int | _NotGiven = field(default_factory=lambda: _NOT_GIVEN) + + class BaseOpenAILLMService(LLMService): """Base class for all services that use the AsyncOpenAI client. @@ -54,9 +68,16 @@ class BaseOpenAILLMService(LLMService): configurations. """ + Settings = OpenAILLMSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for OpenAI model configuration. + .. deprecated:: 0.0.105 + Use ``settings=BaseOpenAILLMService.Settings(...)`` instead of + ``params=InputParams(...)``. + Parameters: frequency_penalty: Penalty for frequent tokens (-2.0 to 2.0). presence_penalty: Penalty for new tokens (-2.0 to 2.0). @@ -90,13 +111,15 @@ class BaseOpenAILLMService(LLMService): def __init__( self, *, - model: str, + model: Optional[str] = None, api_key=None, base_url=None, organization=None, project=None, default_headers: Optional[Mapping[str, str]] = None, + service_tier: Optional[str] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, retry_timeout_secs: Optional[float] = 5.0, retry_on_timeout: Optional[bool] = False, **kwargs, @@ -105,34 +128,71 @@ class BaseOpenAILLMService(LLMService): Args: model: The OpenAI model name to use (e.g., "gpt-4.1", "gpt-4o"). + + .. deprecated:: 0.0.105 + Use ``settings=BaseOpenAILLMService.Settings(model=...)`` instead. + api_key: OpenAI API key. If None, uses environment variable. base_url: Custom base URL for OpenAI API. If None, uses default. organization: OpenAI organization ID. project: OpenAI project ID. default_headers: Additional HTTP headers to include in requests. + service_tier: Service tier to use (e.g., "auto", "flex", "priority"). params: Input parameters for model configuration and behavior. + + .. deprecated:: 0.0.105 + Use ``settings=BaseOpenAILLMService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. retry_timeout_secs: Request timeout in seconds. Defaults to 5.0 seconds. retry_on_timeout: Whether to retry the request once if it times out. **kwargs: Additional arguments passed to the parent LLMService. """ - super().__init__(**kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="gpt-4.1", + system_instruction=None, + frequency_penalty=NOT_GIVEN, + presence_penalty=NOT_GIVEN, + seed=NOT_GIVEN, + temperature=NOT_GIVEN, + top_p=NOT_GIVEN, + top_k=None, + max_tokens=NOT_GIVEN, + max_completion_tokens=NOT_GIVEN, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + extra={}, + ) - params = params or BaseOpenAILLMService.InputParams() + # 2. Apply direct init arg overrides (no warnings in base class) + if model is not None: + default_settings.model = model - self._settings = { - "frequency_penalty": params.frequency_penalty, - "presence_penalty": params.presence_penalty, - "seed": params.seed, - "temperature": params.temperature, - "top_p": params.top_p, - "max_tokens": params.max_tokens, - "max_completion_tokens": params.max_completion_tokens, - "service_tier": params.service_tier, - "extra": params.extra if isinstance(params.extra, dict) else {}, - } + # 3. Apply params overrides — only if settings not provided + if params is not None and not settings: + default_settings.frequency_penalty = params.frequency_penalty + default_settings.presence_penalty = params.presence_penalty + default_settings.seed = params.seed + default_settings.temperature = params.temperature + default_settings.top_p = params.top_p + default_settings.max_tokens = params.max_tokens + default_settings.max_completion_tokens = params.max_completion_tokens + if isinstance(params.extra, dict): + default_settings.extra = params.extra + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + settings=default_settings, + **kwargs, + ) + self._service_tier = service_tier self._retry_timeout_secs = retry_timeout_secs self._retry_on_timeout = retry_on_timeout - self.set_model_name(model) self._full_model_name: str = "" self._client = self.create_client( api_key=api_key, @@ -143,6 +203,9 @@ class BaseOpenAILLMService(LLMService): **kwargs, ) + if self._settings.system_instruction: + logger.debug(f"{self}: Using system instruction: {self._settings.system_instruction}") + def create_client( self, api_key=None, @@ -246,30 +309,51 @@ class BaseOpenAILLMService(LLMService): Dictionary of parameters for the chat completion request. """ params = { - "model": self.model_name, + "model": self._settings.model, "stream": True, "stream_options": {"include_usage": True}, - "frequency_penalty": self._settings["frequency_penalty"], - "presence_penalty": self._settings["presence_penalty"], - "seed": self._settings["seed"], - "temperature": self._settings["temperature"], - "top_p": self._settings["top_p"], - "max_tokens": self._settings["max_tokens"], - "max_completion_tokens": self._settings["max_completion_tokens"], - "service_tier": self._settings["service_tier"], + "frequency_penalty": self._settings.frequency_penalty, + "presence_penalty": self._settings.presence_penalty, + "seed": self._settings.seed, + "temperature": self._settings.temperature, + "top_p": self._settings.top_p, + "max_tokens": self._settings.max_tokens, + "max_completion_tokens": self._settings.max_completion_tokens, + "service_tier": self._service_tier if self._service_tier is not None else NOT_GIVEN, } # Messages, tools, tool_choice params.update(params_from_context) - params.update(self._settings["extra"]) + params.update(self._settings.extra) + + # Prepend system instruction from constructor + if self._settings.system_instruction: + messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) + params["messages"] = [ + {"role": "system", "content": self._settings.system_instruction} + ] + messages + return params - async def run_inference(self, context: LLMContext | OpenAILLMContext) -> Optional[str]: + async def run_inference( + self, + context: LLMContext | OpenAILLMContext, + max_tokens: Optional[int] = None, + system_instruction: Optional[str] = None, + ) -> Optional[str]: """Run a one-shot, out-of-band (i.e. out-of-pipeline) inference with the given LLM context. Args: context: The LLM context containing conversation history. + max_tokens: Optional maximum number of tokens to generate. If provided, + overrides the service's default max_tokens/max_completion_tokens setting. + system_instruction: Optional system instruction to use for this inference. + If provided, overrides any system instruction in the context. Returns: The LLM's response as a string, or None if no response is generated. @@ -291,6 +375,23 @@ class BaseOpenAILLMService(LLMService): params["stream"] = False params.pop("stream_options", None) + # Prepend system instruction if provided + if system_instruction is not None: + messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) + params["messages"] = [{"role": "system", "content": system_instruction}] + messages + + # Override max_tokens if provided + if max_tokens is not None: + # Use max_completion_tokens for newer models, fallback to max_tokens + if "max_completion_tokens" in params: + params["max_completion_tokens"] = max_tokens + else: + params["max_tokens"] = max_tokens + # LLM completion response = await self._client.chat.completions.create(**params) @@ -362,74 +463,96 @@ class BaseOpenAILLMService(LLMService): else self._stream_chat_completions_universal_context(context) ) - async for chunk in chunk_stream: - if chunk.usage: - cached_tokens = ( - chunk.usage.prompt_tokens_details.cached_tokens - if chunk.usage.prompt_tokens_details - else None - ) - reasoning_tokens = ( - chunk.usage.completion_tokens_details.reasoning_tokens - if chunk.usage.completion_tokens_details - else None - ) - tokens = LLMTokenUsage( - prompt_tokens=chunk.usage.prompt_tokens, - completion_tokens=chunk.usage.completion_tokens, - total_tokens=chunk.usage.total_tokens, - cache_read_input_tokens=cached_tokens, - reasoning_tokens=reasoning_tokens, - ) - await self.start_llm_usage_metrics(tokens) + # 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 chunk_iter + finally: + # 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() - if chunk.model and self.get_full_model_name() != chunk.model: - self.set_full_model_name(chunk.model) + 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 + if chunk.usage.prompt_tokens_details + else None + ) + reasoning_tokens = ( + chunk.usage.completion_tokens_details.reasoning_tokens + if chunk.usage.completion_tokens_details + else None + ) + tokens = LLMTokenUsage( + prompt_tokens=chunk.usage.prompt_tokens, + completion_tokens=chunk.usage.completion_tokens, + total_tokens=chunk.usage.total_tokens, + cache_read_input_tokens=cached_tokens, + reasoning_tokens=reasoning_tokens, + ) + await self.start_llm_usage_metrics(tokens) - if chunk.choices is None or len(chunk.choices) == 0: - continue + if chunk.model and self.get_full_model_name() != chunk.model: + self.set_full_model_name(chunk.model) - await self.stop_ttfb_metrics() + if chunk.choices is None or len(chunk.choices) == 0: + continue - if not chunk.choices[0].delta: - continue + await self.stop_ttfb_metrics() - if chunk.choices[0].delta.tool_calls: - # We're streaming the LLM response to enable the fastest response times. - # For text, we just yield each chunk as we receive it and count on consumers - # to do whatever coalescing they need (eg. to pass full sentences to TTS) - # - # If the LLM is a function call, we'll do some coalescing here. - # If the response contains a function name, we'll yield a frame to tell consumers - # that they can start preparing to call the function with that name. - # We accumulate all the arguments for the rest of the streamed response, then when - # the response is done, we package up all the arguments and the function name and - # yield a frame containing the function name and the arguments. + if not chunk.choices[0].delta: + continue - tool_call = chunk.choices[0].delta.tool_calls[0] - if tool_call.index != func_idx: - functions_list.append(function_name) - arguments_list.append(arguments) - tool_id_list.append(tool_call_id) - function_name = "" - arguments = "" - tool_call_id = "" - func_idx += 1 - if tool_call.function and tool_call.function.name: - function_name += tool_call.function.name - tool_call_id = tool_call.id - if tool_call.function and tool_call.function.arguments: - # Keep iterating through the response to collect all the argument fragments - arguments += tool_call.function.arguments - elif chunk.choices[0].delta.content: - await self.push_frame(LLMTextFrame(chunk.choices[0].delta.content)) + if chunk.choices[0].delta.tool_calls: + # We're streaming the LLM response to enable the fastest response times. + # For text, we just yield each chunk as we receive it and count on consumers + # to do whatever coalescing they need (eg. to pass full sentences to TTS) + # + # If the LLM is a function call, we'll do some coalescing here. + # If the response contains a function name, we'll yield a frame to tell consumers + # that they can start preparing to call the function with that name. + # We accumulate all the arguments for the rest of the streamed response, then when + # the response is done, we package up all the arguments and the function name and + # yield a frame containing the function name and the arguments. - # When gpt-4o-audio / gpt-4o-mini-audio is used for llm or stt+llm - # we need to get LLMTextFrame for the transcript - elif hasattr(chunk.choices[0].delta, "audio") and chunk.choices[0].delta.audio.get( - "transcript" - ): - await self.push_frame(LLMTextFrame(chunk.choices[0].delta.audio["transcript"])) + tool_call = chunk.choices[0].delta.tool_calls[0] + if tool_call.index != func_idx: + functions_list.append(function_name) + arguments_list.append(arguments) + tool_id_list.append(tool_call_id) + function_name = "" + arguments = "" + tool_call_id = "" + func_idx += 1 + if tool_call.function and tool_call.function.name: + function_name += tool_call.function.name + tool_call_id = tool_call.id + if tool_call.function and tool_call.function.arguments: + # Keep iterating through the response to collect all the argument fragments + arguments += tool_call.function.arguments + elif chunk.choices[0].delta.content: + await self._push_llm_text(chunk.choices[0].delta.content) + + # When gpt-4o-audio / gpt-4o-mini-audio is used for llm or stt+llm + # we need to get LLMTextFrame for the transcript + elif hasattr(chunk.choices[0].delta, "audio") and chunk.choices[0].delta.audio.get( + "transcript" + ): + await self.push_frame(LLMTextFrame(chunk.choices[0].delta.audio["transcript"])) # if we got a function name and arguments, check to see if it's a function with # a registered handler. If so, run the registered callback, save the result to @@ -482,8 +605,6 @@ class BaseOpenAILLMService(LLMService): # NOTE: LLMMessagesFrame is deprecated, so we don't support the newer universal # LLMContext with it context = OpenAILLMContext.from_messages(frame.messages) - elif isinstance(frame, LLMUpdateSettingsFrame): - await self._update_settings(frame.settings) else: await self.push_frame(frame, direction) @@ -492,8 +613,11 @@ class BaseOpenAILLMService(LLMService): await self.push_frame(LLMFullResponseStartFrame()) await self.start_processing_metrics() await self._process_context(context) - except httpx.TimeoutException: + except httpx.TimeoutException as e: await self._call_event_handler("on_completion_timeout") + await self.push_error(error_msg="LLM completion timeout", exception=e) + except Exception as e: + await self.push_error(error_msg=f"Error during completion: {e}", exception=e) finally: await self.stop_processing_metrics() await self.push_frame(LLMFullResponseEndFrame()) diff --git a/src/pipecat/services/openai/image.py b/src/pipecat/services/openai/image.py index d6ca51ae7..de010247b 100644 --- a/src/pipecat/services/openai/image.py +++ b/src/pipecat/services/openai/image.py @@ -11,6 +11,7 @@ for creating images from text prompts. """ import io +from dataclasses import dataclass, field from typing import AsyncGenerator, Literal, Optional import aiohttp @@ -24,6 +25,19 @@ from pipecat.frames.frames import ( URLImageRawFrame, ) from pipecat.services.image_service import ImageGenService +from pipecat.services.settings import NOT_GIVEN, ImageGenSettings, _NotGiven + + +@dataclass +class OpenAIImageGenSettings(ImageGenSettings): + """Settings for the OpenAI image generation service. + + Parameters: + model: DALL-E model identifier. + image_size: Target size for generated images. + """ + + image_size: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) class OpenAIImageGenService(ImageGenService): @@ -34,14 +48,20 @@ class OpenAIImageGenService(ImageGenService): with configurable quality and style parameters. """ + Settings = OpenAIImageGenSettings + _settings: Settings + def __init__( self, *, api_key: str, base_url: Optional[str] = None, aiohttp_session: aiohttp.ClientSession, - image_size: Literal["256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"], - model: str = "dall-e-3", + image_size: Optional[ + Literal["256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"] + ] = None, + model: Optional[str] = None, + settings: Optional[Settings] = None, ): """Initialize the OpenAI image generation service. @@ -49,12 +69,39 @@ class OpenAIImageGenService(ImageGenService): api_key: OpenAI API key for authentication. base_url: Custom base URL for OpenAI API. If None, uses default. aiohttp_session: HTTP session for downloading generated images. - image_size: Target size for generated images. + image_size: Target size for generated images. Defaults to "1024x1024". + + .. deprecated:: 0.0.105 + Use ``settings=OpenAIImageGenService.Settings(image_size=...)`` instead. + model: DALL-E model to use for generation. Defaults to "dall-e-3". + + .. deprecated:: 0.0.105 + Use ``settings=OpenAIImageGenService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. """ - super().__init__() - self.set_model_name(model) - self._image_size = image_size + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="dall-e-3", + image_size=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + if image_size is not None: + self._warn_init_param_moved_to_settings("image_size", "image_size") + default_settings.image_size = image_size + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(settings=default_settings) self._client = AsyncOpenAI(api_key=api_key, base_url=base_url) self._aiohttp_session = aiohttp_session @@ -70,7 +117,7 @@ class OpenAIImageGenService(ImageGenService): logger.debug(f"Generating image from prompt: {prompt}") image = await self._client.images.generate( - prompt=prompt, model=self.model_name, n=1, size=self._image_size + prompt=prompt, model=self._settings.model, n=1, size=self._settings.image_size ) image_url = image.data[0].url diff --git a/src/pipecat/services/openai/llm.py b/src/pipecat/services/openai/llm.py index b760b0d6e..553733922 100644 --- a/src/pipecat/services/openai/llm.py +++ b/src/pipecat/services/openai/llm.py @@ -10,6 +10,8 @@ import json from dataclasses import dataclass from typing import Any, Optional +from openai import NOT_GIVEN + from pipecat.frames.frames import ( FunctionCallCancelFrame, FunctionCallInProgressFrame, @@ -69,21 +71,80 @@ class OpenAILLMService(BaseOpenAILLMService): context aggregator creation. """ + Settings = BaseOpenAILLMService.Settings + def __init__( self, *, - model: str = "gpt-4.1", + model: Optional[str] = None, + service_tier: Optional[str] = None, params: Optional[BaseOpenAILLMService.InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize OpenAI LLM service. Args: model: The OpenAI model name to use. Defaults to "gpt-4.1". + + .. deprecated:: 0.0.105 + Use ``settings=OpenAILLMService.Settings(model=...)`` instead. + + service_tier: Service tier to use (e.g., "auto", "flex", "priority"). params: Input parameters for model configuration. + + .. deprecated:: 0.0.105 + Use ``settings=OpenAILLMService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to the parent BaseOpenAILLMService. """ - super().__init__(model=model, params=params, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="gpt-4.1", + system_instruction=None, + frequency_penalty=NOT_GIVEN, + presence_penalty=NOT_GIVEN, + seed=NOT_GIVEN, + temperature=NOT_GIVEN, + top_p=NOT_GIVEN, + top_k=None, + max_tokens=NOT_GIVEN, + max_completion_tokens=NOT_GIVEN, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + extra={}, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # Handle service_tier from deprecated params + if params is not None and not settings and params.service_tier is not NOT_GIVEN: + service_tier = service_tier or params.service_tier + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.frequency_penalty = params.frequency_penalty + default_settings.presence_penalty = params.presence_penalty + default_settings.seed = params.seed + default_settings.temperature = params.temperature + default_settings.top_p = params.top_p + default_settings.max_tokens = params.max_tokens + default_settings.max_completion_tokens = params.max_completion_tokens + if isinstance(params.extra, dict): + default_settings.extra = params.extra + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(service_tier=service_tier, settings=default_settings, **kwargs) def create_context_aggregator( self, @@ -194,7 +255,7 @@ class OpenAIAssistantContextAggregator(LLMAssistantContextAggregator): frame: Frame containing the function call result. """ if frame.result: - result = json.dumps(frame.result) + result = json.dumps(frame.result, ensure_ascii=False) await self._update_function_call_result(frame.function_name, frame.tool_call_id, result) else: await self._update_function_call_result( diff --git a/src/pipecat/services/openai/realtime/events.py b/src/pipecat/services/openai/realtime/events.py index c0ef5b890..0aa1355e6 100644 --- a/src/pipecat/services/openai/realtime/events.py +++ b/src/pipecat/services/openai/realtime/events.py @@ -1002,17 +1002,14 @@ class TokenDetails(BaseModel): image_tokens: Number of image tokens used (for input only). """ + model_config = ConfigDict(extra="allow") + cached_tokens: Optional[int] = 0 text_tokens: Optional[int] = 0 audio_tokens: Optional[int] = 0 cached_tokens_details: Optional[CachedTokensDetails] = None image_tokens: Optional[int] = 0 - class Config: - """Pydantic configuration for TokenDetails.""" - - extra = "allow" - class Usage(BaseModel): """Token usage statistics for a response. diff --git a/src/pipecat/services/openai/realtime/llm.py b/src/pipecat/services/openai/realtime/llm.py index 598d4f510..bd31369ae 100644 --- a/src/pipecat/services/openai/realtime/llm.py +++ b/src/pipecat/services/openai/realtime/llm.py @@ -10,8 +10,9 @@ import base64 import io import json import time -from dataclasses import dataclass -from typing import Optional +from dataclasses import dataclass, field +from dataclasses import fields as dataclass_fields +from typing import Any, Dict, Mapping, Optional, Type from loguru import logger from PIL import Image @@ -36,7 +37,6 @@ from pipecat.frames.frames import ( LLMMessagesAppendFrame, LLMSetToolsFrame, LLMTextFrame, - LLMUpdateSettingsFrame, StartFrame, TranscriptionFrame, TTSAudioRawFrame, @@ -59,6 +59,12 @@ from pipecat.processors.aggregators.openai_llm_context import ( ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService +from pipecat.services.settings import ( + NOT_GIVEN, + LLMSettings, + _NotGiven, + is_given, +) from pipecat.transcriptions.language import Language from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_openai_realtime, traced_stt @@ -90,6 +96,111 @@ class CurrentAudioResponse: total_size: int = 0 +@dataclass +class OpenAIRealtimeLLMSettings(LLMSettings): + """Settings for OpenAIRealtimeLLMService. + + Parameters: + session_properties: OpenAI Realtime session properties (modalities, + audio config, tools, etc.). ``model`` and ``instructions`` are + synced bidirectionally with the top-level ``model`` and + ``system_instruction`` fields. + """ + + session_properties: events.SessionProperties | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + + # -- Bidirectional sync helpers ------------------------------------------ + + @staticmethod + def _sync_top_level_to_sp(settings: "OpenAIRealtimeLLMService.Settings"): + """Push top-level ``model``/``system_instruction`` into ``session_properties``.""" + if not is_given(settings.session_properties): + return + sp = settings.session_properties + if is_given(settings.model) and settings.model is not None: + sp.model = settings.model + if is_given(settings.system_instruction): + sp.instructions = settings.system_instruction + + # -- apply_update override ----------------------------------------------- + + def apply_update(self, delta: "OpenAIRealtimeLLMService.Settings") -> Dict[str, Any]: + """Merge a delta, keeping ``model``/``system_instruction`` in sync with SP. + + When the delta contains ``session_properties``, it **replaces** the + stored SP wholesale (matching legacy behaviour). Top-level field + values always take precedence over conflicting SP values. + """ + # 1. Let the base class handle all fields including session_properties + # (wholesale replacement when given). + changed = super().apply_update(delta) + + # 2. SP → top-level: if the SP was just replaced and carries + # model/instructions that the delta didn't set at top level, + # pull them up. + if "session_properties" in changed and is_given(self.session_properties): + sp = self.session_properties + if "model" not in changed and sp.model is not None: + old_model = self.model + self.model = sp.model + if old_model != self.model: + changed["model"] = old_model + if "system_instruction" not in changed and sp.instructions is not None: + old_si = self.system_instruction + self.system_instruction = sp.instructions + if old_si != self.system_instruction: + changed["system_instruction"] = old_si + + # 3. Top-level → SP: ensure SP mirrors the authoritative top-level + # values. Covers all cases: top-level-only delta, SP-only delta, + # and mixed deltas where top-level takes precedence. + self._sync_top_level_to_sp(self) + + return changed + + # -- from_mapping override ----------------------------------------------- + + @classmethod + def from_mapping( + cls: Type["OpenAIRealtimeLLMService.Settings"], settings: Mapping[str, Any] + ) -> "OpenAIRealtimeLLMService.Settings": + """Build a delta from a plain dict, routing SP keys into ``session_properties``. + + Keys that correspond to ``SessionProperties`` fields (except ``model``) + are collected into a nested ``session_properties`` value. ``model`` is + always routed to the top-level field. Unknown keys go to ``extra``. + """ + # Determine which keys belong to our own dataclass fields. + own_field_names = {f.name for f in dataclass_fields(cls)} - {"extra"} + + top: Dict[str, Any] = {} + sp_dict: Dict[str, Any] = {} + extra: Dict[str, Any] = {} + + # Build the SP field set without instantiating (avoid __post_init__ + # cost for every from_mapping call). + sp_keys = set(events.SessionProperties.model_fields.keys()) - {"model"} + + for key, value in settings.items(): + # Resolve aliases first + canonical = cls._aliases.get(key, key) + if canonical in own_field_names: + top[canonical] = value + elif canonical in sp_keys: + sp_dict[canonical] = value + else: + extra[key] = value + + if sp_dict: + top["session_properties"] = events.SessionProperties(**sp_dict) + + instance = cls(**top) + instance.extra = extra + return instance + + class OpenAIRealtimeLLMService(LLMService): """OpenAI Realtime LLM service providing real-time audio and text communication. @@ -98,6 +209,9 @@ class OpenAIRealtimeLLMService(LLMService): management, and real-time transcription. """ + Settings = OpenAIRealtimeLLMSettings + _settings: Settings + # Overriding the default adapter to use the OpenAIRealtimeLLMAdapter one. adapter_class = OpenAIRealtimeLLMAdapter @@ -105,9 +219,10 @@ class OpenAIRealtimeLLMService(LLMService): self, *, api_key: str, - model: str = "gpt-realtime", + model: Optional[str] = None, base_url: str = "wss://api.openai.com/v1/realtime", session_properties: Optional[events.SessionProperties] = None, + settings: Optional[Settings] = None, start_audio_paused: bool = False, start_video_paused: bool = False, video_frame_detail: str = "auto", @@ -118,14 +233,22 @@ class OpenAIRealtimeLLMService(LLMService): Args: api_key: OpenAI API key for authentication. - model: OpenAI model name. Defaults to "gpt-realtime". + model: OpenAI model name. + + .. deprecated:: 0.0.105 + Use ``settings=OpenAIRealtimeLLMService.Settings(model=...)`` instead. + This is a connection-level parameter set via the WebSocket URL query parameter and cannot be changed during the session. base_url: WebSocket base URL for the realtime API. Defaults to "wss://api.openai.com/v1/realtime". session_properties: Configuration properties for the realtime session. - These are session-level settings that can be updated during the session - (except for voice and model). If None, uses default SessionProperties. + If None, uses default SessionProperties. + + .. deprecated:: 0.0.105 + Use ``settings=OpenAIRealtimeLLMService.Settings(session_properties=...)`` + instead. + settings: Runtime-updatable settings for this service. start_audio_paused: Whether to start with audio input paused. Defaults to False. start_video_paused: Whether to start with video input paused. Defaults to False. video_frame_detail: Detail level for video processing. Can be "auto", "low", or "high". @@ -152,19 +275,59 @@ class OpenAIRealtimeLLMService(LLMService): stacklevel=2, ) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="gpt-realtime-1.5", + system_instruction=None, + temperature=None, + max_tokens=None, + top_p=None, + top_k=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + session_properties=events.SessionProperties(), + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + if session_properties is not None: + _warn_deprecated_param( + "session_properties", + self.Settings, + "session_properties", + ) + default_settings.session_properties = session_properties + # Sync model/instructions from the deprecated SP arg to top-level, + # but only if the deprecated `model` arg didn't already set it. + if model is None and session_properties.model is not None: + default_settings.model = session_properties.model + if session_properties.instructions is not None: + default_settings.system_instruction = session_properties.instructions + + # Sync top-level model back into session_properties + self.Settings._sync_top_level_to_sp(default_settings) + + # 3. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + # Build WebSocket URL with model query parameter # Source: https://platform.openai.com/docs/guides/realtime-websocket - full_url = f"{base_url}?model={model}" - super().__init__(base_url=full_url, **kwargs) + full_url = f"{base_url}?model={default_settings.model}" + super().__init__( + base_url=full_url, + settings=default_settings, + **kwargs, + ) self.api_key = api_key self.base_url = full_url - self.set_model_name(model) - - # Initialize session_properties - self._session_properties: events.SessionProperties = ( - session_properties or events.SessionProperties() - ) self._audio_input_paused = start_audio_paused self._video_input_paused = start_video_paused self._video_frame_detail = video_frame_detail @@ -227,12 +390,12 @@ class OpenAIRealtimeLLMService(LLMService): def _is_modality_enabled(self, modality: str) -> bool: """Check if a specific modality is enabled, "text" or "audio".""" - modalities = self._session_properties.output_modalities or ["audio", "text"] + modalities = self._settings.session_properties.output_modalities or ["audio", "text"] return modality in modalities def _get_enabled_modalities(self) -> list[str]: """Get the list of enabled modalities.""" - modalities = self._session_properties.output_modalities or ["audio", "text"] + modalities = self._settings.session_properties.output_modalities or ["audio", "text"] # API only supports single modality responses: either ["text"] or ["audio"] if "audio" in modalities: return ["audio"] @@ -305,9 +468,9 @@ class OpenAIRealtimeLLMService(LLMService): # None and False are different. Check for False. None means we're using OpenAI's # built-in turn detection defaults. turn_detection_disabled = ( - self._session_properties.audio - and self._session_properties.audio.input - and self._session_properties.audio.input.turn_detection is False + self._settings.session_properties.audio + and self._settings.session_properties.audio.input + and self._settings.session_properties.audio.input.turn_detection is False ) if turn_detection_disabled: await self.send_client_event(events.InputAudioBufferClearEvent()) @@ -327,9 +490,9 @@ class OpenAIRealtimeLLMService(LLMService): # None and False are different. Check for False. None means we're using OpenAI's # built-in turn detection defaults. turn_detection_disabled = ( - self._session_properties.audio - and self._session_properties.audio.input - and self._session_properties.audio.input.turn_detection is False + self._settings.session_properties.audio + and self._settings.session_properties.audio.input + and self._settings.session_properties.audio.input.turn_detection is False ) if turn_detection_disabled: await self.send_client_event(events.InputAudioBufferCommitEvent()) @@ -424,11 +587,8 @@ class OpenAIRealtimeLLMService(LLMService): await self._handle_bot_stopped_speaking() elif isinstance(frame, LLMMessagesAppendFrame): await self._handle_messages_append(frame) - elif isinstance(frame, LLMUpdateSettingsFrame): - self._session_properties = events.SessionProperties(**frame.settings) - await self._update_settings() elif isinstance(frame, LLMSetToolsFrame): - await self._update_settings() + await self._send_session_update() await self.push_frame(frame, direction) @@ -513,8 +673,17 @@ class OpenAIRealtimeLLMService(LLMService): # treat a send-side error as fatal. await self.push_error(error_msg=f"Error sending client event: {e}", exception=e) - async def _update_settings(self): - settings = self._session_properties + async def _update_settings(self, delta): + """Apply a settings delta, sending a session update when needed.""" + changed = await super()._update_settings(delta) + handled = {"session_properties", "system_instruction"} + if changed.keys() & handled: + await self._send_session_update() + self._warn_unhandled_updated_settings(changed.keys() - handled) + return changed + + async def _send_session_update(self): + settings = self._settings.session_properties adapter: OpenAIRealtimeLLMAdapter = self.get_llm_adapter() if self._context: @@ -577,15 +746,21 @@ class OpenAIRealtimeLLMService(LLMService): await self._handle_evt_function_call_arguments_done(evt) elif evt.type == "error": if not await self._maybe_handle_evt_retrieve_conversation_item_error(evt): - await self._handle_evt_error(evt) - # errors are fatal, so exit the receive loop - return + if evt.error.code in ( + "response_cancel_not_active", + "conversation_already_has_active_response", + ): + logger.debug(f"{self} {evt.error.message}") + else: + await self._handle_evt_error(evt) + # errors are fatal, so exit the receive loop + return @traced_openai_realtime(operation="llm_setup") async def _handle_evt_session_created(self, evt): # session.created is received right after connecting. Send a message # to configure the session properties. - await self._update_settings() + await self._send_session_update() async def _handle_evt_session_updated(self, evt): # If this is our first context frame, run the LLM @@ -599,6 +774,14 @@ class OpenAIRealtimeLLMService(LLMService): # note: ttfb is faster by 1/2 RTT than ttfb as measured for other services, since we're getting # this event from the server await self.stop_ttfb_metrics() + + if self._current_audio_response and self._current_audio_response.item_id != evt.item_id: + logger.warning( + f"Received a new audio delta for an already completed audio response before receiving the BotStoppedSpeakingFrame." + ) + logger.debug("Forcing previous audio response to None") + self._current_audio_response = None + if not self._current_audio_response: self._current_audio_response = CurrentAudioResponse( item_id=evt.item_id, @@ -724,10 +907,26 @@ class OpenAIRealtimeLLMService(LLMService): # We receive audio transcript deltas (as opposed to text deltas) when # the output modality is "audio" (the default) if evt.delta: - frame = TTSTextFrame(evt.delta, aggregated_by=AggregationType.SENTENCE) - # OpenAI Realtime text already includes any necessary inter-chunk spaces - frame.includes_inter_frame_spaces = True - await self.push_frame(frame) + await self._push_output_transcript_text_frames(evt.delta) + + async def _push_output_transcript_text_frames(self, text: str): + # In a typical "cascade" LLM + TTS setup, LLMTextFrames would not + # proceed beyond the TTS service. Therefore, since a speech-to-speech + # service like OpenAI Realtime combines both LLM and TTS functionality, + # you might think we wouldn't need to push LLMTextFrames at all. + # However, RTVI relies on LLMTextFrames being pushed to trigger its + # "bot-llm-text" event. So here we push an LLMTextFrame, too, but avoid + # appending it to context to avoid context message duplication. + + # Push LLMTextFrame + llm_text_frame = LLMTextFrame(text) + llm_text_frame.append_to_context = False + await self.push_frame(llm_text_frame) + + # Push TTSTextFrame + tts_text_frame = TTSTextFrame(text, aggregated_by=AggregationType.SENTENCE) + tts_text_frame.includes_inter_frame_spaces = True + await self.push_frame(tts_text_frame) async def _handle_evt_function_call_arguments_done(self, evt): """Handle completion of function call arguments. @@ -771,12 +970,12 @@ class OpenAIRealtimeLLMService(LLMService): async def _handle_evt_speech_started(self, evt): await self._truncate_current_audio_response() await self.broadcast_frame(UserStartedSpeakingFrame) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_evt_speech_stopped(self, evt): await self.start_ttfb_metrics() await self.start_processing_metrics() - await self.push_frame(UserStoppedSpeakingFrame()) + await self.broadcast_frame(UserStoppedSpeakingFrame) async def _maybe_handle_evt_retrieve_conversation_item_error(self, evt: events.ErrorEvent): """Maybe handle an error event related to retrieving a conversation item. @@ -844,7 +1043,7 @@ class OpenAIRealtimeLLMService(LLMService): await self.send_client_event(evt) # Send new settings if needed - await self._update_settings() + await self._send_session_update() # We're done configuring the LLM for this session self._llm_needs_conversation_setup = False @@ -929,7 +1128,7 @@ class OpenAIRealtimeLLMService(LLMService): item = events.ConversationItem( type="function_call_output", call_id=tool_call_id, - output=json.dumps(result), + output=json.dumps(result, ensure_ascii=False), ) await self.send_client_event(events.ConversationItemCreateEvent(item=item)) diff --git a/src/pipecat/services/openai/responses/__init__.py b/src/pipecat/services/openai/responses/__init__.py new file mode 100644 index 000000000..c4d243b97 --- /dev/null +++ b/src/pipecat/services/openai/responses/__init__.py @@ -0,0 +1,5 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# diff --git a/src/pipecat/services/openai/responses/llm.py b/src/pipecat/services/openai/responses/llm.py new file mode 100644 index 000000000..e9e5d3a1f --- /dev/null +++ b/src/pipecat/services/openai/responses/llm.py @@ -0,0 +1,400 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""OpenAI Responses API LLM service implementation.""" + +import json +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional + +import httpx +from loguru import logger +from openai import NOT_GIVEN, AsyncOpenAI, AsyncStream, DefaultAsyncHttpxClient +from openai.types.responses import ( + ResponseCompletedEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseStreamEvent, + ResponseTextDeltaEvent, +) + +from pipecat.adapters.services.open_ai_responses_adapter import ( + OpenAIResponsesLLMAdapter, + OpenAIResponsesLLMInvocationParams, +) +from pipecat.frames.frames import ( + Frame, + LLMContextFrame, + LLMFullResponseEndFrame, + LLMFullResponseStartFrame, +) +from pipecat.metrics.metrics import LLMTokenUsage +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.llm_service import FunctionCallFromLLM, LLMService +from pipecat.services.settings import NOT_GIVEN as _NOT_GIVEN +from pipecat.services.settings import LLMSettings, _NotGiven +from pipecat.utils.tracing.service_decorators import traced_llm + + +@dataclass +class OpenAIResponsesLLMSettings(LLMSettings): + """Settings for OpenAIResponsesLLMService. + + Parameters: + max_completion_tokens: Maximum completion tokens to generate. + """ + + max_completion_tokens: int | _NotGiven = field(default_factory=lambda: _NOT_GIVEN) + + +class OpenAIResponsesLLMService(LLMService): + """OpenAI Responses API LLM service. + + This service works with the universal LLMContext and LLMContextAggregatorPair. + + Example:: + + llm = OpenAIResponsesLLMService( + api_key=os.getenv("OPENAI_API_KEY"), + settings=OpenAIResponsesLLMService.Settings( + model="gpt-4.1", + system_instruction="You are a helpful assistant.", + ), + ) + """ + + Settings = OpenAIResponsesLLMSettings + _settings: Settings + + adapter_class = OpenAIResponsesLLMAdapter + + def __init__( + self, + *, + api_key=None, + base_url=None, + organization=None, + project=None, + default_headers: Optional[Mapping[str, str]] = None, + service_tier: Optional[str] = None, + settings: Optional[Settings] = None, + **kwargs, + ): + """Initialize the OpenAI Responses API LLM service. + + Args: + api_key: OpenAI API key. If None, uses environment variable. + base_url: Custom base URL for OpenAI API. If None, uses default. + organization: OpenAI organization ID. + project: OpenAI project ID. + default_headers: Additional HTTP headers to include in requests. + service_tier: Service tier to use (e.g., "auto", "flex", "priority"). + settings: Runtime-updatable settings. + **kwargs: Additional arguments passed to the parent LLMService. + """ + default_settings = self.Settings( + model="gpt-4.1", + system_instruction=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + temperature=NOT_GIVEN, + top_p=NOT_GIVEN, + top_k=None, + max_tokens=None, + max_completion_tokens=NOT_GIVEN, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + extra={}, + ) + + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + settings=default_settings, + **kwargs, + ) + + self._service_tier = service_tier + self._client = self._create_client( + api_key=api_key, + base_url=base_url, + organization=organization, + project=project, + default_headers=default_headers, + ) + + if self._settings.system_instruction: + logger.debug(f"{self}: Using system instruction: {self._settings.system_instruction}") + + def _create_client( + self, + api_key=None, + base_url=None, + organization=None, + project=None, + default_headers=None, + ) -> AsyncOpenAI: + """Create an AsyncOpenAI client instance. + + Args: + api_key: OpenAI API key. + base_url: Custom base URL for the API. + organization: OpenAI organization ID. + project: OpenAI project ID. + default_headers: Additional HTTP headers. + + Returns: + Configured AsyncOpenAI client instance. + """ + return AsyncOpenAI( + api_key=api_key, + base_url=base_url, + organization=organization, + project=project, + http_client=DefaultAsyncHttpxClient( + limits=httpx.Limits( + max_keepalive_connections=100, max_connections=1000, keepalive_expiry=None + ) + ), + default_headers=default_headers, + ) + + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics.""" + return True + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames for LLM completion requests. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ + await super().process_frame(frame, direction) + + context = None + if isinstance(frame, LLMContextFrame): + context = frame.context + else: + await self.push_frame(frame, direction) + + if context: + try: + await self.push_frame(LLMFullResponseStartFrame()) + await self.start_processing_metrics() + await self._process_context(context) + except httpx.TimeoutException as e: + await self._call_event_handler("on_completion_timeout") + await self.push_error(error_msg="LLM completion timeout", exception=e) + except Exception as e: + await self.push_error(error_msg=f"Error during completion: {e}", exception=e) + finally: + await self.stop_processing_metrics() + await self.push_frame(LLMFullResponseEndFrame()) + + @traced_llm + async def _process_context(self, context: LLMContext): + adapter: OpenAIResponsesLLMAdapter = self.get_llm_adapter() + logger.debug( + f"{self}: Generating response from universal context " + f"{adapter.get_messages_for_logging(context)}" + ) + + invocation_params = adapter.get_llm_invocation_params( + context, system_instruction=self._settings.system_instruction + ) + + params = self._build_response_params(invocation_params) + + await self.start_ttfb_metrics() + + stream: AsyncStream[ResponseStreamEvent] = await self._client.responses.create(**params) + + # Track function calls across stream events + function_calls: Dict[str, Dict[str, str]] = {} # item_id -> {name, call_id, arguments} + current_arguments: Dict[str, str] = {} # item_id -> accumulated arguments + + # 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 chunk_iter + finally: + # 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(stream) as event_iter: + async for event in event_iter: + if isinstance(event, ResponseTextDeltaEvent): + await self.stop_ttfb_metrics() + await self._push_llm_text(event.delta) + + elif isinstance(event, ResponseOutputItemAddedEvent): + await self.stop_ttfb_metrics() + item = event.item + if isinstance(item, ResponseFunctionToolCall): + item_id = item.id or "" + function_calls[item_id] = { + "name": item.name, + "call_id": item.call_id, + "arguments": "", + } + current_arguments[item_id] = "" + + elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): + item_id = event.item_id + if item_id in current_arguments: + current_arguments[item_id] += event.delta + + elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): + item_id = event.item_id + if item_id in function_calls: + function_calls[item_id]["arguments"] = event.arguments + + elif isinstance(event, ResponseOutputItemDoneEvent): + item = event.item + if isinstance(item, ResponseFunctionToolCall): + item_id = item.id or "" + if item_id in function_calls: + function_calls[item_id]["name"] = item.name + function_calls[item_id]["call_id"] = item.call_id + function_calls[item_id]["arguments"] = item.arguments + + elif isinstance(event, ResponseCompletedEvent): + response = event.response + if response.usage: + tokens = LLMTokenUsage( + prompt_tokens=response.usage.input_tokens, + completion_tokens=response.usage.output_tokens, + total_tokens=response.usage.total_tokens, + cache_read_input_tokens=response.usage.input_tokens_details.cached_tokens, + reasoning_tokens=response.usage.output_tokens_details.reasoning_tokens, + ) + await self.start_llm_usage_metrics(tokens) + + # This field is used by @traced_llm for more detailed + # model name in tracing spans + self._full_model_name = response.model + + # Process any function calls + if function_calls: + fc_list: List[FunctionCallFromLLM] = [] + for item_id, fc in function_calls.items(): + try: + arguments = json.loads(fc["arguments"]) if fc["arguments"] else {} + except json.JSONDecodeError: + logger.warning( + f"{self}: Failed to parse function call arguments: {fc['arguments']}" + ) + arguments = {} + fc_list.append( + FunctionCallFromLLM( + context=context, + tool_call_id=fc["call_id"], + function_name=fc["name"], + arguments=arguments, + ) + ) + await self.run_function_calls(fc_list) + + def _build_response_params(self, invocation_params: OpenAIResponsesLLMInvocationParams) -> dict: + """Build parameters for the responses.create() call. + + Args: + invocation_params: Parameters derived from the LLM context. + + Returns: + Dictionary of parameters for the Responses API call. + """ + params: Dict[str, Any] = { + "model": self._settings.model, + "stream": True, + "store": False, + "input": invocation_params["input"], + } + + # instructions (set by the adapter when input is non-empty) + if "instructions" in invocation_params: + params["instructions"] = invocation_params["instructions"] + + # Optional parameters - only include if given + if isinstance(self._settings.temperature, (int, float)): + params["temperature"] = self._settings.temperature + + if isinstance(self._settings.top_p, (int, float)): + params["top_p"] = self._settings.top_p + + if isinstance(self._settings.max_completion_tokens, int): + params["max_output_tokens"] = self._settings.max_completion_tokens + + if self._service_tier is not None: + params["service_tier"] = self._service_tier + + # Tools + tools = invocation_params.get("tools") + if tools is not None and not isinstance(tools, type(NOT_GIVEN)): + params["tools"] = tools + + # Extra settings + params.update(self._settings.extra) + + return params + + async def run_inference( + self, + context: LLMContext, + max_tokens: Optional[int] = None, + system_instruction: Optional[str] = None, + ) -> Optional[str]: + """Run a one-shot, out-of-band inference with the given LLM context. + + Args: + context: The LLM context containing conversation history. + max_tokens: Optional maximum number of tokens to generate. + system_instruction: Optional system instruction for this inference. + + Returns: + The LLM's response as a string, or None if no response is generated. + """ + adapter: OpenAIResponsesLLMAdapter = self.get_llm_adapter() + effective_instruction = system_instruction or self._settings.system_instruction + invocation_params = adapter.get_llm_invocation_params( + context, system_instruction=effective_instruction + ) + + params = self._build_response_params(invocation_params) + + # Override for non-streaming + params["stream"] = False + + if max_tokens is not None: + params["max_output_tokens"] = max_tokens + + response = await self._client.responses.create(**params) + + return response.output_text + + +__all__ = ["OpenAIResponsesLLMService", "OpenAIResponsesLLMSettings"] diff --git a/src/pipecat/services/openai/stt.py b/src/pipecat/services/openai/stt.py index 538b69990..fd98dbf49 100644 --- a/src/pipecat/services/openai/stt.py +++ b/src/pipecat/services/openai/stt.py @@ -4,12 +4,61 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""OpenAI Speech-to-Text service implementation using OpenAI's transcription API.""" +"""OpenAI Speech-to-Text service implementations. -from typing import Optional +Provides two STT services: -from pipecat.services.whisper.base_stt import BaseWhisperSTTService, Transcription +- ``OpenAISTTService``: REST-based transcription using the Audio API + (Whisper / GPT-4o). +- ``OpenAIRealtimeSTTService``: WebSocket-based streaming transcription + using the Realtime API in transcription-only mode. +""" + +import base64 +import json +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Literal, Optional, Union + +from loguru import logger + +from pipecat.audio.utils import create_stream_resampler +from pipecat.frames.frames import ( + CancelFrame, + EndFrame, + Frame, + InterimTranscriptionFrame, + StartFrame, + TranscriptionFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, +) +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven +from pipecat.services.stt_latency import OPENAI_REALTIME_TTFS_P99, OPENAI_TTFS_P99 +from pipecat.services.stt_service import WebsocketSTTService +from pipecat.services.whisper.base_stt import ( + BaseWhisperSTTService, + Transcription, +) from pipecat.transcriptions.language import Language +from pipecat.utils.time import time_now_iso8601 +from pipecat.utils.tracing.service_decorators import traced_stt + +try: + from websockets.asyncio.client import connect as websocket_connect + from websockets.protocol import State +except ModuleNotFoundError: + websocket_connect = None + State = None + + +@dataclass +class OpenAISTTSettings(BaseWhisperSTTService.Settings): + """Settings for the OpenAI STT service.""" + + pass class OpenAISTTService(BaseWhisperSTTService): @@ -19,61 +68,713 @@ class OpenAISTTService(BaseWhisperSTTService): set via the api_key parameter or OPENAI_API_KEY environment variable. """ + Settings = OpenAISTTSettings + _settings: Settings + def __init__( self, *, - model: str = "gpt-4o-transcribe", + model: Optional[str] = None, api_key: Optional[str] = None, base_url: Optional[str] = None, language: Optional[Language] = Language.EN, prompt: Optional[str] = None, temperature: Optional[float] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = OPENAI_TTFS_P99, **kwargs, ): """Initialize OpenAI STT service. Args: - model: Model to use — either gpt-4o or Whisper. Defaults to "gpt-4o-transcribe". + model: Model to use — either gpt-4o or Whisper. + + .. deprecated:: 0.0.105 + Use ``settings=OpenAISTTService.Settings(model=...)`` instead. + api_key: OpenAI API key. Defaults to None. base_url: API base URL. Defaults to None. language: Language of the audio input. Defaults to English. + + .. deprecated:: 0.0.105 + Use ``settings=OpenAISTTService.Settings(language=...)`` instead. + prompt: Optional text to guide the model's style or continue a previous segment. + + .. deprecated:: 0.0.105 + Use ``settings=OpenAISTTService.Settings(prompt=...)`` instead. + temperature: Optional sampling temperature between 0 and 1. Defaults to 0.0. + + .. deprecated:: 0.0.105 + Use ``settings=OpenAISTTService.Settings(temperature=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to BaseWhisperSTTService. """ + # --- 1. Hardcoded defaults --- + _language = language or Language.EN + default_settings = self.Settings( + model="gpt-4o-transcribe", + language=_language, + prompt=None, + temperature=None, + ) + + # --- 2. Deprecated direct-arg overrides --- + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if prompt is not None: + self._warn_init_param_moved_to_settings("prompt", "prompt") + default_settings.prompt = prompt + if temperature is not None: + self._warn_init_param_moved_to_settings("temperature", "temperature") + default_settings.temperature = temperature + + # --- 3. (no params object for this service) --- + + # --- 4. Settings delta (canonical API, always wins) --- + if settings is not None: + default_settings.apply_update(settings) + super().__init__( - model=model, api_key=api_key, base_url=base_url, - language=language, - prompt=prompt, - temperature=temperature, + settings=default_settings, + ttfs_p99_latency=ttfs_p99_latency, **kwargs, ) async def _transcribe(self, audio: bytes) -> Transcription: - assert self._language is not None # Assigned in the BaseWhisperSTTService class + assert self._settings.language is not None # Build kwargs dict with only set parameters kwargs = { "file": ("audio.wav", audio, "audio/wav"), - "model": self.model_name, - "language": self._language, + "model": self._settings.model, + "language": self._settings.language, } if self._include_prob_metrics: # GPT-4o-transcribe models only support logprobs (not verbose_json) - if self.model_name in ("gpt-4o-transcribe", "gpt-4o-mini-transcribe"): + if self._settings.model in ("gpt-4o-transcribe", "gpt-4o-mini-transcribe"): kwargs["response_format"] = "json" kwargs["include"] = ["logprobs"] else: # Whisper models support verbose_json kwargs["response_format"] = "verbose_json" - if self._prompt is not None: - kwargs["prompt"] = self._prompt + if self._settings.prompt is not None: + kwargs["prompt"] = self._settings.prompt - if self._temperature is not None: - kwargs["temperature"] = self._temperature + if self._settings.temperature is not None: + kwargs["temperature"] = self._settings.temperature return await self._client.audio.transcriptions.create(**kwargs) + + +_OPENAI_SAMPLE_RATE = 24000 + + +@dataclass +class OpenAIRealtimeSTTSettings(STTSettings): + """Settings for OpenAIRealtimeSTTService. + + Parameters: + prompt: Optional prompt text to guide transcription style. + noise_reduction: Noise reduction mode. ``"near_field"`` for close + microphones, ``"far_field"`` for distant microphones, or ``None`` + to disable. + """ + + prompt: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + noise_reduction: Literal["near_field", "far_field"] | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + + +class OpenAIRealtimeSTTService(WebsocketSTTService): + """OpenAI Realtime Speech-to-Text service using WebSocket transcription sessions. + + Uses OpenAI's Realtime API in transcription-only mode for real-time streaming + speech recognition with optional server-side VAD and noise reduction. The model + does not generate conversational responses — only transcription output. + + This service supports two VAD modes: + + **Local VAD** (default): Disable server-side VAD and use + a local VAD processor in the pipeline instead. When a + ``VADUserStoppedSpeakingFrame`` is received, the service commits the + audio buffer so that the server begins transcription for the completed + speech segment. + + **Server-side VAD** (``turn_detection=None``): The OpenAI server performs voice-activity + detection. The service broadcasts ``UserStartedSpeakingFrame`` and + ``UserStoppedSpeakingFrame`` when the server detects speech boundaries. + Do **not** use a separate VAD processor in the pipeline in this mode. + + Audio is sent as 24 kHz 16-bit mono PCM as required by the OpenAI Realtime + API. If the pipeline runs at a different sample rate (e.g. 16 kHz for Silero + VAD compatibility), audio is automatically upsampled before sending. + + Example:: + + stt = OpenAIRealtimeSTTService( + api_key="sk-...", + settings=OpenAIRealtimeSTTService.Settings( + model="gpt-4o-transcribe", + noise_reduction="near_field", + ), + ) + """ + + Settings = OpenAIRealtimeSTTSettings + _settings: Settings + + def __init__( + self, + *, + api_key: str, + model: Optional[str] = None, + base_url: str = "wss://api.openai.com/v1/realtime", + language: Optional[Language] = Language.EN, + prompt: Optional[str] = None, + turn_detection: Optional[Union[dict, Literal[False]]] = False, + noise_reduction: Optional[Literal["near_field", "far_field"]] = None, + should_interrupt: bool = True, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = OPENAI_REALTIME_TTFS_P99, + **kwargs, + ): + """Initialize the OpenAI Realtime STT service. + + Args: + api_key: OpenAI API key for authentication. + model: Transcription model. Supported values are + ``"gpt-4o-transcribe"`` and ``"gpt-4o-mini-transcribe"``. + + .. deprecated:: 0.0.105 + Use ``settings=OpenAIRealtimeSTTService.Settings(model=...)`` instead. + + base_url: WebSocket base URL for the Realtime API. + Defaults to ``"wss://api.openai.com/v1/realtime"``. + language: Language of the audio input. Defaults to English. + + .. deprecated:: 0.0.105 + Use ``settings=OpenAIRealtimeSTTService.Settings(language=...)`` instead. + + prompt: Optional prompt text to guide transcription style + or provide keyword hints. + + .. deprecated:: 0.0.105 + Use ``settings=OpenAIRealtimeSTTService.Settings(prompt=...)`` instead. + + turn_detection: Server-side VAD configuration. Defaults to + ``False`` (disabled), which relies on a local VAD + processor in the pipeline. Pass ``None`` to use server + defaults (``server_vad``), or a dict with custom + settings (e.g. ``{"type": "server_vad", "threshold": 0.5}``). + noise_reduction: Noise reduction mode. ``"near_field"`` for + close microphones, ``"far_field"`` for distant + microphones, or ``None`` to disable. + + .. deprecated:: 0.0.106 + Use ``settings=OpenAIRealtimeSTTService.Settings(noise_reduction=...)`` instead. + should_interrupt: Whether to interrupt bot output when + speech is detected by server-side VAD. Only applies when + turn detection is enabled. Defaults to True. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark + **kwargs: Additional arguments passed to parent + WebsocketSTTService. + """ + if websocket_connect is None: + raise ImportError( + "websockets is required for OpenAIRealtimeSTTService. " + "Install it with: pip install pipecat-ai[openai]" + ) + + # --- 1. Hardcoded defaults --- + default_settings = self.Settings( + model="gpt-4o-transcribe", + language=Language.EN, + prompt=None, + noise_reduction=None, + ) + + # --- 2. Deprecated direct-arg overrides --- + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if language is not None and language != Language.EN: + self._warn_init_param_moved_to_settings("language", "language") + default_settings.language = language + if prompt is not None: + self._warn_init_param_moved_to_settings("prompt", "prompt") + default_settings.prompt = prompt + if noise_reduction is not None: + self._warn_init_param_moved_to_settings("noise_reduction", "noise_reduction") + default_settings.noise_reduction = noise_reduction + + # --- 3. (no params object for this service) --- + + # --- 4. Settings delta (canonical API, always wins) --- + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) + + self._api_key = api_key + self._base_url = base_url + + self._turn_detection = turn_detection + self._should_interrupt = should_interrupt + + self._receive_task = None + self._session_ready = False + self._resampler = create_stream_resampler() + + # Server-side VAD is disabled by default (turn_detection=False). + # Set to None or a dict to enable server-side VAD. + self._server_vad_enabled = turn_detection is not False + + @staticmethod + def _language_to_code(language: Language) -> str: + """Convert a Language enum value to an ISO-639-1 code. + + Args: + language: The Language enum value. + + Returns: + Two-letter ISO-639-1 language code. + """ + # Language value is e.g. "en", "en-US", "fr", "zh". + return str(language).split("-")[0].lower() + + def can_generate_metrics(self) -> bool: + """Check if the service can generate processing metrics. + + Returns: + True, as this service supports metrics generation. + """ + return True + + async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: + """Apply a settings delta and send session update if needed. + + Sends a ``session.update`` to the server when the session is active. + + Args: + delta: A :class:`STTSettings` (or ``OpenAIRealtimeSTTService.Settings``) delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + + if changed and self._session_ready: + await self._send_session_update() + + return changed + + async def start(self, frame: StartFrame): + """Start the service and establish WebSocket connection. + + Args: + frame: The start frame triggering service initialization. + """ + await super().start(frame) + await self._connect() + + async def stop(self, frame: EndFrame): + """Stop the service and close WebSocket connection. + + Args: + frame: The end frame triggering service shutdown. + """ + await super().stop(frame) + await self._disconnect() + + async def cancel(self, frame: CancelFrame): + """Cancel the service and close WebSocket connection. + + Args: + frame: The cancel frame triggering service cancellation. + """ + await super().cancel(frame) + await self._disconnect() + + async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: + """Send audio data to the transcription session. + + Audio is streamed over the WebSocket. Transcription results arrive + asynchronously via the receive task and are pushed as + ``InterimTranscriptionFrame`` or ``TranscriptionFrame``. + + Args: + audio: Raw audio bytes (16-bit mono PCM at the pipeline + sample rate). Automatically resampled to 24 kHz. + + Yields: + None — results are delivered via the WebSocket receive task. + """ + await self._send_audio(audio) + yield None + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames from the pipeline. + + Extends the base STT service to handle local VAD events when + server-side VAD is disabled. On ``VADUserStoppedSpeakingFrame``, + commits the audio buffer so the server begins transcription for + the completed speech segment. + + Args: + frame: The frame to process. + direction: The direction of frame flow in the pipeline. + """ + await super().process_frame(frame, direction) + + # Handle local VAD events when server-side VAD is disabled. + if not self._server_vad_enabled: + if isinstance(frame, VADUserStartedSpeakingFrame): + await self.start_processing_metrics() + elif isinstance(frame, VADUserStoppedSpeakingFrame): + await self._commit_audio_buffer() + + # ------------------------------------------------------------------ + # WebSocket connection management + # ------------------------------------------------------------------ + + async def _connect(self): + """Connect to the transcription endpoint and start receiving.""" + await super()._connect() + await self._connect_websocket() + if self._websocket and not self._receive_task: + self._receive_task = self.create_task(self._receive_task_handler(self._report_error)) + + async def _disconnect(self): + """Disconnect and clean up background tasks.""" + await super()._disconnect() + if self._receive_task: + await self.cancel_task(self._receive_task, timeout=1.0) + self._receive_task = None + await self._disconnect_websocket() + + async def _connect_websocket(self): + """Establish the WebSocket connection to the transcription endpoint.""" + try: + if self._websocket and self._websocket.state is State.OPEN: + return + + self._session_ready = False + url = f"{self._base_url}?intent=transcription" + self._websocket = await websocket_connect( + uri=url, + additional_headers={ + "Authorization": f"Bearer {self._api_key}", + }, + ) + await self._call_event_handler("on_connected") + except Exception as e: + await self.push_error( + error_msg=f"Error connecting to OpenAI Realtime STT: {e}", + exception=e, + ) + self._websocket = None + + async def _disconnect_websocket(self): + """Close the WebSocket connection.""" + try: + self._session_ready = False + if self._websocket: + await self._websocket.close() + except Exception as e: + await self.push_error( + error_msg=f"Error disconnecting: {e}", + exception=e, + ) + finally: + self._websocket = None + await self._call_event_handler("on_disconnected") + + async def _ws_send(self, message: dict): + """Send a JSON message over the WebSocket. + + Args: + message: The message dict to serialize and send. + """ + try: + if not self._disconnecting and self._websocket: + await self._websocket.send(json.dumps(message)) + except Exception as e: + if self._disconnecting or not self._websocket: + return + await self.push_error( + error_msg=f"Error sending message: {e}", + exception=e, + ) + + # ------------------------------------------------------------------ + # Client events + # ------------------------------------------------------------------ + + async def _send_session_update(self): + """Send ``session.update`` to configure the transcription session.""" + transcription: dict = {"model": self._settings.model} + + language_code = ( + self._language_to_code(self._settings.language) if self._settings.language else None + ) + if language_code: + transcription["language"] = language_code + + if self._settings.prompt: + transcription["prompt"] = self._settings.prompt + + input_audio: dict = { + "format": { + "type": "audio/pcm", + "rate": _OPENAI_SAMPLE_RATE, + }, + "transcription": transcription, + } + + # Turn detection + if self._turn_detection is False: + input_audio["turn_detection"] = None + elif self._turn_detection is not None: + input_audio["turn_detection"] = self._turn_detection + + # Noise reduction + if self._settings.noise_reduction: + input_audio["noise_reduction"] = { + "type": self._settings.noise_reduction, + } + + await self._ws_send( + { + "type": "session.update", + "session": { + "type": "transcription", + "audio": { + "input": input_audio, + }, + }, + } + ) + + async def _send_audio(self, audio: bytes): + """Send audio data via ``input_audio_buffer.append``. + + Resamples from the pipeline sample rate to 24 kHz if needed. + + Args: + audio: Raw audio bytes at the pipeline sample rate. + """ + audio = await self._resampler.resample(audio, self.sample_rate, _OPENAI_SAMPLE_RATE) + if not audio: + return + payload = base64.b64encode(audio).decode("utf-8") + await self._ws_send( + { + "type": "input_audio_buffer.append", + "audio": payload, + } + ) + + async def _commit_audio_buffer(self): + """Commit the current audio buffer for transcription.""" + await self._ws_send({"type": "input_audio_buffer.commit"}) + + async def _clear_audio_buffer(self): + """Clear the current audio buffer.""" + await self._ws_send({"type": "input_audio_buffer.clear"}) + + # ------------------------------------------------------------------ + # Server event handling + # ------------------------------------------------------------------ + + async def _receive_messages(self): + """Receive and dispatch server events from the transcription session. + + Called by ``WebsocketService._receive_task_handler`` which wraps + this method with automatic reconnection on connection errors. + """ + async for message in self._websocket: + try: + evt = json.loads(message) + except json.JSONDecodeError: + logger.warning("Failed to parse WebSocket message") + continue + + evt_type = evt.get("type", "") + + if evt_type == "session.created": + await self._handle_session_created(evt) + elif evt_type == "session.updated": + await self._handle_session_updated(evt) + elif evt_type == "conversation.item.input_audio_transcription.delta": + await self._handle_transcription_delta(evt) + elif evt_type == "conversation.item.input_audio_transcription.completed": + await self._handle_transcription_completed(evt) + elif evt_type == "conversation.item.input_audio_transcription.failed": + await self._handle_transcription_failed(evt) + elif evt_type == "input_audio_buffer.speech_started": + await self._handle_speech_started(evt) + elif evt_type == "input_audio_buffer.speech_stopped": + await self._handle_speech_stopped(evt) + elif evt_type == "input_audio_buffer.committed": + logger.trace(f"Audio buffer committed: item_id={evt.get('item_id', '')}") + elif evt_type == "error": + await self._handle_error(evt) + else: + logger.trace(f"Unhandled event: {evt_type}") + + async def _handle_session_created(self, evt: dict): + """Handle ``session.created``. + + Sent immediately after connecting. We respond by configuring the + session with our desired settings. + + Args: + evt: The session created event from the server. + """ + logger.debug("Transcription session created, sending configuration") + await self._send_session_update() + + async def _handle_session_updated(self, evt: dict): + """Handle ``session.updated``. + + The session is now fully configured and ready to transcribe. + + Args: + evt: The session updated event from the server. + """ + logger.debug("Transcription session configured and ready") + self._session_ready = True + + async def _handle_transcription_delta(self, evt: dict): + """Handle incremental transcription text. + + For ``gpt-4o-transcribe`` and ``gpt-4o-mini-transcribe``, deltas + contain streaming partial text. For ``whisper-1``, each delta + contains the full turn transcript. + + Args: + evt: The delta event from the server. + """ + delta = evt.get("delta", "") + if delta: + await self.push_frame( + InterimTranscriptionFrame( + delta, + self._user_id, + time_now_iso8601(), + result=evt, + ) + ) + + async def _handle_transcription_completed(self, evt: dict): + """Handle a completed transcription for a speech segment. + + Pushes a ``TranscriptionFrame`` and records the result for + tracing. + + Args: + evt: The completed event containing the full transcript. + """ + transcript = evt.get("transcript", "") + if transcript: + await self.push_frame( + TranscriptionFrame( + transcript, + self._user_id, + time_now_iso8601(), + result=evt, + ) + ) + await self._handle_transcription_trace(transcript, True) + await self.stop_processing_metrics() + + @traced_stt + async def _handle_transcription_trace( + self, + transcript: str, + is_final: bool, + language: Optional[Language] = None, + ): + """Record transcription result for tracing. + + Args: + transcript: The transcribed text. + is_final: Whether this is a final transcription result. + language: Optional language of the transcription. + """ + pass + + async def _handle_speech_started(self, evt: dict): + """Handle server-side VAD speech start. + + Broadcasts ``UserStartedSpeakingFrame`` and optionally triggers + interruption of current bot output. + + Args: + evt: The ``input_audio_buffer.speech_started`` event. + """ + logger.debug("Server VAD: speech started") + await self.broadcast_frame(UserStartedSpeakingFrame) + if self._should_interrupt: + await self.broadcast_interruption() + await self.start_processing_metrics() + + async def _handle_speech_stopped(self, evt: dict): + """Handle server-side VAD speech stop. + + Broadcasts ``UserStoppedSpeakingFrame``. The audio buffer is + automatically committed by the server when VAD is enabled. + + Args: + evt: The ``input_audio_buffer.speech_stopped`` event. + """ + logger.debug("Server VAD: speech stopped") + await self.broadcast_frame(UserStoppedSpeakingFrame) + + async def _handle_transcription_failed(self, evt: dict): + """Handle a transcription failure for a speech segment. + + Logs the error but does not treat it as fatal — the session + remains active for subsequent turns. + + Args: + evt: The failed event containing error details. + """ + error = evt.get("error", {}) + error_msg = error.get("message", "Transcription failed") + await self.push_error(error_msg=f"OpenAI Realtime STT error: {error_msg}") + + async def _handle_error(self, evt: dict): + """Handle a fatal error from the transcription session. + + Raises an exception so that ``WebsocketService`` can decide + whether to attempt reconnection. + + Args: + evt: The error event. + """ + error = evt.get("error", {}) + error_msg = error.get("message", "Unknown error") + error_code = error.get("code", "") + msg = f"OpenAI Realtime STT error [{error_code}]: {error_msg}" + await self.push_error(error_msg=msg) + raise Exception(msg) diff --git a/src/pipecat/services/openai/tts.py b/src/pipecat/services/openai/tts.py index 593ff7d4c..074792b33 100644 --- a/src/pipecat/services/openai/tts.py +++ b/src/pipecat/services/openai/tts.py @@ -10,6 +10,7 @@ This module provides integration with OpenAI's text-to-speech API for generating high-quality synthetic speech from text input. """ +from dataclasses import dataclass, field from typing import AsyncGenerator, Dict, Literal, Optional from loguru import logger @@ -21,31 +22,57 @@ from pipecat.frames.frames import ( Frame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, - TTSStoppedFrame, ) +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 ValidVoice = Literal[ - "alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse" + "alloy", + "ash", + "ballad", + "cedar", + "coral", + "echo", + "fable", + "marin", + "nova", + "onyx", + "sage", + "shimmer", + "verse", ] VALID_VOICES: Dict[str, ValidVoice] = { "alloy": "alloy", "ash": "ash", "ballad": "ballad", + "cedar": "cedar", "coral": "coral", "echo": "echo", "fable": "fable", - "onyx": "onyx", + "marin": "marin", "nova": "nova", + "onyx": "onyx", "sage": "sage", "shimmer": "shimmer", "verse": "verse", } +@dataclass +class OpenAITTSSettings(TTSSettings): + """Settings for OpenAITTSService. + + Parameters: + instructions: Instructions to guide voice synthesis behavior. + speed: Voice speed control (0.25 to 4.0, default 1.0). + """ + + instructions: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + speed: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class OpenAITTSService(TTSService): """OpenAI Text-to-Speech service that generates audio from text. @@ -54,11 +81,17 @@ class OpenAITTSService(TTSService): speech synthesis with streaming audio output. """ + Settings = OpenAITTSSettings + _settings: Settings + OPENAI_SAMPLE_RATE = 24000 # OpenAI TTS always outputs at 24kHz class InputParams(BaseModel): """Input parameters for OpenAI TTS configuration. + .. deprecated:: 0.0.105 + Use ``settings=OpenAITTSService.Settings(...)`` instead. + Parameters: instructions: Instructions to guide voice synthesis behavior. speed: Voice speed control (0.25 to 4.0, default 1.0). @@ -72,12 +105,13 @@ class OpenAITTSService(TTSService): *, api_key: Optional[str] = None, base_url: Optional[str] = None, - voice: str = "alloy", - model: str = "gpt-4o-mini-tts", + voice: Optional[str] = None, + model: Optional[str] = None, sample_rate: Optional[int] = None, instructions: Optional[str] = None, speed: Optional[float] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize OpenAI TTS service. @@ -86,43 +120,87 @@ class OpenAITTSService(TTSService): api_key: OpenAI API key for authentication. If None, uses environment variable. base_url: Custom base URL for OpenAI API. If None, uses default. voice: Voice ID to use for synthesis. Defaults to "alloy". + + .. deprecated:: 0.0.105 + Use ``settings=OpenAITTSService.Settings(voice=...)`` instead. + model: TTS model to use. Defaults to "gpt-4o-mini-tts". + + .. deprecated:: 0.0.105 + Use ``settings=OpenAITTSService.Settings(model=...)`` instead. + sample_rate: Output audio sample rate in Hz. If None, uses OpenAI's default 24kHz. instructions: Optional instructions to guide voice synthesis behavior. - speed: Voice speed control (0.25 to 4.0, default 1.0). - params: Optional synthesis controls (acting instructions, speed, ...). - **kwargs: Additional keyword arguments passed to TTSService. - .. deprecated:: 0.0.91 - The `instructions` and `speed` parameters are deprecated, use `InputParams` instead. + .. deprecated:: 0.0.105 + Use ``settings=OpenAITTSService.Settings(instructions=...)`` instead. + + speed: Voice speed control (0.25 to 4.0, default 1.0). + + .. deprecated:: 0.0.105 + Use ``settings=OpenAITTSService.Settings(speed=...)`` instead. + + params: Optional synthesis controls (acting instructions, speed, ...). + + .. deprecated:: 0.0.105 + Use ``settings=OpenAITTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + **kwargs: Additional keyword arguments passed to TTSService. """ if sample_rate and sample_rate != self.OPENAI_SAMPLE_RATE: logger.warning( f"OpenAI TTS only supports {self.OPENAI_SAMPLE_RATE}Hz sample rate. " f"Current rate of {sample_rate}Hz may cause issues." ) - super().__init__(sample_rate=sample_rate, **kwargs) - self.set_model_name(model) - self.set_voice(voice) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="gpt-4o-mini-tts", + voice="alloy", + language=None, + instructions=None, + speed=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice is not None: + self._warn_init_param_moved_to_settings("voice", "voice") + default_settings.voice = voice + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if instructions is not None: + self._warn_init_param_moved_to_settings("instructions", "instructions") + default_settings.instructions = instructions + if speed is not None: + self._warn_init_param_moved_to_settings("speed", "speed") + default_settings.speed = speed + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.instructions is not None: + default_settings.instructions = params.instructions + if params.speed is not None: + default_settings.speed = params.speed + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) + self._client = AsyncOpenAI(api_key=api_key, base_url=base_url) - if instructions or speed: - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "The `instructions` and `speed` parameters are deprecated, use `InputParams` instead.", - DeprecationWarning, - stacklevel=2, - ) - - self._settings = { - "instructions": params.instructions if params else instructions, - "speed": params.speed if params else speed, - } - def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -131,15 +209,6 @@ class OpenAITTSService(TTSService): """ return True - async def set_model(self, model: str): - """Set the TTS model to use. - - Args: - model: The model name to use for text-to-speech synthesis. - """ - logger.info(f"Switching TTS model to: [{model}]") - self.set_model_name(model) - async def start(self, frame: StartFrame): """Start the OpenAI TTS service. @@ -154,32 +223,31 @@ class OpenAITTSService(TTSService): ) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using OpenAI's TTS API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech data. """ logger.debug(f"{self}: Generating TTS [{text}]") try: - await self.start_ttfb_metrics() - # Setup API parameters create_params = { "input": text, - "model": self.model_name, - "voice": VALID_VOICES[self._voice_id], + "model": self._settings.model, + "voice": VALID_VOICES[self._settings.voice], "response_format": "pcm", } - if self._settings["instructions"]: - create_params["instructions"] = self._settings["instructions"] + if self._settings.instructions: + create_params["instructions"] = self._settings.instructions - if self._settings["speed"]: - create_params["speed"] = self._settings["speed"] + if self._settings.speed: + create_params["speed"] = self._settings.speed async with self._client.audio.speech.with_streaming_response.create( **create_params @@ -198,12 +266,10 @@ class OpenAITTSService(TTSService): CHUNK_SIZE = self.chunk_size - yield TTSStartedFrame() async for chunk in r.iter_bytes(CHUNK_SIZE): if len(chunk) > 0: await self.stop_ttfb_metrics() - frame = TTSAudioRawFrame(chunk, self.sample_rate, 1) + frame = TTSAudioRawFrame(chunk, self.sample_rate, 1, context_id=context_id) yield frame - yield TTSStoppedFrame() except BadRequestError as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") diff --git a/src/pipecat/services/openai_realtime/__init__.py b/src/pipecat/services/openai_realtime/__init__.py index a302c783b..90729ef31 100644 --- a/src/pipecat/services/openai_realtime/__init__.py +++ b/src/pipecat/services/openai_realtime/__init__.py @@ -25,3 +25,13 @@ with warnings.catch_warnings(): DeprecationWarning, stacklevel=2, ) + +__all__ = [ + "AzureRealtimeLLMService", + "InputAudioNoiseReduction", + "InputAudioTranscription", + "SemanticTurnDetection", + "SessionProperties", + "TurnDetection", + "OpenAIRealtimeLLMService", +] diff --git a/src/pipecat/services/openai_realtime_beta/__init__.py b/src/pipecat/services/openai_realtime_beta/__init__.py index 595105d7f..b7c976bb6 100644 --- a/src/pipecat/services/openai_realtime_beta/__init__.py +++ b/src/pipecat/services/openai_realtime_beta/__init__.py @@ -7,3 +7,13 @@ from .events import ( TurnDetection, ) from .openai import OpenAIRealtimeBetaLLMService + +__all__ = [ + "AzureRealtimeBetaLLMService", + "InputAudioNoiseReduction", + "InputAudioTranscription", + "SemanticTurnDetection", + "SessionProperties", + "TurnDetection", + "OpenAIRealtimeBetaLLMService", +] diff --git a/src/pipecat/services/openai_realtime_beta/azure.py b/src/pipecat/services/openai_realtime_beta/azure.py index 6370ac0f4..b590f2c29 100644 --- a/src/pipecat/services/openai_realtime_beta/azure.py +++ b/src/pipecat/services/openai_realtime_beta/azure.py @@ -7,6 +7,7 @@ """Azure OpenAI Realtime Beta LLM service implementation.""" import warnings +from dataclasses import dataclass from loguru import logger @@ -22,6 +23,13 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class AzureRealtimeBetaLLMSettings(OpenAIRealtimeBetaLLMService.Settings): + """Settings for AzureRealtimeBetaLLMService.""" + + pass + + class AzureRealtimeBetaLLMService(OpenAIRealtimeBetaLLMService): """Azure OpenAI Realtime Beta LLM service with Azure-specific authentication. @@ -34,6 +42,9 @@ class AzureRealtimeBetaLLMService(OpenAIRealtimeBetaLLMService): real-time audio and text communication capabilities as the base OpenAI service. """ + Settings = AzureRealtimeBetaLLMSettings + _settings: Settings + def __init__( self, *, diff --git a/src/pipecat/services/openai_realtime_beta/events.py b/src/pipecat/services/openai_realtime_beta/events.py index ce582e9bd..596a11a3b 100644 --- a/src/pipecat/services/openai_realtime_beta/events.py +++ b/src/pipecat/services/openai_realtime_beta/events.py @@ -877,15 +877,12 @@ class TokenDetails(BaseModel): audio_tokens: Number of audio tokens used. Defaults to 0. """ + model_config = ConfigDict(extra="allow") + cached_tokens: Optional[int] = 0 text_tokens: Optional[int] = 0 audio_tokens: Optional[int] = 0 - class Config: - """Pydantic configuration for TokenDetails.""" - - extra = "allow" - class Usage(BaseModel): """Token usage statistics for a response. diff --git a/src/pipecat/services/openai_realtime_beta/openai.py b/src/pipecat/services/openai_realtime_beta/openai.py index bea26c3e6..0d20039b1 100644 --- a/src/pipecat/services/openai_realtime_beta/openai.py +++ b/src/pipecat/services/openai_realtime_beta/openai.py @@ -54,6 +54,7 @@ from pipecat.processors.aggregators.openai_llm_context import ( from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService from pipecat.services.openai.llm import OpenAIContextAggregatorPair +from pipecat.services.settings import LLMSettings from pipecat.transcriptions.language import Language from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_openai_realtime, traced_stt @@ -91,6 +92,13 @@ class CurrentAudioResponse: total_size: int = 0 +@dataclass +class OpenAIRealtimeBetaLLMSettings(LLMSettings): + """Settings for OpenAIRealtimeBetaLLMService.""" + + pass + + class OpenAIRealtimeBetaLLMService(LLMService): """OpenAI Realtime Beta LLM service providing real-time audio and text communication. @@ -103,6 +111,9 @@ class OpenAIRealtimeBetaLLMService(LLMService): management, and real-time transcription. """ + Settings = OpenAIRealtimeBetaLLMSettings + _settings: Settings + # Overriding the default adapter to use the OpenAIRealtimeLLMAdapter one. adapter_class = OpenAIRealtimeLLMAdapter @@ -110,9 +121,10 @@ class OpenAIRealtimeBetaLLMService(LLMService): self, *, api_key: str, - model: str = "gpt-4o-realtime-preview-2025-06-03", + model: Optional[str] = None, base_url: str = "wss://api.openai.com/v1/realtime", session_properties: Optional[events.SessionProperties] = None, + settings: Optional[Settings] = None, start_audio_paused: bool = False, send_transcription_frames: bool = True, **kwargs, @@ -121,11 +133,16 @@ class OpenAIRealtimeBetaLLMService(LLMService): Args: api_key: OpenAI API key for authentication. - model: OpenAI model name. Defaults to "gpt-4o-realtime-preview-2025-06-03". + model: OpenAI model name. + + .. deprecated:: 0.0.105 + Use ``settings=OpenAIRealtimeBetaLLMService.Settings(model=...)`` instead. + base_url: WebSocket base URL for the realtime API. Defaults to "wss://api.openai.com/v1/realtime". session_properties: Configuration properties for the realtime session. If None, uses default SessionProperties. + settings: Runtime-updatable settings for this service. start_audio_paused: Whether to start with audio input paused. Defaults to False. send_transcription_frames: Whether to emit transcription frames. Defaults to True. **kwargs: Additional arguments passed to parent LLMService. @@ -139,16 +156,39 @@ class OpenAIRealtimeBetaLLMService(LLMService): stacklevel=2, ) - full_url = f"{base_url}?model={model}" - super().__init__(base_url=full_url, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="gpt-4o-realtime-preview-2025-06-03", + system_instruction=None, + temperature=None, + max_tokens=None, + top_p=None, + top_k=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + # 3. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + full_url = f"{base_url}?model={default_settings.model}" + super().__init__( + base_url=full_url, + settings=default_settings, + **kwargs, + ) self.api_key = api_key self.base_url = full_url - self.set_model_name(model) - - self._session_properties: events.SessionProperties = ( - session_properties or events.SessionProperties() - ) + self._session_properties = session_properties or events.SessionProperties() self._audio_input_paused = start_audio_paused self._send_transcription_frames = send_transcription_frames self._websocket = None @@ -342,6 +382,16 @@ class OpenAIRealtimeBetaLLMService(LLMService): frame: The frame to process. direction: The direction of frame flow in the pipeline. """ + # Backward-compatible dict path: frame.settings contains SessionProperties + # fields, not our Settings fields, so we construct SessionProperties + # directly. The frame.delta path falls through to super, which calls + # _update_settings → our override handles the rest. + if isinstance(frame, LLMUpdateSettingsFrame) and frame.delta is None: + self._session_properties = events.SessionProperties(**frame.settings) + await self._send_session_update() + await self.push_frame(frame, direction) + return + await super().process_frame(frame, direction) if isinstance(frame, TranscriptionFrame): @@ -377,11 +427,8 @@ class OpenAIRealtimeBetaLLMService(LLMService): await self._handle_messages_append(frame) elif isinstance(frame, RealtimeMessagesUpdateFrame): self._context = frame.context - elif isinstance(frame, LLMUpdateSettingsFrame): - self._session_properties = events.SessionProperties(**frame.settings) - await self._update_settings() elif isinstance(frame, LLMSetToolsFrame): - await self._update_settings() + await self._send_session_update() elif isinstance(frame, RealtimeFunctionCallResultFrame): await self._handle_function_call_result(frame.result_frame) @@ -394,7 +441,7 @@ class OpenAIRealtimeBetaLLMService(LLMService): item = events.ConversationItem( type="function_call_output", call_id=frame.tool_call_id, - output=json.dumps(frame.result), + output=json.dumps(frame.result, ensure_ascii=False), ) await self.send_client_event(events.ConversationItemCreateEvent(item=item)) @@ -456,7 +503,13 @@ class OpenAIRealtimeBetaLLMService(LLMService): # treat a send-side error as fatal. await self.push_error(error_msg=f"Error sending client event: {e}", exception=e) - async def _update_settings(self): + async def _update_settings(self, delta): + """Apply a settings delta.""" + changed = await super()._update_settings(delta) + self._warn_unhandled_updated_settings(changed.keys()) + return changed + + async def _send_session_update(self): settings = self._session_properties # tools given in the context override the tools in the session properties if self._context and self._context.tools: @@ -503,15 +556,21 @@ class OpenAIRealtimeBetaLLMService(LLMService): await self._handle_evt_audio_transcript_delta(evt) elif evt.type == "error": if not await self._maybe_handle_evt_retrieve_conversation_item_error(evt): - await self._handle_evt_error(evt) - # errors are fatal, so exit the receive loop - return + if evt.error.code in ( + "response_cancel_not_active", + "conversation_already_has_active_response", + ): + logger.debug(f"{self} {evt.error.message}") + else: + await self._handle_evt_error(evt) + # errors are fatal, so exit the receive loop + return @traced_openai_realtime(operation="llm_setup") async def _handle_evt_session_created(self, evt): # session.created is received right after connecting. Send a message # to configure the session properties. - await self._update_settings() + await self._send_session_update() async def _handle_evt_session_updated(self, evt): # If this is our first context frame, run the LLM @@ -525,6 +584,14 @@ class OpenAIRealtimeBetaLLMService(LLMService): # note: ttfb is faster by 1/2 RTT than ttfb as measured for other services, since we're getting # this event from the server await self.stop_ttfb_metrics() + + if self._current_audio_response and self._current_audio_response.item_id != evt.item_id: + logger.warning( + f"Received a new audio delta for an already completed audio response before receiving the BotStoppedSpeakingFrame." + ) + logger.debug("Forcing previous audio response to None") + self._current_audio_response = None + if not self._current_audio_response: self._current_audio_response = CurrentAudioResponse( item_id=evt.item_id, @@ -657,12 +724,12 @@ class OpenAIRealtimeBetaLLMService(LLMService): async def _handle_evt_speech_started(self, evt): await self._truncate_current_audio_response() await self.broadcast_frame(UserStartedSpeakingFrame) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_evt_speech_stopped(self, evt): await self.start_ttfb_metrics() await self.start_processing_metrics() - await self.push_frame(UserStoppedSpeakingFrame()) + await self.broadcast_frame(UserStoppedSpeakingFrame) async def _maybe_handle_evt_retrieve_conversation_item_error(self, evt: events.ErrorEvent): """Maybe handle an error event related to retrieving a conversation item. @@ -742,7 +809,7 @@ class OpenAIRealtimeBetaLLMService(LLMService): self._context.llm_needs_initial_messages = False if self._context.llm_needs_settings_update: - await self._update_settings() + await self._send_session_update() self._context.llm_needs_settings_update = False logger.debug(f"Creating response: {self._context.get_messages_for_logging()}") diff --git a/src/pipecat/services/openpipe/llm.py b/src/pipecat/services/openpipe/llm.py index f3e95f71b..0a8fd9044 100644 --- a/src/pipecat/services/openpipe/llm.py +++ b/src/pipecat/services/openpipe/llm.py @@ -10,11 +10,13 @@ This module provides an OpenPipe-specific implementation of the OpenAI LLM servi enabling integration with OpenPipe's fine-tuning and monitoring capabilities. """ -from typing import Dict, List, Optional +from dataclasses import dataclass +from typing import Dict, Optional from loguru import logger from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService try: @@ -25,6 +27,13 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class OpenPipeLLMSettings(BaseOpenAILLMService.Settings): + """Settings for OpenPipeLLMService.""" + + pass + + class OpenPipeLLMService(OpenAILLMService): """OpenPipe-powered Large Language Model service. @@ -33,34 +42,58 @@ class OpenPipeLLMService(OpenAILLMService): for model training and evaluation. """ + Settings = OpenPipeLLMSettings + _settings: Settings + def __init__( self, *, - model: str = "gpt-4.1", + model: Optional[str] = None, api_key: Optional[str] = None, base_url: Optional[str] = None, openpipe_api_key: Optional[str] = None, openpipe_base_url: str = "https://app.openpipe.ai/api/v1", tags: Optional[Dict[str, str]] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize OpenPipe LLM service. Args: model: The model name to use. Defaults to "gpt-4.1". + + .. deprecated:: 0.0.105 + Use ``settings=OpenPipeLLMService.Settings(model=...)`` instead. + api_key: OpenAI API key for authentication. If None, reads from environment. base_url: Custom OpenAI API endpoint URL. Uses default if None. openpipe_api_key: OpenPipe API key for enhanced features. If None, reads from environment. openpipe_base_url: OpenPipe API endpoint URL. Defaults to "https://app.openpipe.ai/api/v1". tags: Optional dictionary of tags to apply to all requests for tracking. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent OpenAILLMService. """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="gpt-4.1") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( - model=model, api_key=api_key, base_url=base_url, openpipe_api_key=openpipe_api_key, openpipe_base_url=openpipe_base_url, + settings=default_settings, **kwargs, ) self._tags = tags diff --git a/src/pipecat/services/openrouter/llm.py b/src/pipecat/services/openrouter/llm.py index 62992eb23..f92fb5e3b 100644 --- a/src/pipecat/services/openrouter/llm.py +++ b/src/pipecat/services/openrouter/llm.py @@ -10,13 +10,22 @@ This module provides an OpenAI-compatible interface for interacting with OpenRou extending the base OpenAI LLM service functionality. """ -from typing import Optional +from dataclasses import dataclass +from typing import Any, Dict, Optional from loguru import logger +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class OpenRouterLLMSettings(BaseOpenAILLMService.Settings): + """Settings for OpenRouterLLMService.""" + + pass + + class OpenRouterLLMService(OpenAILLMService): """A service for interacting with OpenRouter's API using the OpenAI-compatible interface. @@ -24,12 +33,16 @@ class OpenRouterLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. """ + Settings = OpenRouterLLMSettings + _settings: Settings + def __init__( self, *, api_key: Optional[str] = None, - model: str = "openai/gpt-4o-2024-11-20", + model: Optional[str] = None, base_url: str = "https://openrouter.ai/api/v1", + settings: Optional[Settings] = None, **kwargs, ): """Initialize the OpenRouter LLM service. @@ -38,13 +51,33 @@ class OpenRouterLLMService(OpenAILLMService): api_key: The API key for accessing OpenRouter's API. If None, will attempt to read from environment variables. model: The model identifier to use. Defaults to "openai/gpt-4o-2024-11-20". + + .. deprecated:: 0.0.105 + Use ``settings=OpenRouterLLMService.Settings(model=...)`` instead. + base_url: The base URL for OpenRouter API. Defaults to "https://openrouter.ai/api/v1". + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="openai/gpt-4o-2024-11-20") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( api_key=api_key, base_url=base_url, - model=model, + settings=default_settings, **kwargs, ) @@ -61,3 +94,34 @@ class OpenRouterLLMService(OpenAILLMService): """ logger.debug(f"Creating OpenRouter client with api {base_url}") return super().create_client(api_key, base_url, **kwargs) + + def build_chat_completion_params(self, params_from_context: Dict[str, Any]) -> Dict[str, Any]: + """Builds chat parameters, handling model-specific constraints. + + Args: + params_from_context: Parameters from the LLM context. + + Returns: + Transformed parameters ready for the API call. + """ + params = super().build_chat_completion_params(params_from_context) + if "gemini" in self._settings.model.lower(): + messages = params.get("messages", []) + if not messages: + return params + transformed_messages = [] + system_message_seen = False + for msg in messages: + if msg.get("role") == "system": + if not system_message_seen: + transformed_messages.append(msg) + system_message_seen = True + else: + new_msg = msg.copy() + new_msg["role"] = "user" + transformed_messages.append(new_msg) + else: + transformed_messages.append(msg) + params["messages"] = transformed_messages + + return params diff --git a/src/pipecat/services/perplexity/llm.py b/src/pipecat/services/perplexity/llm.py index 4ea23aa82..9ea323c5d 100644 --- a/src/pipecat/services/perplexity/llm.py +++ b/src/pipecat/services/perplexity/llm.py @@ -11,15 +11,27 @@ an OpenAI-compatible interface. It handles Perplexity's unique token usage reporting patterns while maintaining compatibility with the Pipecat framework. """ -from openai import NOT_GIVEN +from dataclasses import dataclass +from typing import Optional + +from loguru import logger from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams +from pipecat.adapters.services.perplexity_adapter import PerplexityLLMAdapter from pipecat.metrics.metrics import LLMTokenUsage from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class PerplexityLLMSettings(BaseOpenAILLMService.Settings): + """Settings for PerplexityLLMService.""" + + pass + + class PerplexityLLMService(OpenAILLMService): """A service for interacting with Perplexity's API. @@ -28,12 +40,18 @@ class PerplexityLLMService(OpenAILLMService): in token usage reporting between Perplexity (incremental) and OpenAI (final summary). """ + adapter_class = PerplexityLLMAdapter + + Settings = PerplexityLLMSettings + _settings: Settings + def __init__( self, *, api_key: str, base_url: str = "https://api.perplexity.ai", - model: str = "sonar", + model: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Perplexity LLM service. @@ -42,9 +60,29 @@ class PerplexityLLMService(OpenAILLMService): api_key: The API key for accessing Perplexity's API. base_url: The base URL for Perplexity's API. Defaults to "https://api.perplexity.ai". model: The model identifier to use. Defaults to "sonar". + + .. deprecated:: 0.0.105 + Use ``settings=PerplexityLLMService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="sonar") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) # Counters for accumulating token usage metrics self._prompt_tokens = 0 self._completion_tokens = 0 @@ -66,22 +104,33 @@ class PerplexityLLMService(OpenAILLMService): Dictionary of parameters for the chat completion request. """ params = { - "model": self.model_name, + "model": self._settings.model, "stream": True, "messages": params_from_context["messages"], } # Add OpenAI-compatible parameters if they're set - if self._settings["frequency_penalty"] is not NOT_GIVEN: - params["frequency_penalty"] = self._settings["frequency_penalty"] - if self._settings["presence_penalty"] is not NOT_GIVEN: - params["presence_penalty"] = self._settings["presence_penalty"] - if self._settings["temperature"] is not NOT_GIVEN: - params["temperature"] = self._settings["temperature"] - if self._settings["top_p"] is not NOT_GIVEN: - params["top_p"] = self._settings["top_p"] - if self._settings["max_tokens"] is not NOT_GIVEN: - params["max_tokens"] = self._settings["max_tokens"] + if self._settings.frequency_penalty is not None: + params["frequency_penalty"] = self._settings.frequency_penalty + if self._settings.presence_penalty is not None: + params["presence_penalty"] = self._settings.presence_penalty + if self._settings.temperature is not None: + params["temperature"] = self._settings.temperature + if self._settings.top_p is not None: + params["top_p"] = self._settings.top_p + if self._settings.max_tokens is not None: + params["max_tokens"] = self._settings.max_tokens + + # Prepend system instruction if set + if self._settings.system_instruction: + messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) + params["messages"] = [ + {"role": "system", "content": self._settings.system_instruction} + ] + messages return params diff --git a/src/pipecat/services/piper/tts.py b/src/pipecat/services/piper/tts.py index ce47c885e..1b0037abb 100644 --- a/src/pipecat/services/piper/tts.py +++ b/src/pipecat/services/piper/tts.py @@ -6,7 +6,10 @@ """Piper TTS service implementation.""" -from typing import AsyncGenerator, Optional +import asyncio +from dataclasses import dataclass +from pathlib import Path +from typing import Any, AsyncGenerator, AsyncIterator, Optional import aiohttp from loguru import logger @@ -14,30 +17,200 @@ from loguru import logger from pipecat.frames.frames import ( ErrorFrame, Frame, - TTSStartedFrame, TTSStoppedFrame, ) +from pipecat.services.settings import TTSSettings from pipecat.services.tts_service import TTSService from pipecat.utils.tracing.service_decorators import traced_tts +try: + from piper import PiperVoice + from piper.download_voices import download_voice +except ModuleNotFoundError as e: + logger.error(f"Exception: {e}") + logger.error("In order to use Piper, you need to `pip install pipecat-ai[piper]`.") + raise Exception(f"Missing module: {e}") + + +@dataclass +class PiperTTSSettings(TTSSettings): + """Settings for PiperTTSService.""" + + pass + -# This assumes a running TTS service running: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/API_HTTP.md class PiperTTSService(TTSService): """Piper TTS service implementation. + Provides local text-to-speech synthesis using Piper voice models. Automatically + downloads voice models if not already present and resamples audio output to + match the configured sample rate. + """ + + Settings = PiperTTSSettings + _settings: Settings + + def __init__( + self, + *, + voice_id: Optional[str] = None, + download_dir: Optional[Path] = None, + force_redownload: bool = False, + use_cuda: bool = False, + settings: Optional[Settings] = None, + **kwargs, + ): + """Initialize the Piper TTS service. + + Args: + voice_id: Piper voice model identifier (e.g. `en_US-ryan-high`). + + .. deprecated:: 0.0.105 + Use ``settings=PiperTTSService.Settings(voice=...)`` instead. + + download_dir: Directory for storing voice model files. Defaults to + the current working directory. + force_redownload: Re-download the voice model even if it already exists. + use_cuda: Use CUDA for GPU-accelerated inference. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + **kwargs: Additional arguments passed to the parent `TTSService`. + """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model=None, voice=None, language=None) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) + + download_dir = download_dir or Path.cwd() + + _voice = self._settings.voice + model_file = f"{_voice}.onnx" + model_path_resolved = Path(download_dir) / model_file + + if not model_path_resolved.exists(): + logger.debug(f"Downloading Piper '{_voice}' model") + download_voice(_voice, download_dir, force_redownload=force_redownload) + + logger.debug(f"Loading Piper '{_voice}' model from {model_path_resolved}") + + self._voice = PiperVoice.load(model_path_resolved, use_cuda=use_cuda) + + logger.debug(f"Loaded Piper '{_voice}' model") + + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as Piper service supports metrics generation. + """ + return True + + async def _update_settings(self, delta: Settings) -> dict[str, Any]: + """Apply a settings delta. + + Settings are stored but not applied to the active connection. + """ + changed = await super()._update_settings(delta) + if not changed: + return changed + # TODO: voice changes would require re-downloading and loading the model. + self._warn_unhandled_updated_settings(changed) + return changed + + @traced_tts + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: + """Generate speech from text using Piper. + + Args: + text: The text to convert to speech. + context_id: Unique identifier for this TTS context. + + Yields: + Frame: Audio frames containing the synthesized speech and status frames. + """ + + def async_next(it): + try: + return next(it) + except StopIteration: + return None + + async def async_iterator(iterator) -> AsyncIterator[bytes]: + while True: + item = await asyncio.to_thread(async_next, iterator) + if item is None: + return + yield item.audio_int16_bytes + + logger.debug(f"{self}: Generating TTS [{text}]") + + try: + await self.start_tts_usage_metrics(text) + + async for frame in self._stream_audio_frames_from_iterator( + async_iterator(self._voice.synthesize(text)), + in_sample_rate=self._voice.config.sample_rate, + context_id=context_id, + ): + await self.stop_ttfb_metrics() + yield frame + except Exception as e: + logger.error(f"{self} exception: {e}") + yield ErrorFrame(error=f"Unknown error occurred: {e}") + finally: + logger.debug(f"{self}: Finished TTS [{text}]") + await self.stop_ttfb_metrics() + + +# This assumes a running TTS service running: +# https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/API_HTTP.md +# +# Usage: +# +# $ uv pip install "piper-tts[http]" +# $ uv run python -m piper.http_server -m en_US-ryan-high +# +@dataclass +class PiperHttpTTSSettings(TTSSettings): + """Settings for PiperHttpTTSService.""" + + pass + + +class PiperHttpTTSService(TTSService): + """Piper HTTP TTS service implementation. + Provides integration with Piper's HTTP TTS server for text-to-speech synthesis. Supports streaming audio generation with configurable sample rates and automatic WAV header removal. """ + Settings = PiperHttpTTSSettings + _settings: Settings + def __init__( self, *, base_url: str, aiohttp_session: aiohttp.ClientSession, - # When using Piper, the sample rate of the generated audio depends on the - # voice model being used. - sample_rate: Optional[int] = None, + voice_id: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Piper TTS service. @@ -45,10 +218,35 @@ class PiperTTSService(TTSService): Args: base_url: Base URL for the Piper TTS HTTP server. aiohttp_session: aiohttp ClientSession for making HTTP requests. - sample_rate: Output sample rate. If None, uses the voice model's native rate. + voice_id: Piper voice model identifier (e.g. `en_US-ryan-high`). + + .. deprecated:: 0.0.105 + Use ``settings=PiperHttpTTSService.Settings(voice=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to the parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model=None, voice=None, language=None) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) if base_url.endswith("/"): logger.warning("Base URL ends with a slash, this is not allowed.") @@ -56,7 +254,6 @@ class PiperTTSService(TTSService): self._base_url = base_url self._session = aiohttp_session - self._settings = {"base_url": base_url} def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -67,11 +264,12 @@ class PiperTTSService(TTSService): return True @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Piper's HTTP API. Args: text: The text to convert to speech. + context_id: Unique identifier for this TTS context. Yields: Frame: Audio frames containing the synthesized speech and status frames. @@ -81,33 +279,32 @@ class PiperTTSService(TTSService): "Content-Type": "application/json", } try: - await self.start_ttfb_metrics() + data = { + "text": text, + "voice": self._settings.voice, + } - async with self._session.post( - self._base_url, json={"text": text}, headers=headers - ) as response: + async with self._session.post(self._base_url, json=data, headers=headers) as response: if response.status != 200: error = await response.text() yield ErrorFrame( error=f"Error getting audio (status: {response.status}, error: {error})" ) + yield TTSStoppedFrame(context_id=context_id) return await self.start_tts_usage_metrics(text) - yield TTSStartedFrame() - CHUNK_SIZE = self.chunk_size async for frame in self._stream_audio_frames_from_iterator( - response.content.iter_chunked(CHUNK_SIZE), strip_wav_header=True + response.content.iter_chunked(CHUNK_SIZE), + strip_wav_header=True, + context_id=context_id, ): await self.stop_ttfb_metrics() yield frame except Exception as e: - logger.error(f"{self} exception: {e}") yield ErrorFrame(error=f"Unknown error occurred: {e}") finally: - logger.debug(f"{self}: Finished TTS [{text}]") await self.stop_ttfb_metrics() - yield TTSStoppedFrame() diff --git a/src/pipecat/services/playht/__init__.py b/src/pipecat/services/playht/__init__.py deleted file mode 100644 index 500ea0fdc..000000000 --- a/src/pipecat/services/playht/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -import sys - -from pipecat.services import DeprecatedModuleProxy - -from .tts import * - -sys.modules[__name__] = DeprecatedModuleProxy(globals(), "playht", "playht.tts") diff --git a/src/pipecat/services/playht/tts.py b/src/pipecat/services/playht/tts.py deleted file mode 100644 index 1e9f83500..000000000 --- a/src/pipecat/services/playht/tts.py +++ /dev/null @@ -1,627 +0,0 @@ -# -# Copyright (c) 2024-2026, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -"""PlayHT text-to-speech service implementations. - -This module provides integration with PlayHT's text-to-speech API -supporting both WebSocket streaming and HTTP-based synthesis. -""" - -import io -import json -import struct -import uuid -import warnings -from typing import AsyncGenerator, Optional - -import aiohttp -from loguru import logger -from pydantic import BaseModel - -from pipecat.frames.frames import ( - CancelFrame, - EndFrame, - ErrorFrame, - Frame, - InterruptionFrame, - StartFrame, - TTSAudioRawFrame, - TTSStartedFrame, - TTSStoppedFrame, -) -from pipecat.processors.frame_processor import FrameDirection -from pipecat.services.tts_service import InterruptibleTTSService, TTSService -from pipecat.transcriptions.language import Language, resolve_language -from pipecat.utils.tracing.service_decorators import traced_tts - -try: - from websockets.asyncio.client import connect as websocket_connect - from websockets.protocol import State -except ModuleNotFoundError as e: - logger.error(f"Exception: {e}") - logger.error("In order to use PlayHTTTSService, you need to `pip install pipecat-ai[playht]`.") - raise Exception(f"Missing module: {e}") - - -def language_to_playht_language(language: Language) -> Optional[str]: - """Convert a Language enum to PlayHT language code. - - Args: - language: The Language enum value to convert. - - Returns: - The corresponding PlayHT language code, or None if not supported. - """ - LANGUAGE_MAP = { - Language.AF: "afrikans", - Language.AM: "amharic", - Language.AR: "arabic", - Language.BN: "bengali", - Language.BG: "bulgarian", - Language.CA: "catalan", - Language.CS: "czech", - Language.DA: "danish", - Language.DE: "german", - Language.EL: "greek", - Language.EN: "english", - Language.ES: "spanish", - Language.FR: "french", - Language.GL: "galician", - Language.HE: "hebrew", - Language.HI: "hindi", - Language.HR: "croatian", - Language.HU: "hungarian", - Language.ID: "indonesian", - Language.IT: "italian", - Language.JA: "japanese", - Language.KO: "korean", - Language.MS: "malay", - Language.NL: "dutch", - Language.PL: "polish", - Language.PT: "portuguese", - Language.RU: "russian", - Language.SQ: "albanian", - Language.SR: "serbian", - Language.SV: "swedish", - Language.TH: "thai", - Language.TL: "tagalog", - Language.TR: "turkish", - Language.UK: "ukrainian", - Language.UR: "urdu", - Language.XH: "xhosa", - Language.ZH: "mandarin", - } - - return resolve_language(language, LANGUAGE_MAP, use_base_code=False) - - -class PlayHTTTSService(InterruptibleTTSService): - """PlayHT WebSocket-based text-to-speech service. - - .. deprecated:: 0.0.88 - - This class is deprecated and will be removed in a future version. - PlayHT is shutting down their API on December 31st, 2025. - - Provides real-time text-to-speech synthesis using PlayHT's WebSocket API. - Supports streaming audio generation with configurable voice engines and - language settings. - """ - - class InputParams(BaseModel): - """Input parameters for PlayHT TTS configuration. - - Parameters: - language: Language for synthesis. Defaults to English. - speed: Speech speed multiplier. Defaults to 1.0. - seed: Random seed for voice consistency. - """ - - language: Optional[Language] = Language.EN - speed: Optional[float] = 1.0 - seed: Optional[int] = None - - def __init__( - self, - *, - api_key: str, - user_id: str, - voice_url: str, - voice_engine: str = "Play3.0-mini", - sample_rate: Optional[int] = None, - output_format: str = "wav", - params: Optional[InputParams] = None, - **kwargs, - ): - """Initialize the PlayHT WebSocket TTS service. - - Args: - api_key: PlayHT API key for authentication. - user_id: PlayHT user ID for authentication. - voice_url: URL of the voice to use for synthesis. - voice_engine: Voice engine to use. Defaults to "Play3.0-mini". - sample_rate: Audio sample rate. If None, uses default. - output_format: Audio output format. Defaults to "wav". - params: Additional input parameters for voice customization. - **kwargs: Additional arguments passed to parent InterruptibleTTSService. - """ - super().__init__( - pause_frame_processing=True, - sample_rate=sample_rate, - **kwargs, - ) - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "PlayHT is shutting down their API on December 31st, 2025. " - "'PlayHTTTSService' is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) - - params = params or PlayHTTTSService.InputParams() - - self._api_key = api_key - self._user_id = user_id - self._websocket_url = None - self._receive_task = None - self._request_id = None - - self._settings = { - "language": self.language_to_service_language(params.language) - if params.language - else "english", - "output_format": output_format, - "voice_engine": voice_engine, - "speed": params.speed, - "seed": params.seed, - } - self.set_model_name(voice_engine) - self.set_voice(voice_url) - - def can_generate_metrics(self) -> bool: - """Check if this service can generate processing metrics. - - Returns: - True, as PlayHT service supports metrics generation. - """ - return True - - def language_to_service_language(self, language: Language) -> Optional[str]: - """Convert a Language enum to PlayHT service language format. - - Args: - language: The language to convert. - - Returns: - The PlayHT-specific language code, or None if not supported. - """ - return language_to_playht_language(language) - - async def start(self, frame: StartFrame): - """Start the PlayHT 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 PlayHT TTS service. - - Args: - frame: The end frame. - """ - await super().stop(frame) - await self._disconnect() - - async def cancel(self, frame: CancelFrame): - """Cancel the PlayHT TTS service. - - Args: - frame: The cancel frame. - """ - await super().cancel(frame) - await self._disconnect() - - async def _connect(self): - """Connect to PlayHT WebSocket and start receive task.""" - await self._connect_websocket() - - if self._websocket and not self._receive_task: - self._receive_task = self.create_task(self._receive_task_handler(self._report_error)) - - async def _disconnect(self): - """Disconnect from PlayHT WebSocket and clean up tasks.""" - if self._receive_task: - await self.cancel_task(self._receive_task) - self._receive_task = None - - await self._disconnect_websocket() - - async def _connect_websocket(self): - """Connect to PlayHT websocket.""" - try: - if self._websocket and self._websocket.state is State.OPEN: - return - - logger.debug("Connecting to PlayHT") - - if not self._websocket_url: - await self._get_websocket_url() - - if not isinstance(self._websocket_url, str): - raise ValueError("WebSocket URL is not a string") - - self._websocket = await websocket_connect(self._websocket_url) - - await self._call_event_handler("on_connected") - except ValueError as e: - logger.error(f"{self} initialization error: {e}") - self._websocket = None - await self._call_event_handler("on_connection_error", f"{e}") - except Exception as e: - await self.push_error(error_msg=f"Error connecting: {e}", exception=e) - self._websocket = None - await self._call_event_handler("on_connection_error", f"{e}") - - async def _disconnect_websocket(self): - """Disconnect from PlayHT websocket.""" - try: - await self.stop_all_metrics() - - if self._websocket: - logger.debug("Disconnecting from PlayHT") - await self._websocket.close() - except Exception as e: - await self.push_error(error_msg=f"Error disconnecting: {e}", exception=e) - finally: - self._request_id = None - self._websocket = None - await self._call_event_handler("on_disconnected") - - async def _get_websocket_url(self): - """Retrieve WebSocket URL from PlayHT API.""" - async with aiohttp.ClientSession() as session: - async with session.post( - "https://api.play.ht/api/v4/websocket-auth", - headers={ - "Authorization": f"Bearer {self._api_key}", - "X-User-Id": self._user_id, - "Content-Type": "application/json", - }, - ) as response: - if response.status in (200, 201): - data = await response.json() - # Handle the new response format with multiple URLs - if "websocket_urls" in data: - # Select URL based on voice_engine - if self._settings["voice_engine"] in data["websocket_urls"]: - self._websocket_url = data["websocket_urls"][ - self._settings["voice_engine"] - ] - else: - raise ValueError( - f"Unsupported voice engine: {self._settings['voice_engine']}" - ) - else: - raise ValueError("Invalid response: missing websocket_urls") - else: - raise Exception(f"Failed to get WebSocket URL: {response.status}") - - def _get_websocket(self): - """Get the WebSocket connection if available.""" - if self._websocket: - return self._websocket - raise Exception("Websocket not connected") - - async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): - """Handle interruption by stopping metrics and clearing request ID.""" - await super()._handle_interruption(frame, direction) - await self.stop_all_metrics() - self._request_id = None - - async def _receive_messages(self): - """Receive messages from PlayHT websocket.""" - async for message in self._get_websocket(): - if isinstance(message, bytes): - # Skip the WAV header message - if message.startswith(b"RIFF"): - continue - await self.stop_ttfb_metrics() - frame = TTSAudioRawFrame(message, self.sample_rate, 1) - await self.push_frame(frame) - else: - logger.debug(f"Received text message: {message}") - try: - msg = json.loads(message) - if msg.get("type") == "start": - # Handle start of stream - logger.debug(f"Started processing request: {msg.get('request_id')}") - elif msg.get("type") == "end": - # Handle end of stream - if "request_id" in msg and msg["request_id"] == self._request_id: - await self.push_frame(TTSStoppedFrame()) - self._request_id = None - elif "error" in msg: - await self.push_error(error_msg=f"Error: {msg['error']}") - except json.JSONDecodeError: - logger.error(f"Invalid JSON message: {message}") - - @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: - """Generate TTS audio from text using PlayHT's WebSocket API. - - Args: - text: The text to synthesize into speech. - - Yields: - Frame: Audio frames containing the synthesized speech. - """ - logger.debug(f"{self}: Generating TTS [{text}]") - - try: - # Reconnect if the websocket is closed - if not self._websocket or self._websocket.state is State.CLOSED: - await self._connect() - - if not self._request_id: - await self.start_ttfb_metrics() - yield TTSStartedFrame() - self._request_id = str(uuid.uuid4()) - - tts_command = { - "text": text, - "voice": self._voice_id, - "voice_engine": self._settings["voice_engine"], - "output_format": self._settings["output_format"], - "sample_rate": self.sample_rate, - "language": self._settings["language"], - "speed": self._settings["speed"], - "seed": self._settings["seed"], - "request_id": self._request_id, - } - - try: - await self._get_websocket().send(json.dumps(tts_command)) - await self.start_tts_usage_metrics(text) - except Exception as e: - yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() - await self._disconnect() - await self._connect() - return - - # The actual audio frames will be handled in _receive_task_handler - yield None - - except Exception as e: - yield ErrorFrame(error=f"Unknown error occurred: {e}") - - -class PlayHTHttpTTSService(TTSService): - """PlayHT HTTP-based text-to-speech service. - - .. deprecated:: 0.0.88 - - This class is deprecated and will be removed in a future version. - PlayHT is shutting down their API on December 31st, 2025. - - Provides text-to-speech synthesis using PlayHT's HTTP API for simpler, - non-streaming synthesis. Suitable for use cases where streaming is not - required and simpler integration is preferred. - """ - - class InputParams(BaseModel): - """Input parameters for PlayHT HTTP TTS configuration. - - Parameters: - language: Language for synthesis. Defaults to English. - speed: Speech speed multiplier. Defaults to 1.0. - seed: Random seed for voice consistency. - """ - - language: Optional[Language] = Language.EN - speed: Optional[float] = 1.0 - seed: Optional[int] = None - - def __init__( - self, - *, - api_key: str, - user_id: str, - voice_url: str, - voice_engine: str = "Play3.0-mini", - protocol: Optional[str] = None, - output_format: str = "wav", - sample_rate: Optional[int] = None, - params: Optional[InputParams] = None, - **kwargs, - ): - """Initialize the PlayHT HTTP TTS service. - - Args: - api_key: PlayHT API key for authentication. - user_id: PlayHT user ID for authentication. - voice_url: URL of the voice to use for synthesis. - voice_engine: Voice engine to use. Defaults to "Play3.0-mini". - protocol: Protocol to use ("http" or "ws"). - - .. deprecated:: 0.0.80 - This parameter no longer has any effect and will be removed in a future version. - Use PlayHTTTSService for WebSocket or PlayHTHttpTTSService for HTTP. - - output_format: Audio output format. Defaults to "wav". - sample_rate: Audio sample rate. If None, uses default. - params: Additional input parameters for voice customization. - **kwargs: Additional arguments passed to parent TTSService. - """ - super().__init__(sample_rate=sample_rate, **kwargs) - - # Warn about deprecated protocol parameter if explicitly provided - if protocol: - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "The 'protocol' parameter is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "PlayHT is shutting down their API on December 31st, 2025. " - "'PlayHTHttpTTSService' is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) - - params = params or PlayHTHttpTTSService.InputParams() - - self._user_id = user_id - self._api_key = api_key - - # Check if voice_engine contains protocol information (backward compatibility) - if "-http" in voice_engine: - # Extract the base engine name - voice_engine = voice_engine.replace("-http", "") - elif "-ws" in voice_engine: - # Extract the base engine name - voice_engine = voice_engine.replace("-ws", "") - - self._settings = { - "language": self.language_to_service_language(params.language) - if params.language - else "english", - "output_format": output_format, - "voice_engine": voice_engine, - "speed": params.speed, - "seed": params.seed, - } - self.set_model_name(voice_engine) - self.set_voice(voice_url) - - async def start(self, frame: StartFrame): - """Start the PlayHT HTTP TTS service. - - Args: - frame: The start frame containing initialization parameters. - """ - await super().start(frame) - self._settings["sample_rate"] = self.sample_rate - - def can_generate_metrics(self) -> bool: - """Check if this service can generate processing metrics. - - Returns: - True, as PlayHT HTTP service supports metrics generation. - """ - return True - - def language_to_service_language(self, language: Language) -> Optional[str]: - """Convert a Language enum to PlayHT service language format. - - Args: - language: The language to convert. - - Returns: - The PlayHT-specific language code, or None if not supported. - """ - return language_to_playht_language(language) - - @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: - """Generate TTS audio from text using PlayHT's HTTP API. - - Args: - text: The text to synthesize into speech. - - Yields: - Frame: Audio frames containing the synthesized speech. - """ - logger.debug(f"{self}: Generating TTS [{text}]") - - try: - await self.start_ttfb_metrics() - - # Prepare the request payload - payload = { - "text": text, - "voice": self._voice_id, - "voice_engine": self._settings["voice_engine"], - "output_format": self._settings["output_format"], - "sample_rate": self.sample_rate, - "language": self._settings["language"], - } - - # Add optional parameters if they exist - if self._settings["speed"] is not None: - payload["speed"] = self._settings["speed"] - if self._settings["seed"] is not None: - payload["seed"] = self._settings["seed"] - - headers = { - "Authorization": f"Bearer {self._api_key}", - "X-User-Id": self._user_id, - "Content-Type": "application/json", - "Accept": "*/*", - } - - await self.start_tts_usage_metrics(text) - - yield TTSStartedFrame() - - async with aiohttp.ClientSession() as session: - async with session.post( - "https://api.play.ht/api/v2/tts/stream", - headers=headers, - json=payload, - ) as response: - if response.status not in (200, 201): - error_text = await response.text() - raise Exception(f"PlayHT API error {response.status}: {error_text}") - - in_header = True - buffer = b"" - - CHUNK_SIZE = self.chunk_size - - async for chunk in response.content.iter_chunked(CHUNK_SIZE): - if len(chunk) == 0: - continue - - # Skip the RIFF header - if in_header: - buffer += chunk - if len(buffer) <= 36: - continue - else: - fh = io.BytesIO(buffer) - fh.seek(36) - (data, size) = struct.unpack("<4sI", fh.read(8)) - while data != b"data": - fh.read(size) - (data, size) = struct.unpack("<4sI", fh.read(8)) - # Extract audio data after header - audio_data = buffer[fh.tell() :] - if len(audio_data) > 0: - await self.stop_ttfb_metrics() - frame = TTSAudioRawFrame(audio_data, self.sample_rate, 1) - yield frame - in_header = False - elif len(chunk) > 0: - await self.stop_ttfb_metrics() - frame = TTSAudioRawFrame(chunk, self.sample_rate, 1) - yield frame - - except Exception as e: - yield ErrorFrame(error=f"Unknown error occurred: {e}") - finally: - await self.stop_ttfb_metrics() - yield TTSStoppedFrame() diff --git a/src/pipecat/services/qwen/llm.py b/src/pipecat/services/qwen/llm.py index 6b58faea2..857c89bea 100644 --- a/src/pipecat/services/qwen/llm.py +++ b/src/pipecat/services/qwen/llm.py @@ -6,11 +6,22 @@ """Qwen LLM service implementation using OpenAI-compatible interface.""" +from dataclasses import dataclass +from typing import Optional + from loguru import logger +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class QwenLLMSettings(BaseOpenAILLMService.Settings): + """Settings for QwenLLMService.""" + + pass + + class QwenLLMService(OpenAILLMService): """A service for interacting with Alibaba Cloud's Qwen LLM API using the OpenAI-compatible interface. @@ -18,12 +29,16 @@ class QwenLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. """ + Settings = QwenLLMSettings + _settings: Settings + def __init__( self, *, api_key: str, base_url: str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", - model: str = "qwen-plus", + model: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Qwen LLM service. @@ -32,10 +47,30 @@ class QwenLLMService(OpenAILLMService): api_key: The API key for accessing Qwen's API (DashScope API key). base_url: Base URL for Qwen API. Defaults to "https://dashscope-intl.aliyuncs.com/compatible-mode/v1". model: The model identifier to use. Defaults to "qwen-plus". + + .. deprecated:: 0.0.105 + Use ``settings=QwenLLMService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) - logger.info(f"Initialized Qwen LLM service with model: {model}") + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="qwen-plus") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) + logger.info(f"Initialized Qwen LLM service with model: {self._settings.model}") def create_client(self, api_key=None, base_url=None, **kwargs): """Create OpenAI-compatible client for Qwen API endpoint. diff --git a/src/pipecat/services/resembleai/__init__.py b/src/pipecat/services/resembleai/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pipecat/services/resembleai/tts.py b/src/pipecat/services/resembleai/tts.py new file mode 100644 index 000000000..fc70d814b --- /dev/null +++ b/src/pipecat/services/resembleai/tts.py @@ -0,0 +1,468 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Resemble AI text-to-speech service implementations.""" + +import base64 +import json +from dataclasses import dataclass +from typing import AsyncGenerator, Optional + +from loguru import logger + +from pipecat.frames.frames import ( + CancelFrame, + EndFrame, + ErrorFrame, + Frame, + StartFrame, + TTSAudioRawFrame, + TTSStartedFrame, + TTSStoppedFrame, +) +from pipecat.services.settings import TTSSettings +from pipecat.services.tts_service import WebsocketTTSService +from pipecat.utils.tracing.service_decorators import traced_tts + +try: + from websockets.asyncio.client import connect as websocket_connect + from websockets.protocol import State +except ModuleNotFoundError as e: + logger.error(f"Exception: {e}") + logger.error("In order to use Resemble AI, you need to `pip install pipecat-ai[resembleai]`.") + raise Exception(f"Missing module: {e}") + + +@dataclass +class ResembleAITTSSettings(TTSSettings): + """Settings for ResembleAITTSService.""" + + pass + + +class ResembleAITTSService(WebsocketTTSService): + """Resemble AI TTS service with WebSocket streaming and word timestamps. + + Provides text-to-speech using Resemble AI's streaming WebSocket API. + Supports word-level timestamps and audio context management for handling + multiple simultaneous synthesis requests with proper interruption support. + """ + + Settings = ResembleAITTSSettings + _settings: Settings + + def __init__( + self, + *, + api_key: str, + voice_id: Optional[str] = None, + url: str = "wss://websocket.cluster.resemble.ai/stream", + precision: Optional[str] = "PCM_16", + output_format: Optional[str] = "wav", + sample_rate: Optional[int] = 22050, + settings: Optional[Settings] = None, + **kwargs, + ): + """Initialize the Resemble AI TTS service. + + Args: + api_key: Resemble AI API key for authentication. + voice_id: Voice UUID to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=ResembleAITTSService.Settings(voice=...)`` instead. + + url: WebSocket URL for Resemble AI TTS API. + precision: PCM bit depth (PCM_32, PCM_24, PCM_16, or MULAW). + output_format: Audio format (wav or mp3). + sample_rate: Audio sample rate (8000, 16000, 22050, 32000, or 44100). Defaults to 22050. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + **kwargs: Additional arguments passed to the parent service. + """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice=None, + language=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + reuse_context_id_within_turn=False, + settings=default_settings, + **kwargs, + ) + + self._api_key = api_key + self._url = url + + # Init-only audio format config (not runtime-updatable). + self._precision = precision or "PCM_16" + self._output_format = output_format or "wav" + self._resemble_sample_rate = 0 # Set in start() + + self._websocket = None + self._request_id_counter = 0 + self._receive_task = None + + # Map request_id to context_id for tracking TTS requests + self._request_id_to_context: dict[int, str] = {} + + # Per-request audio buffers to handle concurrent TTS requests + # ResembleAI may send odd-length data even for PCM_16, so buffering helps us + # create properly aligned frames while maintaining smooth audio output + self._audio_buffers: dict[str, bytearray] = {} + self._buffer_threshold_bytes = 2208 + + # Jitter buffer: accumulate audio before starting playback to absorb network latency + # ResembleAI sends audio in bursts with 300-450ms gaps between them + # We need to buffer enough to cover these gaps before starting playback + self._jitter_buffer_bytes = 44100 # ~1000ms at 22050Hz to handle 400ms+ network gaps + self._playback_started: dict[str, bool] = {} # Track if we've started playback per request + + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as Resemble AI service supports metrics generation. + """ + return True + + def _build_msg(self, text: str = "") -> str: + """Build a JSON message for the Resemble AI WebSocket API. + + Args: + text: The text or SSML to synthesize. + + Returns: + JSON string containing the request payload. + """ + msg = { + "voice_uuid": self._settings.voice, + "data": text, + "binary_response": False, # Use JSON frames to get timestamps + "request_id": self._request_id_counter, # ResembleAI only accepts number + "output_format": self._output_format, + "sample_rate": self._resemble_sample_rate, + "precision": self._precision, + "no_audio_header": True, + } + + self._request_id_counter += 1 + return json.dumps(msg) + + async def start(self, frame: StartFrame): + """Start the Resemble AI TTS service. + + Args: + frame: The start frame containing initialization parameters. + """ + await super().start(frame) + self._resemble_sample_rate = self.sample_rate + await self._connect() + + async def stop(self, frame: EndFrame): + """Stop the Resemble AI TTS service. + + Args: + frame: The end frame. + """ + await super().stop(frame) + await self._disconnect() + + async def cancel(self, frame: CancelFrame): + """Cancel the Resemble AI TTS service. + + Args: + frame: The cancel frame. + """ + await super().cancel(frame) + await self._disconnect() + + async def _connect(self): + """Connect to the Resemble AI WebSocket.""" + await self._connect_websocket() + + if self._websocket and not self._receive_task: + self._receive_task = self.create_task(self._receive_task_handler(self._report_error)) + + async def _disconnect(self): + """Disconnect from the Resemble AI WebSocket.""" + if self._receive_task: + await self.cancel_task(self._receive_task) + self._receive_task = None + + await self._disconnect_websocket() + + async def _connect_websocket(self): + """Establish WebSocket connection to Resemble AI.""" + try: + if self._websocket and self._websocket.state is State.OPEN: + return + logger.debug("Connecting to Resemble AI TTS") + headers = {"Authorization": f"Bearer {self._api_key}"} + self._websocket = await websocket_connect(self._url, additional_headers=headers) + await self._call_event_handler("on_connected") + except Exception as e: + await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + self._websocket = None + await self._call_event_handler("on_connection_error", f"{e}") + + async def _disconnect_websocket(self): + """Close WebSocket connection to Resemble AI.""" + try: + await self.stop_all_metrics() + + if self._websocket: + logger.debug("Disconnecting from Resemble AI") + # ResembleAI doesn't send disconnect acknowledgement, set close_timeout to 0 + self._websocket.close_timeout = 0 + await self._websocket.close() + except Exception as e: + await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + finally: + self._websocket = None + self._audio_buffers.clear() + self._playback_started.clear() + self._request_id_to_context.clear() + await self._call_event_handler("on_disconnected") + + def _get_websocket(self): + """Get the current WebSocket connection. + + Returns: + The active WebSocket connection. + + Raises: + Exception: If websocket is not connected. + """ + if self._websocket: + return self._websocket + raise Exception("Websocket not connected") + + async def on_audio_context_interrupted(self, context_id: str): + """Stop metrics when the bot is interrupted.""" + await self.stop_all_metrics() + + async def on_audio_context_completed(self, context_id: str): + """Stop metrics after the Resemble AI context finishes playing. + + No close message is needed: Resemble AI signals completion with an + ``audio_end`` message (handled in ``_process_messages``), after which + the server-side context is already closed. + """ + pass + + async def flush_audio(self, context_id: Optional[str] = None): + """Flush any pending audio and finalize the current context.""" + logger.trace(f"{self}: flushing audio") + # For Resemble AI, we just wait for the audio_end message + # which is handled in _process_messages + + async def _process_messages(self): + """Process incoming WebSocket messages from Resemble AI.""" + async for message in self._get_websocket(): + try: + msg = json.loads(message) + except json.JSONDecodeError: + await self.push_error(error_msg=f"Received invalid JSON: {message}") + continue + + if not msg: + continue + + msg_type = msg.get("type") + request_id = msg.get("request_id") + + # Convert request_id to string for audio context tracking + context_id = self._request_id_to_context.get(request_id, str(request_id)) + + # Check if this message belongs to a valid audio context + if not self.audio_context_available(context_id): + continue + + if msg_type == "audio": + # Decode base64 audio content + audio_content = msg.get("audio_content", "") + if not audio_content: + continue + + audio_bytes = base64.b64decode(audio_content) + if len(audio_bytes) == 0: + continue + + # Get or create buffer for this request + if context_id not in self._audio_buffers: + self._audio_buffers[context_id] = bytearray() + self._playback_started[context_id] = False + buffer = self._audio_buffers[context_id] + + # Add to buffer + buffer.extend(audio_bytes) + + # Wait for jitter buffer to fill before starting playback + # This absorbs network latency gaps (ResembleAI sends in bursts) + if not self._playback_started.get(context_id, False): + if len(buffer) < self._jitter_buffer_bytes: + continue + self._playback_started[context_id] = True + + # Send complete (even-byte) chunks for PCM_16 alignment + while len(buffer) >= self._buffer_threshold_bytes: + chunk_size = self._buffer_threshold_bytes + if chunk_size % 2 != 0: + chunk_size -= 1 + + chunk_to_send = bytes(buffer[:chunk_size]) + self._audio_buffers[context_id] = buffer[chunk_size:] + buffer = self._audio_buffers[context_id] + + if len(chunk_to_send) == 0: + continue + + frame = TTSAudioRawFrame( + audio=chunk_to_send, + sample_rate=self.sample_rate, + num_channels=1, + context_id=context_id, + ) + await self.append_to_audio_context(context_id, frame) + + # Process timestamps if available + timestamps = msg.get("audio_timestamps", {}) + if timestamps: + graph_chars = timestamps.get("graph_chars", []) + graph_times = timestamps.get("graph_times", []) + + # Convert graph_times (start, end pairs) to word timestamps + word_times = [] + for char, times in zip(graph_chars, graph_times): + if times and len(times) >= 2: + start_time = times[0] + word_times.append((char, start_time)) + + if word_times: + await self.add_word_timestamps(word_times, context_id) + + elif msg_type == "audio_end": + await self.stop_ttfb_metrics() + + # Flush remaining buffer, ensuring even length for PCM_16 + buffer = self._audio_buffers.get(context_id, bytearray()) + if buffer: + remaining = bytes(buffer) + # PCM_16 requires even number of bytes + if len(remaining) % 2 != 0: + remaining = remaining[:-1] + if remaining: + frame = TTSAudioRawFrame( + audio=remaining, + sample_rate=self.sample_rate, + num_channels=1, + context_id=context_id, + ) + await self.append_to_audio_context(context_id, frame) + + # Clean up buffer and playback tracking for this request + if context_id in self._audio_buffers: + del self._audio_buffers[context_id] + if context_id in self._playback_started: + del self._playback_started[context_id] + # Clean up request_id mapping + if request_id in self._request_id_to_context: + del self._request_id_to_context[request_id] + + await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], context_id) + await self.remove_audio_context(context_id) + + elif msg_type == "error": + error_name = msg.get("error_name", "Unknown") + error_msg = msg.get("message", "Unknown error") + status_code = msg.get("status_code", 0) + await self.push_error( + error_msg=f"Error: {error_name} (status {status_code}): {error_msg}" + ) + + # Clean up buffer and playback tracking for this request + if context_id in self._audio_buffers: + del self._audio_buffers[context_id] + if context_id in self._playback_started: + del self._playback_started[context_id] + + await self.push_frame(TTSStoppedFrame(context_id=context_id)) + await self.stop_all_metrics() + await self.push_error(ErrorFrame(error=f"{self} error: {error_name} - {error_msg}")) + + # Check if this is an unrecoverable error (connection-level failure) + if status_code in [401, 403]: + # Close and reconnect for auth errors + await self._disconnect_websocket() + await self._connect_websocket() + else: + logger.warning(f"{self} unknown message type: {msg_type}") + + async def _receive_messages(self): + """Main loop for receiving messages from Resemble AI.""" + while True: + try: + await self._process_messages() + except Exception as e: + await self.push_error(error_msg=f"Error in receive loop: {e}", exception=e) + # Try to reconnect + logger.debug(f"{self} Resemble AI connection lost, reconnecting") + await self._connect_websocket() + + @traced_tts + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: + """Generate speech from text using Resemble AI's streaming API. + + Args: + text: The text to synthesize into speech. + context_id: Unique identifier for this TTS context. + + Yields: + Frame: Audio frames containing the synthesized speech. + """ + logger.debug(f"{self}: Generating TTS [{text}]") + + try: + if not self._websocket or self._websocket.state is State.CLOSED: + await self._connect() + + if not self.audio_context_available(context_id): + await self.create_audio_context(context_id) + await self.start_ttfb_metrics() + yield TTSStartedFrame(context_id=context_id) + + # Map request_id to context_id for tracking + self._request_id_to_context[self._request_id_counter] = context_id + + msg = self._build_msg(text=text) + + try: + await self._get_websocket().send(msg) + await self.start_tts_usage_metrics(text) + except Exception as e: + yield ErrorFrame(error=f"Unknown error occurred: {e}") + yield TTSStoppedFrame(context_id=context_id) + await self._disconnect() + await self._connect() + return + yield None + except Exception as e: + yield ErrorFrame(error=f"Unknown error occurred: {e}") diff --git a/src/pipecat/services/rime/tts.py b/src/pipecat/services/rime/tts.py index 6018730b6..4dca789e5 100644 --- a/src/pipecat/services/rime/tts.py +++ b/src/pipecat/services/rime/tts.py @@ -12,8 +12,8 @@ using Rime's API for streaming and batch audio synthesis. import base64 import json -import uuid -from typing import Any, AsyncGenerator, Mapping, Optional +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, ClassVar, Dict, Optional import aiohttp from loguru import logger @@ -31,10 +31,12 @@ from pipecat.frames.frames import ( TTSStoppedFrame, ) from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven from pipecat.services.tts_service import ( - AudioContextWordTTSService, InterruptibleTTSService, + TextAggregationMode, TTSService, + WebsocketTTSService, ) from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.text.base_text_aggregator import BaseTextAggregator @@ -69,7 +71,59 @@ def language_to_rime_language(language: Language) -> str: return resolve_language(language, LANGUAGE_MAP, use_base_code=False) -class RimeTTSService(AudioContextWordTTSService): +@dataclass +class RimeTTSSettings(TTSSettings): + """Settings for RimeTTSService and RimeHttpTTSService. + + Parameters: + 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). + """ + + 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"} + + +@dataclass +class RimeNonJsonTTSSettings(TTSSettings): + """Settings for RimeNonJsonTTSService. + + Parameters: + segment: Text segmentation mode ("immediate", "bySentence", "never"). + repetition_penalty: Token repetition penalty (1.0-2.0). + temperature: Sampling temperature (0.0-1.0). + top_p: Cumulative probability threshold (0.0-1.0). + """ + + 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) + top_p: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + _aliases: ClassVar[Dict[str, str]] = {"speaker": "voice"} + + +class RimeTTSService(WebsocketTTSService): """Text-to-Speech service using Rime's websocket API. Uses Rime's websocket JSON API to convert text to speech with word-level timing @@ -77,34 +131,56 @@ class RimeTTSService(AudioContextWordTTSService): within a turn. """ + Settings = RimeTTSSettings + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for Rime TTS service. + .. deprecated:: 0.0.105 + Use ``settings=RimeTTSService.Settings(...)`` instead. + 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"). + speed_alpha: Speech speed multiplier. + repetition_penalty: Token repetition penalty (arcana only). + temperature: Sampling temperature (arcana only). + top_p: Cumulative probability threshold (arcana 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 + speed_alpha: Optional[float] = None + # Arcana params + repetition_penalty: Optional[float] = None + temperature: Optional[float] = None + top_p: Optional[float] = None + # Mistv2 params + 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", + voice_id: Optional[str] = None, + url: str = "wss://users-ws.rime.ai/ws3", + model: Optional[str] = None, sample_rate: Optional[int] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, text_aggregator: Optional[BaseTextAggregator] = None, - aggregate_sentences: Optional[bool] = True, + text_aggregation_mode: Optional[TextAggregationMode] = None, + aggregate_sentences: Optional[bool] = None, **kwargs, ): """Initialize Rime TTS service. @@ -112,59 +188,118 @@ class RimeTTSService(AudioContextWordTTSService): Args: api_key: Rime API key for authentication. voice_id: ID of the voice to use. + + .. deprecated:: 0.0.105 + Use ``settings=RimeTTSService.Settings(voice=...)`` instead. + url: Rime websocket API endpoint. model: Model ID to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=RimeTTSService.Settings(model=...)`` instead. + sample_rate: Audio sample rate in Hz. params: Additional configuration parameters. + + .. deprecated:: 0.0.105 + Use ``settings=RimeTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. text_aggregator: Custom text aggregator for processing input text. .. deprecated:: 0.0.95 Use an LLMTextProcessor before the TTSService for custom text aggregation. - aggregate_sentences: Whether to aggregate sentences within the TTSService. + text_aggregation_mode: How to aggregate incoming text before synthesis. + aggregate_sentences: Deprecated. Use text_aggregation_mode instead. + + .. deprecated:: 0.0.104 + Use ``text_aggregation_mode`` instead. + **kwargs: Additional arguments passed to parent class. """ - # Initialize with parent class settings for proper frame handling + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="arcana", + voice=None, + language=None, + segment=None, + inlineSpeedAlpha=None, + speedAlpha=None, + # Arcana params + repetition_penalty=None, + temperature=None, + top_p=None, + # Mistv2 params + reduceLatency=None, + pauseBetweenBrackets=None, + phonemizeBetweenBrackets=None, + noTextNormalization=None, + saveOovs=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = params.language + default_settings.segment = params.segment + default_settings.speedAlpha = params.speed_alpha + # Arcana params + default_settings.repetition_penalty = params.repetition_penalty + default_settings.temperature = params.temperature + default_settings.top_p = params.top_p + # Mistv2 params + default_settings.reduceLatency = params.reduce_latency + default_settings.pauseBetweenBrackets = params.pause_between_brackets + default_settings.phonemizeBetweenBrackets = params.phonemize_between_brackets + default_settings.noTextNormalization = params.no_text_normalization + default_settings.saveOovs = params.save_oovs + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( + text_aggregation_mode=text_aggregation_mode, aggregate_sentences=aggregate_sentences, push_text_frames=False, push_stop_frames=True, pause_frame_processing=True, + append_trailing_space=True, sample_rate=sample_rate, + settings=default_settings, **kwargs, ) + # Init-only audio format fields (not runtime-updatable) + self._audio_format = "pcm" + self._sampling_rate = 0 # updated in start() + if not text_aggregator: # Always skip tags added for spelled-out text # Note: This is primarily to support backwards compatibility. # The preferred way of taking advantage of Rime spelling is # to use an LLMTextProcessor and/or a text_transformer to identify # and insert these tags for the purpose of the TTS service alone. - self._text_aggregator = SkipTagsAggregator([("spell(", ")")]) - - params = params or RimeTTSService.InputParams() + self._text_aggregator = SkipTagsAggregator( + [("spell(", ")")], aggregation_type=self._text_aggregation_mode + ) # Store service configuration self._api_key = api_key self._url = url - self._voice_id = voice_id - self._model = model - self._settings = { - "speaker": voice_id, - "modelId": model, - "audioFormat": "pcm", - "samplingRate": 0, - "lang": self.language_to_service_language(params.language) - if params.language - else "eng", - "speedAlpha": params.speed_alpha, - "reduceLatency": params.reduce_latency, - "pauseBetweenBrackets": json.dumps(params.pause_between_brackets), - "phonemizeBetweenBrackets": json.dumps(params.phonemize_between_brackets), - } # 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 @@ -188,14 +323,49 @@ class RimeTTSService(AudioContextWordTTSService): """ return language_to_rime_language(language) - async def set_model(self, model: str): - """Update the TTS model. + def _build_ws_params(self) -> dict[str, Any]: + """Build query params for the WebSocket URL from current settings. - Args: - model: The model name to use for synthesis. + 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. """ - self._model = model - await super().set_model(model) + params: dict[str, Any] = { + "speaker": self._settings.voice, + "modelId": self._settings.model, + "audioFormat": self._audio_format, + "samplingRate": self._sampling_rate, + } + if self._settings.language is not None: + params["lang"] = self._settings.language + if self._settings.segment is not None: + params["segment"] = self._settings.segment + if self._settings.speedAlpha is not None: + params["speedAlpha"] = self._settings.speedAlpha + + if self._settings.model == "arcana": + if self._settings.repetition_penalty is not None: + params["repetition_penalty"] = self._settings.repetition_penalty + if self._settings.temperature is not None: + params["temperature"] = self._settings.temperature + if self._settings.top_p is not None: + params["top_p"] = self._settings.top_p + else: # mistv2/mist + if self._settings.reduceLatency is not None: + params["reduceLatency"] = self._settings.reduceLatency + if self._settings.pauseBetweenBrackets is not None: + params["pauseBetweenBrackets"] = json.dumps(self._settings.pauseBetweenBrackets) + if self._settings.phonemizeBetweenBrackets is not None: + params["phonemizeBetweenBrackets"] = json.dumps( + self._settings.phonemizeBetweenBrackets + ) + if self._settings.noTextNormalization is not None: + params["noTextNormalization"] = json.dumps(self._settings.noTextNormalization) + if self._settings.saveOovs is not None: + params["saveOovs"] = json.dumps(self._settings.saveOovs) + + return params # A set of Rime-specific helpers for text transformations def SPELL(text: str) -> str: @@ -222,19 +392,23 @@ class RimeTTSService(AudioContextWordTTSService): self._extra_msg_fields["inlineSpeedAlpha"] = ",".join(speed_vals + [str(speed)]) return f"[{text}]" - async def _update_settings(self, settings: Mapping[str, Any]): - """Update service settings and reconnect if voice changed.""" - prev_voice = self._voice_id - await super()._update_settings(settings) - if not prev_voice == self._voice_id: - self._settings["speaker"] = self._voice_id - logger.info(f"Switching TTS voice to: [{self._voice_id}]") + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta 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(delta) + + if changed and self._websocket: await self._disconnect() await self._connect() - def _build_msg(self, text: str = "") -> dict: + return changed + + def _build_msg(self, text: str = "", context_id: str = "") -> dict: """Build JSON message for Rime API.""" - msg = {"text": text, "contextId": self._context_id} + msg = {"text": text, "contextId": context_id} if self._extra_msg_fields: msg |= self._extra_msg_fields self._extra_msg_fields = {} @@ -255,7 +429,7 @@ class RimeTTSService(AudioContextWordTTSService): frame: The start frame containing initialization parameters. """ await super().start(frame) - self._settings["samplingRate"] = self.sample_rate + self._sampling_rate = self.sample_rate await self._connect() async def stop(self, frame: EndFrame): @@ -278,6 +452,8 @@ class RimeTTSService(AudioContextWordTTSService): async def _connect(self): """Establish websocket connection and start receive task.""" + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: @@ -285,6 +461,8 @@ class RimeTTSService(AudioContextWordTTSService): async def _disconnect(self): """Close websocket connection and clean up tasks.""" + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None @@ -297,7 +475,8 @@ class RimeTTSService(AudioContextWordTTSService): if self._websocket and self._websocket.state is State.OPEN: return - params = "&".join(f"{k}={v}" for k, v in self._settings.items()) + 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) @@ -318,7 +497,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") @@ -328,13 +507,24 @@ class RimeTTSService(AudioContextWordTTSService): return self._websocket raise Exception("Websocket not connected") - async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): - """Handle interruption by clearing current context.""" - await super()._handle_interruption(frame, direction) + async def _close_context(self, context_id: str): + """Clear the Rime speech queue and stop metrics.""" 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 + + async def on_audio_context_interrupted(self, context_id: str): + """Clear the Rime speech queue and stop metrics when the bot is interrupted.""" + await self._close_context(context_id) + + async def on_audio_context_completed(self, context_id: str): + """Clear server-side state and stop metrics after the Rime context finishes playing. + + Rime does not send a server-side completion signal (e.g. ``done`` / ``end_of_stream`` / + ``audio_end``), so we explicitly send a ``clear`` message to clean up + any residual server-side state once all audio has been delivered. + """ + await self._close_context(context_id) def _calculate_word_times(self, words: list, starts: list, ends: list) -> list: """Calculate word timing pairs with proper spacing and punctuation. @@ -365,33 +555,33 @@ class RimeTTSService(AudioContextWordTTSService): return word_pairs - async def flush_audio(self): + async def flush_audio(self, context_id: Optional[str] = None): """Flush any pending audio synthesis.""" - if not self._context_id or not self._websocket: + flush_id = context_id or self.get_active_audio_context_id() + if not flush_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 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"] if msg["type"] == "chunk": # Process audio chunk - await self.stop_ttfb_metrics() - await self.start_word_timestamps() frame = TTSAudioRawFrame( audio=base64.b64decode(msg["data"]), sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) - await self.append_to_audio_context(msg["contextId"], frame) + await self.append_to_audio_context(context_id, frame) elif msg["type"] == "timestamps": # Process word timing information @@ -404,7 +594,7 @@ class RimeTTSService(AudioContextWordTTSService): # Calculate word timing pairs word_pairs = self._calculate_word_times(words, starts, ends) if word_pairs: - await self.add_word_timestamps(word_pairs) + await self.add_word_timestamps(word_pairs, context_id=context_id) self._cumulative_time = ends[-1] + self._cumulative_time logger.debug(f"Updated cumulative time to: {self._cumulative_time}") @@ -412,7 +602,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. @@ -427,11 +617,12 @@ class RimeTTSService(AudioContextWordTTSService): await self.add_word_timestamps([("Reset", 0)]) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Rime's streaming API. Args: text: The text to convert to speech. + context_id: Unique identifier for this TTS context. Yields: Frame: Audio frames containing the synthesized speech. @@ -442,19 +633,18 @@ class RimeTTSService(AudioContextWordTTSService): await self._connect() try: - if not self._context_id: + if not self.audio_context_available(context_id): + await self.create_audio_context(context_id) await self.start_ttfb_metrics() - yield TTSStartedFrame() + yield TTSStartedFrame(context_id=context_id) self._cumulative_time = 0 - self._context_id = str(uuid.uuid4()) - await self.create_audio_context(self._context_id) - msg = self._build_msg(text=text) + msg = self._build_msg(text=text, context_id=context_id) await self._get_websocket().send(json.dumps(msg)) await self.start_tts_usage_metrics(text) except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() + yield TTSStoppedFrame(context_id=context_id) await self._disconnect() await self._connect() return @@ -470,9 +660,15 @@ class RimeHttpTTSService(TTSService): Suitable for use cases where streaming is not required. """ + Settings = RimeTTSSettings + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for Rime HTTP TTS service. + .. deprecated:: 0.0.105 + Use ``settings=RimeHttpTTSService.Settings(...)`` instead. + Parameters: language: Language for synthesis. Defaults to English. pause_between_brackets: Whether to add pauses between bracketed content. @@ -493,11 +689,12 @@ class RimeHttpTTSService(TTSService): self, *, api_key: str, - voice_id: str, + voice_id: Optional[str] = None, aiohttp_session: aiohttp.ClientSession, - model: str = "mistv2", + model: Optional[str] = None, sample_rate: Optional[int] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize Rime HTTP TTS service. @@ -505,33 +702,83 @@ class RimeHttpTTSService(TTSService): Args: api_key: Rime API key for authentication. voice_id: ID of the voice to use. + + .. deprecated:: 0.0.105 + Use ``settings=RimeHttpTTSService.Settings(voice=...)`` instead. + aiohttp_session: Shared aiohttp session for HTTP requests. model: Model ID to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=RimeHttpTTSService.Settings(model=...)`` instead. + sample_rate: Audio sample rate in Hz. params: Additional configuration parameters. + + .. deprecated:: 0.0.105 + Use ``settings=RimeHttpTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="mistv2", + voice=None, + language="eng", + segment=None, + speedAlpha=None, + reduceLatency=None, + pauseBetweenBrackets=None, + phonemizeBetweenBrackets=None, + noTextNormalization=None, + saveOovs=None, + inlineSpeedAlpha=None, + repetition_penalty=None, + temperature=None, + top_p=None, + ) - params = params or RimeHttpTTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = params.language + default_settings.speedAlpha = params.speed_alpha + default_settings.reduceLatency = params.reduce_latency + default_settings.pauseBetweenBrackets = params.pause_between_brackets + default_settings.phonemizeBetweenBrackets = params.phonemize_between_brackets + default_settings.inlineSpeedAlpha = ( + params.inline_speed_alpha if params.inline_speed_alpha else None + ) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_stop_frames=True, + push_start_frame=True, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._session = aiohttp_session self._base_url = "https://users.rime.ai/v1/rime-tts" - self._settings = { - "lang": 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, - "phonemizeBetweenBrackets": params.phonemize_between_brackets, - } - self.set_voice(voice_id) - self.set_model_name(model) - if params.inline_speed_alpha: - self._settings["inlineSpeedAlpha"] = params.inline_speed_alpha + # Init-only audio format fields (not runtime-updatable) + self._audio_format = "pcm" def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -553,11 +800,12 @@ class RimeHttpTTSService(TTSService): return language_to_rime_language(language) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Rime's HTTP API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -570,10 +818,18 @@ class RimeHttpTTSService(TTSService): "Content-Type": "application/json", } - payload = self._settings.copy() + payload = { + "lang": self._settings.language, + "speedAlpha": self._settings.speedAlpha, + "reduceLatency": self._settings.reduceLatency, + "pauseBetweenBrackets": self._settings.pauseBetweenBrackets, + "phonemizeBetweenBrackets": self._settings.phonemizeBetweenBrackets, + } + if self._settings.inlineSpeedAlpha is not None: + payload["inlineSpeedAlpha"] = self._settings.inlineSpeedAlpha payload["text"] = text - payload["speaker"] = self._voice_id - payload["modelId"] = self._model_name + payload["speaker"] = self._settings.voice + payload["modelId"] = self._settings.model payload["samplingRate"] = self.sample_rate # Arcana does not support PCM audio @@ -584,8 +840,6 @@ class RimeHttpTTSService(TTSService): need_to_strip_wav_header = False try: - await self.start_ttfb_metrics() - async with self._session.post( self._base_url, json=payload, headers=headers ) as response: @@ -596,13 +850,12 @@ class RimeHttpTTSService(TTSService): await self.start_tts_usage_metrics(text) - yield TTSStartedFrame() - CHUNK_SIZE = self.chunk_size async for frame in self._stream_audio_frames_from_iterator( response.content.iter_chunked(CHUNK_SIZE), strip_wav_header=need_to_strip_wav_header, + context_id=context_id, ): await self.stop_ttfb_metrics() yield frame @@ -611,31 +864,34 @@ class RimeHttpTTSService(TTSService): yield ErrorFrame(error=f"Unknown error occurred: {e}") finally: await self.stop_ttfb_metrics() - yield TTSStoppedFrame() 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 + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for Rime Non-JSON WebSocket TTS service. + .. deprecated:: 0.0.105 + Use ``settings=RimeNonJsonTTSService.Settings(...)`` instead. + Args: language: Language for synthesis. Defaults to English. segment: Text segmentation mode ("immediate", "bySentence", "never"). @@ -656,13 +912,15 @@ class RimeNonJsonTTSService(InterruptibleTTSService): self, *, api_key: str, - voice_id: str, + voice_id: Optional[str] = None, url: str = "wss://users.rime.ai/ws", - model: str = "arcana", + model: Optional[str] = None, audio_format: str = "pcm", sample_rate: Optional[int] = None, params: Optional[InputParams] = None, - aggregate_sentences: Optional[bool] = True, + settings: Optional[Settings] = None, + aggregate_sentences: Optional[bool] = None, + text_aggregation_mode: Optional[TextAggregationMode] = None, **kwargs, ): """Initialize Rime Non-JSON WebSocket TTS service. @@ -670,48 +928,90 @@ class RimeNonJsonTTSService(InterruptibleTTSService): Args: api_key: Rime API key for authentication. voice_id: ID of the voice to use. + + .. deprecated:: 0.0.105 + Use ``settings=RimeNonJsonTTSService.Settings(voice=...)`` instead. + url: Rime websocket API endpoint. model: Model ID to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=RimeNonJsonTTSService.Settings(model=...)`` instead. + audio_format: Audio format to use. sample_rate: Audio sample rate in Hz. params: Additional configuration parameters. - aggregate_sentences: Whether to aggregate sentences within the TTSService. + + .. deprecated:: 0.0.105 + Use ``settings=RimeNonJsonTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + aggregate_sentences: Deprecated. Use text_aggregation_mode instead. + + .. deprecated:: 0.0.104 + Use ``text_aggregation_mode`` instead. Set to ``TextAggregationMode.SENTENCE`` + to aggregate text into sentences before synthesis, or + ``TextAggregationMode.TOKEN`` to stream tokens directly for lower latency. + + text_aggregation_mode: How to aggregate text before synthesis. **kwargs: Additional arguments passed to parent class. """ + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + voice=None, + model="arcana", + language=None, + segment=None, + repetition_penalty=None, + temperature=None, + top_p=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = params.language + default_settings.segment = params.segment + default_settings.repetition_penalty = params.repetition_penalty + default_settings.temperature = params.temperature + default_settings.top_p = params.top_p + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + super().__init__( sample_rate=sample_rate, aggregate_sentences=aggregate_sentences, + text_aggregation_mode=text_aggregation_mode, push_stop_frames=True, + push_start_frame=True, pause_frame_processing=True, + append_trailing_space=True, + settings=default_settings, **kwargs, ) - params = params or RimeNonJsonTTSService.InputParams() + + # Init-only audio format fields (not runtime-updatable) + self._audio_format = audio_format + self._sampling_rate = sample_rate + self._api_key = api_key self._url = url - self._voice_id = voice_id - self._model = model - self._settings = { - "speaker": voice_id, - "modelId": model, - "audioFormat": audio_format, - "samplingRate": sample_rate, - } - - if params.language: - self._settings["lang"] = self.language_to_service_language(params.language) - if params.segment is not None: - self._settings["segment"] = params.segment - if params.repetition_penalty is not None: - self._settings["repetition_penalty"] = params.repetition_penalty - if params.temperature is not None: - self._settings["temperature"] = params.temperature - if params.top_p is not None: - self._settings["top_p"] = params.top_p # Add any extra parameters for future compatibility - if params.extra: - self._settings.update(params.extra) + if params and params.extra: + self._settings.extra.update(params.extra) - self._started = False self._receive_task = None def can_generate_metrics(self) -> bool: @@ -741,7 +1041,7 @@ class RimeNonJsonTTSService(InterruptibleTTSService): frame: The start frame containing initialization parameters. """ await super().start(frame) - self._settings["samplingRate"] = self.sample_rate + self._sampling_rate = self.sample_rate await self._connect() async def stop(self, frame: EndFrame): @@ -754,25 +1054,18 @@ class RimeNonJsonTTSService(InterruptibleTTSService): await super().cancel(frame) await self._disconnect() - async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): - """Push a frame downstream with special handling for stop conditions. - - Args: - frame: The frame to push. - direction: The direction to push the frame. - """ - await super().push_frame(frame, direction) - if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)): - self._started = False - async def _connect(self): """Establish WebSocket connection and start receive task.""" + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: self._receive_task = self.create_task(self._receive_task_handler(self._report_error)) async def _disconnect(self): """Close WebSocket connection and clean up tasks.""" + await super()._disconnect() + if self._receive_task: await self.cancel_task(self._receive_task) self._receive_task = None @@ -783,8 +1076,26 @@ class RimeNonJsonTTSService(InterruptibleTTSService): try: if self._websocket and self._websocket.state is State.OPEN: return - # Build URL with query parameters (only non-None values) - params = "&".join(f"{k}={v}" for k, v in self._settings.items() if v is not None) + # Build URL with query parameters (only given, non-None values) + settings_dict = { + "speaker": self._settings.voice, + "modelId": self._settings.model, + "audioFormat": self._audio_format, + "samplingRate": self._sampling_rate, + } + if self._settings.language is not None: + settings_dict["lang"] = self._settings.language + if self._settings.segment is not None: + settings_dict["segment"] = self._settings.segment + if self._settings.repetition_penalty is not None: + settings_dict["repetition_penalty"] = self._settings.repetition_penalty + if self._settings.temperature is not None: + settings_dict["temperature"] = self._settings.temperature + if self._settings.top_p is not None: + settings_dict["top_p"] = self._settings.top_p + # Include extras + settings_dict.update(self._settings.extra) + params = "&".join(f"{k}={v}" for k, v in settings_dict.items() if v is not None) url = f"{self._url}?{params}" headers = {"Authorization": f"Bearer {self._api_key}"} self._websocket = await websocket_connect( @@ -808,7 +1119,6 @@ class RimeNonJsonTTSService(InterruptibleTTSService): except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) finally: - self._started = False self._websocket = None await self._call_event_handler("on_disconnected") @@ -818,7 +1128,7 @@ class RimeNonJsonTTSService(InterruptibleTTSService): return self._websocket raise Exception("Websocket not connected") - async def flush_audio(self): + async def flush_audio(self, context_id: Optional[str] = None): """Flush any pending audio synthesis.""" if not self._websocket: return @@ -834,21 +1144,24 @@ class RimeNonJsonTTSService(InterruptibleTTSService): if isinstance(message, bytes): await self.stop_ttfb_metrics() + context_id = self.get_active_audio_context_id() frame = TTSAudioRawFrame( audio=message, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) - await self.push_frame(frame) + await self.append_to_audio_context(context_id, frame) except Exception as e: await self.push_error(error_msg=f"Error: {e}", exception=e) @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Rime's streaming API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -858,17 +1171,13 @@ class RimeNonJsonTTSService(InterruptibleTTSService): if not self._websocket or self._websocket.state is State.CLOSED: await self._connect() try: - if not self._started: - await self.start_ttfb_metrics() - yield TTSStartedFrame() - self._started = True # Send bare text (not JSON) await self._get_websocket().send(text) await self.start_tts_usage_metrics(text) except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() + yield TTSStoppedFrame(context_id=context_id) await self._disconnect() await self._connect() return @@ -876,68 +1185,17 @@ class RimeNonJsonTTSService(InterruptibleTTSService): except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - async def _update_settings(self, settings: Mapping[str, Any]): - """Update service settings and reconnect if necessary. + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta and reconnect if necessary. Since all settings are WebSocket URL query parameters, any setting change requires reconnecting to apply the new values. """ - needs_reconnect = False + changed = await super()._update_settings(delta) - # Track previous values from self._settings only - prev_settings = self._settings.copy() - - # Let parent class handle standard settings (voice, model, language) - await super()._update_settings(settings) - - # Check if voice changed and update settings dict - if "voice" in settings or "voice_id" in settings: - self._settings["speaker"] = self._voice_id - if prev_settings.get("speaker") != self._voice_id: - logger.info(f"Switching TTS voice to: [{self._voice_id}]") - needs_reconnect = True - - # Check if model changed and update settings dict - if "model" in settings: - self._settings["modelId"] = self._model - if prev_settings.get("modelId") != self._model: - logger.info(f"Switching TTS model to: [{self._model}]") - needs_reconnect = True - - # Handle language explicitly - if "language" in settings: - new_lang = self.language_to_service_language(settings["language"]) - if new_lang and new_lang != prev_settings.get("lang"): - logger.info(f"Updating language to: [{new_lang}]") - self._settings["lang"] = new_lang - needs_reconnect = True - - # Check other parameters - for key in ["segment", "repetition_penalty", "temperature", "top_p"]: - if key in settings and settings[key] != prev_settings.get(key): - logger.info(f"Updating {key} to: [{settings[key]}]") - self._settings[key] = settings[key] - needs_reconnect = True - - # Handle extra parameters - for key, value in settings.items(): - if key not in [ - "voice", - "voice_id", - "model", - "language", - "segment", - "repetition_penalty", - "temperature", - "top_p", - ]: - if value != prev_settings.get(key): - logger.info(f"Updating extra parameter {key} to: [{value}]") - self._settings[key] = value - needs_reconnect = True - - # Reconnect if any setting changed - if needs_reconnect: + if changed: logger.debug("Settings changed, reconnecting WebSocket with new parameters") await self._disconnect() await self._connect() + + return changed diff --git a/src/pipecat/services/sambanova/llm.py b/src/pipecat/services/sambanova/llm.py index d50978d72..710a22db2 100644 --- a/src/pipecat/services/sambanova/llm.py +++ b/src/pipecat/services/sambanova/llm.py @@ -7,6 +7,7 @@ """SambaNova LLM service implementation using OpenAI-compatible interface.""" import json +from dataclasses import dataclass from typing import Any, Dict, Optional from loguru import logger @@ -21,10 +22,18 @@ from pipecat.metrics.metrics import LLMTokenUsage from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.services.llm_service import FunctionCallFromLLM +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService from pipecat.utils.tracing.service_decorators import traced_llm +@dataclass +class SambaNovaLLMSettings(BaseOpenAILLMService.Settings): + """Settings for SambaNovaLLMService.""" + + pass + + class SambaNovaLLMService(OpenAILLMService): # type: ignore """A service for interacting with SambaNova using the OpenAI-compatible interface. @@ -32,12 +41,16 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore maintaining full compatibility with OpenAI's interface and functionality. """ + Settings = SambaNovaLLMSettings + _settings: Settings + def __init__( self, *, api_key: str, - model: str = "Llama-4-Maverick-17B-128E-Instruct", + model: Optional[str] = None, base_url: str = "https://api.sambanova.ai/v1", + settings: Optional[Settings] = None, **kwargs: Dict[Any, Any], ) -> None: """Initialize SambaNova LLM service. @@ -45,10 +58,30 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore Args: api_key: The API key for accessing SambaNova API. model: The model identifier to use. Defaults to "Llama-4-Maverick-17B-128E-Instruct". + + .. deprecated:: 0.0.105 + Use ``settings=SambaNovaLLMService.Settings(model=...)`` instead. + base_url: The base URL for SambaNova API. Defaults to "https://api.sambanova.ai/v1". + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="Llama-4-Maverick-17B-128E-Instruct") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) def create_client( self, @@ -84,19 +117,31 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore Dictionary of parameters for the chat completion request. """ params = { - "model": self.model_name, + "model": self._settings.model, "stream": True, "stream_options": {"include_usage": True}, - "temperature": self._settings["temperature"], - "top_p": self._settings["top_p"], - "max_tokens": self._settings["max_tokens"], - "max_completion_tokens": self._settings["max_completion_tokens"], + "temperature": self._settings.temperature, + "top_p": self._settings.top_p, + "max_tokens": self._settings.max_tokens, + "max_completion_tokens": self._settings.max_completion_tokens, } # Messages, tools, tool_choice params.update(params_from_context) - params.update(self._settings["extra"]) + params.update(self._settings.extra) + + # Prepend system instruction if set + if self._settings.system_instruction: + messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) + params["messages"] = [ + {"role": "system", "content": self._settings.system_instruction} + ] + messages + return params @traced_llm # type: ignore @@ -131,59 +176,62 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore else self._stream_chat_completions_universal_context(context) ) - async for chunk in chunk_stream: - if chunk.usage: - tokens = LLMTokenUsage( - prompt_tokens=chunk.usage.prompt_tokens, - completion_tokens=chunk.usage.completion_tokens, - total_tokens=chunk.usage.total_tokens, - ) - await self.start_llm_usage_metrics(tokens) + # Use context manager to ensure stream is closed on cancellation/exception. + # Without this, CancelledError during iteration leaves the underlying socket open. + async with chunk_stream: + async for chunk in chunk_stream: + if chunk.usage: + tokens = LLMTokenUsage( + prompt_tokens=chunk.usage.prompt_tokens, + completion_tokens=chunk.usage.completion_tokens, + total_tokens=chunk.usage.total_tokens, + ) + await self.start_llm_usage_metrics(tokens) - if chunk.choices is None or len(chunk.choices) == 0: - continue + if chunk.choices is None or len(chunk.choices) == 0: + continue - await self.stop_ttfb_metrics() + await self.stop_ttfb_metrics() - if not chunk.choices[0].delta: - continue + if not chunk.choices[0].delta: + continue - if chunk.choices[0].delta.tool_calls: - # We're streaming the LLM response to enable the fastest response times. - # For text, we just yield each chunk as we receive it and count on consumers - # to do whatever coalescing they need (eg. to pass full sentences to TTS) - # - # If the LLM is a function call, we'll do some coalescing here. - # If the response contains a function name, we'll yield a frame to tell consumers - # that they can start preparing to call the function with that name. - # We accumulate all the arguments for the rest of the streamed response, then when - # the response is done, we package up all the arguments and the function name and - # yield a frame containing the function name and the arguments. + if chunk.choices[0].delta.tool_calls: + # We're streaming the LLM response to enable the fastest response times. + # For text, we just yield each chunk as we receive it and count on consumers + # to do whatever coalescing they need (eg. to pass full sentences to TTS) + # + # If the LLM is a function call, we'll do some coalescing here. + # If the response contains a function name, we'll yield a frame to tell consumers + # that they can start preparing to call the function with that name. + # We accumulate all the arguments for the rest of the streamed response, then when + # the response is done, we package up all the arguments and the function name and + # yield a frame containing the function name and the arguments. - tool_call = chunk.choices[0].delta.tool_calls[0] - if tool_call.index != func_idx: - functions_list.append(function_name) - arguments_list.append(arguments) - tool_id_list.append(tool_call_id) - function_name = "" - arguments = "" - tool_call_id = "" - func_idx += 1 - if tool_call.function and tool_call.function.name: - function_name += tool_call.function.name - tool_call_id = tool_call.id # type: ignore - if tool_call.function and tool_call.function.arguments: - # Keep iterating through the response to collect all the argument fragments - arguments += tool_call.function.arguments - elif chunk.choices[0].delta.content: - await self.push_frame(LLMTextFrame(chunk.choices[0].delta.content)) + tool_call = chunk.choices[0].delta.tool_calls[0] + if tool_call.index != func_idx: + functions_list.append(function_name) + arguments_list.append(arguments) + tool_id_list.append(tool_call_id) + function_name = "" + arguments = "" + tool_call_id = "" + func_idx += 1 + if tool_call.function and tool_call.function.name: + function_name += tool_call.function.name + tool_call_id = tool_call.id # type: ignore + if tool_call.function and tool_call.function.arguments: + # Keep iterating through the response to collect all the argument fragments + arguments += tool_call.function.arguments + elif chunk.choices[0].delta.content: + await self.push_frame(LLMTextFrame(chunk.choices[0].delta.content)) - # When gpt-4o-audio / gpt-4o-mini-audio is used for llm or stt+llm - # we need to get LLMTextFrame for the transcript - elif hasattr(chunk.choices[0].delta, "audio") and chunk.choices[0].delta.audio.get( - "transcript" - ): - await self.push_frame(LLMTextFrame(chunk.choices[0].delta.audio["transcript"])) + # When gpt-4o-audio / gpt-4o-mini-audio is used for llm or stt+llm + # we need to get LLMTextFrame for the transcript + elif hasattr(chunk.choices[0].delta, "audio") and chunk.choices[0].delta.audio.get( + "transcript" + ): + await self.push_frame(LLMTextFrame(chunk.choices[0].delta.audio["transcript"])) # if we got a function name and arguments, check to see if it's a function with # a registered handler. If so, run the registered callback, save the result to diff --git a/src/pipecat/services/sambanova/stt.py b/src/pipecat/services/sambanova/stt.py index 311f37307..5cf12d771 100644 --- a/src/pipecat/services/sambanova/stt.py +++ b/src/pipecat/services/sambanova/stt.py @@ -6,14 +6,26 @@ """SambaNova's Speech-to-Text service implementation for real-time transcription.""" +from dataclasses import dataclass from typing import Any, Optional from loguru import logger -from pipecat.services.whisper.base_stt import BaseWhisperSTTService, Transcription +from pipecat.services.stt_latency import SAMBANOVA_TTFS_P99 +from pipecat.services.whisper.base_stt import ( + BaseWhisperSTTService, + Transcription, +) from pipecat.transcriptions.language import Language +@dataclass +class SambaNovaSTTSettings(BaseWhisperSTTService.Settings): + """Settings for the SambaNova STT service.""" + + pass + + class SambaNovaSTTService(BaseWhisperSTTService): # type: ignore """SambaNova Whisper speech-to-text service. @@ -21,40 +33,90 @@ class SambaNovaSTTService(BaseWhisperSTTService): # type: ignore Requires a SambaNova API key set via the api_key parameter or SAMBANOVA_API_KEY environment variable. """ + Settings = SambaNovaSTTSettings + def __init__( self, *, - model: str = "Whisper-Large-v3", + model: Optional[str] = None, api_key: Optional[str] = None, base_url: str = "https://api.sambanova.ai/v1", - language: Optional[Language] = Language.EN, + language: Optional[Language] = None, prompt: Optional[str] = None, temperature: Optional[float] = None, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = SAMBANOVA_TTFS_P99, **kwargs: Any, ) -> None: """Initialize SambaNova STT service. Args: - model: Whisper model to use. Defaults to "Whisper-Large-v3". + model: Whisper model to use. + + .. deprecated:: 0.0.105 + Use ``settings=SambaNovaSTTService.Settings(model=...)`` instead. + api_key: SambaNova API key. Defaults to None. base_url: API base URL. Defaults to "https://api.sambanova.ai/v1". - language: Language of the audio input. Defaults to English. + language: Language of the audio input. + + .. deprecated:: 0.0.105 + Use ``settings=SambaNovaSTTService.Settings(language=...)`` instead. + prompt: Optional text to guide the model's style or continue a previous segment. - temperature: Optional sampling temperature between 0 and 1. Defaults to 0.0. + + .. deprecated:: 0.0.105 + Use ``settings=SambaNovaSTTService.Settings(prompt=...)`` instead. + + temperature: Optional sampling temperature between 0 and 1. + + .. deprecated:: 0.0.105 + Use ``settings=SambaNovaSTTService.Settings(temperature=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to `pipecat.services.whisper.base_stt.BaseWhisperSTTService`. """ + # --- 1. Hardcoded defaults --- + default_settings = self.Settings( + model="Whisper-Large-v3", + language=Language.EN, + prompt=None, + temperature=None, + ) + + # --- 2. Deprecated direct-arg overrides --- + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if language is not None: + self._warn_init_param_moved_to_settings("language", "language") + default_settings.language = language + if prompt is not None: + self._warn_init_param_moved_to_settings("prompt", "prompt") + default_settings.prompt = prompt + if temperature is not None: + self._warn_init_param_moved_to_settings("temperature", "temperature") + default_settings.temperature = temperature + + # --- 3. (no params object for this service) --- + + # --- 4. Settings delta (canonical API, always wins) --- + if settings is not None: + default_settings.apply_update(settings) + super().__init__( - model=model, api_key=api_key, base_url=base_url, - language=language, - prompt=prompt, - temperature=temperature, + settings=default_settings, + ttfs_p99_latency=ttfs_p99_latency, **kwargs, ) async def _transcribe(self, audio: bytes) -> Transcription: - assert self._language is not None # Assigned in the BaseWhisperSTTService class + assert self._settings.language is not None if self._include_prob_metrics: # https://docs.sambanova.ai/docs/en/features/audio#request-parameters @@ -67,15 +129,15 @@ class SambaNovaSTTService(BaseWhisperSTTService): # type: ignore # Build kwargs dict with only set parameters kwargs = { "file": ("audio.wav", audio, "audio/wav"), - "model": self.model_name, + "model": self._settings.model, "response_format": "json", - "language": self._language, + "language": self._settings.language, } - if self._prompt is not None: - kwargs["prompt"] = self._prompt + if self._settings.prompt is not None: + kwargs["prompt"] = self._settings.prompt - if self._temperature is not None: - kwargs["temperature"] = self._temperature + if self._settings.temperature is not None: + kwargs["temperature"] = self._settings.temperature return await self._client.audio.transcriptions.create(**kwargs) diff --git a/src/pipecat/services/sarvam/stt.py b/src/pipecat/services/sarvam/stt.py index f4e5c676b..8d0a38810 100644 --- a/src/pipecat/services/sarvam/stt.py +++ b/src/pipecat/services/sarvam/stt.py @@ -1,3 +1,9 @@ +# +# Copyright (c) 2024–2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + """Sarvam AI Speech-to-Text service implementation. This module provides a streaming Speech-to-Text service using Sarvam AI's WebSocket-based @@ -6,7 +12,8 @@ can handle multiple audio formats for Indian language speech recognition. """ import base64 -from typing import Optional +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Dict, Literal, Optional from loguru import logger from pydantic import BaseModel @@ -15,10 +22,23 @@ from pipecat.frames.frames import ( CancelFrame, EndFrame, ErrorFrame, + Frame, StartFrame, TranscriptionFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, ) +from pipecat.processors.frame_processor import FrameDirection from pipecat.services.sarvam._sdk import sdk_headers +from pipecat.services.settings import ( + NOT_GIVEN, + STTSettings, + _NotGiven, + is_given, +) +from pipecat.services.stt_latency import SARVAM_TTFS_P99 from pipecat.services.stt_service import STTService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.time import time_now_iso8601 @@ -62,36 +82,137 @@ def language_to_sarvam_language(language: Language) -> str: return resolve_language(language, LANGUAGE_MAP, use_base_code=False) +@dataclass(frozen=True) +class ModelConfig: + """Immutable configuration for a Sarvam STT model. + + Attributes: + supports_prompt: Whether the model accepts prompt parameter. + supports_mode: Whether the model accepts mode parameter. + supports_language: Whether the model accepts language parameter. + default_language: Default language code (None = auto-detect). + default_mode: Default mode (None = not applicable). + use_translate_endpoint: Whether to use speech_to_text_translate_streaming endpoint. + use_translate_method: Whether to use translate() method instead of transcribe(). + """ + + supports_prompt: bool + supports_mode: bool + supports_language: bool + default_language: Optional[str] + default_mode: Optional[str] + use_translate_endpoint: bool + use_translate_method: bool + + +MODEL_CONFIGS: Dict[str, ModelConfig] = { + "saarika:v2.5": ModelConfig( + supports_prompt=False, + supports_mode=False, + supports_language=True, + default_language="unknown", + default_mode=None, + use_translate_endpoint=False, + use_translate_method=False, + ), + "saaras:v2.5": ModelConfig( + supports_prompt=True, + supports_mode=False, + supports_language=False, + default_language=None, # Auto-detects language + default_mode=None, + use_translate_endpoint=True, + use_translate_method=True, + ), + "saaras:v3": ModelConfig( + supports_prompt=False, + supports_mode=True, + supports_language=True, + default_language="unknown", + default_mode="transcribe", + use_translate_endpoint=False, + use_translate_method=False, + ), +} + + +@dataclass +class SarvamSTTSettings(STTSettings): + """Settings for SarvamSTTService. + + Parameters: + prompt: Optional prompt to guide transcription/translation style/context. + Only applicable to models that support prompts (e.g., saaras:v2.5). + vad_signals: Enable VAD signals in response. + high_vad_sensitivity: Enable high VAD sensitivity. + """ + + prompt: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + vad_signals: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + high_vad_sensitivity: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + 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 + _settings: Settings + class InputParams(BaseModel): """Configuration parameters for Sarvam STT service. + .. deprecated:: 0.0.105 + Use ``settings=SarvamSTTService.Settings(...)`` instead. + Parameters: - language: Target language for transcription. Defaults to None (required for saarika models). - prompt: Optional prompt to guide translation style/context for STT-Translate models. - Only applicable to saaras (STT-Translate) models. Defaults to None. - vad_signals: Enable VAD signals in response. Defaults to True. - high_vad_sensitivity: Enable high VAD (Voice Activity Detection) sensitivity. Defaults to False. + 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 "unknown" (auto-detect supported) + prompt: Optional prompt to guide transcription/translation style/context. + 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. + high_vad_sensitivity: Enable high VAD (Voice Activity Detection) sensitivity. Defaults to None. """ language: Optional[Language] = None prompt: Optional[str] = None - vad_signals: bool = True - high_vad_sensitivity: bool = False + mode: Optional[Literal["transcribe", "translate", "verbatim", "translit", "codemix"]] = None + vad_signals: Optional[bool] = None + high_vad_sensitivity: Optional[bool] = None def __init__( self, *, api_key: str, - model: str = "saarika:v2.5", + model: Optional[str] = None, + mode: Optional[ + Literal["transcribe", "translate", "verbatim", "translit", "codemix"] + ] = None, sample_rate: Optional[int] = None, input_audio_codec: str = "wav", params: Optional[InputParams] = None, + settings: Optional[Settings] = 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. @@ -99,63 +220,114 @@ class SarvamSTTService(STTService): Args: api_key: Sarvam API key for authentication. model: Sarvam model to use for transcription. + + .. deprecated:: 0.0.105 + Use ``settings=SarvamSTTService.Settings(model=...)`` instead. + + mode: Mode of operation. Options: transcribe, translate, verbatim, + translit, codemix. Only applicable to models that support it + (e.g., saaras:v3). Defaults to the model's default 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. + + .. deprecated:: 0.0.105 + Use ``settings=SarvamSTTService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + 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() + # --- 1. Hardcoded defaults --- + default_settings = self.Settings( + model="saarika:v2.5", + language=None, + prompt=None, + vad_signals=None, + high_vad_sensitivity=None, + ) - # Validate that saaras models don't accept language parameter - if "saaras" in model.lower(): - if params.language is not None: - raise ValueError( - f"Model '{model}' does not accept language parameter. " - "STT-Translate models auto-detect language." - ) + # --- 2. Deprecated direct-arg overrides --- + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model - # Validate that saarika models don't accept prompt parameter - if "saarika" in model.lower(): - if params.prompt is not None: - raise ValueError( - f"Model '{model}' does not accept prompt parameter. " - "Prompts are only supported for STT-Translate models" - ) + # --- 3. Deprecated params overrides --- + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = params.language + default_settings.prompt = params.prompt + if params.mode is not None: + mode = params.mode + default_settings.vad_signals = params.vad_signals + default_settings.high_vad_sensitivity = params.high_vad_sensitivity - super().__init__(sample_rate=sample_rate, **kwargs) + # --- 4. Settings delta (canonical API, always wins) --- + if settings is not None: + default_settings.apply_update(settings) + + # Resolve model config and validate (after all overrides) + resolved_model = default_settings.model + if resolved_model not in MODEL_CONFIGS: + allowed = ", ".join(sorted(MODEL_CONFIGS.keys())) + raise ValueError(f"Unsupported model '{resolved_model}'. Allowed values: {allowed}.") + + self._config = MODEL_CONFIGS[resolved_model] + + # Validate parameters against model capabilities + if default_settings.prompt is not None and not self._config.supports_prompt: + raise ValueError(f"Model '{resolved_model}' does not support prompt parameter.") + if mode is not None and not self._config.supports_mode: + raise ValueError(f"Model '{resolved_model}' does not support mode parameter.") + if default_settings.language is not None and not self._config.supports_language: + raise ValueError( + f"Model '{resolved_model}' does not support language parameter (auto-detects language)." + ) + + # Resolve mode default from model config + if mode is None: + mode = self._config.default_mode + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + keepalive_timeout=keepalive_timeout, + keepalive_interval=keepalive_interval, + settings=default_settings, + **kwargs, + ) - self.set_model_name(model) self._api_key = api_key - self._language_code: Optional[Language] = params.language - # For saarika models, default to "unknown" if language is not provided - if params.language: - self._language_string = language_to_sarvam_language(params.language) - elif "saarika" in model.lower(): - self._language_string = "unknown" - else: - self._language_string = None - self._prompt = params.prompt + + # Init-only connection config (not runtime-updatable) + self._mode = mode # Store connection parameters - self._vad_signals = params.vad_signals - self._high_vad_sensitivity = params.high_vad_sensitivity self._input_audio_codec = input_audio_codec # Initialize Sarvam SDK client self._sdk_headers = sdk_headers() - # NOTE: We avoid passing non-standard kwargs here because different sarvamai - # versions expose different constructor signatures (static type checkers - # complain otherwise). We instead inject headers best-effort below. - self._sarvam_client = AsyncSarvamAI(api_subscription_key=api_key) - for attr in ("default_headers", "_default_headers", "headers", "_headers"): - d = getattr(self._sarvam_client, attr, None) - if isinstance(d, dict): - d.update(self._sdk_headers) - break + # Pass Pipecat SDK headers directly at client construction time so they are + # merged by the Sarvam SDK's client wrapper and consistently applied to + # WebSocket handshake requests. + self._sarvam_client = AsyncSarvamAI(api_subscription_key=api_key, headers=self._sdk_headers) self._websocket_context = None self._socket_client = None self._receive_task = None + if default_settings.vad_signals: + self._register_event_handler("on_speech_started") + self._register_event_handler("on_speech_stopped") + self._register_event_handler("on_utterance_end") + + logger.info(f"Sarvam STT initialized with SDK headers: {self._sdk_headers}") + def language_to_service_language(self, language: Language) -> str: """Convert pipecat Language enum to Sarvam's language code. @@ -167,6 +339,12 @@ class SarvamSTTService(STTService): """ return language_to_sarvam_language(language) + def _get_language_string(self) -> Optional[str]: + """Resolve the current language setting to a Sarvam language code string.""" + if self._settings.language: + return language_to_sarvam_language(self._settings.language) + return self._config.default_language + def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -175,45 +353,93 @@ class SarvamSTTService(STTService): """ return True - async def set_language(self, language: Language): - """Set the recognition language and reconnect. + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process incoming frames. + + Handles VAD frames for TTFB tracking when using Pipecat's VAD + instead of Sarvam's built-in VAD. + """ + await super().process_frame(frame, direction) + + # Only handle VAD frames when not using Sarvam's VAD signals + if not self._settings.vad_signals: + if isinstance(frame, VADUserStartedSpeakingFrame): + await self._start_metrics() + elif isinstance(frame, VADUserStoppedSpeakingFrame): + if self._socket_client: + await self._socket_client.flush() + + async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: + """Apply a settings delta, validate, sync state, and reconnect. Args: - language: The language to use for speech recognition. - """ - # saaras models do not accept a language parameter - if "saaras" in self.model_name.lower(): - raise ValueError( - f"Model '{self.model_name}' (saaras) does not accept language parameter. " - "saaras models auto-detect language." - ) + delta: A :class:`STTSettings` (or ``SarvamSTTService.Settings``) delta. - logger.info(f"Switching STT language to: [{language}]") - self._language_code = language - self._language_string = language_to_sarvam_language(language) - await self._disconnect() - await self._connect() + Returns: + Dict mapping changed field names to their previous values. + + Raises: + ValueError: If a setting is not supported by the current model. + """ + # Validate against model capabilities before applying + if is_given(delta.language) and delta.language is not None: + if not self._config.supports_language: + raise ValueError( + f"Model '{self._settings.model}' does not support language parameter " + "(auto-detects language)." + ) + if isinstance(delta, self.Settings) and is_given(delta.prompt) and delta.prompt is not None: + if not self._config.supports_prompt: + raise ValueError( + f"Model '{self._settings.model}' does not support prompt parameter." + ) + + changed = await super()._update_settings(delta) + + # Language and prompt are WebSocket connect-time parameters; reconnect to apply. + reconnect_fields = {"language", "prompt"} + if changed.keys() & reconnect_fields: + await self._disconnect() + await self._connect() + + unhandled = {k: v for k, v in changed.items() if k not in reconnect_fields} + if unhandled: + self._warn_unhandled_updated_settings(unhandled) + + return changed async def set_prompt(self, prompt: Optional[str]): - """Set the translation prompt and reconnect. + """Set the transcription/translation prompt and reconnect. + + .. deprecated:: 0.0.104 + Use ``STTUpdateSettingsFrame(SarvamSTTService.Settings(prompt=...))`` instead. Args: - prompt: Prompt text to guide translation style/context. + prompt: Prompt text to guide transcription/translation style/context. Pass None to clear/disable prompt. - Only applicable to STT-Translate models, not STT models. + Only applicable to models that support prompts. """ - # saarika models do not accept prompt parameter - if "saarika" in self.model_name.lower(): + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + f"{self.__class__.__name__}.set_prompt() is deprecated. " + "Use STTUpdateSettingsFrame(self.Settings(prompt=...)) instead.", + DeprecationWarning, + stacklevel=2, + ) + + if not self._config.supports_prompt: if prompt is not None: raise ValueError( - f"Model '{self.model_name}' does not accept prompt parameter. " - "Prompts are only supported for STT-Translate models." + f"Model '{self._settings.model}' does not support prompt parameter." ) - # If prompt is None and it's saarika, just silently return (no-op) + # If prompt is None and model doesn't support prompts, silently return (no-op) return - logger.info("Updating STT-Translate prompt.") - self._prompt = prompt + logger.info(f"Updating {self._settings.model} prompt.") + self._settings.prompt = prompt await self._disconnect() await self._connect() @@ -244,7 +470,7 @@ class SarvamSTTService(STTService): await super().cancel(frame) await self._disconnect() - async def run_stt(self, audio: bytes): + async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: """Send audio data to Sarvam for transcription. Args: @@ -254,7 +480,6 @@ class SarvamSTTService(STTService): Frame: None (transcription results come via WebSocket callbacks). """ if not self._socket_client: - logger.warning("WebSocket not connected, cannot process audio") yield None return @@ -276,13 +501,11 @@ class SarvamSTTService(STTService): "sample_rate": self.sample_rate, } - # Use appropriate method based on service type - if "saarika" in self.model_name.lower(): - # STT service - await self._socket_client.transcribe(**method_kwargs) - else: - # STT-Translate service - auto-detects input language and returns translated text + # Use appropriate method based on model configuration + if self._config.use_translate_method: await self._socket_client.translate(**method_kwargs) + else: + await self._socket_client.transcribe(**method_kwargs) except Exception as e: yield ErrorFrame(error=f"Error sending audio to Sarvam: {e}", exception=e) @@ -294,49 +517,88 @@ class SarvamSTTService(STTService): logger.debug("Connecting to Sarvam") try: - # Convert boolean parameters to string for SDK - vad_signals_str = "true" if self._vad_signals else "false" - high_vad_sensitivity_str = "true" if self._high_vad_sensitivity else "false" - # Build common connection parameters connect_kwargs = { - "model": self.model_name, - "vad_signals": vad_signals_str, - "high_vad_sensitivity": high_vad_sensitivity_str, - "input_audio_codec": self._input_audio_codec, + "model": self._settings.model, "sample_rate": str(self.sample_rate), } + # Enable flush signal when using Pipecat's VAD (not Sarvam's) so that + # the flush() call on user-stopped-speaking is honored by the server. + if not self._settings.vad_signals: + connect_kwargs["flush_signal"] = "true" + + # Only send vad parameters when explicitly set (avoid overriding server defaults) + if self._settings.vad_signals is not None: + connect_kwargs["vad_signals"] = "true" if self._settings.vad_signals else "false" + if self._settings.high_vad_sensitivity is not None: + connect_kwargs["high_vad_sensitivity"] = ( + "true" if self._settings.high_vad_sensitivity else "false" + ) + + # Add language_code for models that support it + language_string = self._get_language_string() + if language_string is not None: + connect_kwargs["language_code"] = language_string + + # Add mode for models that support it + if self._config.supports_mode and self._mode is not None: + connect_kwargs["mode"] = self._mode + + # Prompt support differs across sarvamai versions. Prefer connect-time prompt + # when available and gracefully degrade if the SDK doesn't accept it. + if self._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. + # Headers are supplied through request_options because this is a + # documented SDK parameter that survives SDK signature changes. + request_options = {"additional_headers": self._sdk_headers} + + attempts = [kwargs] + if "prompt" in kwargs: + attempts.append({k: v for k, v in kwargs.items() if k != "prompt"}) + + last_type_error = None + for attempt_kwargs in attempts: try: - return connect_fn(**kwargs, **{header_kw: self._sdk_headers}) - except TypeError: - pass + return connect_fn( + **attempt_kwargs, + request_options=request_options, + ) + except TypeError as e: + last_type_error = e + try: + # Fallback for SDK builds that don't expose request_options. + return connect_fn(**attempt_kwargs) + except TypeError as e: + last_type_error = e + + if last_type_error is not None: + raise last_type_error return connect_fn(**kwargs) - # Choose the appropriate service based on model - if "saarika" in self.model_name.lower(): - # STT service - requires language_code - connect_kwargs["language_code"] = self._language_string + # Choose the appropriate endpoint based on model configuration + if self._config.use_translate_endpoint: self._websocket_context = _connect_with_sdk_headers( - self._sarvam_client.speech_to_text_streaming.connect, + self._sarvam_client.speech_to_text_translate_streaming.connect, **connect_kwargs, ) else: - # STT-Translate service - auto-detects input language and returns translated text self._websocket_context = _connect_with_sdk_headers( - self._sarvam_client.speech_to_text_translate_streaming.connect, + self._sarvam_client.speech_to_text_streaming.connect, **connect_kwargs, ) # Enter the async context manager self._socket_client = await self._websocket_context.__aenter__() - # Set prompt if provided (only for STT-Translate models, after connection) - if self._prompt is not None and "saaras" in self.model_name.lower(): - await self._socket_client.set_prompt(self._prompt) + # Fallback for SDKs that support runtime prompt updates. + if 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): @@ -349,9 +611,13 @@ 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: + self._socket_client = None + self._websocket_context = None await self.push_error(error_msg=f"Sarvam API error: {e}", exception=e) except Exception as e: self._socket_client = None @@ -360,22 +626,28 @@ 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 - if self._websocket_context and self._socket_client: + # Clear references first to prevent run_stt from sending audio + # during the close handshake. + socket_client = self._socket_client + websocket_context = self._websocket_context + self._socket_client = None + self._websocket_context = None + + if websocket_context and socket_client: try: - # Exit the async context manager - await self._websocket_context.__aexit__(None, None, None) + await websocket_context.__aexit__(None, None, None) except Exception as e: await self.push_error( error_msg=f"Error closing WebSocket connection: {e}", exception=e ) finally: logger.debug("Disconnected from Sarvam WebSocket") - self._socket_client = None - self._websocket_context = None async def _receive_task_handler(self): """Handle incoming messages from Sarvam WebSocket. @@ -411,25 +683,29 @@ class SarvamSTTService(STTService): logger.debug(f"VAD Signal: {signal}, Occurred at: {timestamp}") if signal == "START_SPEECH": - await self.start_metrics() + await self._start_metrics() logger.debug("User started speaking") await self._call_event_handler("on_speech_started") + await self.broadcast_frame(UserStartedSpeakingFrame) + await self.broadcast_interruption() elif signal == "END_SPEECH": logger.debug("User stopped speaking") await self._call_event_handler("on_speech_stopped") + await self.broadcast_frame(UserStoppedSpeakingFrame) elif message.type == "data": - await self.stop_ttfb_metrics() transcript = message.data.transcript language_code = message.data.language_code # Prefer language from message (auto-detected for translate models). Fallback to configured. if language_code: language = self._map_language_code_to_enum(language_code) - elif self._language_string: - language = self._map_language_code_to_enum(self._language_string) else: - language = Language.HI_IN + language_string = self._get_language_string() + if language_string: + language = self._map_language_code_to_enum(language_string) + else: + language = Language.HI_IN # Emit utterance end event await self._call_event_handler("on_utterance_end") @@ -482,7 +758,32 @@ class SarvamSTTService(STTService): } return mapping.get(language_code, Language.HI_IN) - async def start_metrics(self): - """Start TTFB and processing metrics collection.""" - await self.start_ttfb_metrics() + 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/sarvam/tts.py b/src/pipecat/services/sarvam/tts.py index 2837b3e20..8bfeea8c6 100644 --- a/src/pipecat/services/sarvam/tts.py +++ b/src/pipecat/services/sarvam/tts.py @@ -4,12 +4,45 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Sarvam AI text-to-speech service implementation.""" +"""Sarvam AI text-to-speech service implementation. + +This module provides TTS services using Sarvam AI's API with support for multiple +Indian languages and two model variants: + +**Model Variants:** + +- **bulbul:v2** (default): Standard TTS model + - Supports: pitch, loudness, pace (0.3-3.0) + - Default sample rate: 22050 Hz + - Speakers: anushka (default), abhilash, manisha, vidya, arya, karun, hitesh + +- **bulbul:v3-beta**: Advanced TTS model with temperature control + - Does NOT support: pitch, loudness + - Supports: pace (0.5-2.0), temperature (0.01-1.0) + - Default sample rate: 24000 Hz + - Preprocessing is always enabled + - Speakers: aditya (default), ritu, priya, neha, rahul, pooja, rohan, simran, + kavya, amit, dev, ishita, shreya, ratan, varun, manan, sumit, roopa, kabir, + aayan, shubh, ashutosh, advait, amelia, sophia + +- **bulbul:v3**: Advanced TTS model with temperature control + - Does NOT support: pitch, loudness + - Supports: pace (0.5-2.0), temperature (0.01-1.0) + - Default sample rate: 24000 Hz + - Preprocessing is always enabled + - Speakers: aditya (default), ritu, priya, neha, rahul, pooja, rohan, simran, + kavya, amit, dev, ishita, shreya, ratan, varun, manan, sumit, roopa, kabir, + aayan, shubh, ashutosh, advait, amelia, sophia + +See https://docs.sarvam.ai/api-reference-docs/text-to-speech/stream for full API details. +""" import asyncio import base64 import json -from typing import Any, AsyncGenerator, Mapping, Optional +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, AsyncGenerator, ClassVar, Dict, List, Optional, Tuple import aiohttp from loguru import logger @@ -20,16 +53,15 @@ from pipecat.frames.frames import ( EndFrame, ErrorFrame, Frame, - InterruptionFrame, LLMFullResponseEndFrame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, TTSStoppedFrame, ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.sarvam._sdk import sdk_headers -from pipecat.services.tts_service import InterruptibleTTSService, TTSService +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven +from pipecat.services.tts_service import InterruptibleTTSService, TextAggregationMode, TTSService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.tracing.service_decorators import traced_tts @@ -42,6 +74,149 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +class SarvamTTSModel(str, Enum): + """Available Sarvam TTS models. + + Attributes: + BULBUL_V2: Standard TTS model with pitch/loudness control. + - Supports pitch, loudness, pace (0.3-3.0) + - Default sample rate: 22050 Hz + BULBUL_V3_BETA: Advanced model with temperature control. + - Does NOT support pitch/loudness + - Pace range: 0.5-2.0 + - Supports temperature parameter + - Default sample rate: 24000 Hz + - Preprocessing is always enabled + """ + + BULBUL_V2 = "bulbul:v2" + BULBUL_V3_BETA = "bulbul:v3-beta" + BULBUL_V3 = "bulbul:v3" + + +class SarvamTTSSpeakerV2(str, Enum): + """Available speakers for bulbul:v2 model. + + Female voices: anushka, manisha, vidya, arya + Male voices: abhilash, karun, hitesh + """ + + ANUSHKA = "anushka" + ABHILASH = "abhilash" + MANISHA = "manisha" + VIDYA = "vidya" + ARYA = "arya" + KARUN = "karun" + HITESH = "hitesh" + + +class SarvamTTSSpeakerV3(str, Enum): + """Available speakers for bulbul:v3-beta model. + + Includes a wider variety of voices with different characteristics. + """ + + ADITYA = "aditya" + RITU = "ritu" + PRIYA = "priya" + NEHA = "neha" + RAHUL = "rahul" + POOJA = "pooja" + ROHAN = "rohan" + SIMRAN = "simran" + KAVYA = "kavya" + AMIT = "amit" + DEV = "dev" + ISHITA = "ishita" + SHREYA = "shreya" + RATAN = "ratan" + VARUN = "varun" + MANAN = "manan" + SUMIT = "sumit" + ROOPA = "roopa" + KABIR = "kabir" + AAYAN = "aayan" + SHUBH = "shubh" + ASHUTOSH = "ashutosh" + ADVAIT = "advait" + AMELIA = "amelia" + SOPHIA = "sophia" + + +@dataclass(frozen=True) +class TTSModelConfig: + """Immutable configuration for a Sarvam TTS model. + + Attributes: + supports_pitch: Whether the model accepts pitch parameter. + supports_loudness: Whether the model accepts loudness parameter. + supports_temperature: Whether the model accepts temperature parameter. + default_sample_rate: Default audio sample rate in Hz. + default_speaker: Default speaker voice ID. + pace_range: Valid range for pace parameter (min, max). + preprocessing_always_enabled: Whether preprocessing is always enabled. + speakers: Tuple of available speaker names for this model. + """ + + supports_pitch: bool + supports_loudness: bool + supports_temperature: bool + default_sample_rate: int + default_speaker: str + pace_range: Tuple[float, float] + preprocessing_always_enabled: bool + speakers: Tuple[str, ...] + + +TTS_MODEL_CONFIGS: Dict[str, TTSModelConfig] = { + "bulbul:v2": TTSModelConfig( + supports_pitch=True, + supports_loudness=True, + supports_temperature=False, + default_sample_rate=22050, + default_speaker="anushka", + pace_range=(0.3, 3.0), + preprocessing_always_enabled=False, + speakers=tuple(s.value for s in SarvamTTSSpeakerV2), + ), + "bulbul:v3-beta": TTSModelConfig( + supports_pitch=False, + supports_loudness=False, + supports_temperature=True, + default_sample_rate=24000, + default_speaker="shubh", + pace_range=(0.5, 2.0), + preprocessing_always_enabled=True, + speakers=tuple(s.value for s in SarvamTTSSpeakerV3), + ), + "bulbul:v3": TTSModelConfig( + supports_pitch=False, + supports_loudness=False, + supports_temperature=True, + default_sample_rate=24000, + default_speaker="shubh", + pace_range=(0.5, 2.0), + preprocessing_always_enabled=True, + speakers=tuple(s.value for s in SarvamTTSSpeakerV3), + ), +} + + +def get_speakers_for_model(model: str) -> List[str]: + """Get the list of available speakers for a given model. + + Args: + model: The model name (e.g., "bulbul:v2" or "bulbul:v3-beta"). + + Returns: + List of speaker names available for the model. + """ + if model in TTS_MODEL_CONFIGS: + return list(TTS_MODEL_CONFIGS[model].speakers) + # Default to v2 speakers for unknown models + return list(TTS_MODEL_CONFIGS["bulbul:v2"].speakers) + + def language_to_sarvam_language(language: Language) -> Optional[str]: """Convert Pipecat Language enum to Sarvam AI language codes. @@ -53,78 +228,182 @@ def language_to_sarvam_language(language: Language) -> Optional[str]: """ LANGUAGE_MAP = { Language.BN: "bn-IN", # Bengali + Language.BN_IN: "bn-IN", Language.EN: "en-IN", # English (India) + Language.EN_IN: "en-IN", Language.GU: "gu-IN", # Gujarati + Language.GU_IN: "gu-IN", Language.HI: "hi-IN", # Hindi + Language.HI_IN: "hi-IN", Language.KN: "kn-IN", # Kannada + Language.KN_IN: "kn-IN", Language.ML: "ml-IN", # Malayalam + Language.ML_IN: "ml-IN", Language.MR: "mr-IN", # Marathi + Language.MR_IN: "mr-IN", Language.OR: "od-IN", # Odia + Language.OR_IN: "od-IN", Language.PA: "pa-IN", # Punjabi + Language.PA_IN: "pa-IN", Language.TA: "ta-IN", # Tamil + Language.TA_IN: "ta-IN", Language.TE: "te-IN", # Telugu + Language.TE_IN: "te-IN", } return resolve_language(language, LANGUAGE_MAP, use_base_code=False) +@dataclass +class SarvamHttpTTSSettings(TTSSettings): + """Settings for SarvamHttpTTSService. + + Parameters: + enable_preprocessing: Whether to enable text preprocessing. Defaults to False. + **Note:** Always enabled for bulbul:v3-beta (cannot be disabled). + pace: Speech pace multiplier. Defaults to 1.0. + - bulbul:v2: Range 0.3 to 3.0 + - bulbul:v3-beta: Range 0.5 to 2.0 + pitch: Voice pitch adjustment (-0.75 to 0.75). Defaults to 0.0. + **Note:** Only supported for bulbul:v2. Ignored for v3 models. + loudness: Volume multiplier (0.3 to 3.0). Defaults to 1.0. + **Note:** Only supported for bulbul:v2. Ignored for v3 models. + temperature: Controls output randomness for bulbul:v3-beta (0.01 to 1.0). + Lower values = more deterministic, higher = more random. Defaults to 0.6. + **Note:** Only supported for bulbul:v3-beta. Ignored for v2. + """ + + enable_preprocessing: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + pace: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + pitch: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + loudness: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + temperature: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + +@dataclass +class SarvamTTSSettings(SarvamHttpTTSSettings): + """Settings for SarvamTTSService. + + Extends :class:`SarvamHttpTTSService.Settings` with WebSocket-specific buffering parameters. + + Parameters: + min_buffer_size: Minimum characters to buffer before generating audio. + Lower values reduce latency but may affect quality. Defaults to 50. + max_chunk_length: Maximum characters processed in a single chunk. + Controls memory usage and processing efficiency. Defaults to 150. + """ + + _aliases: ClassVar[Dict[str, str]] = {"target_language_code": "language"} + + min_buffer_size: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + max_chunk_length: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class SarvamHttpTTSService(TTSService): """Text-to-Speech service using Sarvam AI's API. Converts text to speech using Sarvam AI's TTS models with support for multiple - Indian languages. Provides control over voice characteristics like pitch, pace, - and loudness. + Indian languages. Provides control over voice characteristics. + + **Model Differences:** + + - **bulbul:v2** (default): + - Supports: pitch (-0.75 to 0.75), loudness (0.3 to 3.0), pace (0.3 to 3.0) + - Default sample rate: 22050 Hz + - Speakers: anushka, abhilash, manisha, vidya, arya, karun, hitesh + + - **bulbul:v3-beta**: + - Does NOT support: pitch, loudness (will be ignored) + - Supports: pace (0.5 to 2.0), temperature (0.01 to 1.0) + - Default sample rate: 24000 Hz + - Preprocessing is always enabled + - Speakers: aditya, ritu, priya, neha, rahul, pooja, rohan, simran, kavya, + amit, dev, ishita, shreya, ratan, varun, manan, sumit, roopa, kabir, + aayan, shubh, ashutosh, advait, amelia, sophia Example:: + # Using bulbul:v2 (default) tts = SarvamHttpTTSService( api_key="your-api-key", - voice_id="anushka", - model="bulbul:v2", aiohttp_session=session, - params=SarvamHttpTTSService.InputParams( + settings=SarvamHttpTTSService.Settings( + voice="anushka", + model="bulbul:v2", language=Language.HI, pitch=0.1, - pace=1.2 - ) + pace=1.2, + loudness=1.5, + ), ) - # For bulbul v3 beta with any speaker: + # Using bulbul:v3-beta with temperature control tts_v3 = SarvamHttpTTSService( api_key="your-api-key", - voice_id="speaker_name", - model="bulbul:v3, aiohttp_session=session, - params=SarvamHttpTTSService.InputParams( + settings=SarvamHttpTTSService.Settings( + voice="aditya", # Use v3 speaker + model="bulbul:v3-beta", language=Language.HI, - temperature=0.8 - ) + pace=1.2, # Range: 0.5-2.0 for v3 + temperature=0.8, + ), ) """ + Settings = SarvamHttpTTSSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Sarvam TTS configuration. + .. deprecated:: 0.0.105 + Use ``SarvamHttpTTSService.Settings`` directly via the ``settings`` parameter instead. + Parameters: language: Language for synthesis. Defaults to English (India). pitch: Voice pitch adjustment (-0.75 to 0.75). Defaults to 0.0. - pace: Speech pace multiplier (0.3 to 3.0). Defaults to 1.0. - loudness: Volume multiplier (0.1 to 3.0). Defaults to 1.0. + **Note:** Only supported for bulbul:v2. Ignored for v3 models. + pace: Speech pace multiplier. Defaults to 1.0. + - bulbul:v2: Range 0.3 to 3.0 + - bulbul:v3-beta: Range 0.5 to 2.0 + loudness: Volume multiplier (0.3 to 3.0). Defaults to 1.0. + **Note:** Only supported for bulbul:v2. Ignored for v3 models. enable_preprocessing: Whether to enable text preprocessing. Defaults to False. + **Note:** Always enabled for bulbul:v3-beta (cannot be disabled). + temperature: Controls output randomness for bulbul:v3-beta (0.01 to 1.0). + Lower values = more deterministic, higher = more random. Defaults to 0.6. + **Note:** Only supported for bulbul:v3-beta. Ignored for v2. """ language: Optional[Language] = Language.EN - pitch: Optional[float] = Field(default=0.0, ge=-0.75, le=0.75) - pace: Optional[float] = Field(default=1.0, ge=0.3, le=3.0) - loudness: Optional[float] = Field(default=1.0, ge=0.1, le=3.0) - enable_preprocessing: Optional[bool] = False + pitch: Optional[float] = Field( + default=0.0, + ge=-0.75, + le=0.75, + description="Voice pitch adjustment. Only for bulbul:v2.", + ) + pace: Optional[float] = Field( + default=1.0, + ge=0.3, + le=3.0, + description="Speech pace. v2: 0.3-3.0, v3: 0.5-2.0.", + ) + loudness: Optional[float] = Field( + default=1.0, + ge=0.3, + le=3.0, + description="Volume multiplier. Only for bulbul:v2.", + ) + enable_preprocessing: Optional[bool] = Field( + default=False, + description="Enable text preprocessing. Always enabled for v3-beta model.", + ) temperature: Optional[float] = Field( default=0.6, ge=0.01, le=1.0, - description="Controls the randomness of the output for bulbul v3 beta. " - "Lower values make the output more focused and deterministic, while " - "higher values make it more random. Range: 0.01 to 1.0. Default: 0.6.", + description="Output randomness for bulbul:v3-beta only. Range: 0.01-1.0.", ) def __init__( @@ -132,11 +411,12 @@ class SarvamHttpTTSService(TTSService): *, api_key: str, aiohttp_session: aiohttp.ClientSession, - voice_id: str = "anushka", - model: str = "bulbul:v2", + voice_id: Optional[str] = None, + model: Optional[str] = None, base_url: str = "https://api.sarvam.ai", sample_rate: Optional[int] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Sarvam TTS service. @@ -144,50 +424,124 @@ class SarvamHttpTTSService(TTSService): Args: api_key: Sarvam AI API subscription key. aiohttp_session: Shared aiohttp session for making requests. - voice_id: Speaker voice ID (e.g., "anushka", "meera"). Defaults to "anushka". - model: TTS model to use ("bulbul:v2" or "bulbul:v3-beta" or "bulbul:v3"). Defaults to "bulbul:v2". + voice_id: Speaker voice ID. If None, uses model-appropriate default. + + .. deprecated:: 0.0.105 + Use ``settings=SarvamHttpTTSService.Settings(voice=...)`` instead. + + model: TTS model to use. Options: + - "bulbul:v2" (default): Standard model with pitch/loudness support + - "bulbul:v3-beta": Advanced model with temperature control + + .. deprecated:: 0.0.105 + Use ``settings=SarvamHttpTTSService.Settings(model=...)`` instead. + base_url: Sarvam AI API base URL. Defaults to "https://api.sarvam.ai". - sample_rate: Audio sample rate in Hz (8000, 16000, 22050, 24000). If None, uses default. + sample_rate: Audio sample rate in Hz (8000, 16000, 22050, 24000). + If None, uses model-specific default. params: Additional voice and preprocessing parameters. If None, uses defaults. + + .. deprecated:: 0.0.105 + Use ``settings=SarvamHttpTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="bulbul:v2", + voice="anushka", + language="en-IN", + enable_preprocessing=False, + pace=1.0, + pitch=None, + loudness=None, + temperature=None, + ) - params = params or SarvamHttpTTSService.InputParams() + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.enable_preprocessing is not None: + default_settings.enable_preprocessing = params.enable_preprocessing + if params.pace is not None: + default_settings.pace = params.pace + if params.pitch is not None: + default_settings.pitch = params.pitch + if params.loudness is not None: + default_settings.loudness = params.loudness + if params.temperature is not None: + default_settings.temperature = params.temperature + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + # Get model configuration (validates model exists) + resolved_model = default_settings.model + if resolved_model not in TTS_MODEL_CONFIGS: + allowed = ", ".join(sorted(TTS_MODEL_CONFIGS.keys())) + raise ValueError(f"Unsupported model '{resolved_model}'. Allowed values: {allowed}.") + + self._config = TTS_MODEL_CONFIGS[resolved_model] + + # Set default sample rate based on model if not specified + if sample_rate is None: + sample_rate = self._config.default_sample_rate + + # Set default voice based on model if not specified via any mechanism + if voice_id is None and (settings is None or settings.voice is NOT_GIVEN): + default_settings.voice = self._config.default_speaker + + # Validate and clamp pace to model's valid range + pace = default_settings.pace + pace_min, pace_max = self._config.pace_range + if pace is not None and (pace < pace_min or pace > pace_max): + logger.warning(f"Pace {pace} is outside model range ({pace_min}-{pace_max}). Clamping.") + default_settings.pace = max(pace_min, min(pace_max, pace)) + + # Force preprocessing for models that require it + if self._config.preprocessing_always_enabled: + default_settings.enable_preprocessing = True + + # Warn about unsupported model-specific parameters + if not self._config.supports_pitch and default_settings.pitch not in (None, 0.0): + logger.warning(f"pitch parameter is ignored for {resolved_model}") + default_settings.pitch = None + if not self._config.supports_loudness and default_settings.loudness not in (None, 1.0): + logger.warning(f"loudness parameter is ignored for {resolved_model}") + default_settings.loudness = None + if not self._config.supports_temperature and default_settings.temperature not in ( + None, + 0.6, + ): + logger.warning(f"temperature parameter is ignored for {resolved_model}") + default_settings.temperature = None + + super().__init__( + sample_rate=sample_rate, + push_stop_frames=True, + push_start_frame=True, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._base_url = base_url self._session = aiohttp_session - # Build base settings common to all models - self._settings = { - "language": ( - self.language_to_service_language(params.language) if params.language else "en-IN" - ), - "enable_preprocessing": params.enable_preprocessing, - } - - # Add model-specific parameters - if model in ("bulbul:v3-beta", "bulbul:v3"): - self._settings.update( - { - "temperature": getattr(params, "temperature", 0.6), - "model": model, - } - ) - else: - self._settings.update( - { - "pitch": params.pitch, - "pace": params.pace, - "loudness": params.loudness, - "model": model, - } - ) - - self.set_model_name(model) - self.set_voice(voice_id) - def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -214,14 +568,14 @@ class SarvamHttpTTSService(TTSService): frame: The start frame containing initialization parameters. """ await super().start(frame) - self._settings["sample_rate"] = self.sample_rate @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Sarvam AI's API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -229,20 +583,29 @@ class SarvamHttpTTSService(TTSService): logger.debug(f"{self}: Generating TTS [{text}]") try: - await self.start_ttfb_metrics() - + # Build payload with common parameters payload = { "text": text, - "target_language_code": self._settings["language"], - "speaker": self._voice_id, - "pitch": self._settings["pitch"], - "pace": self._settings["pace"], - "loudness": self._settings["loudness"], + "target_language_code": self._settings.language, + "speaker": self._settings.voice, "sample_rate": self.sample_rate, - "enable_preprocessing": self._settings["enable_preprocessing"], - "model": self._model_name, + "enable_preprocessing": self._settings.enable_preprocessing, + "model": self._settings.model, + "pace": self._settings.pace if self._settings.pace is not None else 1.0, } + # Add model-specific parameters based on config + if self._config.supports_pitch: + payload["pitch"] = self._settings.pitch if self._settings.pitch is not None else 0.0 + if self._config.supports_loudness: + payload["loudness"] = ( + self._settings.loudness if self._settings.loudness is not None else 1.0 + ) + if self._config.supports_temperature: + payload["temperature"] = ( + self._settings.temperature if self._settings.temperature is not None else 0.6 + ) + headers = { "api-subscription-key": self._api_key, "Content-Type": "application/json", @@ -251,8 +614,6 @@ class SarvamHttpTTSService(TTSService): url = f"{self._base_url}/text-to-speech" - yield TTSStartedFrame() - async with self._session.post(url, json=payload, headers=headers) as response: if response.status != 200: error_text = await response.text() @@ -273,7 +634,7 @@ class SarvamHttpTTSService(TTSService): audio_data = base64.b64decode(base64_audio) # Strip WAV header (first 44 bytes) if present - if audio_data.startswith(b"RIFF"): + if len(audio_data) > 44 and audio_data.startswith(b"RIFF"): logger.debug("Stripping WAV header from Sarvam audio data") audio_data = audio_data[44:] @@ -281,6 +642,7 @@ class SarvamHttpTTSService(TTSService): audio=audio_data, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) yield frame @@ -289,165 +651,327 @@ class SarvamHttpTTSService(TTSService): yield ErrorFrame(error=f"Error generating TTS: {e}", exception=e) finally: await self.stop_ttfb_metrics() - yield TTSStoppedFrame() class SarvamTTSService(InterruptibleTTSService): """WebSocket-based text-to-speech service using Sarvam AI. Provides streaming TTS with real-time audio generation for multiple Indian languages. - Supports voice control parameters like pitch, pace, and loudness adjustment. + Uses WebSocket for low-latency streaming audio synthesis. + + **Model Differences:** + + - **bulbul:v2** (default): + - Supports: pitch (-0.75 to 0.75), loudness (0.3 to 3.0), pace (0.3 to 3.0) + - Default sample rate: 22050 Hz + - Speakers: anushka, abhilash, manisha, vidya, arya, karun, hitesh + + - **bulbul:v3-beta** / **bulbul:v3**: + - Does NOT support: pitch, loudness (will be ignored) + - Supports: pace (0.5 to 2.0), temperature (0.01 to 1.0) + - Default sample rate: 24000 Hz + - Preprocessing is always enabled + - Speakers: aditya, ritu, priya, neha, rahul, pooja, rohan, simran, kavya, + amit, dev, ishita, shreya, ratan, varun, manan, sumit, roopa, kabir, + aayan, shubh, ashutosh, advait, amelia, sophia + + **WebSocket Protocol:** + The service uses a WebSocket connection for real-time streaming. Messages include: + - config: Initial configuration with voice settings + - text: Text chunks for synthesis + - flush: Signal to process remaining buffered text + - ping: Keepalive signal Example:: + # Using bulbul:v2 (default) tts = SarvamTTSService( api_key="your-api-key", - voice_id="anushka", - model="bulbul:v2", - params=SarvamTTSService.InputParams( + settings=SarvamTTSService.Settings( + voice="anushka", + model="bulbul:v2", language=Language.HI, pitch=0.1, - pace=1.2 - ) + pace=1.2, + loudness=1.5, + ), ) - # For bulbul v3 beta with any speaker and temperature: - # Note: pace and loudness are not supported for bulbul v3 and bulbul v3 beta + # Using bulbul:v3-beta with temperature control tts_v3 = SarvamTTSService( api_key="your-api-key", - voice_id="speaker_name", - model="bulbul:v3", - params=SarvamTTSService.InputParams( + settings=SarvamTTSService.Settings( + voice="aditya", # Use v3 speaker + model="bulbul:v3-beta", language=Language.HI, - temperature=0.8 - ) + pace=1.2, # Range: 0.5-2.0 for v3 + temperature=0.8, + ), ) + + See https://docs.sarvam.ai/api-reference-docs/text-to-speech/stream for API details. """ + Settings = SarvamTTSSettings + _settings: Settings + class InputParams(BaseModel): - """Configuration parameters for Sarvam TTS. + """Configuration parameters for Sarvam TTS WebSocket service. + + .. deprecated:: 0.0.105 + Use ``SarvamTTSService.Settings`` directly via the ``settings`` parameter instead. Parameters: pitch: Voice pitch adjustment (-0.75 to 0.75). Defaults to 0.0. - pace: Speech pace multiplier (0.3 to 3.0). Defaults to 1.0. - loudness: Volume multiplier (0.1 to 3.0). Defaults to 1.0. + **Note:** Only supported for bulbul:v2. Ignored for v3 models. + pace: Speech pace multiplier. Defaults to 1.0. + - bulbul:v2: Range 0.3 to 3.0 + - bulbul:v3-beta: Range 0.5 to 2.0 + loudness: Volume multiplier (0.3 to 3.0). Defaults to 1.0. + **Note:** Only supported for bulbul:v2. Ignored for v3 models. enable_preprocessing: Enable text preprocessing. Defaults to False. - min_buffer_size: Minimum number of characters to buffer before generating audio. + **Note:** Always enabled for bulbul:v3-beta. + min_buffer_size: Minimum characters to buffer before generating audio. Lower values reduce latency but may affect quality. Defaults to 50. - max_chunk_length: Maximum number of characters processed in a single chunk. - Controls memory usage and processing efficiency. Defaults to 200. - output_audio_codec: Audio codec format. Defaults to "linear16". - output_audio_bitrate: Audio bitrate. Defaults to "128k". - language: Target language for synthesis. Supports Bengali (bn-IN), English (en-IN), - Gujarati (gu-IN), Hindi (hi-IN), Kannada (kn-IN), Malayalam (ml-IN), - Marathi (mr-IN), Odia (od-IN), Punjabi (pa-IN), Tamil (ta-IN), - Telugu (te-IN). Defaults to en-IN. + max_chunk_length: Maximum characters processed in a single chunk. + Controls memory usage and processing efficiency. Defaults to 150. + output_audio_codec: Audio codec format. Options: linear16, mulaw, alaw, + opus, flac, aac, wav, mp3. Defaults to "linear16". + output_audio_bitrate: Audio bitrate (32k, 64k, 96k, 128k, 192k). + Defaults to "128k". + language: Target language for synthesis. Supports Indian languages. + temperature: Controls output randomness for bulbul:v3-beta (0.01 to 1.0). + Lower = more deterministic, higher = more random. Defaults to 0.6. + **Note:** Only supported for bulbul:v3-beta. Ignored for v2. - Available Speakers: - Female: anushka, manisha, vidya, arya - Male: abhilash, karun, hitesh + **Speakers by Model:** + + bulbul:v2: + - Female: anushka (default), manisha, vidya, arya + - Male: abhilash, karun, hitesh + + bulbul:v3-beta: + - aditya (default), ritu, priya, neha, rahul, pooja, rohan, simran, + kavya, amit, dev, ishita, shreya, ratan, varun, manan, sumit, + roopa, kabir, aayan, shubh, ashutosh, advait, amelia, sophia """ - pitch: Optional[float] = Field(default=0.0, ge=-0.75, le=0.75) - pace: Optional[float] = Field(default=1.0, ge=0.3, le=3.0) - loudness: Optional[float] = Field(default=1.0, ge=0.1, le=3.0) - enable_preprocessing: Optional[bool] = False - min_buffer_size: Optional[int] = 50 - max_chunk_length: Optional[int] = 200 - output_audio_codec: Optional[str] = "linear16" - output_audio_bitrate: Optional[str] = "128k" + pitch: Optional[float] = Field( + default=0.0, + ge=-0.75, + le=0.75, + description="Voice pitch adjustment. Only for bulbul:v2.", + ) + pace: Optional[float] = Field( + default=1.0, + ge=0.3, + le=3.0, + description="Speech pace. v2: 0.3-3.0, v3: 0.5-2.0.", + ) + loudness: Optional[float] = Field( + default=1.0, + ge=0.3, + le=3.0, + description="Volume multiplier. Only for bulbul:v2.", + ) + enable_preprocessing: Optional[bool] = Field( + default=False, + description="Enable text preprocessing. Always enabled for v3 models.", + ) + min_buffer_size: Optional[int] = Field( + default=50, + description="Minimum characters to buffer before TTS processing.", + ) + max_chunk_length: Optional[int] = Field( + default=150, + description="Maximum length for sentence splitting.", + ) + output_audio_codec: Optional[str] = Field( + default="linear16", + description="Audio codec: linear16, mulaw, alaw, opus, flac, aac, wav, mp3.", + ) + output_audio_bitrate: Optional[str] = Field( + default="128k", + description="Audio bitrate: 32k, 64k, 96k, 128k, 192k.", + ) language: Optional[Language] = Language.EN temperature: Optional[float] = Field( default=0.6, ge=0.01, le=1.0, - description="Controls the randomness of the output for bulbul v3 beta. " - "Lower values make the output more focused and deterministic, while " - "higher values make it more random. Range: 0.01 to 1.0. Default: 0.6.", + description="Output randomness for bulbul:v3-beta only. Range: 0.01-1.0.", ) def __init__( self, *, api_key: str, - model: str = "bulbul:v2", - voice_id: str = "anushka", + model: Optional[str] = None, + voice_id: Optional[str] = None, url: str = "wss://api.sarvam.ai/text-to-speech/ws", - aggregate_sentences: Optional[bool] = True, + aggregate_sentences: Optional[bool] = None, + text_aggregation_mode: Optional[TextAggregationMode] = None, sample_rate: Optional[int] = None, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Sarvam TTS service with voice and transport configuration. Args: api_key: Sarvam API key for authenticating TTS requests. - model: Identifier of the Sarvam speech model (default "bulbul:v2"). - Supports "bulbul:v2", "bulbul:v3-beta" and "bulbul:v3". - voice_id: Voice identifier for synthesis (default "anushka"). - url: WebSocket URL for connecting to the TTS backend (default production URL). - aggregate_sentences: Whether to merge multiple sentences into one audio chunk (default True). - sample_rate: Desired sample rate for the output audio in Hz (overrides default if set). - params: Optional input parameters to override global configuration. - **kwargs: Optional keyword arguments forwarded to InterruptibleTTSService (such as - `push_stop_frames`, `sample_rate`, task manager parameters, event hooks, etc.) - to customize transport behavior or enable metrics support. + model: TTS model to use. Options: + - "bulbul:v2" (default): Standard model with pitch/loudness support + - "bulbul:v3-beta": Advanced model with temperature control - This method sets up the internal TTS configuration mapping, constructs the WebSocket - URL based on the chosen model, and initializes state flags before connecting. + .. deprecated:: 0.0.105 + Use ``settings=SarvamTTSService.Settings(model=...)`` instead. + + voice_id: Speaker voice ID. If None, uses model-appropriate default. + + .. deprecated:: 0.0.105 + Use ``settings=SarvamTTSService.Settings(voice=...)`` instead. + + url: WebSocket URL for the TTS backend (default production URL). + aggregate_sentences: Deprecated. Use text_aggregation_mode instead. + + .. deprecated:: 0.0.104 + Use ``text_aggregation_mode`` instead. + + text_aggregation_mode: How to aggregate text before synthesis. + sample_rate: Output audio sample rate in Hz (8000, 16000, 22050, 24000). + If None, uses model-specific default. + params: Optional input parameters to override defaults. + + .. deprecated:: 0.0.105 + Use ``settings=SarvamTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + **kwargs: Arguments forwarded to InterruptibleTTSService. + + See https://docs.sarvam.ai/api-reference-docs/text-to-speech/stream """ - # Initialize parent class first + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model="bulbul:v2", + voice="anushka", + language="en-IN", + enable_preprocessing=False, + min_buffer_size=50, + max_chunk_length=150, + pace=1.0, + pitch=None, + loudness=None, + temperature=None, + ) + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # Init-only audio format fields (not runtime-updatable) + output_audio_codec = "linear16" + output_audio_bitrate = "128k" + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + if params.language is not None: + default_settings.language = params.language + if params.enable_preprocessing is not None: + default_settings.enable_preprocessing = params.enable_preprocessing + if params.min_buffer_size is not None: + default_settings.min_buffer_size = params.min_buffer_size + if params.max_chunk_length is not None: + default_settings.max_chunk_length = params.max_chunk_length + if params.output_audio_codec is not None: + output_audio_codec = params.output_audio_codec + if params.output_audio_bitrate is not None: + output_audio_bitrate = params.output_audio_bitrate + if params.pace is not None: + default_settings.pace = params.pace + if params.pitch is not None: + default_settings.pitch = params.pitch + if params.loudness is not None: + default_settings.loudness = params.loudness + if params.temperature is not None: + default_settings.temperature = params.temperature + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + # Get model configuration (validates model exists) + resolved_model = default_settings.model + if resolved_model not in TTS_MODEL_CONFIGS: + allowed = ", ".join(sorted(TTS_MODEL_CONFIGS.keys())) + raise ValueError(f"Unsupported model '{resolved_model}'. Allowed values: {allowed}.") + + self._config = TTS_MODEL_CONFIGS[resolved_model] + + # Set default sample rate based on model if not specified + if sample_rate is None: + sample_rate = self._config.default_sample_rate + + # Set default voice based on model if not specified via any mechanism + if voice_id is None and (settings is None or settings.voice is NOT_GIVEN): + default_settings.voice = self._config.default_speaker + + # Validate and clamp pace to model's valid range + pace = default_settings.pace + pace_min, pace_max = self._config.pace_range + if pace is not None and (pace < pace_min or pace > pace_max): + logger.warning(f"Pace {pace} is outside model range ({pace_min}-{pace_max}). Clamping.") + default_settings.pace = max(pace_min, min(pace_max, pace)) + + # Force preprocessing for models that require it + if self._config.preprocessing_always_enabled: + default_settings.enable_preprocessing = True + + # Warn about unsupported model-specific parameters + if not self._config.supports_pitch and default_settings.pitch not in (None, 0.0): + logger.warning(f"pitch parameter is ignored for {resolved_model}") + default_settings.pitch = None + if not self._config.supports_loudness and default_settings.loudness not in (None, 1.0): + logger.warning(f"loudness parameter is ignored for {resolved_model}") + default_settings.loudness = None + if not self._config.supports_temperature and default_settings.temperature not in ( + None, + 0.6, + ): + logger.warning(f"temperature parameter is ignored for {resolved_model}") + default_settings.temperature = None + + # Initialize parent class super().__init__( aggregate_sentences=aggregate_sentences, + text_aggregation_mode=text_aggregation_mode, push_text_frames=True, pause_frame_processing=True, push_stop_frames=True, + push_start_frame=True, sample_rate=sample_rate, + settings=default_settings, **kwargs, ) - params = params or SarvamTTSService.InputParams() - # WebSocket endpoint URL - self._websocket_url = f"{url}?model={model}" + # Init-only audio format fields (not runtime-updatable) + self._speech_sample_rate = str(sample_rate) + self._output_audio_codec = output_audio_codec + self._output_audio_bitrate = output_audio_bitrate + + # WebSocket endpoint URL with model query parameter + self._websocket_url = f"{url}?model={resolved_model}" self._api_key = api_key - self.set_model_name(model) - self.set_voice(voice_id) - # Build base settings common to all models - self._settings = { - "target_language_code": ( - self.language_to_service_language(params.language) if params.language else "en-IN" - ), - "speaker": voice_id, - "speech_sample_rate": 0, - "enable_preprocessing": params.enable_preprocessing, - "min_buffer_size": params.min_buffer_size, - "max_chunk_length": params.max_chunk_length, - "output_audio_codec": params.output_audio_codec, - "output_audio_bitrate": params.output_audio_bitrate, - } - - # Add model-specific parameters - if model in ("bulbul:v3-beta", "bulbul:v3"): - self._settings.update( - { - "temperature": getattr(params, "temperature", 0.6), - "model": model, - } - ) - else: - self._settings.update( - { - "pitch": params.pitch, - "pace": params.pace, - "loudness": params.loudness, - "model": model, - } - ) - self._started = False self._receive_task = None self._keepalive_task = None - self._disconnecting = False def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -476,7 +1000,8 @@ class SarvamTTSService(InterruptibleTTSService): """ await super().start(frame) - self._settings["speech_sample_rate"] = self.sample_rate + # WebSocket API expects sample rate as string + self._speech_sample_rate = str(self.sample_rate) await self._connect() async def stop(self, frame: EndFrame): @@ -497,41 +1022,28 @@ class SarvamTTSService(InterruptibleTTSService): await super().cancel(frame) await self._disconnect() - async def flush_audio(self): - """Flush any pending audio synthesis by sending stop command.""" - if self._websocket: - msg = {"type": "flush"} - await self._websocket.send(json.dumps(msg)) + async def flush_audio(self, context_id: Optional[str] = None): + """Flush any pending audio synthesis by sending flush command.""" + try: + if self._websocket: + msg = {"type": "flush"} + await self._websocket.send(json.dumps(msg)) + except Exception as e: + await self.push_error(error_msg=f"Error sending flush to Sarvam: {e}", exception=e) - async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): - """Push a frame downstream with special handling for stop conditions. + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a settings delta and resend config if voice changed.""" + changed = await super()._update_settings(delta) - Args: - frame: The frame to push. - direction: The direction to push the frame. - """ - await super().push_frame(frame, direction) - if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)): - self._started = False - - async def process_frame(self, frame: Frame, direction: FrameDirection): - """Process a frame and flush audio if it's the end of a full response.""" - await super().process_frame(frame, direction) - - # When the LLM finishes responding, flush any remaining text in Sarvam's buffer - if isinstance(frame, (LLMFullResponseEndFrame, EndFrame)): - await self.flush_audio() - - async def _update_settings(self, settings: Mapping[str, Any]): - """Update service settings and reconnect if voice changed.""" - prev_voice = self._voice_id - await super()._update_settings(settings) - if not prev_voice == self._voice_id: - logger.info(f"Switching TTS voice to: [{self._voice_id}]") + if changed: await self._send_config() + return changed + async def _connect(self): """Connect to Sarvam WebSocket and start background tasks.""" + await super()._connect() + await self._connect_websocket() if self._websocket and not self._receive_task: @@ -544,29 +1056,17 @@ class SarvamTTSService(InterruptibleTTSService): async def _disconnect(self): """Disconnect from Sarvam WebSocket and clean up tasks.""" - try: - # First, set a flag to prevent new operations - self._disconnecting = True + await super()._disconnect() - # Cancel background tasks BEFORE closing websocket - if self._receive_task: - await self.cancel_task(self._receive_task, timeout=2.0) - self._receive_task = None + if self._receive_task: + await self.cancel_task(self._receive_task) + self._receive_task = None - if self._keepalive_task: - await self.cancel_task(self._keepalive_task, timeout=2.0) - self._keepalive_task = None + if self._keepalive_task: + await self.cancel_task(self._keepalive_task) + self._keepalive_task = None - # Now close the websocket - await self._disconnect_websocket() - - except Exception as e: - await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) - finally: - # Reset state only after everything is cleaned up - self._started = False - self._websocket = None - self._disconnecting = False + await self._disconnect_websocket() async def _connect_websocket(self): """Establish WebSocket connection to Sarvam API.""" @@ -574,12 +1074,14 @@ class SarvamTTSService(InterruptibleTTSService): if self._websocket and self._websocket.state is State.OPEN: return + ws_additional_headers = { + "api-subscription-key": self._api_key, + **sdk_headers(), + } + self._websocket = await websocket_connect( self._websocket_url, - additional_headers={ - "api-subscription-key": self._api_key, - **sdk_headers(), - }, + additional_headers=ws_additional_headers, ) logger.debug("Connected to Sarvam TTS Websocket") await self._send_config() @@ -596,9 +1098,27 @@ class SarvamTTSService(InterruptibleTTSService): """Send initial configuration message.""" if not self._websocket: raise Exception("WebSocket not connected") - self._settings["speaker"] = self._voice_id - logger.debug(f"Config being sent is {self._settings}") - config_message = {"type": "config", "data": self._settings} + # Build config dict for the API + config_data = { + "target_language_code": self._settings.language, + "speaker": self._settings.voice, + "speech_sample_rate": self._speech_sample_rate, + "enable_preprocessing": self._settings.enable_preprocessing, + "min_buffer_size": self._settings.min_buffer_size, + "max_chunk_length": self._settings.max_chunk_length, + "output_audio_codec": self._output_audio_codec, + "output_audio_bitrate": self._output_audio_bitrate, + "pace": self._settings.pace, + "model": self._settings.model, + } + if self._settings.pitch is not None: + config_data["pitch"] = self._settings.pitch + if self._settings.loudness is not None: + config_data["loudness"] = self._settings.loudness + if self._settings.temperature is not None: + config_data["temperature"] = self._settings.temperature + logger.debug(f"Config being sent is {config_data}") + config_message = {"type": "config", "data": config_data} try: await self._websocket.send(json.dumps(config_message)) @@ -618,7 +1138,6 @@ class SarvamTTSService(InterruptibleTTSService): except Exception as e: await self.push_error(error_msg=f"Error closing websocket: {e}", exception=e) finally: - self._started = False self._websocket = None await self._call_event_handler("on_disconnected") @@ -632,12 +1151,13 @@ class SarvamTTSService(InterruptibleTTSService): async for message in self._get_websocket(): if isinstance(message, str): msg = json.loads(message) + context_id = self.get_active_audio_context_id() if msg.get("type") == "audio": # Check for interruption before processing audio await self.stop_ttfb_metrics() audio = base64.b64decode(msg["data"]["audio"]) - frame = TTSAudioRawFrame(audio, self.sample_rate, 1) - await self.push_frame(frame) + frame = TTSAudioRawFrame(audio, self.sample_rate, 1, context_id=context_id) + await self.append_to_audio_context(context_id, frame) elif msg.get("type") == "error": error_msg = msg["data"]["message"] await self.push_error(error_msg=f"TTS Error: {error_msg}") @@ -645,8 +1165,9 @@ class SarvamTTSService(InterruptibleTTSService): # If it's a timeout error, the connection might need to be reset if "too long" in error_msg.lower() or "timeout" in error_msg.lower(): logger.warning("Connection timeout detected, service may need restart") - - await self.push_frame(ErrorFrame(error=f"TTS Error: {error_msg}")) + await self.append_to_audio_context( + context_id, ErrorFrame(error=f"TTS Error: {error_msg}") + ) async def _keepalive_task_handler(self): """Handle keepalive messages to maintain WebSocket connection.""" @@ -657,19 +1178,12 @@ class SarvamTTSService(InterruptibleTTSService): async def _send_keepalive(self): """Send keepalive message to maintain connection.""" - if self._disconnecting: - return - if self._websocket and self._websocket.state == State.OPEN: msg = {"type": "ping"} await self._websocket.send(json.dumps(msg)) async def _send_text(self, text: str): """Send text to Sarvam WebSocket for synthesis.""" - if self._disconnecting: - logger.warning("Service is disconnecting, ignoring text send") - return - if self._websocket and self._websocket.state == State.OPEN: msg = {"type": "text", "data": {"text": text}} await self._websocket.send(json.dumps(msg)) @@ -677,16 +1191,17 @@ class SarvamTTSService(InterruptibleTTSService): logger.warning("WebSocket not ready, cannot send text") @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech audio frames from input text using Sarvam TTS. Sends text over WebSocket for synthesis and yields corresponding audio or status frames. Args: text: The text input to synthesize. + context_id: The context ID for tracking audio frames. Yields: - Frame objects including TTSStartedFrame, TTSAudioRawFrame(s), or TTSStoppedFrame. + Frame objects including TTSStartedFrame, TTSAudioRawFrame(s, context_id=context_id), or TTSStoppedFrame. """ logger.debug(f"Generating TTS: [{text}]") @@ -695,15 +1210,11 @@ class SarvamTTSService(InterruptibleTTSService): await self._connect() try: - if not self._started: - await self.start_ttfb_metrics() - yield TTSStartedFrame() - self._started = True await self._send_text(text) await self.start_tts_usage_metrics(text) except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") - yield TTSStoppedFrame() + yield TTSStoppedFrame(context_id=context_id) await self._disconnect() await self._connect() return diff --git a/src/pipecat/services/settings.py b/src/pipecat/services/settings.py new file mode 100644 index 000000000..a0bf3cd58 --- /dev/null +++ b/src/pipecat/services/settings.py @@ -0,0 +1,436 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Settings infrastructure for Pipecat AI services. + +Each service type has a settings dataclass (``LLMSettings``, ``TTSSettings``, +``STTSettings``, or a service-specific subclass). The same class is used in +two distinct modes: + +**Store mode** — the service's ``self._settings`` object that holds the full +current state. Every field must have a real value; ``NOT_GIVEN`` is never +valid here. Services that don't support an inherited field should set it to +``None``. ``validate_complete()`` (called automatically in +``AIService.start()``) enforces this invariant. + +**Delta mode** — a sparse update object carried by an +``*UpdateSettingsFrame``. Only the fields the caller wants to change are set; +all others remain at their default of ``NOT_GIVEN``. ``apply_update()`` +merges a delta into a store, skipping any ``NOT_GIVEN`` fields. + +Key helpers: + +- ``NOT_GIVEN`` / ``is_given()`` — sentinel and check for "field not provided + in this delta". +- ``apply_update(delta)`` — merge a delta into a store, returning changed + fields. +- ``from_mapping(dict)`` — build a delta from a plain dict (for backward + compatibility with dict-based ``*UpdateSettingsFrame``). +- ``validate_complete()`` — assert that a store has no ``NOT_GIVEN`` fields. +- ``extra`` dict — overflow for service-specific keys that don't map to a + declared field. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass, field, fields +from typing import TYPE_CHECKING, Any, ClassVar, Dict, Mapping, Optional, Type, TypeVar + +from loguru import logger + +from pipecat.transcriptions.language import Language + +if TYPE_CHECKING: + from pipecat.turns.user_turn_completion_mixin import UserTurnCompletionConfig + + +# --------------------------------------------------------------------------- +# NOT_GIVEN sentinel +# --------------------------------------------------------------------------- + + +class _NotGiven: + """Sentinel meaning "this field was not included in the delta". + + ``NOT_GIVEN`` is distinct from ``None`` (which is a valid stored value, + typically meaning "this service doesn't support this field"). Every + settings field defaults to ``NOT_GIVEN`` so that delta-mode objects are + sparse by default and ``apply_update`` can skip untouched fields. + + ``NOT_GIVEN`` must never appear in a store-mode object — see + ``validate_complete()``. + """ + + _instance: Optional[_NotGiven] = None + + def __new__(cls) -> _NotGiven: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "NOT_GIVEN" + + def __bool__(self) -> bool: + return False + + +NOT_GIVEN: _NotGiven = _NotGiven() +"""Singleton sentinel meaning "this field was not included in the delta". + +Valid only in delta-mode settings objects. Must never appear in a service's +``self._settings`` (store mode) — use ``None`` instead for unsupported fields. +""" + + +def is_given(value: Any) -> bool: + """Check whether a delta field was explicitly provided. + + Typically used when processing a delta to decide whether a field + should be applied:: + + if is_given(delta.voice): + # caller wants to change the voice + ... + + For store-mode objects this always returns ``True`` (since + ``validate_complete`` ensures no ``NOT_GIVEN`` fields remain). + + Args: + value: The value to check. + + Returns: + ``True`` if *value* is anything other than ``NOT_GIVEN``. + """ + return not isinstance(value, _NotGiven) + + +# --------------------------------------------------------------------------- +# Base ServiceSettings +# --------------------------------------------------------------------------- + +_S = TypeVar("_S", bound="ServiceSettings") + + +@dataclass +class ServiceSettings: + """Base class for runtime-updatable service settings. + + These settings capture the subset of a service's configuration that can + be changed **while the pipeline is running** (e.g. switching the model or + changing the voice). They are *not* meant to capture every constructor + parameter — only those that support live updates via + ``*UpdateSettingsFrame``. + + Every AI service type (LLM, TTS, STT) extends this with its own fields. + Each instance operates in one of two modes (see module docstring): + + - **Store mode** (``self._settings``): holds the full current state. + Every field must be a real value — ``NOT_GIVEN`` is never valid. + Use ``None`` for inherited fields the service doesn't support. + Enforced at runtime by ``validate_complete()``. + - **Delta mode** (``*UpdateSettingsFrame``): a sparse update. + Only fields the caller wants to change are set; all others stay at + the default ``NOT_GIVEN`` and are skipped by ``apply_update()``. + + Parameters: + model: The model identifier used by the service. Set to ``None`` + in store mode if the service has no model concept. + extra: Overflow dict for service-specific keys that don't map to a + declared field. + """ + + # -- common fields ------------------------------------------------------- + + model: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + """AI model identifier (e.g. ``"gpt-4o"``, ``"eleven_turbo_v2_5"``). + + Defaults to ``NOT_GIVEN`` for delta mode. In store mode, set to a + model string or ``None`` if the service has no model concept. + """ + + extra: Dict[str, Any] = field(default_factory=dict) + """Catch-all for service-specific keys that have no declared field.""" + + # -- class-level configuration ------------------------------------------- + + _aliases: ClassVar[Dict[str, str]] = {} + """Map of alternative key names to canonical field names. + + For example ``{"voice_id": "voice"}`` lets callers use either spelling. + Subclasses should override this as needed. + """ + + # -- public API ---------------------------------------------------------- + + def given_fields(self) -> Dict[str, Any]: + """Return a dict of only the fields that are not ``NOT_GIVEN``. + + Primarily useful for delta-mode objects to inspect which fields were + set. For a store-mode object this returns all declared fields (since + none should be ``NOT_GIVEN``). + + Skips the ``extra`` field itself but merges its entries into the + returned dict at the top level. + + Returns: + Dictionary mapping field names to their provided values. + """ + result: Dict[str, Any] = {} + for f in fields(self): + if f.name == "extra": + continue + val = getattr(self, f.name) + if is_given(val): + result[f.name] = val + result.update(self.extra) + return result + + def apply_update(self: _S, delta: _S) -> Dict[str, Any]: + """Merge a delta-mode object into this store-mode object. + + Only fields in *delta* that are **given** (i.e. not ``NOT_GIVEN``) + are considered. A field is "changed" if its new value differs from + the current value. + + The ``extra`` dicts are merged: keys present in the delta overwrite + keys in the target. + + Args: + delta: A delta-mode settings object of the same type. + + Returns: + A dict mapping each changed field name to its **pre-update** value. + Use ``changed.keys()`` for the set of names, or index with + ``changed["field"]`` to inspect the old value. + + Examples:: + + # store-mode object (all fields given) + current = TTSSettings(voice="alice", language="en") + # delta-mode object (only voice is set) + delta = TTSSettings(voice="bob") + changed = current.apply_update(delta) + # changed == {"voice": "alice"} + # current.voice == "bob", current.language == "en" + """ + changed: Dict[str, Any] = {} + for f in fields(self): + if f.name == "extra": + continue + new_val = getattr(delta, f.name, NOT_GIVEN) + if not is_given(new_val): + continue + old_val = getattr(self, f.name) + if old_val != new_val: + setattr(self, f.name, new_val) + changed[f.name] = old_val + + # Merge extra + for key, new_val in delta.extra.items(): + old_val = self.extra.get(key, NOT_GIVEN) + if old_val != new_val: + self.extra[key] = new_val + changed[key] = old_val + + return changed + + @classmethod + def from_mapping(cls: Type[_S], settings: Mapping[str, Any]) -> _S: + """Build a **delta-mode** settings object from a plain dictionary. + + This exists for backward compatibility with code that passes plain + dicts via ``*UpdateSettingsFrame(settings={...})``. The returned + object is a delta: only the keys present in *settings* are set; + all other fields remain ``NOT_GIVEN``. + + Keys are matched to dataclass fields by name. Keys listed in + ``_aliases`` are translated to their canonical name first. Any + remaining unrecognized keys are placed into ``extra``. + + Args: + settings: A dictionary of setting names to values. + + Returns: + A new delta-mode settings instance. + + Examples:: + + delta = TTSSettings.from_mapping({"voice_id": "alice", "speed": 1.2}) + # delta.voice == "alice" (via alias) + # delta.language is NOT_GIVEN (not in the dict) + # delta.extra == {"speed": 1.2} + """ + field_names = {f.name for f in fields(cls)} - {"extra"} + kwargs: Dict[str, Any] = {} + extra: Dict[str, Any] = {} + + for key, value in settings.items(): + # Resolve aliases first + canonical = cls._aliases.get(key, key) + if canonical in field_names: + kwargs[canonical] = value + else: + extra[key] = value + + instance = cls(**kwargs) + instance.extra = extra + return instance + + def validate_complete(self) -> None: + """Check that this is a valid store-mode object (no ``NOT_GIVEN`` fields). + + Called automatically by ``AIService.start()`` to catch fields that a + service forgot to initialize in its ``__init__``. Can also be called + manually after constructing a store-mode settings object. + + Logs a warning for each uninitialized field. Failure to initialize + all fields may or may not cause runtime issues — it depends on + whether and how the service actually reads the field — but it indicates + a deviation from expectations and should be fixed. + """ + missing = [ + f.name + for f in fields(self) + if f.name != "extra" and isinstance(getattr(self, f.name), _NotGiven) + ] + if missing: + names = ", ".join(missing) + logger.error( + f"{type(self).__name__}: the following fields are NOT_GIVEN: {names}. " + f"All settings fields should be initialized in the service's " + f"__init__ (use None for unsupported fields)." + ) + + def copy(self: _S) -> _S: + """Return a deep copy of this settings instance. + + Returns: + A new settings object with the same field values. + """ + return copy.deepcopy(self) + + +# --------------------------------------------------------------------------- +# Service-specific settings +# --------------------------------------------------------------------------- + + +@dataclass +class ImageGenSettings(ServiceSettings): + """Runtime-updatable settings for image generation services. + + Used in both store and delta mode — see ``ServiceSettings``. + + Parameters: + model: Image generation model identifier. + """ + + +@dataclass +class VisionSettings(ServiceSettings): + """Runtime-updatable settings for vision services. + + Used in both store and delta mode — see ``ServiceSettings``. + + Parameters: + model: Vision model identifier. + """ + + +@dataclass +class LLMSettings(ServiceSettings): + """Runtime-updatable settings for LLM services. + + Used in both store and delta mode — see ``ServiceSettings``. + + These fields are common across LLM providers. Not every provider supports + every field; in store mode, set unsupported fields to ``None`` (e.g. a + service that doesn't support ``seed`` should initialize it as + ``seed=None``). + + Parameters: + model: LLM model identifier. + system_instruction: System instruction/prompt for the model. + temperature: Sampling temperature. + max_tokens: Maximum tokens to generate. + top_p: Nucleus sampling probability. + top_k: Top-k sampling parameter. + frequency_penalty: Frequency penalty. + presence_penalty: Presence penalty. + seed: Random seed for reproducibility. + filter_incomplete_user_turns: Enable LLM-based turn completion detection + to suppress bot responses when the user was cut off mid-thought. + See ``examples/foundational/22-filter-incomplete-turns.py`` and + ``UserTurnCompletionLLMServiceMixin``. + user_turn_completion_config: Configuration for turn completion behavior + when ``filter_incomplete_user_turns`` is enabled. Controls timeouts + and prompts for incomplete turns. + """ + + system_instruction: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + temperature: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + max_tokens: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + top_p: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + top_k: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + frequency_penalty: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + presence_penalty: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + seed: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + filter_incomplete_user_turns: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + user_turn_completion_config: UserTurnCompletionConfig | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + + +@dataclass +class TTSSettings(ServiceSettings): + """Runtime-updatable settings for TTS services. + + Used in both store and delta mode — see ``ServiceSettings``. + + In store mode, set unsupported fields to ``None`` (e.g. ``language=None`` + if the service doesn't expose a language setting). + + Parameters: + model: TTS model identifier. + voice: Voice identifier or name. + language: Language for speech synthesis. The union type reflects the + *input* side: callers may pass a ``Language`` enum or a raw string + in a delta. However, the **stored** value (in store mode) is + always a service-specific string or ``None`` — + ``TTSService._update_settings`` converts ``Language`` enums via + ``language_to_service_language()`` before writing, and + ``__init__`` methods do the same at construction time. + """ + + voice: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + language: Language | str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + _aliases: ClassVar[Dict[str, str]] = {"voice_id": "voice"} + + +@dataclass +class STTSettings(ServiceSettings): + """Runtime-updatable settings for STT services. + + Used in both store and delta mode — see ``ServiceSettings``. + + In store mode, set unsupported fields to ``None`` (e.g. ``language=None`` + if the service auto-detects language). + + Parameters: + model: STT model identifier. + language: Language for speech recognition. The union type reflects the + *input* side: callers may pass a ``Language`` enum or a raw string + in a delta. However, the **stored** value (in store mode) is + always a service-specific string or ``None`` — + ``STTService._update_settings`` converts ``Language`` enums via + ``language_to_service_language()`` before writing, and + ``__init__`` methods do the same at construction time. + """ + + language: Language | str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) diff --git a/src/pipecat/services/simli/video.py b/src/pipecat/services/simli/video.py index b1f7961af..f994ef0dc 100644 --- a/src/pipecat/services/simli/video.py +++ b/src/pipecat/services/simli/video.py @@ -8,6 +8,7 @@ import asyncio import warnings +from dataclasses import dataclass from typing import Optional import numpy as np @@ -20,11 +21,14 @@ from pipecat.frames.frames import ( Frame, InterruptionFrame, OutputImageRawFrame, + StartFrame, TTSAudioRawFrame, TTSStoppedFrame, UserStartedSpeakingFrame, ) -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, StartFrame +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.ai_service import AIService +from pipecat.services.settings import ServiceSettings try: from av.audio.frame import AudioFrame @@ -36,7 +40,14 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") -class SimliVideoService(FrameProcessor): +@dataclass +class SimliVideoSettings(ServiceSettings): + """Settings for the Simli video service.""" + + pass + + +class SimliVideoService(AIService): """Simli video service for real-time avatar generation. Provides real-time avatar video generation by processing audio frames @@ -44,9 +55,15 @@ class SimliVideoService(FrameProcessor): audio resampling, video frame processing, and connection management. """ + Settings = SimliVideoSettings + _settings: Settings + class InputParams(BaseModel): """Input parameters for Simli video configuration. + .. deprecated:: 0.0.106 + Use ``SimliVideoService.Settings(...)`` instead. + Parameters: enable_logging: Whether to enable Simli logging. max_session_length: Absolute maximum session duration in seconds. @@ -66,10 +83,13 @@ class SimliVideoService(FrameProcessor): face_id: Optional[str] = None, simli_config: Optional[SimliConfig] = None, use_turn_server: bool = False, - latency_interval: int = 0, simli_url: str = "https://api.simli.ai", is_trinity_avatar: bool = False, params: Optional[InputParams] = None, + max_session_length: Optional[int] = None, + max_idle_time: Optional[int] = None, + enable_logging: Optional[bool] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Simli video service. @@ -90,18 +110,42 @@ class SimliVideoService(FrameProcessor): .. deprecated:: 0.0.95 The 'use_turn_server' parameter is deprecated and will be removed in a future version. - latency_interval: Latency interval setting for sending health checks to check - the latency to Simli Servers. Defaults to 0. simli_url: URL of the simli servers. Can be changed for custom deployments of enterprise users. is_trinity_avatar: Boolean to tell simli client that this is a Trinity avatar which reduces latency when using Trinity. params: Additional input parameters for session configuration. - **kwargs: Additional arguments passed to the parent FrameProcessor. - """ - super().__init__(**kwargs) - params = params or SimliVideoService.InputParams() + .. deprecated:: 0.0.106 + Use ``settings=SimliVideoService.Settings(...)`` instead. + + max_session_length: Absolute maximum session duration in seconds. + Avatar will disconnect after this time even if it's speaking. + max_idle_time: Maximum duration in seconds the avatar is not speaking + before the avatar disconnects. + enable_logging: Whether to enable Simli logging. + settings: Service settings. + **kwargs: Additional arguments passed to the parent AIService. + """ + # 1. Default settings + default_settings = ServiceSettings(model=None) + + # 2. Apply deprecated params overrides + if params is not None: + self._warn_init_param_moved_to_settings("params") + if max_session_length is None and hasattr(params, "max_session_length"): + max_session_length = params.max_session_length + if max_idle_time is None and hasattr(params, "max_idle_time"): + max_idle_time = params.max_idle_time + if enable_logging is None and hasattr(params, "enable_logging"): + enable_logging = params.enable_logging + + # 3. Apply settings delta + if settings is not None: + default_settings.apply_update(settings) + + # 4. Call super + super().__init__(settings=default_settings, **kwargs) # Handle deprecated simli_config parameter if simli_config is not None: @@ -131,13 +175,12 @@ 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: - config_kwargs["maxSessionLength"] = params.max_session_length - if params.max_idle_time is not None: - config_kwargs["maxIdleTime"] = params.max_idle_time + if max_session_length is not None: + config_kwargs["maxSessionLength"] = max_session_length + if max_idle_time is not None: + config_kwargs["maxIdleTime"] = max_idle_time config = SimliConfig(**config_kwargs) @@ -153,10 +196,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 @@ -169,11 +212,38 @@ class SimliVideoService(FrameProcessor): self._previously_interrupted = is_trinity_avatar self._audio_buffer = bytearray() + async def start(self, frame: StartFrame): + """Start the Simli video service. + + Args: + frame: The start frame containing initialization parameters. + """ + await super().start(frame) + await self._start_connection() + + async def stop(self, frame: EndFrame): + """Stop the Simli video service. + + Args: + frame: The end frame. + """ + await super().stop(frame) + await self._stop_connection() + + async def cancel(self, frame: CancelFrame): + """Cancel the Simli video service. + + Args: + frame: The cancel frame. + """ + await super().cancel(frame) + await self._stop_connection() + async def _start_connection(self): """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 @@ -223,9 +293,7 @@ class SimliVideoService(FrameProcessor): direction: The direction of frame processing. """ await super().process_frame(frame, direction) - if isinstance(frame, StartFrame): - await self._start_connection() - elif isinstance(frame, TTSAudioRawFrame): + if isinstance(frame, TTSAudioRawFrame): # Send audio frame to Simli try: old_frame = AudioFrame.from_ndarray( @@ -269,8 +337,6 @@ class SimliVideoService(FrameProcessor): except Exception as e: await self.push_error(error_msg=f"Error stopping TTS: {e}", exception=e) return - elif isinstance(frame, (EndFrame, CancelFrame)): - await self._stop() elif isinstance(frame, (InterruptionFrame, UserStartedSpeakingFrame)): if not self._previously_interrupted: await self._simli_client.clearBuffer() @@ -278,7 +344,7 @@ class SimliVideoService(FrameProcessor): await self.push_frame(frame, direction) - async def _stop(self): + async def _stop_connection(self): """Stop the Simli client and cancel processing tasks.""" await self._simli_client.stop() if self._audio_task: diff --git a/src/pipecat/services/soniox/stt.py b/src/pipecat/services/soniox/stt.py index 34b4bc396..5163ef113 100644 --- a/src/pipecat/services/soniox/stt.py +++ b/src/pipecat/services/soniox/stt.py @@ -6,10 +6,10 @@ """Soniox speech-to-text service implementation.""" -import asyncio import json import time -from typing import AsyncGenerator, List, Optional +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, List, Optional from loguru import logger from pydantic import BaseModel @@ -21,11 +21,13 @@ from pipecat.frames.frames import ( InterimTranscriptionFrame, StartFrame, TranscriptionFrame, - UserStoppedSpeakingFrame, + VADUserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven +from pipecat.services.stt_latency import SONIOX_TTFS_P99 from pipecat.services.stt_service import WebsocketSTTService -from pipecat.transcriptions.language import Language +from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_stt @@ -77,6 +79,9 @@ class SonioxContextObject(BaseModel): class SonioxInputParams(BaseModel): """Real-time transcription settings. + .. deprecated:: 0.0.105 + Use ``settings=SonioxSTTService.Settings(...)`` instead. + See Soniox WebSocket API documentation for more details: https://soniox.com/docs/speech-to-text/api-reference/websocket-api#configuration-parameters @@ -92,7 +97,7 @@ class SonioxInputParams(BaseModel): client_reference_id: Client reference ID to use for transcription. """ - model: str = "stt-rt-preview" + model: str = "stt-rt-v4" audio_format: Optional[str] = "pcm_s16le" num_channels: Optional[int] = 1 @@ -113,14 +118,75 @@ def is_end_token(token: dict) -> bool: def language_to_soniox_language(language: Language) -> str: - """Pipecat Language enum uses same ISO 2-letter codes as Soniox, except with added regional variants. + """Convert a Pipecat Language to a Soniox language code. - For a list of all supported languages, see: https://soniox.com/docs/speech-to-text/core-concepts/supported-languages + For a list of all supported languages, see: + https://soniox.com/docs/speech-to-text/core-concepts/supported-languages """ - lang_str = str(language.value).lower() - if "-" in lang_str: - return lang_str.split("-")[0] - return lang_str + LANGUAGE_MAP = { + Language.AF: "af", + Language.AR: "ar", + Language.AZ: "az", + Language.BE: "be", + Language.BG: "bg", + Language.BN: "bn", + Language.BS: "bs", + Language.CA: "ca", + Language.CS: "cs", + Language.CY: "cy", + Language.DA: "da", + Language.DE: "de", + Language.EL: "el", + Language.EN: "en", + Language.ES: "es", + Language.ET: "et", + Language.EU: "eu", + Language.FA: "fa", + Language.FI: "fi", + Language.FR: "fr", + Language.GL: "gl", + Language.GU: "gu", + Language.HE: "he", + Language.HI: "hi", + Language.HR: "hr", + Language.HU: "hu", + Language.ID: "id", + Language.IT: "it", + Language.JA: "ja", + Language.KA: "ka", + Language.KK: "kk", + Language.KN: "kn", + Language.KO: "ko", + Language.LT: "lt", + Language.LV: "lv", + Language.MK: "mk", + Language.ML: "ml", + Language.MR: "mr", + Language.MS: "ms", + Language.NL: "nl", + Language.NO: "no", + Language.PA: "pa", + Language.PL: "pl", + Language.PT: "pt", + Language.RO: "ro", + Language.RU: "ru", + Language.SK: "sk", + Language.SL: "sl", + Language.SQ: "sq", + Language.SR: "sr", + Language.SV: "sv", + Language.SW: "sw", + Language.TA: "ta", + Language.TE: "te", + Language.TH: "th", + Language.TL: "tl", + Language.TR: "tr", + Language.UK: "uk", + Language.UR: "ur", + Language.VI: "vi", + Language.ZH: "zh", + } + return resolve_language(language, LANGUAGE_MAP, use_base_code=True) def _prepare_language_hints( @@ -134,6 +200,31 @@ def _prepare_language_hints( return list(set(prepared_languages)) +@dataclass +class SonioxSTTSettings(STTSettings): + """Settings for SonioxSTTService. + + Parameters: + language_hints: List of language hints to use for transcription. + language_hints_strict: If true, strictly enforce language hints. + context: Customization for transcription. String for models with + context_version 1 and SonioxContextObject for models with + context_version 2. + enable_speaker_diarization: Whether to enable speaker diarization. + enable_language_identification: Whether to enable language identification. + client_reference_id: Client reference ID to use for transcription. + """ + + language_hints: List[Language] | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + language_hints_strict: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + context: SonioxContextObject | str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_speaker_diarization: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_language_identification: bool | None | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + client_reference_id: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class SonioxSTTService(WebsocketSTTService): """Speech-to-Text service using Soniox's WebSocket API. @@ -144,14 +235,22 @@ class SonioxSTTService(WebsocketSTTService): For complete API documentation, see: https://soniox.com/docs/speech-to-text/api-reference/websocket-api """ + Settings = SonioxSTTSettings + _settings: Settings + def __init__( self, *, api_key: str, url: str = "wss://stt-rt.soniox.com/transcribe-websocket", sample_rate: Optional[int] = None, + model: Optional[str] = None, + audio_format: str = "pcm_s16le", + num_channels: int = 1, params: Optional[SonioxInputParams] = None, - vad_force_turn_endpoint: bool = False, + vad_force_turn_endpoint: bool = True, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = SONIOX_TTFS_P99, **kwargs, ): """Initialize the Soniox STT service. @@ -160,25 +259,95 @@ class SonioxSTTService(WebsocketSTTService): api_key: Soniox API key. url: Soniox WebSocket API URL. sample_rate: Audio sample rate. + model: Soniox model to use for transcription. + + .. deprecated:: 0.0.105 + Use ``settings=SonioxSTTService.Settings(model=...)`` instead. + + audio_format: Audio format for transcription. Defaults to ``"pcm_s16le"``. + num_channels: Number of audio channels. Defaults to 1. params: Additional configuration parameters, such as language hints, context and speaker diarization. - vad_force_turn_endpoint: Listen to `UserStoppedSpeakingFrame` to send finalize message to Soniox. If disabled, Soniox will detect the end of the speech. + + .. deprecated:: 0.0.105 + Use ``settings=SonioxSTTService.Settings(...)`` instead. + + vad_force_turn_endpoint: Listen to `VADUserStoppedSpeakingFrame` to send finalize message to Soniox. + If disabled, Soniox will detect the end of the speech. Defaults to True. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to the STTService. """ - super().__init__(sample_rate=sample_rate, **kwargs) - params = params or SonioxInputParams() + # --- 1. Hardcoded defaults --- + default_settings = self.Settings( + model="stt-rt-v4", + language=None, + language_hints=None, + language_hints_strict=None, + context=None, + enable_speaker_diarization=False, + enable_language_identification=False, + client_reference_id=None, + ) + + # --- 2. Deprecated direct-arg overrides --- + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # --- 3. Deprecated params overrides --- + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.model = params.model + if params.audio_format is not None: + audio_format = params.audio_format + if params.num_channels is not None: + num_channels = params.num_channels + default_settings.language_hints = params.language_hints + default_settings.language_hints_strict = params.language_hints_strict + default_settings.context = params.context + default_settings.enable_speaker_diarization = params.enable_speaker_diarization + default_settings.enable_language_identification = ( + params.enable_language_identification + ) + default_settings.client_reference_id = params.client_reference_id + + # --- 4. Settings delta (canonical API, always wins) --- + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + keepalive_timeout=1, + keepalive_interval=5, + settings=default_settings, + **kwargs, + ) self._api_key = api_key self._url = url - self.set_model_name(params.model) - self._params = params self._vad_force_turn_endpoint = vad_force_turn_endpoint + # Init-only audio config + self._audio_format = audio_format + self._num_channels = num_channels + self._final_transcription_buffer = [] self._last_tokens_received: Optional[float] = None self._receive_task = None - self._keepalive_task = None + + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True, as Soniox STT supports metrics generation. + """ + return True async def start(self, frame: StartFrame): """Start the Soniox STT websocket connection. @@ -189,6 +358,23 @@ class SonioxSTTService(WebsocketSTTService): await super().start(frame) await self._connect() + async def _update_settings(self, delta: Settings) -> dict[str, Any]: + """Apply settings delta and reconnect if anything changed. + + Args: + delta: A settings delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + + if changed: + await self._disconnect() + await self._connect() + + return changed + async def stop(self, frame: EndFrame): """Stop the Soniox STT websocket connection. @@ -224,10 +410,8 @@ class SonioxSTTService(WebsocketSTTService): Yields: Frame: None (transcription results come via WebSocket callbacks). """ - await self.start_processing_metrics() if self._websocket and self._websocket.state is State.OPEN: await self._websocket.send(audio) - await self.stop_processing_metrics() yield None @@ -247,7 +431,7 @@ class SonioxSTTService(WebsocketSTTService): """ await super().process_frame(frame, direction) - if isinstance(frame, UserStoppedSpeakingFrame) and self._vad_force_turn_endpoint: + if isinstance(frame, VADUserStoppedSpeakingFrame) and self._vad_force_turn_endpoint: # Send finalize message to Soniox so we get the final tokens asap. if self._websocket and self._websocket.state is State.OPEN: await self._websocket.send(FINALIZE_MESSAGE) @@ -266,20 +450,17 @@ class SonioxSTTService(WebsocketSTTService): """ await self._connect_websocket() + await super()._connect() + if self._websocket and not self._receive_task: self._receive_task = self.create_task(self._receive_task_handler(self._report_error)) - if self._websocket and not self._keepalive_task: - self._keepalive_task = self.create_task(self._keepalive_task_handler()) - async def _disconnect(self): """Disconnect from the Soniox service. Cleans up tasks and closes websocket connection. """ - if self._keepalive_task: - await self.cancel_task(self._keepalive_task) - self._keepalive_task = None + await super()._disconnect() if self._receive_task: await self.cancel_task(self._receive_task) @@ -305,24 +486,26 @@ class SonioxSTTService(WebsocketSTTService): # Either one or the other is required. enable_endpoint_detection = not self._vad_force_turn_endpoint - context = self._params.context + s = self._settings + + context = s.context if isinstance(context, SonioxContextObject): context = context.model_dump() # Send the initial configuration message. config = { "api_key": self._api_key, - "model": self._model_name, - "audio_format": self._params.audio_format, - "num_channels": self._params.num_channels or 1, + "model": s.model, + "audio_format": self._audio_format, + "num_channels": self._num_channels, "enable_endpoint_detection": enable_endpoint_detection, "sample_rate": self.sample_rate, - "language_hints": _prepare_language_hints(self._params.language_hints), - "language_hints_strict": self._params.language_hints_strict, + "language_hints": _prepare_language_hints(s.language_hints), + "language_hints_strict": s.language_hints_strict, "context": context, - "enable_speaker_diarization": self._params.enable_speaker_diarization, - "enable_language_identification": self._params.enable_language_identification, - "client_reference_id": self._params.client_reference_id, + "enable_speaker_diarization": s.enable_speaker_diarization, + "enable_language_identification": s.enable_language_identification, + "client_reference_id": s.client_reference_id, } # Send the configuration message. @@ -370,12 +553,15 @@ class SonioxSTTService(WebsocketSTTService): async def send_endpoint_transcript(): if self._final_transcription_buffer: text = "".join(map(lambda token: token["text"], self._final_transcription_buffer)) + # Soniox only pushes TranscriptionFrame when an end token is received, + # so every TranscriptionFrame is inherently finalized await self.push_frame( TranscriptionFrame( text=text, user_id=self._user_id, timestamp=time_now_iso8601(), result=self._final_transcription_buffer, + finalized=True, ) ) await self._handle_transcription(text, is_final=True) @@ -406,6 +592,8 @@ class SonioxSTTService(WebsocketSTTService): # the rest will be sent as interim tokens (even final tokens). await send_endpoint_transcript() else: + if not self._final_transcription_buffer: + await self.start_processing_metrics() self._final_transcription_buffer.append(token) else: non_final_transcription.append(token) @@ -450,17 +638,10 @@ class SonioxSTTService(WebsocketSTTService): except Exception as e: logger.warning(f"Error processing message: {e}") - async def _keepalive_task_handler(self): - """Connection has to be open all the time.""" - try: - while True: - logger.trace("Sending keepalive message") - if self._websocket and self._websocket.state is State.OPEN: - await self._websocket.send(KEEPALIVE_MESSAGE) - else: - logger.debug("WebSocket connection closed.") - break - await asyncio.sleep(5) + async def _send_keepalive(self, silence: bytes): + """Send a Soniox protocol-level keepalive message. - except Exception as e: - logger.debug(f"Keepalive task stopped: {e}") + Args: + silence: Silent PCM audio bytes (unused, Soniox uses a protocol message). + """ + await self._websocket.send(KEEPALIVE_MESSAGE) diff --git a/src/pipecat/services/speechmatics/stt.py b/src/pipecat/services/speechmatics/stt.py index 5d6a5e205..ae8e35850 100644 --- a/src/pipecat/services/speechmatics/stt.py +++ b/src/pipecat/services/speechmatics/stt.py @@ -8,9 +8,10 @@ import asyncio import os -import time +import warnings +from dataclasses import dataclass, field from enum import Enum -from typing import Any, AsyncGenerator +from typing import Any, AsyncGenerator, ClassVar from dotenv import load_dotenv from loguru import logger @@ -32,6 +33,8 @@ from pipecat.frames.frames import ( VADUserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven +from pipecat.services.stt_latency import SPEECHMATICS_TTFS_P99 from pipecat.services.stt_service import STTService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.tracing.service_decorators import traced_stt @@ -67,7 +70,7 @@ class TurnDetectionMode(str, Enum): """Endpoint and turn detection handling mode. How the STT engine handles the endpointing of speech. If using Pipecat's built-in endpointing, - then use `TurnDetectionMode.FIXED` (default). + then use `TurnDetectionMode.EXTERNAL` (default). To use the STT engine's built-in endpointing, then use `TurnDetectionMode.ADAPTIVE` for simple voice activity detection or `TurnDetectionMode.SMART_TURN` for more advanced ML-based @@ -80,14 +83,101 @@ class TurnDetectionMode(str, Enum): SMART_TURN = "smart_turn" +@dataclass +class SpeechmaticsSTTSettings(STTSettings): + """Settings for SpeechmaticsSTTService. + + See ``SpeechmaticsSTTService.InputParams`` for detailed descriptions of each field. + + Parameters: + domain: Domain for Speechmatics API. + turn_detection_mode: Endpoint handling mode. + speaker_active_format: Formatter for active speaker ID. + speaker_passive_format: Formatter for passive speaker ID. + focus_speakers: List of speaker IDs to focus on. + ignore_speakers: List of speaker IDs to ignore. + focus_mode: Speaker focus mode for diarization. + known_speakers: List of known speaker labels and identifiers. + additional_vocab: List of additional vocabulary entries. + operating_point: Operating point for accuracy vs. latency. + max_delay: Maximum delay in seconds for transcription. + end_of_utterance_silence_trigger: Maximum delay for end of utterance trigger. + end_of_utterance_max_delay: Maximum delay for end of utterance. + punctuation_overrides: Punctuation overrides. + include_partials: Include partial segment fragments. + split_sentences: Emit finalized sentences mid-turn. + enable_diarization: Enable speaker diarization. + speaker_sensitivity: Diarization sensitivity. + max_speakers: Maximum number of speakers to detect. + prefer_current_speaker: Prefer current speaker ID. + extra_params: Extra parameters for the STT engine. + """ + + domain: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + turn_detection_mode: TurnDetectionMode | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + speaker_active_format: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + speaker_passive_format: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + focus_speakers: list[str] | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + ignore_speakers: list[str] | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + focus_mode: SpeakerFocusMode | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + known_speakers: list[SpeakerIdentifier] | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + additional_vocab: list[AdditionalVocabEntry] | _NotGiven = field( + default_factory=lambda: NOT_GIVEN + ) + operating_point: OperatingPoint | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + max_delay: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + end_of_utterance_silence_trigger: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + end_of_utterance_max_delay: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + punctuation_overrides: dict[str, Any] | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + include_partials: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + split_sentences: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + enable_diarization: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + speaker_sensitivity: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + max_speakers: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + prefer_current_speaker: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + extra_params: dict[str, Any] | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + #: Fields that can be updated on a live connection via the Speechmatics + #: diarization-config API — no reconnect needed. + HOT_FIELDS: ClassVar[frozenset[str]] = frozenset( + { + "focus_speakers", + "ignore_speakers", + "focus_mode", + } + ) + + #: Fields that are purely local (formatting templates) — no reconnect + #: and no API call needed. + LOCAL_FIELDS: ClassVar[frozenset[str]] = frozenset( + { + "speaker_active_format", + "speaker_passive_format", + } + ) + + class SpeechmaticsSTTService(STTService): """Speechmatics STT service implementation. 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 + _settings: Settings + # Export related classes as class attributes TurnDetectionMode = TurnDetectionMode AudioEncoding = AudioEncoding @@ -107,7 +197,7 @@ class SpeechmaticsSTTService(STTService): turn_detection_mode: Endpoint handling, one of `TurnDetectionMode.FIXED`, `TurnDetectionMode.EXTERNAL`, `TurnDetectionMode.ADAPTIVE` and - `TurnDetectionMode.SMART_TURN`. Defaults to `TurnDetectionMode.FIXED`. + `TurnDetectionMode.SMART_TURN`. Defaults to `TurnDetectionMode.EXTERNAL`. speaker_active_format: Formatter for active speaker ID. This formatter is used to format the text output for individual speakers and ensures that the context is clear for @@ -201,6 +291,7 @@ class SpeechmaticsSTTService(STTService): extra_params: Extra parameters to pass to the STT engine. This is a dictionary of additional parameters that can be used to configure the STT engine. Default to None. + """ # Service configuration @@ -208,7 +299,7 @@ class SpeechmaticsSTTService(STTService): language: Language | str = Language.EN # Endpointing mode - turn_detection_mode: TurnDetectionMode = TurnDetectionMode.FIXED + turn_detection_mode: TurnDetectionMode = TurnDetectionMode.EXTERNAL # Output formatting speaker_active_format: str | None = None @@ -251,8 +342,8 @@ class SpeechmaticsSTTService(STTService): class UpdateParams(BaseModel): """Update parameters for Speechmatics STT service. - These are the only parameters that can be changed once a session has started. If you need to - change the language, etc., then you must create a new instance of the service. + .. deprecated:: 0.0.104 + Use ``SpeechmaticsSTTService.Settings`` with ``STTUpdateSettingsFrame`` instead. Parameters: focus_speakers: List of speaker IDs to focus on. When enabled, only these speakers are @@ -286,8 +377,11 @@ class SpeechmaticsSTTService(STTService): api_key: str | None = None, base_url: str | None = None, sample_rate: int | None = None, + encoding: AudioEncoding = AudioEncoding.PCM_S16LE, params: InputParams | None = None, should_interrupt: bool = True, + settings: Settings | None = None, + ttfs_p99_latency: float | None = SPEECHMATICS_TTFS_P99, **kwargs, ): """Initialize the Speechmatics STT service. @@ -298,12 +392,19 @@ class SpeechmaticsSTTService(STTService): base_url: Base URL for Speechmatics API. Uses environment variable `SPEECHMATICS_RT_URL` or defaults to `wss://eu2.rt.speechmatics.com/v2`. sample_rate: Optional audio sample rate in Hz. - params: Optional[InputParams]: Input parameters for the service. + encoding: Audio encoding format. Defaults to ``AudioEncoding.PCM_S16LE``. + params: Input parameters for the service. + + .. deprecated:: 0.0.105 + Use ``settings=SpeechmaticsSTTService.Settings(...)`` instead. + should_interrupt: Determine whether the bot should be interrupted when Speechmatics turn_detection_mode is configured to detect user speech. + settings: Runtime-updatable settings. When provided alongside deprecated + ``params``, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to STTService. """ - super().__init__(sample_rate=sample_rate, **kwargs) - # Service parameters self._api_key: str = api_key or os.getenv("SPEECHMATICS_API_KEY") self._base_url: str = ( @@ -316,38 +417,104 @@ class SpeechmaticsSTTService(STTService): if not self._base_url: raise ValueError("Missing Speechmatics base URL") - # Default params - params = params or SpeechmaticsSTTService.InputParams() self._should_interrupt = should_interrupt - # Deprecation check - self._check_deprecated_args(kwargs, params) + # Deprecation check (mutates params in-place for legacy kwargs migration) + _params = params or SpeechmaticsSTTService.InputParams() + self._check_deprecated_args(kwargs, _params) - # Voice agent + # --- 1. Hardcoded defaults --- + default_settings = self.Settings( + model=None, # Will be resolved from operating_point after config is built + language=Language.EN, + domain=None, + turn_detection_mode=TurnDetectionMode.EXTERNAL, + speaker_active_format="{text}", + speaker_passive_format="{text}", + focus_speakers=[], + ignore_speakers=[], + focus_mode=SpeakerFocusMode.RETAIN, + known_speakers=[], + additional_vocab=[], + operating_point=None, + max_delay=None, + end_of_utterance_silence_trigger=None, + end_of_utterance_max_delay=None, + punctuation_overrides=None, + include_partials=None, + split_sentences=None, + enable_diarization=None, + speaker_sensitivity=None, + max_speakers=None, + prefer_current_speaker=None, + extra_params=None, + ) + + # --- 2. No direct init arg overrides --- + + # --- 3. Deprecated params overrides --- + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.language = _params.language + default_settings.domain = _params.domain + default_settings.turn_detection_mode = _params.turn_detection_mode + # Output formatting defaults + speaker_active_format = _params.speaker_active_format + if speaker_active_format is None: + speaker_active_format = ( + "@{speaker_id}: {text}" if _params.enable_diarization else "{text}" + ) + default_settings.speaker_active_format = speaker_active_format + default_settings.speaker_passive_format = ( + _params.speaker_passive_format or speaker_active_format + ) + default_settings.focus_speakers = _params.focus_speakers + default_settings.ignore_speakers = _params.ignore_speakers + default_settings.focus_mode = _params.focus_mode + default_settings.known_speakers = _params.known_speakers + default_settings.additional_vocab = _params.additional_vocab + encoding = _params.audio_encoding + default_settings.operating_point = _params.operating_point + default_settings.max_delay = _params.max_delay + default_settings.end_of_utterance_silence_trigger = ( + _params.end_of_utterance_silence_trigger + ) + default_settings.end_of_utterance_max_delay = _params.end_of_utterance_max_delay + default_settings.punctuation_overrides = _params.punctuation_overrides + default_settings.include_partials = _params.include_partials + default_settings.split_sentences = _params.split_sentences + default_settings.enable_diarization = _params.enable_diarization + default_settings.speaker_sensitivity = _params.speaker_sensitivity + default_settings.max_speakers = _params.max_speakers + default_settings.prefer_current_speaker = _params.prefer_current_speaker + default_settings.extra_params = _params.extra_params + + # --- 4. Settings delta (canonical API, always wins) --- + if settings is not None: + default_settings.apply_update(settings) + + # Build SDK config from settings, set model name before calling super self._client: VoiceAgentClient | None = None - self._config: VoiceAgentConfig = self._prepare_config(params) + self._audio_encoding = encoding + self._config: VoiceAgentConfig = self._build_config(default_settings) + default_settings.model = self._config.operating_point.value + + super().__init__( + sample_rate=sample_rate, + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) # Outbound frame queue self._outbound_frames: asyncio.Queue[Frame] = asyncio.Queue() - # Output formatting - if params.speaker_active_format is None: - params.speaker_active_format = ( - "@{speaker_id}: {text}" if params.enable_diarization else "{text}" - ) - # Framework options self._enable_vad: bool = self._config.end_of_utterance_mode not in [ EndOfUtteranceMode.FIXED, EndOfUtteranceMode.EXTERNAL, ] - self._speaker_active_format: str = params.speaker_active_format - self._speaker_passive_format: str = ( - params.speaker_passive_format or params.speaker_active_format - ) - - # Metrics - self.set_model_name(self._config.operating_point.value) # Message queue self._stt_msg_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() @@ -358,7 +525,7 @@ class SpeechmaticsSTTService(STTService): self._bot_speaking: bool = False # Event handlers - if params.enable_diarization: + if default_settings.enable_diarization: self._register_event_handler("on_speakers_result") # ============================================================================ @@ -369,17 +536,72 @@ class SpeechmaticsSTTService(STTService): """Called when the new session starts.""" await super().start(frame) await self._connect() - self._stt_msg_task = self.create_task(self._process_stt_messages()) + + async def _update_settings(self, delta: Settings) -> dict[str, Any]: + """Apply settings delta, reconnecting only when necessary. + + Fields are classified into three categories (see + ``SpeechmaticsSTTService.Settings``): + + * **HOT_FIELDS** – diarization speaker settings that can be pushed + to a live Speechmatics connection without reconnecting. + * **LOCAL_FIELDS** – formatting templates evaluated locally; no + reconnect or API call needed. + * Everything else – baked into ``VoiceAgentConfig`` at connection + time and therefore require a full disconnect / reconnect. + + Args: + delta: A settings delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + changed = await super()._update_settings(delta) + + if not changed: + return changed + + no_reconnect = self.Settings.HOT_FIELDS | self.Settings.LOCAL_FIELDS + needs_reconnect = bool(changed.keys() - no_reconnect) + + if needs_reconnect: + logger.debug(f"{self} settings update requires reconnect: {changed.keys()}") + # Connection-level fields changed — rebuild the SDK config + # from the now-updated self._settings, then reconnect. + self._config = self._build_config(self._settings) + await self._disconnect() + await self._connect() + elif changed.keys() & self.Settings.HOT_FIELDS: + logger.debug(f"{self} applying hot settings update: {changed.keys()}") + if self._config.enable_diarization: + # Only hot-updatable fields changed — push to the live session. + self._config.speaker_config.focus_speakers = self._settings.focus_speakers + self._config.speaker_config.ignore_speakers = self._settings.ignore_speakers + self._config.speaker_config.focus_mode = self._settings.focus_mode + if self._client: + self._client.update_diarization_config(self._config.speaker_config) + else: + logger.debug( + f"{self} hot settings updated but diarization not enabled: {changed.keys()}. ignoring." + ) + # Diarization not enabled — the new settings will take effect + # if/when diarization is enabled, which does require a reconnect. + elif changed.keys() & self.Settings.LOCAL_FIELDS: + logger.debug( + f"{self} local settings update, no special action required: {changed.keys()}" + ) + # Only local fields changed — no need to push to the STT engine, + # the new settings will take effect immediately. + + return changed async def stop(self, frame: EndFrame): """Called when the session ends.""" - await self.cancel_task(self._stt_msg_task) await super().stop(frame) await self._disconnect() async def cancel(self, frame: CancelFrame): """Called when the session is cancelled.""" - await self.cancel_task(self._stt_msg_task) await super().cancel(frame) await self._disconnect() @@ -389,6 +611,7 @@ class SpeechmaticsSTTService(STTService): - Create STT client - Register handlers for messages - Connect to the client + - Start message processing task """ # Log the event logger.debug(f"{self} connecting to Speechmatics STT service") @@ -436,12 +659,22 @@ class SpeechmaticsSTTService(STTService): self._client = None await self.push_error(error_msg=f"Error connecting to STT service: {e}", exception=e) + # Start message processing task + if not self._stt_msg_task: + self._stt_msg_task = self.create_task(self._process_stt_messages()) + async def _disconnect(self) -> None: """Disconnect from the STT service. + - Cancel message processing task - Disconnect the client - Emit on_disconnected event handler for clients """ + # Cancel the message processing task + if self._stt_msg_task: + await self.cancel_task(self._stt_msg_task) + self._stt_msg_task = None + # Disconnect the client logger.debug(f"{self} disconnecting from Speechmatics STT service") try: @@ -472,28 +705,42 @@ class SpeechmaticsSTTService(STTService): # CONFIGURATION # ============================================================================ - def _prepare_config(self, params: InputParams) -> VoiceAgentConfig: - """Parse the InputParams into VoiceAgentConfig.""" - # Preset - config = VoiceAgentConfigPreset.load(params.turn_detection_mode.value) + def _build_config(self, settings: Settings) -> VoiceAgentConfig: + """Build a ``VoiceAgentConfig`` from the given settings. + + Used both at init time (with explicit settings, before + ``super().__init__`` has run) and before reconnecting so the + connection always reflects the latest settings. + + Args: + settings: Settings to build from. + """ + s = settings + + # Preset from turn detection mode + config = VoiceAgentConfigPreset.load(s.turn_detection_mode.value) + + # Audio encoding (init-only, stored as instance attribute) + config.audio_encoding = self._audio_encoding # Language + domain - config.language = self._language_to_speechmatics_language(params.language) - config.domain = params.domain - config.output_locale = self._locale_to_speechmatics_locale(config.language, params.language) + language = s.language + config.language = self._language_to_speechmatics_language(language) + config.domain = s.domain if s.domain is not None else None + config.output_locale = self._locale_to_speechmatics_locale(config.language, language) # Speaker config config.speaker_config = SpeakerFocusConfig( - focus_speakers=params.focus_speakers, - ignore_speakers=params.ignore_speakers, - focus_mode=params.focus_mode, + focus_speakers=s.focus_speakers if s.focus_speakers is not None else [], + ignore_speakers=s.ignore_speakers if s.ignore_speakers is not None else [], + focus_mode=s.focus_mode if s.focus_mode is not None else SpeakerFocusMode.RETAIN, ) - config.known_speakers = params.known_speakers + config.known_speakers = s.known_speakers if s.known_speakers is not None else [] # Custom dictionary - config.additional_vocab = params.additional_vocab + config.additional_vocab = s.additional_vocab if s.additional_vocab is not None else [] - # Advanced parameters + # Advanced parameters — only set if not None for param in [ "operating_point", "max_delay", @@ -507,21 +754,20 @@ class SpeechmaticsSTTService(STTService): "max_speakers", "prefer_current_speaker", ]: - if getattr(params, param) is not None: - setattr(config, param, getattr(params, param)) + val = getattr(s, param) + if val is not None: + setattr(config, param, val) # Extra parameters - if isinstance(params.extra_params, dict): - for key, value in params.extra_params.items(): + if isinstance(s.extra_params, dict): + for key, value in s.extra_params.items(): if hasattr(config, key): setattr(config, key, value) # Enable sentences - config.speech_segment_config = SpeechSegmentConfig( - emit_sentences=params.split_sentences or False - ) + split = s.split_sentences if s.split_sentences is not None else False + config.speech_segment_config = SpeechSegmentConfig(emit_sentences=split or False) - # Return the complete config return config def update_params( @@ -530,12 +776,23 @@ class SpeechmaticsSTTService(STTService): ) -> None: """Updates the speaker configuration. + .. deprecated:: 0.0.104 + Use ``STTUpdateSettingsFrame`` with + ``SpeechmaticsSTTService.Settings(...)`` instead. + This can update the speakers to listen to or ignore during an in-flight transcription. Only available if diarization is enabled. Args: params: Update parameters for the service. """ + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "update_params() is deprecated. Use STTUpdateSettingsFrame with " + "self.Settings(...) instead.", + DeprecationWarning, + ) # Check possible if not self._config.enable_diarization: raise ValueError("Diarization is not enabled") @@ -590,9 +847,6 @@ class SpeechmaticsSTTService(STTService): if segments: await self._send_frames(segments) - # Update metrics - await self._emit_metrics(message.get("metadata", {}).get("processing_time", 0.0)) - async def _handle_segment(self, message: dict[str, Any]) -> None: """Handle AddSegment events. @@ -627,7 +881,7 @@ class SpeechmaticsSTTService(STTService): # await self.start_processing_metrics() await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_end_of_turn(self, message: dict[str, Any]) -> None: """Handle EndOfTurn events. @@ -687,6 +941,7 @@ class SpeechmaticsSTTService(STTService): f"{self} VADUserStoppedSpeakingFrame received but internal VAD is being used" ) elif not self._enable_vad and self._client is not None: + self.request_finalize() self._client.finalize() async def _send_frames(self, segments: list[dict[str, Any]], finalized: bool = False) -> None: @@ -707,9 +962,9 @@ class SpeechmaticsSTTService(STTService): def attr_from_segment(segment: dict[str, Any]) -> dict[str, Any]: # Formats the output text based on the speaker and defined formats from the config. text = ( - self._speaker_active_format + self._settings.speaker_active_format if segment.get("is_active", True) - else self._speaker_passive_format + else self._settings.speaker_passive_format ).format( **{ "speaker_id": segment.get("speaker_id", "UU"), @@ -730,16 +985,33 @@ class SpeechmaticsSTTService(STTService): # If final, then re-parse into TranscriptionFrame if finalized: + # Do any segments have `is_eou` set to True? + if ( + any(segment.get("is_eou", False) for segment in segments) + and self._finalize_requested + ): + self.confirm_finalize() + + # Add the finalized frames frames += [TranscriptionFrame(**attr_from_segment(segment)) for segment in segments] + + # Handle the text (for metrics reporting) finalized_text = "|".join([s["text"] for s in segments]) - await self._handle_transcription(finalized_text, True, segments[0]["language"]) + await self._handle_transcription( + finalized_text, is_final=True, language=segments[0]["language"] + ) + + # Log the frames logger.debug(f"{self} finalized transcript: {[f.text for f in frames]}") # Return as interim results (unformatted) else: + # Add the interim frames frames += [ InterimTranscriptionFrame(**attr_from_segment(segment)) for segment in segments ] + + # Log the frames logger.debug(f"{self} interim transcript: {[f.text for f in frames]}") # Send the frames @@ -796,28 +1068,6 @@ class SpeechmaticsSTTService(STTService): yield ErrorFrame(f"Speechmatics error: {e}") await self._disconnect() - async def _emit_metrics(self, processing_time: float) -> None: - """Create TTFB metrics. - - The TTFB is the seconds between the person speaking and the STT - engine emitting the first partial. This is only calculated at the - start of an utterance. - """ - # Skip if metrics not available - if not self._metrics or processing_time == 0.0: - return - - # Calculate time as time.time() - ttfb (which is seconds) - start_time = time.time() - processing_time - - # Update internal metrics - self._metrics._start_ttfb_time = start_time - self._metrics._start_processing_time = start_time - - # Stop TTFB metrics - await self.stop_ttfb_metrics() - await self.stop_processing_metrics() - # ============================================================================ # HELPERS # ============================================================================ diff --git a/src/pipecat/services/speechmatics/tts.py b/src/pipecat/services/speechmatics/tts.py index d7b2f4e2a..64f64378a 100644 --- a/src/pipecat/services/speechmatics/tts.py +++ b/src/pipecat/services/speechmatics/tts.py @@ -7,6 +7,7 @@ """Speechmatics TTS service integration.""" import asyncio +from dataclasses import dataclass, field from typing import AsyncGenerator, Optional from urllib.parse import urlencode @@ -18,9 +19,8 @@ from pipecat.frames.frames import ( ErrorFrame, Frame, TTSAudioRawFrame, - TTSStartedFrame, - TTSStoppedFrame, ) +from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven from pipecat.services.tts_service import TTSService from pipecat.utils.network import exponential_backoff_time from pipecat.utils.tracing.service_decorators import traced_tts @@ -35,6 +35,17 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class SpeechmaticsTTSSettings(TTSSettings): + """Settings for SpeechmaticsTTSService. + + Parameters: + max_retries: Maximum number of retries for HTTP requests. + """ + + max_retries: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class SpeechmaticsTTSService(TTSService): """Speechmatics TTS service implementation. @@ -42,11 +53,17 @@ class SpeechmaticsTTSService(TTSService): It converts text to speech and returns raw PCM audio data for real-time playback. """ + Settings = SpeechmaticsTTSSettings + _settings: Settings + SPEECHMATICS_SAMPLE_RATE = 16000 class InputParams(BaseModel): """Optional input parameters for Speechmatics TTS configuration. + .. deprecated:: 0.0.105 + Use ``settings=SpeechmaticsTTSService.Settings(...)`` instead. + Parameters: max_retries: Maximum number of retries for TTS requests. Defaults to 5. """ @@ -58,10 +75,11 @@ class SpeechmaticsTTSService(TTSService): *, api_key: str, base_url: str = "https://preview.tts.speechmatics.com", - voice_id: str = "sarah", + voice_id: Optional[str] = None, aiohttp_session: aiohttp.ClientSession, sample_rate: Optional[int] = SPEECHMATICS_SAMPLE_RATE, params: Optional[InputParams] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Speechmatics TTS service. @@ -70,9 +88,19 @@ class SpeechmaticsTTSService(TTSService): api_key: Speechmatics API key for authentication. base_url: Base URL for Speechmatics TTS API. voice_id: Voice model to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=SpeechmaticsTTSService.Settings(voice=...)`` instead. + aiohttp_session: Shared aiohttp session for HTTP requests. sample_rate: Audio sample rate in Hz. - params: Optional[InputParams]: Input parameters for the service. + params: Input parameters for the service. + + .. deprecated:: 0.0.105 + Use ``settings=SpeechmaticsTTSService.Settings(...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to TTSService. """ if sample_rate and sample_rate != self.SPEECHMATICS_SAMPLE_RATE: @@ -80,7 +108,37 @@ class SpeechmaticsTTSService(TTSService): f"Speechmatics TTS only supports {self.SPEECHMATICS_SAMPLE_RATE}Hz sample rate. " f"Current rate of {sample_rate}Hz may cause issues." ) - super().__init__(sample_rate=sample_rate, **kwargs) + + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice="sarah", + language=None, + max_retries=5, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + + # 3. Apply params overrides — only if settings not provided + if params is not None: + self._warn_init_param_moved_to_settings("params") + if not settings: + default_settings.max_retries = params.max_retries + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) # Service parameters self._api_key: str = api_key @@ -91,12 +149,6 @@ class SpeechmaticsTTSService(TTSService): if not self._api_key: raise ValueError("Missing Speechmatics API key") - # Default parameters - self._params = params or SpeechmaticsTTSService.InputParams() - - # Set voice from constructor parameter - self.set_voice(voice_id) - def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -106,11 +158,12 @@ class SpeechmaticsTTSService(TTSService): return True @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Speechmatics' HTTP API. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -130,12 +183,9 @@ class SpeechmaticsTTSService(TTSService): } # Complete HTTP URL - url = _get_endpoint_url(self._base_url, self._voice_id, self.sample_rate) + url = _get_endpoint_url(self._base_url, self._settings.voice, self.sample_rate) try: - # Start TTS TTFB metrics - await self.start_ttfb_metrics() - # Track attempt attempt = 0 @@ -158,7 +208,7 @@ class SpeechmaticsTTSService(TTSService): attempt += 1 # Check if we've exceeded the maximum number of attempts - if attempt >= self._params.max_retries: + if attempt >= self._settings.max_retries: raise ValueError() # Report error frame @@ -186,9 +236,6 @@ class SpeechmaticsTTSService(TTSService): # Update Pipecat metrics await self.start_tts_usage_metrics(text) - # Emit the TTS started frame - yield TTSStartedFrame() - # Process the response in streaming chunks first_chunk = True buffer = b"" @@ -216,6 +263,7 @@ class SpeechmaticsTTSService(TTSService): audio=audio_data, sample_rate=self.sample_rate, num_channels=1, + context_id=context_id, ) # Successfully processed the response, break out of retry loop @@ -224,8 +272,7 @@ class SpeechmaticsTTSService(TTSService): except Exception as e: yield ErrorFrame(error=f"Error generating TTS: {e}") finally: - # Emit the TTS stopped frame - yield TTSStoppedFrame() + await self.stop_ttfb_metrics() def _get_endpoint_url(base_url: str, voice: str, sample_rate: int) -> str: diff --git a/src/pipecat/services/stt_latency.py b/src/pipecat/services/stt_latency.py new file mode 100644 index 000000000..351e041a6 --- /dev/null +++ b/src/pipecat/services/stt_latency.py @@ -0,0 +1,52 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""STT service latency defaults. + +This module contains P99 time-to-final-segment (TTFS) latency values for STT +services. TTFS measures the time from when speech ends to when the final +transcript is received. + +These values are used by turn stop strategies to optimize timing. Each STT +service publishes its latency via STTMetadataFrame at pipeline start. + +To measure latency for your specific deployment (region, network conditions, +self-hosted instances), use the STT benchmark tool: +https://github.com/pipecat-ai/stt-benchmark + +Run the TTFS benchmark for your service and configuration, then pass the +measured value to your STT service constructor: + + stt = DeepgramSTTService(api_key="...", ttfs_p99_latency=0.45) +""" + +# Conservative fallback for services without measured values +DEFAULT_TTFS_P99: float = 1.0 + +# Measured P99 TTFS latency values (in seconds) +ASSEMBLYAI_TTFS_P99: float = 0.42 +AWS_TRANSCRIBE_TTFS_P99: float = 1.90 +AZURE_TTFS_P99: float = 1.80 +CARTESIA_TTFS_P99: float = 0.81 +DEEPGRAM_TTFS_P99: float = 0.35 +DEEPGRAM_SAGEMAKER_TTFS_P99: float = 0.35 +ELEVENLABS_TTFS_P99: float = 2.01 +ELEVENLABS_REALTIME_TTFS_P99: float = 0.41 +FAL_TTFS_P99: float = 2.07 +GLADIA_TTFS_P99: float = 1.49 +GOOGLE_TTFS_P99: float = 1.57 +GRADIUM_TTFS_P99: float = 1.61 +GROQ_TTFS_P99: float = 1.54 +OPENAI_TTFS_P99: float = 2.01 +OPENAI_REALTIME_TTFS_P99: float = 1.66 +SAMBANOVA_TTFS_P99: float = 2.20 +SARVAM_TTFS_P99: float = 1.17 +SONIOX_TTFS_P99: float = 0.35 +SPEECHMATICS_TTFS_P99: float = 0.74 + +# These services run locally and should be replaced with measured values +NVIDIA_TTFS_P99: float = DEFAULT_TTFS_P99 +WHISPER_TTFS_P99: float = DEFAULT_TTFS_P99 diff --git a/src/pipecat/services/stt_service.py b/src/pipecat/services/stt_service.py index 0c0d2afe7..ecfec7b9b 100644 --- a/src/pipecat/services/stt_service.py +++ b/src/pipecat/services/stt_service.py @@ -6,28 +6,41 @@ """Base classes for Speech-to-Text services with continuous and segmented processing.""" +import asyncio import io +import time +import warnings import wave from abc import abstractmethod -from typing import Any, AsyncGenerator, Dict, Mapping, Optional +from typing import Any, AsyncGenerator, Optional from loguru import logger +from websockets.protocol import State from pipecat.frames.frames import ( AudioRawFrame, ErrorFrame, Frame, + InterruptionFrame, + ServiceSwitcherRequestMetadataFrame, StartFrame, + STTMetadataFrame, STTMuteFrame, STTUpdateSettingsFrame, + TranscriptionFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.ai_service import AIService +from pipecat.services.settings import STTSettings, is_given +from pipecat.services.stt_latency import DEFAULT_TTFS_P99 from pipecat.services.websocket_service import WebsocketService from pipecat.transcriptions.language import Language +# Duration in seconds of silent audio sent for WebSocket keepalive (100ms). +_KEEPALIVE_SILENCE_DURATION = 0.1 + class STTService(AIService): """Base class for speech-to-text services. @@ -36,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. @@ -56,11 +75,18 @@ class STTService(AIService): logger.error(f"STT connection error: {error}") """ + _settings: STTSettings + def __init__( self, + *, audio_passthrough=True, - # STT input sample rate 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, + settings: Optional[STTSettings] = None, **kwargs, ): """Initialize the STT service. @@ -70,16 +96,72 @@ class STTService(AIService): Defaults to True. sample_rate: The sample rate for audio input. If None, will be determined from the start frame. + stt_ttfb_timeout: Time in seconds to wait after VAD stop before reporting + TTFB. This delay allows the final transcription to arrive. Defaults to 2.0. + Note: STT "TTFB" differs from traditional TTFB (which measures from a discrete + request to first response byte). Since STT receives continuous audio, we measure + from when the user stops speaking to when the final transcript arrives—capturing + the latency that matters for voice AI applications. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + 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. + settings: The runtime-updatable settings for the STT service. **kwargs: Additional arguments passed to the parent AIService. """ - super().__init__(**kwargs) + super().__init__( + settings=settings + # Here in case subclass doesn't implement more specific settings + # (which hopefully should be rare) + or STTSettings(), + **kwargs, + ) + + # Convert Language enum to service-specific format at init time. + # Runtime updates are handled by _update_settings(), but init-time + # settings bypass that path and need explicit conversion. + # Raw strings (e.g. "de-DE") are first converted to Language enums + # so they go through the same resolution logic. + if isinstance(self._settings.language, str) and not isinstance( + self._settings.language, Language + ): + try: + self._settings.language = Language(self._settings.language) + except ValueError: + logger.warning( + f"Language string '{self._settings.language}' is not a recognized " + f"Language code. It will be passed to the service as-is." + ) + if isinstance(self._settings.language, Language): + converted = self.language_to_service_language(self._settings.language) + if converted is not None: + self._settings.language = converted + self._audio_passthrough = audio_passthrough self._init_sample_rate = sample_rate self._sample_rate = 0 - self._settings: Dict[str, Any] = {} - self._tracing_enabled: bool = False + self._muted: bool = False self._user_id: str = "" + self._ttfs_p99_latency = ttfs_p99_latency + + # STT TTFB tracking state + self._stt_ttfb_timeout = stt_ttfb_timeout + self._ttfb_timeout_task: Optional[asyncio.Task] = None + self._user_speaking: bool = False + self._finalize_pending: bool = False + self._finalize_requested: bool = False + self._last_transcript_time: float = 0 + + # 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") @@ -94,6 +176,31 @@ class STTService(AIService): """ return self._muted + def request_finalize(self): + """Mark that a finalize request has been sent, awaiting server confirmation. + + For providers that have explicit server confirmation of finalization + (e.g., Deepgram's from_finalize field), call this when sending the finalize + request. Then call confirm_finalize() when the server confirms. + + For providers without server confirmation, don't call this method - just + send the finalize/flush/commit command and rely on the TTFB timeout. + """ + self._finalize_requested = True + + def confirm_finalize(self): + """Confirm that the server has acknowledged the finalize request. + + Call this when the server response confirms finalization (e.g., Deepgram's + from_finalize=True). The next TranscriptionFrame pushed will be marked + as finalized. + + Only has effect if request_finalize() was previously called. + """ + if self._finalize_requested: + self._finalize_pending = True + self._finalize_requested = False + @property def sample_rate(self) -> int: """Get the current sample rate for audio processing. @@ -106,18 +213,53 @@ class STTService(AIService): async def set_model(self, model: str): """Set the speech recognition model. + .. deprecated:: 0.0.104 + Use ``STTUpdateSettingsFrame(model=...)`` instead. + Args: model: The name of the model to use for speech recognition. """ - self.set_model_name(model) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "'set_model' is deprecated, use 'STTUpdateSettingsFrame(model=...)' instead.", + DeprecationWarning, + stacklevel=2, + ) + logger.info(f"Switching STT model to: [{model}]") + settings_cls = type(self._settings) + await self._update_settings(settings_cls(model=model)) async def set_language(self, language: Language): """Set the language for speech recognition. + .. deprecated:: 0.0.104 + Use ``STTUpdateSettingsFrame(language=...)`` instead. + Args: language: The language to use for speech recognition. """ - pass + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "'set_language' is deprecated, use 'STTUpdateSettingsFrame(language=...)' instead.", + DeprecationWarning, + stacklevel=2, + ) + logger.info(f"Switching STT language to: [{language}]") + settings_cls = type(self._settings) + await self._update_settings(settings_cls(language=language)) + + def language_to_service_language(self, language: Language) -> Optional[str]: + """Convert a language to the service-specific language format. + + Args: + language: The language to convert. + + Returns: + The service-specific language identifier, or None if not supported. + """ + return Language(language) @abstractmethod async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: @@ -142,20 +284,49 @@ 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 _update_settings(self, settings: Mapping[str, Any]): - logger.info(f"Updating STT settings: {self._settings}") - for key, value in settings.items(): - if key in self._settings: - logger.info(f"Updating STT setting {key} to: [{value}]") - self._settings[key] = value - if key == "language": - await self.set_language(value) - elif key == "model": - self.set_model_name(value) - else: - logger.warning(f"Unknown setting for STT service: {key}") + 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, delta: STTSettings) -> dict[str, Any]: + """Apply an STT settings delta. + + Handles ``model`` (via parent). Translates ``Language`` enum values + before applying so the stored value is a service-specific string. + Concrete services should override this method and handle language + changes (including any reconnect logic) based on the returned + changed-field dict. + + Args: + delta: An STT settings delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + # Translate language *before* applying so the stored value is canonical. + # Raw strings are first converted to Language enums for proper resolution. + if ( + is_given(delta.language) + and isinstance(delta.language, str) + and not isinstance(delta.language, Language) + ): + try: + delta.language = Language(delta.language) + except ValueError: + logger.warning( + f"Language string '{delta.language}' is not a recognized " + f"Language code. It will be passed to the service as-is." + ) + if is_given(delta.language) and isinstance(delta.language, Language): + converted = self.language_to_service_language(delta.language) + if converted is not None: + delta.language = converted + + changed = await super()._update_settings(delta) + return changed async def process_audio_frame(self, frame: AudioRawFrame, direction: FrameDirection): """Process an audio frame for speech recognition. @@ -172,6 +343,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 @@ -197,21 +370,228 @@ class STTService(AIService): """ await super().process_frame(frame, direction) - if isinstance(frame, AudioRawFrame): + if isinstance(frame, StartFrame): + # Push StartFrame first, then metadata so downstream receives them in order + await self.push_frame(frame, direction) + await self._push_stt_metadata() + elif isinstance(frame, ServiceSwitcherRequestMetadataFrame): + await self._push_stt_metadata() + await self.push_frame(frame, direction) + elif isinstance(frame, AudioRawFrame): # In this service we accumulate audio internally and at the end we # push a TextFrame. We also push audio downstream in case someone # else needs it. await self.process_audio_frame(frame, direction) if self._audio_passthrough: await self.push_frame(frame, direction) + elif isinstance(frame, VADUserStartedSpeakingFrame): + await self._handle_vad_user_started_speaking(frame) + await self.push_frame(frame, direction) + elif isinstance(frame, VADUserStoppedSpeakingFrame): + await self._handle_vad_user_stopped_speaking(frame) + await self.push_frame(frame, direction) elif isinstance(frame, STTUpdateSettingsFrame): - await self._update_settings(frame.settings) + if frame.service is not None and frame.service is not self: + await self.push_frame(frame, direction) + elif frame.delta is not None: + await self._update_settings(frame.delta) + elif frame.settings: + # Backward-compatible path: convert legacy dict to settings object. + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Passing a dict via STTUpdateSettingsFrame(settings={...}) is deprecated " + "since 0.0.104, use STTUpdateSettingsFrame(delta=STTSettings(...)) instead.", + DeprecationWarning, + stacklevel=2, + ) + delta = type(self._settings).from_mapping(frame.settings) + await self._update_settings(delta) elif isinstance(frame, STTMuteFrame): self._muted = frame.mute logger.debug(f"STT service {'muted' if frame.mute else 'unmuted'}") + elif isinstance(frame, InterruptionFrame): + await self._reset_stt_ttfb_state() + await self.push_frame(frame, direction) else: await self.push_frame(frame, direction) + async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): + """Push a frame downstream, tracking TranscriptionFrame timestamps for TTFB. + + Stores the timestamp of each TranscriptionFrame for TTFB calculation. + If the frame is marked as finalized (via request_finalize/confirm_finalize), + reports TTFB immediately and cancels any pending timeout. Otherwise, TTFB is + reported after a timeout. + + Args: + frame: The frame to push. + direction: The direction to push the frame. + """ + if isinstance(frame, TranscriptionFrame): + # Store the transcript time for TTFB calculation + self._last_transcript_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: + await self.stop_ttfb_metrics() + # Cancel the timeout since we've already reported + await self._cancel_ttfb_timeout() + + await super().push_frame(frame, direction) + + async def _push_stt_metadata(self): + """Push STT metadata frame for downstream processors (e.g., turn strategies).""" + ttfs = self._ttfs_p99_latency + if ttfs is None: + ttfs = DEFAULT_TTFS_P99 + logger.warning(f"{self.name}: ttfs_p99_latency not set, using default {ttfs}s") + await self.broadcast_frame(STTMetadataFrame, service_name=self.name, ttfs_p99_latency=ttfs) + + async def _cancel_ttfb_timeout(self): + """Cancel any pending TTFB timeout task.""" + if self._ttfb_timeout_task: + await self.cancel_task(self._ttfb_timeout_task) + self._ttfb_timeout_task = None + + async def _reset_stt_ttfb_state(self): + """Reset STT TTFB measurement state. + + Called when starting a new utterance or on interruption to ensure + we don't use stale state for TTFB calculations. This specifically guards + against the case where a TranscriptionFrame is received without corresponding + VADUserStartedSpeakingFrame and VADUserStoppedSpeakingFrame frames. + + Note: Does not reset _user_speaking since InterruptionFrame can arrive + while user is still speaking. + """ + await self._cancel_ttfb_timeout() + + async def _handle_vad_user_started_speaking(self, frame: VADUserStartedSpeakingFrame): + """Handle VAD user started speaking frame to start tracking transcriptions. + + Cancels any pending TTFB timeout, resets TTFB tracking state, and marks user as speaking. + Also resets finalization state to prevent stale finalization from a previous utterance. + + Args: + frame: The VAD user started speaking frame. + """ + await self._reset_stt_ttfb_state() + self._user_speaking = True + self._finalize_requested = False + self._finalize_pending = False + self._last_transcript_time = 0 + + async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame): + """Handle VAD user stopped speaking frame. + + Calculates the actual speech end time and starts a timeout task to wait + for the final transcription before reporting TTFB. + + Args: + frame: The VAD user stopped speaking frame. + """ + self._user_speaking = False + + # Skip TTFB measurement if stop_secs is not set + if frame.stop_secs == 0.0: + return + + # 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. + 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) + self._ttfb_timeout_task = self.create_task( + self._ttfb_timeout_handler(), name="stt_ttfb_timeout" + ) + + async def _ttfb_timeout_handler(self): + """Wait for timeout then report TTFB using the last transcript timestamp. + + This timeout allows the final transcription to arrive before we calculate + and report TTFB. Uses _last_transcript_time as the end time so we measure + to when the transcript actually arrived, not when the timeout fired. + If no transcription arrived, no TTFB is reported. + """ + try: + await asyncio.sleep(self._stt_ttfb_timeout) + if self._last_transcript_time > 0: + await self.stop_ttfb_metrics(end_time=self._last_transcript_time) + except asyncio.CancelledError: + # Task was cancelled (new utterance or interruption), which is expected behavior + pass + finally: + self._ttfb_timeout_task = None + + def _create_keepalive_task(self): + """Start the keepalive task if keepalive is enabled.""" + if self._keepalive_timeout is not None: + self._last_audio_time = time.monotonic() + self._keepalive_task = self.create_task( + self._keepalive_task_handler(), name="keepalive" + ) + + async def _cancel_keepalive_task(self): + """Stop the keepalive task if running.""" + if self._keepalive_task: + await self.cancel_task(self._keepalive_task) + self._keepalive_task = None + + async def _keepalive_task_handler(self): + """Send periodic silent audio to prevent the server from closing the connection. + + When keepalive is enabled, this task checks periodically if the connection + has been idle (no audio sent) for longer than keepalive_timeout seconds. + If so, it generates silent 16-bit mono PCM audio and passes it to + _send_keepalive() for service-specific formatting and sending. + """ + while True: + await asyncio.sleep(self._keepalive_interval) + try: + if not self._is_keepalive_ready(): + continue + elapsed = time.monotonic() - self._last_audio_time + if elapsed < self._keepalive_timeout: + continue + num_samples = int(self.sample_rate * _KEEPALIVE_SILENCE_DURATION) + silence = b"\x00" * (num_samples * 2) + await self._send_keepalive(silence) + self._last_audio_time = time.monotonic() + logger.trace(f"{self} sent keepalive silence") + except Exception as e: + logger.warning(f"{self} keepalive error: {e}") + break + + def _is_keepalive_ready(self) -> bool: + """Check if the service is ready to send keepalive. + + Subclasses should override this to check their connection state. + + Returns: + True if keepalive can be sent. + """ + return True + + async def _send_keepalive(self, silence: bytes): + """Send silent audio to keep the connection alive. + + Subclasses that enable keepalive must override this to deliver silence + in their service-specific protocol. + + Args: + silence: Silent 16-bit mono PCM audio bytes. + """ + raise NotImplementedError("Subclasses must override _send_keepalive") + class SegmentedSTTService(STTService): """STT service that processes speech in segments using VAD events. @@ -248,6 +628,20 @@ class SegmentedSTTService(STTService): await super().start(frame) self._audio_buffer_size_1s = self.sample_rate * 2 + async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): + """Push a frame, marking TranscriptionFrames as finalized. + + Segmented STT services process complete speech segments and return a single + TranscriptionFrame per segment, so every transcription is inherently finalized. + + Args: + frame: The frame to push. + direction: The direction of frame flow in the pipeline. + """ + if isinstance(frame, TranscriptionFrame): + frame.finalized = True + await super().push_frame(frame, direction) + async def process_frame(self, frame: Frame, direction: FrameDirection): """Process frames, handling VAD events and audio segmentation.""" await super().process_frame(frame, direction) @@ -272,11 +666,11 @@ class SegmentedSTTService(STTService): wav.close() content.seek(0) - await self.process_generator(self.run_stt(content.read())) - # Start clean. self._audio_buffer.clear() + await self.process_generator(self.run_stt(content.read())) + async def process_audio_frame(self, frame: AudioRawFrame, direction: FrameDirection): """Process audio frames by buffering them for segmented transcription. @@ -309,19 +703,67 @@ class WebsocketSTTService(STTService, WebsocketService): """Base class for websocket-based STT services. Combines STT functionality with websocket connectivity, providing automatic - error handling and reconnection capabilities. + error handling, reconnection capabilities, and optional silence-based keepalive. + + 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, **kwargs): + def __init__( + self, + *, + reconnect_on_error: bool = True, + **kwargs, + ): """Initialize the Websocket STT service. Args: reconnect_on_error: Whether to automatically reconnect on websocket errors. - **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) + async def _connect(self): + """Connect and start keepalive task if enabled.""" + await super()._connect() + self._create_keepalive_task() + + async def _disconnect(self): + """Disconnect and cancel keepalive task.""" + await super()._disconnect() + await self._cancel_keepalive_task() + + async def _reconnect_websocket(self, attempt_number: int) -> bool: + """Reconnect and restart keepalive task. + + The keepalive task breaks out of its loop on send errors, so it may + be dead after the websocket failure that triggered this reconnect. + """ + result = await super()._reconnect_websocket(attempt_number) + if result: + await self._cancel_keepalive_task() + self._create_keepalive_task() + return result + + 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. + + The default implementation sends raw PCM bytes directly. Subclasses + can override this to wrap the silence in a service-specific protocol. + + Args: + silence: Silent 16-bit mono PCM audio bytes. + """ + await self._websocket.send(silence) + async def _report_error(self, error: ErrorFrame): await self._call_event_handler("on_connection_error", error.error) await self.push_error_frame(error) diff --git a/src/pipecat/services/tavus/video.py b/src/pipecat/services/tavus/video.py index d9f259797..9043710c8 100644 --- a/src/pipecat/services/tavus/video.py +++ b/src/pipecat/services/tavus/video.py @@ -11,6 +11,7 @@ avatar functionality through Tavus's streaming API. """ import asyncio +from dataclasses import dataclass from typing import Optional import aiohttp @@ -34,9 +35,17 @@ from pipecat.frames.frames import ( ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSetup from pipecat.services.ai_service import AIService +from pipecat.services.settings import ServiceSettings from pipecat.transports.tavus.transport import TavusCallbacks, TavusParams, TavusTransportClient +@dataclass +class TavusVideoSettings(ServiceSettings): + """Settings for the Tavus video service.""" + + pass + + class TavusVideoService(AIService): """Service that proxies audio to Tavus and receives audio and video in return. @@ -50,6 +59,9 @@ class TavusVideoService(AIService): - User room: Contains the Pipecat Bot and the user """ + Settings = TavusVideoSettings + _settings: Settings + def __init__( self, *, @@ -57,6 +69,7 @@ class TavusVideoService(AIService): replica_id: str, persona_id: str = "pipecat-stream", session: aiohttp.ClientSession, + settings: Optional[Settings] = None, **kwargs, ) -> None: """Initialize the Tavus video service. @@ -66,9 +79,15 @@ class TavusVideoService(AIService): replica_id: ID of the Tavus voice replica to use for speech synthesis. persona_id: ID of the Tavus persona. Defaults to "pipecat-stream" for Pipecat TTS voice. session: Async HTTP session used for communication with Tavus. + settings: Runtime-updatable settings. Tavus has no model concept, so this + is primarily used for the ``extra`` dict. **kwargs: Additional arguments passed to the parent AIService class. """ - super().__init__(**kwargs) + default_settings = ServiceSettings(model=None) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(settings=default_settings, **kwargs) self._api_key = api_key self._session = session self._replica_id = replica_id @@ -94,6 +113,7 @@ class TavusVideoService(AIService): """ await super().setup(setup) callbacks = TavusCallbacks( + on_joined=self._on_joined, on_participant_joined=self._on_participant_joined, on_participant_left=self._on_participant_left, ) @@ -119,6 +139,10 @@ class TavusVideoService(AIService): await self._client.cleanup() self._client = None + async def _on_joined(self, data): + """Handle bot joined the Daily room.""" + logger.info("Tavus bot joined Daily room") + async def _on_participant_left(self, participant, reason): """Handle participant leaving the session.""" participant_id = participant["id"] diff --git a/src/pipecat/services/together/llm.py b/src/pipecat/services/together/llm.py index 277e8bc9c..4ec2f8244 100644 --- a/src/pipecat/services/together/llm.py +++ b/src/pipecat/services/together/llm.py @@ -6,11 +6,22 @@ """Together.ai LLM service implementation using OpenAI-compatible interface.""" +from dataclasses import dataclass +from typing import Optional + from loguru import logger +from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService +@dataclass +class TogetherLLMSettings(BaseOpenAILLMService.Settings): + """Settings for TogetherLLMService.""" + + pass + + class TogetherLLMService(OpenAILLMService): """A service for interacting with Together.ai's API using the OpenAI-compatible interface. @@ -18,12 +29,16 @@ class TogetherLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. """ + Settings = TogetherLLMSettings + _settings: Settings + def __init__( self, *, api_key: str, base_url: str = "https://api.together.xyz/v1", - model: str = "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", + model: Optional[str] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize Together.ai LLM service. @@ -32,9 +47,29 @@ class TogetherLLMService(OpenAILLMService): api_key: The API key for accessing Together.ai's API. base_url: The base URL for Together.ai API. Defaults to "https://api.together.xyz/v1". model: The model identifier to use. Defaults to "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo". + + .. deprecated:: 0.0.105 + Use ``settings=TogetherLLMService.Settings(model=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional keyword arguments passed to OpenAILLMService. """ - super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings(model="meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo") + + # 2. Apply direct init arg overrides (deprecated) + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs) def create_client(self, api_key=None, base_url=None, **kwargs): """Create OpenAI-compatible client for Together.ai API endpoint. diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index 4b1d20b9b..f16c66191 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -7,7 +7,11 @@ """Base classes for Text-to-speech services.""" import asyncio +import uuid +import warnings from abc import abstractmethod +from dataclasses import dataclass +from enum import Enum from typing import ( Any, AsyncGenerator, @@ -16,7 +20,6 @@ from typing import ( Callable, Dict, List, - Mapping, Optional, Sequence, Tuple, @@ -24,6 +27,7 @@ from typing import ( from loguru import logger +from pipecat.audio.utils import create_stream_resampler from pipecat.frames.frames import ( AggregatedTextFrame, AggregationType, @@ -35,9 +39,11 @@ from pipecat.frames.frames import ( Frame, InterimTranscriptionFrame, InterruptionFrame, + LLMAssistantPushAggregationFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, StartFrame, + SystemFrame, TextFrame, TranscriptionFrame, TTSAudioRawFrame, @@ -49,6 +55,7 @@ from pipecat.frames.frames import ( ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.ai_service import AIService +from pipecat.services.settings import TTSSettings, is_given from pipecat.services.websocket_service import WebsocketService from pipecat.transcriptions.language import Language from pipecat.utils.text.base_text_aggregator import BaseTextAggregator @@ -57,6 +64,49 @@ from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator from pipecat.utils.time import seconds_to_nanoseconds +@dataclass +class TTSContext: + """Context information for a TTS request. + + Attributes: + append_to_context: Whether this TTS output should be appended to the + conversation context after it is spoken. + push_assistant_aggregation: Whether to push an + ``LLMAssistantPushAggregationFrame`` after the TTS has finished + speaking, forcing the assistant aggregator to commit its current + text buffer to the conversation context. + """ + + append_to_context: bool = True + push_assistant_aggregation: Optional[bool] = False + + +class TextAggregationMode(str, Enum): + """Controls how incoming text is aggregated before TTS synthesis. + + Parameters: + SENTENCE: Buffer text until sentence boundaries are detected before synthesis. + Produces more natural speech but adds latency (~200-300ms per sentence). + TOKEN: Stream text tokens directly to TTS as they arrive. + Reduces latency but may affect speech quality depending on the TTS provider. + """ + + SENTENCE = "sentence" + TOKEN = "token" + + def __str__(self): + return self.value + + +@dataclass +class _WordTimestampEntry: + """Internal: word timestamp routed through an audio context queue.""" + + word: str + timestamp: float + context_id: str + + class TTSService(AIService): """Base class for text-to-speech services. @@ -65,9 +115,10 @@ class TTSService(AIService): sentence aggregation, silence insertion, and frame processing control. Event handlers: - on_connected: Called when connected to the STT service. - on_connected: Called when disconnected from the STT service. - on_connection_error: Called when a connection to the STT service error occurs. + on_connected: Called when connected to the TTS service. + on_disconnected: Called when disconnected from the TTS service. + on_connection_error: Called when a connection to the TTS service error occurs. + on_tts_request: Called before a TTS request is made, with the context ID and text. Example:: @@ -80,19 +131,30 @@ class TTSService(AIService): logger.debug(f"TTS disconnected") @tts.event_handler("on_connection_error") - async def on_connection_error(stt: TTSService, error: str): + async def on_connection_error(tts: TTSService, error: str): logger.error(f"TTS connection error: {error}") + + @tts.event_handler("on_tts_request") + async def on_tts_request(tts: TTSService, context_id: str, text: str): + logger.debug(f"TTS request: {context_id} - {text}") """ + _settings: TTSSettings + + _CONTEXT_KEEPALIVE = object() + def __init__( self, *, - aggregate_sentences: bool = True, + text_aggregation_mode: Optional[TextAggregationMode] = None, + aggregate_sentences: Optional[bool] = None, # if True, TTSService will push TextFrames and LLMFullResponseEndFrames, # otherwise subclass must do it push_text_frames: bool = True, # if True, TTSService will push TTSStoppedFrames, otherwise subclass must do it push_stop_frames: bool = False, + # if True, TTSService will push TTSStartedFrames and create audio contexts automatically + push_start_frame: bool = False, # if push_stop_frames is True, wait for this idle period before pushing TTSStoppedFrame stop_frame_timeout_s: float = 2.0, # if True, TTSService will push silence audio frames after TTSStoppedFrame @@ -101,6 +163,9 @@ class TTSService(AIService): silence_time_s: float = 2.0, # if True, we will pause processing frames while we are receiving audio pause_frame_processing: bool = False, + # if True, append a trailing space to text before sending to TTS + # (helps prevent some TTS services from vocalizing trailing punctuation) + append_trailing_space: bool = False, # TTS output sample rate sample_rate: Optional[int] = None, # Text aggregator to aggregate incoming tokens and decide when to push to the TTS. @@ -120,18 +185,35 @@ class TTSService(AIService): text_filter: Optional[BaseTextFilter] = None, # Audio transport destination of the generated frames. transport_destination: Optional[str] = None, + settings: Optional[TTSSettings] = None, + # if True, the context ID is reused within an LLM turn + reuse_context_id_within_turn: bool = True, **kwargs, ): """Initialize the TTS service. Args: + text_aggregation_mode: How to aggregate incoming text before synthesis. + TextAggregationMode.SENTENCE (default) buffers until sentence boundaries, + TextAggregationMode.TOKEN streams tokens directly for lower latency. aggregate_sentences: Whether to aggregate text into sentences before synthesis. + + .. deprecated:: 0.0.104 + Use ``text_aggregation_mode`` instead. Set to ``TextAggregationMode.SENTENCE`` + to aggregate text into sentences before synthesis, or + ``TextAggregationMode.TOKEN`` to stream tokens directly for lower latency. + push_text_frames: Whether to push TextFrames and LLMFullResponseEndFrames. push_stop_frames: Whether to automatically push TTSStoppedFrames. + push_start_frame: Whether to automatically create audio contexts and push TTSStartedFrames. + When True, the base class handles ``create_audio_context`` and yields ``TTSStartedFrame`` + before each synthesis call, so ``run_tts`` implementations do not need to. stop_frame_timeout_s: Idle time before pushing TTSStoppedFrame when push_stop_frames is True. push_silence_after_stop: Whether to push silence audio after TTSStoppedFrame. silence_time_s: Duration of silence to push when push_silence_after_stop is True. pause_frame_processing: Whether to pause frame processing during audio generation. + append_trailing_space: Whether to append a trailing space to text before sending to TTS. + This helps prevent some TTS services from vocalizing trailing punctuation (e.g., "dot"). sample_rate: Output sample rate for generated audio. text_aggregator: Custom text aggregator for processing incoming text. @@ -151,21 +233,76 @@ class TTSService(AIService): Use `text_filters` instead, which allows multiple filters. transport_destination: Destination for generated audio frames. + settings: The runtime-updatable settings for the TTS service. + reuse_context_id_within_turn: Whether the service should reuse context IDs within the + same turn. **kwargs: Additional arguments passed to the parent AIService. """ - super().__init__(**kwargs) - self._aggregate_sentences: bool = aggregate_sentences + super().__init__( + settings=settings + # Here in case subclass doesn't implement more specific settings + # (which hopefully should be rare) + or TTSSettings(), + **kwargs, + ) + + # Convert Language enum to service-specific format at init time. + # Runtime updates are handled by _update_settings(), but init-time + # settings bypass that path and need explicit conversion. + # Raw strings (e.g. "de-DE") are first converted to Language enums + # so they go through the same resolution logic. + if isinstance(self._settings.language, str) and not isinstance( + self._settings.language, Language + ): + try: + self._settings.language = Language(self._settings.language) + except ValueError: + logger.warning( + f"Language string '{self._settings.language}' is not a recognized " + f"Language code. It will be passed to the service as-is." + ) + if isinstance(self._settings.language, Language): + converted = self.language_to_service_language(self._settings.language) + if converted is not None: + self._settings.language = converted + + # Resolve text_aggregation_mode from the new param or deprecated aggregate_sentences + if aggregate_sentences is not None: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Parameter 'aggregate_sentences' is deprecated. " + "Use 'text_aggregation_mode=TextAggregationMode.SENTENCE' or " + "'text_aggregation_mode=TextAggregationMode.TOKEN' instead.", + DeprecationWarning, + stacklevel=2, + ) + if text_aggregation_mode is None: + text_aggregation_mode = ( + TextAggregationMode.SENTENCE + if aggregate_sentences + else TextAggregationMode.TOKEN + ) + + if text_aggregation_mode is None: + text_aggregation_mode = TextAggregationMode.SENTENCE + + self._text_aggregation_mode: TextAggregationMode = text_aggregation_mode self._push_text_frames: bool = push_text_frames self._push_stop_frames: bool = push_stop_frames + self._push_start_frame: bool = push_start_frame self._stop_frame_timeout_s: float = stop_frame_timeout_s self._push_silence_after_stop: bool = push_silence_after_stop self._silence_time_s: float = silence_time_s self._pause_frame_processing: bool = pause_frame_processing + self._append_trailing_space: bool = append_trailing_space self._init_sample_rate = sample_rate self._sample_rate = 0 - self._voice_id: str = "" - self._settings: Dict[str, Any] = {} - self._text_aggregator: BaseTextAggregator = text_aggregator or SimpleTextAggregator() + self._text_aggregator: BaseTextAggregator = text_aggregator or SimpleTextAggregator( + aggregation_type=self._text_aggregation_mode + ) if text_aggregator: import warnings @@ -183,8 +320,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 @@ -196,14 +331,86 @@ class TTSService(AIService): ) self._text_filters = [text_filter] + self._resampler = create_stream_resampler() + self._stop_frame_task: Optional[asyncio.Task] = None self._stop_frame_queue: asyncio.Queue = asyncio.Queue() self._processing_text: bool = False + self._tts_contexts: Dict[str, TTSContext] = {} + self._streamed_text: str = "" + self._text_aggregation_metrics_started: bool = False + + # Word timestamp state + self._initial_word_timestamp: int = -1 + self._initial_word_times: List[Tuple[str, float, Optional[str]]] = [] + # PTS of the last word frame pushed via _add_word_timestamps, used to assign + # correct PTS to sentinel frames ("TTSStoppedFrame", "Reset") that follow. + self._word_last_pts: int = 0 + self._llm_response_started: bool = False + self._reuse_context_id_within_turn: bool = reuse_context_id_within_turn + + # _turn_context_id: + # Set on LLMFullResponseStartFrame and cleared after LLMFullResponseEndFrame + # is processed (i.e. after flush). All sentences within one LLM turn share + # this ID so the TTS service groups them into a single audio context. + # Temporarily set to None for TTSSpeakFrame utterances, which are standalone. + # + # _playing_context_id (playback-side cursor): + # Set by _audio_context_task_handler as it dequeues contexts for playback. + # Cleared by reset_active_audio_context() on interruption. Used by + # has_active_audio_context() and get_active_audio_context_id(). + # + # Both fields may hold the same value during a turn, but + # they clear at different times: _turn_context_id is cleared when the LLM turn + # ends (synthesis done) while _playing_context_id remains set until the audio + # finishes playing. Merging them would null out the playback cursor prematurely. + self._playing_context_id: Optional[str] = None + self._turn_context_id: Optional[str] = None + self._audio_contexts: Dict[str, asyncio.Queue] = {} + self._audio_context_task: Optional[asyncio.Task] = None self._register_event_handler("on_connected") self._register_event_handler("on_disconnected") self._register_event_handler("on_connection_error") + self._register_event_handler("on_tts_request") + + # Whether the TTS process is currently yielding audio frames synchronously. + self._is_yielding_frames_synchronously = False + + @property + def _is_streaming_tokens(self) -> bool: + """Whether the service is streaming tokens directly without sentence aggregation.""" + return self._text_aggregation_mode == TextAggregationMode.TOKEN + + async def start_tts_usage_metrics(self, text: str): + """Record TTS usage metrics. + + When streaming tokens, usage metrics are aggregated and reported at + flush time instead of per token, so individual calls are skipped. + + Args: + text: The text being processed by TTS. + """ + if self._is_streaming_tokens: + return + await super().start_tts_usage_metrics(text) + + async def start_text_aggregation_metrics(self): + """Start text aggregation metrics if not already started. + + Only starts the metric once per LLM response. Skipped when streaming + tokens since per-token aggregation time is not meaningful. + """ + if self._is_streaming_tokens or self._text_aggregation_metrics_started: + return + self._text_aggregation_metrics_started = True + await super().start_text_aggregation_metrics() + + async def stop_text_aggregation_metrics(self): + """Stop text aggregation metrics and reset the started flag.""" + self._text_aggregation_metrics_started = False + await super().stop_text_aggregation_metrics() @property def sample_rate(self) -> int: @@ -234,28 +441,64 @@ class TTSService(AIService): async def set_model(self, model: str): """Set the TTS model to use. + .. deprecated:: 0.0.104 + Use ``TTSUpdateSettingsFrame(model=...)`` instead. + Args: model: The name of the TTS model. """ - self.set_model_name(model) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "'set_model' is deprecated, use 'TTSUpdateSettingsFrame(model=...)' instead.", + DeprecationWarning, + stacklevel=2, + ) + logger.info(f"Switching TTS model to: [{model}]") + settings_cls = type(self._settings) + await self._update_settings(settings_cls(model=model)) - def set_voice(self, voice: str): + async def set_voice(self, voice: str): """Set the voice for speech synthesis. + .. deprecated:: 0.0.104 + Use ``TTSUpdateSettingsFrame(voice=...)`` instead. + Args: voice: The voice identifier or name. """ - self._voice_id = voice + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "'set_voice' is deprecated, use 'TTSUpdateSettingsFrame(voice=...)' instead.", + DeprecationWarning, + stacklevel=2, + ) + logger.info(f"Switching TTS voice to: [{voice}]") + settings_cls = type(self._settings) + await self._update_settings(settings_cls(voice=voice)) + + def create_context_id(self) -> str: + """Generate or reuse a context ID based on concurrent TTS support. + + Returns: + A context ID string for the TTS request. + """ + if self._reuse_context_id_within_turn and self._turn_context_id: + self._refresh_audio_context(self._turn_context_id) + return self._turn_context_id + return str(uuid.uuid4()) # Converts the text to audio. @abstractmethod - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Run text-to-speech synthesis on the provided text. This method must be implemented by subclasses to provide actual TTS functionality. Args: text: The text to synthesize into speech. + context_id: Unique identifier for this TTS context. Yields: Frame: Audio frames containing the synthesized speech. @@ -273,17 +516,26 @@ class TTSService(AIService): """ return Language(language) - async def update_setting(self, key: str, value: Any): - """Update a service-specific setting. + def _prepare_text_for_tts(self, text: str) -> str: + """Prepare text for TTS by applying any transformations required by the TTS service. Args: - key: The setting key to update. - value: The new value for the setting. - """ - pass + text: The text to prepare. - async def flush_audio(self): - """Flush any buffered audio data.""" + Returns: + The prepared text with transformations applied. + """ + if self._append_trailing_space and not text.endswith(" "): + return text + " " + return text + + async def flush_audio(self, context_id: Optional[str] = None): + """Flush any buffered audio data. + + Args: + context_id: The specific context to flush. If None, falls back to the + currently active context (for non-concurrent services). + """ pass async def start(self, frame: StartFrame): @@ -296,7 +548,7 @@ class TTSService(AIService): self._sample_rate = self._init_sample_rate or frame.audio_out_sample_rate if self._push_stop_frames and not self._stop_frame_task: self._stop_frame_task = self.create_task(self._stop_frame_handler()) - self._tracing_enabled = frame.enable_tracing + self._create_audio_context_task() async def stop(self, frame: EndFrame): """Stop the TTS service. @@ -305,6 +557,12 @@ class TTSService(AIService): frame: The end frame. """ await super().stop(frame) + if self._audio_context_task: + # Sentinel None shuts down the serialization queue once all + # pending contexts and frames have been processed. + await self._serialization_queue.put(None) + await self._audio_context_task + self._audio_context_task = None if self._stop_frame_task: await self.cancel_task(self._stop_frame_task) self._stop_frame_task = None @@ -319,6 +577,7 @@ class TTSService(AIService): if self._stop_frame_task: await self.cancel_task(self._stop_frame_task) self._stop_frame_task = None + await self._stop_audio_context_task() def add_text_transformer( self, @@ -353,22 +612,39 @@ class TTSService(AIService): if not (agg_type == aggregation_type and func == transform_function) ] - async def _update_settings(self, settings: Mapping[str, Any]): - for key, value in settings.items(): - if key in self._settings: - logger.info(f"Updating TTS setting {key} to: [{value}]") - self._settings[key] = value - if key == "language": - self._settings[key] = self.language_to_service_language(value) - elif key == "model": - self.set_model_name(value) - elif key == "voice" or key == "voice_id": - self.set_voice(value) - elif key == "text_filter": - for filter in self._text_filters: - await filter.update_settings(value) - else: - logger.warning(f"Unknown setting for TTS service: {key}") + async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]: + """Apply a TTS settings delta. + + Translates language to service-specific value before applying. + + Args: + delta: A TTS settings delta. + + Returns: + Dict mapping changed field names to their previous values. + """ + # Translate language *before* applying so the stored value is canonical. + # Raw strings are first converted to Language enums for proper resolution. + if ( + is_given(delta.language) + and isinstance(delta.language, str) + and not isinstance(delta.language, Language) + ): + try: + delta.language = Language(delta.language) + except ValueError: + logger.warning( + f"Language string '{delta.language}' is not a recognized " + f"Language code. It will be passed to the service as-is." + ) + if is_given(delta.language) and isinstance(delta.language, Language): + converted = self.language_to_service_language(delta.language) + if converted is not None: + delta.language = converted + + changed = await super()._update_settings(delta) + + return changed async def say(self, text: str): """Immediately speak the provided text. @@ -391,6 +667,25 @@ class TTSService(AIService): await self.queue_frame(TTSSpeakFrame(text)) + async def on_turn_context_completed(self): + """Handle the completion of a turn.""" + # For HTTP services they emit the frames synchronously, so close the audio context here + # once all frames (including TTSTextFrame above) have been enqueued. + if self._is_yielding_frames_synchronously and self.audio_context_available( + self._turn_context_id + ): + if self._push_stop_frames: + await self.append_to_audio_context( + self._turn_context_id, TTSStoppedFrame(context_id=self._turn_context_id) + ) + await self.remove_audio_context(self._turn_context_id) + + # Flush any pending audio so the TTS service closes the current context. + await self.flush_audio(context_id=self._turn_context_id) + + # Reset the turn context ID + self._turn_context_id = None + async def process_frame(self, frame: Frame, direction: FrameDirection): """Process frames for text-to-speech conversion. @@ -415,20 +710,34 @@ class TTSService(AIService): and not isinstance(frame, InterimTranscriptionFrame) and not isinstance(frame, TranscriptionFrame) ): + await self.start_text_aggregation_metrics() await self._process_text_frame(frame) elif isinstance(frame, InterruptionFrame): await self._handle_interruption(frame, direction) await self.push_frame(frame, direction) + elif isinstance(frame, LLMFullResponseStartFrame): + self._llm_response_started = True + # New LLM turn → assign a fresh context ID shared by all sentences + self._turn_context_id = self.create_context_id() + await self.push_frame(frame, direction) elif isinstance(frame, (LLMFullResponseEndFrame, EndFrame)): + # Flush any remaining text (including text waiting for lookahead) + remaining = await self._text_aggregator.flush() + # Stop the aggregation metric (no-op if already stopped on first sentence). + await self.stop_text_aggregation_metrics() + if remaining: + await self._push_tts_frames(AggregatedTextFrame(remaining.text, remaining.type)) + # We pause processing incoming frames if the LLM response included # text (it might be that it's only a function calling response). We # pause to avoid audio overlapping. await self._maybe_pause_frame_processing() - # Flush any remaining text (including text waiting for lookahead) - remaining = await self._text_aggregator.flush() - if remaining: - await self._push_tts_frames(AggregatedTextFrame(remaining.text, remaining.type)) + # Log accumulated streamed text and emit aggregated usage metric. + if self._streamed_text: + logger.debug(f"{self}: Generating TTS [{self._streamed_text}]") + await super().start_tts_usage_metrics(self._streamed_text) + self._streamed_text = "" # Reset aggregator state self._processing_text = False @@ -437,23 +746,61 @@ class TTSService(AIService): await self.push_frame(frame, direction) else: await self.push_frame(frame, direction) + + await self.on_turn_context_completed() elif isinstance(frame, TTSSpeakFrame): # Store if we were processing text or not so we can set it back. processing_text = self._processing_text + # TTSSpeakFrame is independent — temporarily clear the turn context + # so create_context_id() generates a fresh UUID for this utterance. + saved_turn_context_id = self._turn_context_id + self._turn_context_id = None + # Creating a new context_id for the TTS request. + self._turn_context_id = self.create_context_id() + # If we are not receiving text from the LLM, we can assume that the SpeakFrame should be automatically added to the context + push_assistant_aggregation = frame.append_to_context and not self._llm_response_started # Assumption: text in TTSSpeakFrame does not include inter-frame spaces - await self._push_tts_frames(AggregatedTextFrame(frame.text, AggregationType.SENTENCE)) + await self._push_tts_frames( + AggregatedTextFrame(frame.text, AggregationType.SENTENCE), + append_tts_text_to_context=frame.append_to_context, + push_assistant_aggregation=push_assistant_aggregation, + ) + await self.on_turn_context_completed() # We pause processing incoming frames because we are sending data to # the TTS. We pause to avoid audio overlapping. await self._maybe_pause_frame_processing() - await self.flush_audio() + self._turn_context_id = saved_turn_context_id self._processing_text = processing_text elif isinstance(frame, TTSUpdateSettingsFrame): - await self._update_settings(frame.settings) + if frame.service is not None and frame.service is not self: + await self.push_frame(frame, direction) + elif frame.delta is not None: + await self._update_settings(frame.delta) + elif frame.settings: + # Backward-compatible path: convert legacy dict to settings object. + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Passing a dict via TTSUpdateSettingsFrame(settings={...}) is deprecated " + "since 0.0.104, use TTSUpdateSettingsFrame(delta=TTSSettings(...)) instead.", + DeprecationWarning, + stacklevel=2, + ) + delta = type(self._settings).from_mapping(frame.settings) + await self._update_settings(delta) elif isinstance(frame, BotStoppedSpeakingFrame): await self._maybe_resume_frame_processing() await self.push_frame(frame, direction) else: - await self.push_frame(frame, direction) + if direction == FrameDirection.DOWNSTREAM and not isinstance(frame, SystemFrame): + # Route non-system downstream frames through the serialization queue so they + # are emitted in the same order they arrive relative to any audio contexts that + # are already queued (e.g. a FooFrame sent right after a TTSSpeakFrame must + # not overtake the TTSStartedFrame / TTSAudioRawFrame / TTSStoppedFrame + # sequence from that speak frame). + await self._serialization_queue.put(frame) + else: + await self.push_frame(frame, direction) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): """Push a frame downstream with TTS-specific handling. @@ -462,6 +809,14 @@ class TTSService(AIService): frame: The frame to push. direction: The direction to push the frame. """ + # Clean up context when we see TTSStoppedFrame + if isinstance(frame, TTSStoppedFrame) and frame.context_id: + if frame.context_id in self._tts_contexts: + if self._tts_contexts[frame.context_id].push_assistant_aggregation: + await self.push_frame(LLMAssistantPushAggregationFrame()) + logger.debug(f"{self} cleaning up TTS context {frame.context_id}") + del self._tts_contexts[frame.context_id] + if self._push_silence_after_stop and isinstance(frame, TTSStoppedFrame): silence_num_bytes = int(self._silence_time_s * self.sample_rate * 2) # 16-bit silence_frame = TTSAudioRawFrame( @@ -486,12 +841,42 @@ class TTSService(AIService): await self._stop_frame_queue.put(frame) async def _stream_audio_frames_from_iterator( - self, iterator: AsyncIterator[bytes], *, strip_wav_header: bool + self, + iterator: AsyncIterator[bytes], + *, + strip_wav_header: bool = False, + in_sample_rate: Optional[int] = None, + context_id: Optional[str] = None, ) -> AsyncGenerator[Frame, None]: + """Stream audio frames from an async byte iterator with optional resampling. + + For WAV data, use `strip_wav_header=True` to strip the header and + auto-detect the source sample rate. For raw PCM data, pass + `in_sample_rate` directly. Audio is resampled to `self.sample_rate` when + the source rate differs. + + Args: + iterator: Async iterator yielding audio bytes. + strip_wav_header: Strip WAV header and parse source sample rate from it. + in_sample_rate: Source sample rate for raw PCM data. Overrides + WAV-detected rate if both are provided. + context_id: Unique identifier for this TTS context. + + """ buffer = bytearray() + source_sample_rate = in_sample_rate need_to_strip_wav_header = strip_wav_header + + async def maybe_resample(audio: bytes) -> bytes: + if source_sample_rate and source_sample_rate != self.sample_rate: + return await self._resampler.resample(audio, source_sample_rate, self.sample_rate) + return audio + async for chunk in iterator: if need_to_strip_wav_header and chunk.startswith(b"RIFF"): + # Parse sample rate from WAV header (bytes 24-28, little-endian uint32). + if len(chunk) >= 44 and source_sample_rate is None: + source_sample_rate = int.from_bytes(chunk[24:28], "little") chunk = chunk[44:] need_to_strip_wav_header = False @@ -501,19 +886,21 @@ class TTSService(AIService): # Round to nearest even number. aligned_length = len(buffer) & ~1 # 111111111...11110 if aligned_length > 0: - aligned_chunk = buffer[:aligned_length] + aligned_chunk = await maybe_resample(bytes(buffer[:aligned_length])) buffer = buffer[aligned_length:] # keep any leftover byte if len(aligned_chunk) > 0: - frame = TTSAudioRawFrame(bytes(aligned_chunk), self.sample_rate, 1) + frame = TTSAudioRawFrame( + bytes(aligned_chunk), self.sample_rate, 1, context_id=context_id + ) yield frame if len(buffer) > 0: # Make sure we don't need an extra padding byte. if len(buffer) % 2 == 1: buffer.extend(b"\x00") - frame = TTSAudioRawFrame(bytes(buffer), self.sample_rate, 1) - yield frame + audio = await maybe_resample(bytes(buffer)) + yield TTSAudioRawFrame(audio, self.sample_rate, 1) async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): self._processing_text = False @@ -521,6 +908,21 @@ class TTSService(AIService): for filter in self._text_filters: await filter.handle_interruption() + self._llm_response_started = False + self._streamed_text = "" + self._text_aggregation_metrics_started = False + await self.reset_word_timestamps() + + await self._stop_audio_context_task() + audio_contexts = self.get_audio_contexts() + if audio_contexts: + for ctx_id in audio_contexts: + await self.on_audio_context_interrupted(context_id=ctx_id) + self.reset_active_audio_context() + self._turn_context_id = None + self._word_last_pts = 0 + self._create_audio_context_task() + async def _maybe_pause_frame_processing(self): if self._processing_text and self._pause_frame_processing: await self.pause_processing_frames() @@ -530,29 +932,25 @@ class TTSService(AIService): await self.resume_processing_frames() async def _process_text_frame(self, frame: TextFrame): - text: Optional[str] = None - includes_inter_frame_spaces: bool = False - if not self._aggregate_sentences: - text = frame.text - includes_inter_frame_spaces = frame.includes_inter_frame_spaces - aggregated_by = "token" - - if text: - logger.trace(f"Pushing TTS frames for text: {text}, {aggregated_by}") - await self._push_tts_frames( - AggregatedTextFrame(text, aggregated_by), includes_inter_frame_spaces - ) - else: - async for aggregate in self._text_aggregator.aggregate(frame.text): - text = aggregate.text - aggregated_by = aggregate.type - logger.trace(f"Pushing TTS frames for text: {text}, {aggregated_by}") - await self._push_tts_frames( - AggregatedTextFrame(text, aggregated_by), includes_inter_frame_spaces - ) + async for aggregate in self._text_aggregator.aggregate(frame.text): + includes_inter_frame_spaces = ( + frame.includes_inter_frame_spaces + if aggregate.type == AggregationType.TOKEN + else False + ) + if aggregate.type != AggregationType.TOKEN: + # Stop the aggregation metric on the first sentence only. + await self.stop_text_aggregation_metrics() + await self._push_tts_frames( + AggregatedTextFrame(aggregate.text, aggregate.type), includes_inter_frame_spaces + ) async def _push_tts_frames( - self, src_frame: AggregatedTextFrame, includes_inter_frame_spaces: Optional[bool] = False + self, + src_frame: AggregatedTextFrame, + includes_inter_frame_spaces: Optional[bool] = False, + append_tts_text_to_context: Optional[bool] = True, + push_assistant_aggregation: Optional[bool] = False, ): type = src_frame.aggregated_by text = src_frame.text @@ -576,7 +974,15 @@ class TTSService(AIService): # or when we received an LLMFullResponseEndFrame self._processing_text = True - await self.start_processing_metrics() + # Accumulate text for a single debug log at flush time when streaming tokens. + if self._is_streaming_tokens: + self._streamed_text += text + + # Skip per-token processing metrics when streaming. The per-token + # processing time is just websocket send overhead (~0.1ms) and not + # meaningful. TTFB captures the important timing for streaming TTS. + if not self._is_streaming_tokens: + await self.start_processing_metrics() # Process all filters. for filter in self._text_filters: @@ -584,15 +990,28 @@ class TTSService(AIService): text = await filter.filter(text) if not text.strip(): - await self.stop_processing_metrics() + if not self._is_streaming_tokens: + await self.stop_processing_metrics() return + # Create context ID and store metadata + context_id = self.create_context_id() + # To support use cases that may want to know the text before it's spoken, we # push the AggregatedTextFrame version before transforming and sending to TTS. # However, we do not want to add this text to the assistant context until it # is spoken, so we set append_to_context to False. src_frame.append_to_context = False - await self.push_frame(src_frame) + src_frame.context_id = context_id + # Route AggregatedTextFrame through the serialization queue so it is emitted + # immediately before the TTSStartedFrame of the audio context it describes, + # rather than racing ahead of audio frames from a previous context. + if not self.audio_context_available(context_id): + await self._serialization_queue.put(src_frame) + # Otherwise, if the context already exists, we append the AggregatedTextFrame + # to the existing context queue. + else: + await self.append_to_audio_context(context_id, src_frame) # Note: Text transformations are meant to only affect the text sent to the TTS for # TTS-specific purposes. This allows for explicit TTS modifications (e.g., inserting @@ -603,9 +1022,29 @@ class TTSService(AIService): for aggregation_type, transform in self._text_transforms: if aggregation_type == type or aggregation_type == "*": transformed_text = await transform(transformed_text, type) - await self.process_generator(self.run_tts(transformed_text)) - await self.stop_processing_metrics() + self._tts_contexts[context_id] = TTSContext( + append_to_context=append_tts_text_to_context + if append_tts_text_to_context is not None + else True, + push_assistant_aggregation=push_assistant_aggregation, + ) + + # Apply any final text preparation (e.g., trailing space) + prepared_text = self._prepare_text_for_tts(transformed_text) + + # Trigger event before starting TTS + await self._call_event_handler("on_tts_request", context_id, prepared_text) + + if self._push_start_frame and not self.audio_context_available(context_id): + await self.create_audio_context(context_id) + await self.start_ttfb_metrics() + await self.append_to_audio_context(context_id, TTSStartedFrame(context_id=context_id)) + + await self.tts_process_generator(context_id, self.run_tts(prepared_text, context_id)) + + if not self._is_streaming_tokens: + await self.stop_processing_metrics() if self._push_text_frames: # In TTS services that support word timestamps, the TTSTextFrames @@ -617,30 +1056,383 @@ class TTSService(AIService): # or transformations. frame = TTSTextFrame(text, aggregated_by=type) frame.includes_inter_frame_spaces = includes_inter_frame_spaces - await self.push_frame(frame) + frame.context_id = context_id + # Only override append_to_context if explicitly set + if append_tts_text_to_context is not None: + frame.append_to_context = append_tts_text_to_context + # Appending to the context, so it preserves the ordering. + await self.append_to_audio_context(context_id, frame) + + async def tts_process_generator( + self, context_id: str, generator: AsyncGenerator[Frame | None, None] + ) -> bool: + """Process frames from an async generator, routing them through the audio context. + + All non-None frames yielded by the generator are appended to the audio context + identified by context_id. The audio context must be created by run_tts (via + create_audio_context) before the first frame is yielded. + + WebSocket services yield None to signal that audio will arrive via a separate + receive loop; those services manage context lifetime themselves (via remove_audio_context + in the receive loop on "done"). HTTP services never yield None and do NOT call + remove_audio_context in run_tts — the caller (_synthesize_text) closes the context + after appending any remaining frames (e.g. TTSTextFrame). + + Args: + context_id: The audio context to route frames to. + generator: An async generator yielding Frame objects or None. + + """ + is_yielding_frames = False + async for frame in generator: + if frame: + await self.append_to_audio_context(context_id, frame) + if isinstance(frame, TTSAudioRawFrame): + is_yielding_frames = True + + self._is_yielding_frames_synchronously = is_yielding_frames async def _stop_frame_handler(self): has_started = False + context_id = None while True: try: frame = await asyncio.wait_for( self._stop_frame_queue.get(), timeout=self._stop_frame_timeout_s ) if isinstance(frame, TTSStartedFrame): + context_id = frame.context_id has_started = True elif isinstance(frame, (TTSStoppedFrame, InterruptionFrame)): has_started = False except asyncio.TimeoutError: if has_started: - await self.push_frame(TTSStoppedFrame()) + await self.push_frame(TTSStoppedFrame(context_id=context_id)) has_started = False + # + # Word timestamp methods + # + + async def start_word_timestamps(self): + """Start tracking word timestamps from the current time.""" + if self._initial_word_timestamp == -1: + current_time = self.get_clock().get_time() + # Initialize word timestamp tracking. Use the last emitted timestamp if it's ahead + # of current time to maintain continuity across overlapping audio contexts. + self._initial_word_timestamp = ( + self._word_last_pts if self._word_last_pts > current_time else current_time + ) + # If we cached some initial word times (because we didn't receive + # audio), let's add them now. + if self._initial_word_times: + cached = self._initial_word_times.copy() + self._initial_word_times = [] + for word, timestamp_seconds, ctx_id in cached: + await self._add_word_timestamps([(word, timestamp_seconds)], ctx_id) + + async def reset_word_timestamps(self): + """Reset word timestamp tracking.""" + self._initial_word_timestamp = -1 + + async def add_word_timestamps( + self, word_times: List[Tuple[str, float]], context_id: Optional[str] = None + ): + """Add word timestamps for processing. + + When an audio context exists for this context_id, timestamps are routed into the + per-context audio queue alongside audio frames so they are processed in strict + playback order by _handle_audio_context. Otherwise they are processed immediately + via _add_word_timestamps. + + Args: + word_times: List of (word, timestamp) tuples where timestamp is in seconds. + context_id: Unique identifier for the TTS context. + """ + if context_id and self.audio_context_available(context_id): + for word, timestamp in word_times: + await self._audio_contexts[context_id].put( + _WordTimestampEntry( + word=word, + timestamp=timestamp, + context_id=context_id, + ) + ) + else: + await self._add_word_timestamps(word_times=word_times, context_id=context_id) + + async def _add_word_timestamps( + self, word_times: List[Tuple[str, float]], context_id: Optional[str] = None + ): + """Process word timestamps directly, building and pushing frames inline. + + This is the single processing path for all word timestamp events, used both + from _handle_audio_context (via _WordTimestampEntry) and from services that + do not use audio contexts. Sentinel entries drive control-frame emission: + + - ("Reset", 0): reset timestamp baseline; emit LLMFullResponseEndFrame if needed. + - ("TTSStoppedFrame", 0): emit TTSStoppedFrame. + - Any other entry: emit TTSTextFrame with a PTS relative to the baseline. + + When the baseline (_initial_word_timestamp) is not yet set, regular word entries + are cached in _initial_word_times and flushed once start_word_timestamps() is + called (i.e. when the first audio chunk is received). + """ + for word, timestamp in word_times: + if word == "Reset" and timestamp == 0: + await self.reset_word_timestamps() + if self._llm_response_started: + self._llm_response_started = False + frame = LLMFullResponseEndFrame() + frame.pts = self._word_last_pts + await self.push_frame(frame) + elif word == "TTSStoppedFrame" and timestamp == 0: + frame = TTSStoppedFrame(context_id=context_id) + frame.pts = self._word_last_pts + frame.context_id = context_id + await self.push_frame(frame) + else: + ts_ns = seconds_to_nanoseconds(timestamp) + if self._initial_word_timestamp == -1: + # Cache until we have audio and can compute PTS. + self._initial_word_times.append((word, timestamp, context_id)) + else: + # Assumption: word-by-word text frames don't include spaces, so + # we can rely on the default includes_inter_frame_spaces=False + frame = TTSTextFrame(word, aggregated_by=AggregationType.WORD) + frame.pts = self._initial_word_timestamp + ts_ns + frame.context_id = context_id + if context_id in self._tts_contexts: + frame.append_to_context = self._tts_contexts[context_id].append_to_context + self._word_last_pts = frame.pts + await self.push_frame(frame) + + # + # Audio context methods (active when using websocket-based TTS with context management) + # + + async def create_audio_context(self, context_id: str): + """Create a new audio context for grouping related audio. + + Args: + context_id: Unique identifier for the audio context. + """ + await self._serialization_queue.put(context_id) + self._audio_contexts[context_id] = asyncio.Queue() + logger.trace(f"{self} created audio context {context_id}") + + async def append_to_audio_context(self, context_id: str, frame: Frame): + """Append audio or control frame to an existing context. + + Args: + context_id: The context to append audio to. + frame: The audio or control frame to append. + """ + if not context_id: + logger.debug(f"{self} unable to append audio to context: no context ID provided") + return + if self.audio_context_available(context_id): + logger.trace(f"{self} appending audio {frame} to audio context {context_id}") + await self._audio_contexts[context_id].put(frame) + elif context_id == self._turn_context_id: + # Sometimes the HTTP service can take more than 3 seconds without sending any audio + # So we are now recreating the context id while we are in the same turn + logger.debug(f"{self} recreating audio context {context_id}") + await self.create_audio_context(context_id) + logger.trace(f"{self} appending audio {frame} to audio context {context_id}") + await self._audio_contexts[context_id].put(frame) + else: + logger.warning(f"{self} unable to append audio to context {context_id}") + + async def remove_audio_context(self, context_id: str): + """Remove an existing audio context. + + Args: + context_id: The context to remove. + """ + if self.audio_context_available(context_id): + # We just mark the audio context for deletion by appending + # None. Once we reach None while handling audio we know we can + # safely remove the context. + logger.trace(f"{self} marking audio context {context_id} for deletion") + await self._audio_contexts[context_id].put(None) + 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._playing_context_id is not None and self.audio_context_available( + self._playing_context_id + ) + + def get_audio_contexts(self) -> List[str]: + """Get a list of all available audio contexts.""" + return list(self._audio_contexts.keys()) + + 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._playing_context_id + + async def remove_active_audio_context(self): + """Remove the active audio context.""" + if self._playing_context_id: + await self.remove_audio_context(self._playing_context_id) + self.reset_active_audio_context() + + def reset_active_audio_context(self): + """Reset the active audio context.""" + self._playing_context_id = None + + def audio_context_available(self, context_id: str) -> bool: + """Check whether the given audio context is registered. + + Args: + context_id: The context ID to check. + + Returns: + True if the context exists and is available. + """ + return context_id in self._audio_contexts + + def _refresh_audio_context(self, context_id: str): + """Signal that the audio context is still in use, resetting the timeout.""" + if self.audio_context_available(context_id): + self._audio_contexts[context_id].put_nowait(TTSService._CONTEXT_KEEPALIVE) + + def _create_audio_context_task(self): + if not self._audio_context_task: + # Single FIFO queue that serializes everything the TTS service emits downstream. + # Items can be: + # str – an audio context ID: process the per-context audio queue in full before + # moving on (see _handle_audio_context). + # Frame – a non-system downstream frame (e.g. AggregatedTextFrame, FooFrame) that + # must be emitted in-order relative to surrounding audio contexts. + # None – shutdown sentinel (sent by stop()). + self._serialization_queue: asyncio.Queue = asyncio.Queue() + self._audio_contexts: Dict[str, asyncio.Queue] = {} + self._audio_context_task = self.create_task(self._audio_context_task_handler()) + + async def _stop_audio_context_task(self): + if self._audio_context_task: + await self.cancel_task(self._audio_context_task) + self._audio_context_task = None + + async def _audio_context_task_handler(self): + """Drain the serialization queue, preserving downstream frame order. + + The queue carries three kinds of items (see _create_audio_context_task): + + * str – audio context ID: block until all audio for that context has been + pushed downstream, then call on_audio_context_completed(). + * Frame – a non-system downstream frame that must be emitted at this exact + position in the output stream (e.g. AggregatedTextFrame preceding + its audio, or an arbitrary frame that arrived between two speak frames). + * None – shutdown sentinel; exit the loop once reached. + """ + running = True + while running: + context_value = await self._serialization_queue.get() + if isinstance(context_value, Frame): + await self.push_frame(context_value) + elif isinstance(context_value, str): + context_id = context_value + self._playing_context_id = context_id + + # Process the audio context until the context doesn't have more + # audio available (i.e. we find None). + await self._handle_audio_context(context_id) + + # We just finished processing the context, so we can safely remove it. + del self._audio_contexts[context_id] + await self.on_audio_context_completed(context_id=context_id) + self.reset_active_audio_context() + else: + running = False + + self._serialization_queue.task_done() + + async def _handle_audio_context(self, context_id: str): + """Process items from an audio context queue until it is exhausted.""" + AUDIO_CONTEXT_TIMEOUT = 3.0 + queue = self._audio_contexts[context_id] + running = True + timestamps_started = False + while running: + try: + frame = await asyncio.wait_for(queue.get(), timeout=AUDIO_CONTEXT_TIMEOUT) + if frame is TTSService._CONTEXT_KEEPALIVE: + # Context is still in use, reset the timeout. + continue + elif frame is None: + running = False + elif isinstance(frame, _WordTimestampEntry): + # _add_word_timestamps is the single processing path: it handles + # sentinel entries ("Reset", "TTSStoppedFrame") and regular words + # inline, keeping all word-frame logic in one place. + await self._add_word_timestamps( + [(frame.word, frame.timestamp)], frame.context_id + ) + continue + elif isinstance(frame, TTSAudioRawFrame): + # Set the word-timestamp baseline once, on the first audio chunk. + if not timestamps_started: + await self.stop_ttfb_metrics() + await self.start_word_timestamps() + timestamps_started = True + + if frame: + if isinstance(frame, ErrorFrame): + await self.push_error_frame(frame) + else: + await self.push_frame(frame) + except asyncio.TimeoutError: + # We didn't get audio, so let's consider this context finished. + logger.trace(f"{self} time out on audio context {context_id}") + break + + async def on_audio_context_interrupted(self, context_id: str): + """Called when an audio context is cancelled due to an interruption. + + Override this in a subclass to perform provider-specific cleanup (e.g. + sending a cancel/close message over the WebSocket) when the bot is + interrupted mid-speech. The audio context task has already been stopped + and the active context has **not** yet been reset when this is called, + so ``context_id`` reflects the context that was cut short. + + Args: + context_id: The ID of the audio context that was interrupted, or + ``None`` if no context was active at the time. + """ + pass + + async def on_audio_context_completed(self, context_id: str): + """Called after an audio context has finished playing all of its audio. + + Override this in a subclass to perform provider-specific cleanup (e.g. + sending a close-context message to free server-side resources) once an + audio context has been fully processed. The context entry has already + been removed from the internal context map, and the active context has + **not** yet been reset when this is called. + + Args: + context_id: The ID of the audio context that finished processing. + """ + pass + class WordTTSService(TTSService): - """Base class for TTS services that support word timestamps. + """Deprecated. Use TTSService directly instead. - Word timestamps are useful to synchronize audio with text of the spoken - words. This way only the spoken words are added to the conversation context. + .. deprecated:: 0.0.105 + Word timestamp functionality is now always active in TTSService. """ def __init__(self, **kwargs): @@ -650,121 +1442,6 @@ class WordTTSService(TTSService): **kwargs: Additional arguments passed to the parent TTSService. """ super().__init__(**kwargs) - self._initial_word_timestamp = -1 - self._initial_word_times = [] - self._words_task = None - self._llm_response_started: bool = False - - async def start_word_timestamps(self): - """Start tracking word timestamps from the current time.""" - if self._initial_word_timestamp == -1: - self._initial_word_timestamp = self.get_clock().get_time() - # If we cached some initial word times (because we didn't receive - # audio), let's add them now. - if self._initial_word_times: - await self._add_word_timestamps(self._initial_word_times) - self._initial_word_times = [] - - async def reset_word_timestamps(self): - """Reset word timestamp tracking.""" - self._initial_word_timestamp = -1 - - async def add_word_timestamps(self, word_times: List[Tuple[str, float]]): - """Add word timestamps to the processing queue. - - Args: - word_times: List of (word, timestamp) tuples where timestamp is in seconds. - """ - if self._initial_word_timestamp == -1: - # Cache word timestamps and don't add them until we have started - # (i.e. we have some audio). - self._initial_word_times.extend(word_times) - else: - await self._add_word_timestamps(word_times) - - async def start(self, frame: StartFrame): - """Start the word TTS service. - - Args: - frame: The start frame containing initialization parameters. - """ - await super().start(frame) - self._create_words_task() - - async def stop(self, frame: EndFrame): - """Stop the word TTS service. - - Args: - frame: The end frame. - """ - await super().stop(frame) - await self._stop_words_task() - - async def cancel(self, frame: CancelFrame): - """Cancel the word TTS service. - - Args: - frame: The cancel frame. - """ - await super().cancel(frame) - await self._stop_words_task() - - async def process_frame(self, frame: Frame, direction: FrameDirection): - """Process frames with word timestamp awareness. - - Args: - frame: The frame to process. - direction: The direction of frame processing. - """ - await super().process_frame(frame, direction) - - if isinstance(frame, LLMFullResponseStartFrame): - self._llm_response_started = True - elif isinstance(frame, (LLMFullResponseEndFrame, EndFrame)): - await self.flush_audio() - - async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): - await super()._handle_interruption(frame, direction) - self._llm_response_started = False - await self.reset_word_timestamps() - - def _create_words_task(self): - if not self._words_task: - self._words_queue = asyncio.Queue() - self._words_task = self.create_task(self._words_task_handler()) - - async def _stop_words_task(self): - if self._words_task: - await self.cancel_task(self._words_task) - self._words_task = None - - async def _add_word_timestamps(self, word_times: List[Tuple[str, float]]): - for word, timestamp in word_times: - await self._words_queue.put((word, seconds_to_nanoseconds(timestamp))) - - async def _words_task_handler(self): - last_pts = 0 - while True: - frame = None - (word, timestamp) = await self._words_queue.get() - if word == "Reset" and timestamp == 0: - await self.reset_word_timestamps() - if self._llm_response_started: - self._llm_response_started = False - frame = LLMFullResponseEndFrame() - frame.pts = last_pts - elif word == "TTSStoppedFrame" and timestamp == 0: - frame = TTSStoppedFrame() - frame.pts = last_pts - else: - # Assumption: word-by-word text frames don't include spaces, so - # we can rely on the default includes_inter_frame_spaces=False - frame = TTSTextFrame(word, aggregated_by=AggregationType.WORD) - frame.pts = self._initial_word_timestamp + timestamp - if frame: - last_pts = frame.pts - await self.push_frame(frame) - self._words_queue.task_done() class WebsocketTTSService(TTSService, WebsocketService): @@ -839,10 +1516,11 @@ class InterruptibleTTSService(WebsocketTTSService): self._bot_speaking = False -class WebsocketWordTTSService(WordTTSService, WebsocketService): - """Base class for websocket-based TTS services that support word timestamps. +class WebsocketWordTTSService(WebsocketTTSService): + """Deprecated. Use WebsocketTTSService directly instead. - Combines word timestamp functionality with websocket connectivity. + .. deprecated:: 0.0.105 + Word timestamp functionality is now always active in TTSService. """ def __init__(self, *, reconnect_on_error: bool = True, **kwargs): @@ -852,229 +1530,71 @@ class WebsocketWordTTSService(WordTTSService, WebsocketService): reconnect_on_error: Whether to automatically reconnect on websocket errors. **kwargs: Additional arguments passed to parent classes. """ - WordTTSService.__init__(self, **kwargs) - WebsocketService.__init__(self, reconnect_on_error=reconnect_on_error, **kwargs) - - async def _report_error(self, error: ErrorFrame): - await self._call_event_handler("on_connection_error", error.error) - await self.push_error_frame(error) + super().__init__(reconnect_on_error=reconnect_on_error, **kwargs) -class InterruptibleWordTTSService(WebsocketWordTTSService): - """Websocket-based TTS service with word timestamps that handles interruptions. +class InterruptibleWordTTSService(InterruptibleTTSService): + """Deprecated. Use InterruptibleTTSService directly instead. - For TTS services that support word timestamps but can't correlate generated - audio with requested text. Handles interruptions by reconnecting when needed. + .. deprecated:: 0.0.105 + Word timestamp functionality is now always active in TTSService. """ def __init__(self, **kwargs): """Initialize the Interruptible Word TTS service. Args: - **kwargs: Additional arguments passed to the parent WebsocketWordTTSService. + **kwargs: Additional arguments passed to the parent InterruptibleTTSService. """ super().__init__(**kwargs) - # Indicates if the bot is speaking. If the bot is not speaking we don't - # need to reconnect when the user speaks. If the bot is speaking and the - # user interrupts we need to reconnect. - self._bot_speaking = False - - async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): - await super()._handle_interruption(frame, direction) - if self._bot_speaking: - await self._disconnect() - await self._connect() - - async def process_frame(self, frame: Frame, direction: FrameDirection): - """Process frames with bot speaking state tracking. - - Args: - frame: The frame to process. - direction: The direction of frame processing. - """ - await super().process_frame(frame, direction) - - if isinstance(frame, BotStartedSpeakingFrame): - self._bot_speaking = True - elif isinstance(frame, BotStoppedSpeakingFrame): - self._bot_speaking = False - class AudioContextTTSService(WebsocketTTSService): - """Base class for websocket-based TTS services with audio context management. + """Deprecated. Inherit from WebsocketTTSService directly instead. - This is a base class for websocket-based TTS services that allow correlating - the generated audio with the requested text through audio contexts. + Audio context management (previously the main purpose of this class) is now + built into TTSService. This class is kept only for backwards compatibility. - Each request could be multiple sentences long which are grouped by - context. For this to work, the TTS service needs to support handling - multiple requests at once (i.e. multiple simultaneous contexts). - - The audio received from the TTS will be played in context order. That is, if - we requested audio for a context "A" and then audio for context "B", the - audio from context ID "A" will be played first. + .. deprecated:: 0.0.105 + Subclass :class:`WebsocketTTSService` directly and pass + ``reuse_context_id_within_turn`` as + keyword arguments to its ``__init__``. """ - def __init__(self, *, reconnect_on_error: bool = True, **kwargs): + def __init__( + self, + *, + reuse_context_id_within_turn: bool = True, + reconnect_on_error: bool = True, + **kwargs, + ): """Initialize the Audio Context TTS service. Args: + reuse_context_id_within_turn: Whether the service should reuse context IDs within the same turn. reconnect_on_error: Whether to automatically reconnect on websocket errors. **kwargs: Additional arguments passed to the parent WebsocketTTSService. """ - super().__init__(reconnect_on_error=reconnect_on_error, **kwargs) - self._contexts: Dict[str, asyncio.Queue] = {} - self._audio_context_task = None + import warnings - async def create_audio_context(self, context_id: str): - """Create a new audio context for grouping related audio. - - Args: - context_id: Unique identifier for the audio context. - """ - await self._contexts_queue.put(context_id) - self._contexts[context_id] = asyncio.Queue() - logger.trace(f"{self} created audio context {context_id}") - - async def append_to_audio_context(self, context_id: str, frame: TTSAudioRawFrame): - """Append audio to an existing context. - - Args: - context_id: The context to append audio to. - frame: The audio frame to append. - """ - if self.audio_context_available(context_id): - logger.trace(f"{self} appending audio {frame} to audio context {context_id}") - await self._contexts[context_id].put(frame) - else: - logger.warning(f"{self} unable to append audio to context {context_id}") - - async def remove_audio_context(self, context_id: str): - """Remove an existing audio context. - - Args: - context_id: The context to remove. - """ - if self.audio_context_available(context_id): - # We just mark the audio context for deletion by appending - # None. Once we reach None while handling audio we know we can - # safely remove the context. - logger.trace(f"{self} marking audio context {context_id} for deletion") - await self._contexts[context_id].put(None) - else: - logger.warning(f"{self} unable to remove context {context_id}") - - def audio_context_available(self, context_id: str) -> bool: - """Check whether the given audio context is registered. - - Args: - context_id: The context ID to check. - - Returns: - True if the context exists and is available. - """ - return context_id in self._contexts - - async def start(self, frame: StartFrame): - """Start the audio context TTS service. - - Args: - frame: The start frame containing initialization parameters. - """ - await super().start(frame) - self._create_audio_context_task() - - async def stop(self, frame: EndFrame): - """Stop the audio context TTS service. - - Args: - frame: The end frame. - """ - await super().stop(frame) - if self._audio_context_task: - # Indicate no more audio contexts are available. this will end the - # task cleanly after all contexts have been processed. - await self._contexts_queue.put(None) - await self._audio_context_task - self._audio_context_task = None - - async def cancel(self, frame: CancelFrame): - """Cancel the audio context TTS service. - - Args: - frame: The cancel frame. - """ - await super().cancel(frame) - await self._stop_audio_context_task() - - async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): - await super()._handle_interruption(frame, direction) - await self._stop_audio_context_task() - self._create_audio_context_task() - - def _create_audio_context_task(self): - if not self._audio_context_task: - self._contexts_queue = asyncio.Queue() - self._contexts: Dict[str, asyncio.Queue] = {} - self._audio_context_task = self.create_task(self._audio_context_task_handler()) - - async def _stop_audio_context_task(self): - if self._audio_context_task: - await self.cancel_task(self._audio_context_task) - self._audio_context_task = None - - async def _audio_context_task_handler(self): - """In this task we process audio contexts in order.""" - running = True - while running: - context_id = await self._contexts_queue.get() - - if context_id: - # Process the audio context until the context doesn't have more - # audio available (i.e. we find None). - await self._handle_audio_context(context_id) - - # We just finished processing the context, so we can safely remove it. - del self._contexts[context_id] - - # Append some silence between sentences. - silence = b"\x00" * self.sample_rate - frame = TTSAudioRawFrame( - audio=silence, sample_rate=self.sample_rate, num_channels=1 - ) - await self.push_frame(frame) - else: - running = False - - self._contexts_queue.task_done() - - async def _handle_audio_context(self, context_id: str): - # If we don't receive any audio during this time, we consider the context finished. - AUDIO_CONTEXT_TIMEOUT = 3.0 - queue = self._contexts[context_id] - running = True - while running: - try: - frame = await asyncio.wait_for(queue.get(), timeout=AUDIO_CONTEXT_TIMEOUT) - if frame: - await self.push_frame(frame) - running = frame is not None - except asyncio.TimeoutError: - # We didn't get audio, so let's consider this context finished. - logger.trace(f"{self} time out on audio context {context_id}") - break + warnings.warn( + "AudioContextTTSService is deprecated. Inherit from WebsocketTTSService directly " + "and pass reuse_context_id_within_turn as kwargs.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__( + reuse_context_id_within_turn=reuse_context_id_within_turn, + reconnect_on_error=reconnect_on_error, + **kwargs, + ) -class AudioContextWordTTSService(AudioContextTTSService, WebsocketWordTTSService): - """Websocket-based TTS service with word timestamps and audio context management. +class AudioContextWordTTSService(AudioContextTTSService): + """Deprecated. Use WebsocketTTSService directly instead. - This is a base class for websocket-based TTS services that support word - timestamps and also allow correlating the generated audio with the requested - text through audio contexts. - - Combines the audio context management capabilities of AudioContextTTSService - with the word timestamp functionality of WebsocketWordTTSService. + .. deprecated:: 0.0.105 + Subclass :class:`WebsocketTTSService` directly. """ def __init__(self, *, reconnect_on_error: bool = True, **kwargs): @@ -1084,5 +1604,11 @@ class AudioContextWordTTSService(AudioContextTTSService, WebsocketWordTTSService reconnect_on_error: Whether to automatically reconnect on websocket errors. **kwargs: Additional arguments passed to parent classes. """ - AudioContextTTSService.__init__(self, reconnect_on_error=reconnect_on_error, **kwargs) - WebsocketWordTTSService.__init__(self, reconnect_on_error=reconnect_on_error, **kwargs) + import warnings + + warnings.warn( + "AudioContextWordTTSService is deprecated. Inherit from WebsocketTTSService directly.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(reconnect_on_error=reconnect_on_error, **kwargs) diff --git a/src/pipecat/services/ultravox/llm.py b/src/pipecat/services/ultravox/llm.py index 489ba367d..fe8a97549 100644 --- a/src/pipecat/services/ultravox/llm.py +++ b/src/pipecat/services/ultravox/llm.py @@ -15,11 +15,11 @@ import asyncio import datetime import json import uuid +from dataclasses import dataclass, field from typing import Any, Dict, List, Literal, Optional, Union import aiohttp from loguru import logger -from openai.types import chat as openai_chat_types from pydantic import BaseModel, Field from pipecat.adapters.schemas.tools_schema import ToolsSchema @@ -31,11 +31,11 @@ from pipecat.frames.frames import ( Frame, InputAudioRawFrame, InputTextRawFrame, + InterruptionFrame, LLMContextFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMTextFrame, - LLMUpdateSettingsFrame, StartFrame, TranscriptionFrame, TTSAudioRawFrame, @@ -43,7 +43,7 @@ from pipecat.frames.frames import ( TTSStoppedFrame, TTSTextFrame, UserAudioRawFrame, - UserStoppedSpeakingFrame, + VADUserStoppedSpeakingFrame, ) from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_response import ( @@ -57,6 +57,7 @@ from pipecat.processors.aggregators.openai_llm_context import ( ) from pipecat.processors.frame_processor import FrameDirection from pipecat.services.llm_service import FunctionCallFromLLM, LLMService +from pipecat.services.settings import NOT_GIVEN, LLMSettings, _NotGiven from pipecat.utils.time import time_now_iso8601 try: @@ -67,6 +68,17 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +@dataclass +class UltravoxRealtimeLLMSettings(LLMSettings): + """Settings for UltravoxRealtimeLLMService. + + Parameters: + output_medium: The output medium for the model ("voice" or "text"). + """ + + output_medium: str | _NotGiven = field(default=NOT_GIVEN) + + class AgentInputParams(BaseModel): """Input parameters for Ultravox Realtime generation using a pre-defined Agent. @@ -79,6 +91,9 @@ class AgentInputParams(BaseModel): template_context: Context variables to use when instantiating a call with the agent. Defaults to an empty dict. metadata: Metadata to attach to the call. Default to an empty dict. + output_medium: The initial output medium for the agent. Use "text" for text + responses or "voice" for audio responses. Defaults to None, which uses the + agent's default. max_duration: The maximum duration of the call. Defaults to None, which will use the agent's default maximum duration. extra: Extra parameters to include in the agent call creation request. Defaults @@ -90,6 +105,7 @@ class AgentInputParams(BaseModel): agent_id: uuid.UUID template_context: Dict[str, Any] = Field(default_factory=dict) metadata: Dict[str, str] = Field(default_factory=dict) + output_medium: Optional[Literal["text", "voice"]] = None max_duration: Optional[datetime.timedelta] = Field( default=None, ge=datetime.timedelta(seconds=10), le=datetime.timedelta(hours=1) ) @@ -106,6 +122,8 @@ class OneShotInputParams(BaseModel): model: Model identifier to use. Defaults to "fixie-ai/ultravox". voice: Voice identifier for speech generation. Defaults to None. metadata: Metadata to attach to the call. Default to an empty dict. + output_medium: The initial output medium for the agent. Use "text" for text + responses or "voice" for audio responses. Defaults to None (voice). max_duration: The maximum duration of the call. Defaults to one hour. extra: Extra parameters to include in the call creation request. Defaults to an empty dict. See the Ultravox API documentation for valid arguments: @@ -118,6 +136,7 @@ class OneShotInputParams(BaseModel): model: Optional[str] = None voice: Optional[uuid.UUID] = None metadata: Dict[str, str] = Field(default_factory=dict) + output_medium: Optional[Literal["text", "voice"]] = None max_duration: datetime.timedelta = Field( default=datetime.timedelta(hours=1), ge=datetime.timedelta(seconds=10), @@ -147,23 +166,53 @@ class UltravoxRealtimeLLMService(LLMService): by the model and may not always align with its understanding of user input. """ + Settings = UltravoxRealtimeLLMSettings + _settings: Settings + def __init__( self, *, params: Union[AgentInputParams, OneShotInputParams, JoinUrlInputParams], + settings: Optional[Settings] = None, one_shot_selected_tools: Optional[ToolsSchema] = None, **kwargs, ): """Initialize the Ultravox Realtime LLM service. Args: - api_key: Ultravox API key for authentication. params: Configuration parameters for the model. + settings: Ultravox Realtime LLM settings. If provided, the ``settings`` + values take precedence over default values. one_shot_selected_tools: ToolsSchema for tools to use with this call. May only be set with OneShotInputParams. **kwargs: Additional arguments passed to parent LLMService. """ - super().__init__(**kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + system_instruction=None, + temperature=None, + max_tokens=None, + top_p=None, + top_k=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + output_medium=None, + ) + + # (No step 2/3 — params is required and not deprecated) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + settings=default_settings, + **kwargs, + ) self._params = params if one_shot_selected_tools: if not isinstance(self._params, OneShotInputParams): @@ -182,6 +231,14 @@ class UltravoxRealtimeLLMService(LLMService): self._sample_rate = 48000 self._resampler = create_stream_resampler() + def can_generate_metrics(self) -> bool: + """Check if the service can generate usage metrics. + + Returns: + True if metrics generation is supported. + """ + return True + # # standard AIService frame handling # @@ -209,6 +266,14 @@ class UltravoxRealtimeLLMService(LLMService): except Exception as e: await self.push_error("Failed to connect to Ultravox", e, fatal=True) + @staticmethod + def _output_medium_to_api(medium: Optional[Literal["text", "voice"]]) -> Optional[str]: + if medium == "text": + return "MESSAGE_MEDIUM_TEXT" + elif medium == "voice": + return "MESSAGE_MEDIUM_VOICE" + return None + async def _start_agent_call(self, params: AgentInputParams) -> str: request_body = { "templateContext": params.template_context, @@ -219,6 +284,9 @@ class UltravoxRealtimeLLMService(LLMService): } }, } + initial_output_medium = self._output_medium_to_api(params.output_medium) + if initial_output_medium: + request_body["initialOutputMedium"] = initial_output_medium if params.max_duration: request_body["maxDuration"] = f"{params.max_duration.total_seconds():3f}s" request_body = request_body | params.extra @@ -249,7 +317,11 @@ class UltravoxRealtimeLLMService(LLMService): "inputSampleRate": self._sample_rate, } }, - } | params.extra + } + initial_output_medium = self._output_medium_to_api(params.output_medium) + if initial_output_medium: + request_body["initialOutputMedium"] = initial_output_medium + request_body = request_body | params.extra async with aiohttp.ClientSession() as session: async with session.post( "https://api.ultravox.ai/api/calls", @@ -311,6 +383,13 @@ class UltravoxRealtimeLLMService(LLMService): await self.cancel_task(self._receive_task, timeout=1.0) self._receive_task = None + async def _update_settings(self, delta: Settings): + changed = await super()._update_settings(delta) + if "output_medium" in changed: + await self._update_output_medium(self._settings.output_medium) + self._warn_unhandled_updated_settings(changed.keys() - {"output_medium"}) + return changed + # # frame processing # StartFrame, StopFrame, CancelFrame implemented in base class @@ -332,21 +411,17 @@ class UltravoxRealtimeLLMService(LLMService): else LLMContext.from_openai_context(frame.context) ) await self._handle_context(context) - elif isinstance(frame, LLMUpdateSettingsFrame): - if "output_medium" in frame.settings: - await self._update_output_medium(frame.settings.get("output_medium")) + elif isinstance(frame, InterruptionFrame): + await self.stop_all_metrics() + await self.push_frame(frame, direction) elif isinstance(frame, InputTextRawFrame): await self._send_user_text(frame.text) await self.push_frame(frame, direction) elif isinstance(frame, InputAudioRawFrame): await self._send_user_audio(frame) await self.push_frame(frame, direction) - elif isinstance(frame, UserStoppedSpeakingFrame): - # This may or may not align with Ultravox's end of user speech detection, - # which relies on a more complex endpointing model. In particular it will - # yield a seemingly very slow TTFB in the case of endpointing false - # negatives. It will be close in the majority of cases though. - await self.start_ttfb_metrics() + elif isinstance(frame, VADUserStoppedSpeakingFrame): + await self._handle_vad_user_stopped_speaking(frame) await self.push_frame(frame, direction) else: await self.push_frame(frame, direction) @@ -367,6 +442,25 @@ class UltravoxRealtimeLLMService(LLMService): } await self._send(socket_message) + async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame): + """Handle VAD user stopped speaking frame. + + Calculates the actual speech end time and starts a timeout task to wait + for the final transcription before reporting TTFB. + + Args: + frame: The VAD user stopped speaking frame. + """ + # Skip TTFB measurement if stop_secs is not set + if frame.stop_secs == 0.0: + return + + # Calculate the actual speech end time (current time minus VAD stop delay). + # This approximates when the last user audio was sent to the Ultravox service, + # which we use to measure against the eventual transcription response. + speech_end_time = frame.timestamp - frame.stop_secs + await self.start_ttfb_metrics(start_time=speech_end_time) + async def _send_user_audio(self, frame: InputAudioRawFrame): """Send user audio frame to Ultravox Realtime.""" if not self._socket: @@ -470,6 +564,7 @@ class UltravoxRealtimeLLMService(LLMService): if not audio: return if not self._bot_responding: + await self.start_processing_metrics() await self.stop_ttfb_metrics() await self.push_frame(LLMFullResponseStartFrame()) await self.push_frame(TTSStartedFrame()) @@ -477,6 +572,7 @@ class UltravoxRealtimeLLMService(LLMService): await self.push_frame(TTSAudioRawFrame(audio, self._sample_rate, 1)) async def _handle_response_end(self): + await self.stop_processing_metrics() if self._bot_responding == "voice": await self.push_frame(TTSStoppedFrame()) await self.push_frame(LLMFullResponseEndFrame()) @@ -510,22 +606,29 @@ class UltravoxRealtimeLLMService(LLMService): async def _handle_agent_transcript( self, medium: str, text: Optional[str], delta: Optional[str], final: bool ): - if text or delta: - frame = LLMTextFrame(text=text or delta) - frame.skip_tts = medium == "voice" - await self.push_frame(frame) - if medium == "text": - if text: - await self.stop_ttfb_metrics() - await self.push_frame(LLMFullResponseStartFrame()) - await self.push_frame(TTSStartedFrame()) - await self.push_frame(TTSTextFrame(text=text, aggregated_by=AggregationType.WORD)) - self._bot_responding = "text" - elif final: + if medium == "voice": + # In voice mode, audio is handled by _handle_audio(). Here we push + # text transcripts of the audio for downstream consumers. + if (text or delta) and not final: + frame = LLMTextFrame(text=text or delta) + frame.append_to_context = False + await self.push_frame(frame) + if delta: + tts_frame = TTSTextFrame(text=delta, aggregated_by=AggregationType.WORD) + tts_frame.includes_inter_frame_spaces = True + await self.push_frame(tts_frame) + elif medium == "text": + if final: + await self.stop_processing_metrics() await self.push_frame(LLMFullResponseEndFrame()) self._bot_responding = None - elif delta: - await self.push_frame(TTSTextFrame(text=delta, aggregated_by=AggregationType.WORD)) + elif text or delta: + if not self._bot_responding: + await self.start_processing_metrics() + await self.stop_ttfb_metrics() + await self.push_frame(LLMFullResponseStartFrame()) + self._bot_responding = "text" + await self.push_frame(LLMTextFrame(text=text or delta)) def create_context_aggregator( self, diff --git a/src/pipecat/services/vision_service.py b/src/pipecat/services/vision_service.py index d12737d84..572f3b423 100644 --- a/src/pipecat/services/vision_service.py +++ b/src/pipecat/services/vision_service.py @@ -12,11 +12,12 @@ visual content. """ from abc import abstractmethod -from typing import AsyncGenerator +from typing import AsyncGenerator, Optional from pipecat.frames.frames import Frame, UserImageRawFrame from pipecat.processors.frame_processor import FrameDirection from pipecat.services.ai_service import AIService +from pipecat.services.settings import VisionSettings class VisionService(AIService): @@ -27,13 +28,20 @@ class VisionService(AIService): with the AI service infrastructure for metrics and lifecycle management. """ - def __init__(self, **kwargs): + def __init__(self, *, settings: Optional[VisionSettings] = None, **kwargs): """Initialize the vision service. Args: + settings: The runtime-updatable settings for the vision service. **kwargs: Additional arguments passed to the parent AIService. """ - super().__init__(**kwargs) + super().__init__( + settings=settings + # Here in case subclass doesn't implement more specific settings + # (which hopefully should be rare) + or VisionSettings(), + **kwargs, + ) self._describe_text = None @abstractmethod diff --git a/src/pipecat/services/websocket_service.py b/src/pipecat/services/websocket_service.py index e9b93af65..85e5b2db7 100644 --- a/src/pipecat/services/websocket_service.py +++ b/src/pipecat/services/websocket_service.py @@ -36,7 +36,8 @@ class WebsocketService(ABC): """ self._websocket: Optional[websockets.WebSocketClientProtocol] = None self._reconnect_on_error = reconnect_on_error - self._reconnect_in_progress: bool = False # Add this flag + self._reconnect_in_progress: bool = False + self._disconnecting: bool = False async def _verify_connection(self) -> bool: """Verify the websocket connection is active and responsive. @@ -120,6 +121,42 @@ class WebsocketService(ABC): else: logger.error(f"{self} send failed; unable to reconnect") + async def _maybe_try_reconnect( + self, + error_message: str, + report_error: Callable[[ErrorFrame], Awaitable[None]], + error: Optional[Exception] = None, + ) -> bool: + """Check if reconnection should be attempted and try if appropriate. + + Args: + error_message: Human-readable error message for logging. + report_error: Callback function to report connection errors. + error: The exception that occurred (optional, may be None for graceful closes). + + Returns: + True if should continue the receive loop, False if should break. + """ + # Don't reconnect if we're intentionally disconnecting + if self._disconnecting: + if error: + logger.warning(f"{self} error during disconnect: {error}") + else: + logger.debug(f"{self} receive loop ended during disconnect") + return False + + # Log the message + logger.warning(error_message) + + # Try to reconnect if enabled + if self._reconnect_on_error: + success = await self._try_reconnect(report_error=report_error) + return success + else: + # Reconnection disabled + await report_error(ErrorFrame(error_message)) + return False + async def _receive_task_handler(self, report_error: Callable[[ErrorFrame], Awaitable[None]]): """Handle websocket message receiving with automatic retry logic. @@ -133,43 +170,51 @@ class WebsocketService(ABC): while True: try: await self._receive_messages() + # _receive_messages() returned normally. This happens when the websocket + # closes gracefully (server sent close frame). The async for loop over + # the websocket exits without raising an exception in this case. + # We must handle this to avoid an infinite loop. + message = f"{self} connection closed by server" + should_continue = await self._maybe_try_reconnect(message, report_error) + if not should_continue: + break except ConnectionClosedOK as e: # Normal closure, don't retry logger.debug(f"{self} connection closed normally: {e}") break except ConnectionClosedError as e: - # Error closure, don't retry - logger.warning(f"{self} connection closed, but with an error: {e}") - break + # Connection closed with error (e.g., no close frame received/sent) + # This often indicates network issues, server problems, or abrupt disconnection + message = f"{self} connection closed, but with an error: {e}" + should_continue = await self._maybe_try_reconnect(message, report_error, e) + if not should_continue: + break except Exception as e: + # General error during message receiving message = f"{self} error receiving messages: {e}" - logger.error(message) - - if self._reconnect_on_error: - success = await self._try_reconnect(report_error=report_error) - if not success: - break - else: - await report_error(ErrorFrame(message)) + should_continue = await self._maybe_try_reconnect(message, report_error, e) + if not should_continue: break - @abstractmethod async def _connect(self): - """Connect to the service. + """Connect to the service and reset disconnecting flag. - Implement service-specific connection logic including websocket connection - via _connect_websocket() and any additional setup required. + Manages the disconnecting flag to enable reconnection. Subclasses should + call super()._connect() first, then implement their specific connection + logic including websocket connection via _connect_websocket() and any + additional setup required. """ - pass + self._disconnecting = False - @abstractmethod async def _disconnect(self): - """Disconnect from the service. + """Disconnect from the service and set disconnecting flag. - Implement service-specific disconnection logic including websocket + Manages the disconnecting flag to prevent reconnection during intentional + disconnect. Subclasses should call super()._disconnect() first, then + implement their specific disconnection logic including websocket disconnection via _disconnect_websocket() and any cleanup required. """ - pass + self._disconnecting = True @abstractmethod async def _connect_websocket(self): diff --git a/src/pipecat/services/whisper/base_stt.py b/src/pipecat/services/whisper/base_stt.py index 32e08de8e..33d19b0aa 100644 --- a/src/pipecat/services/whisper/base_stt.py +++ b/src/pipecat/services/whisper/base_stt.py @@ -10,6 +10,7 @@ This module provides common functionality for services implementing the Whisper interface, including language mapping, metrics generation, and error handling. """ +from dataclasses import dataclass, field from typing import AsyncGenerator, Optional from loguru import logger @@ -17,12 +18,28 @@ from openai import AsyncOpenAI from openai.types.audio import Transcription from pipecat.frames.frames import ErrorFrame, Frame, TranscriptionFrame +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven +from pipecat.services.stt_latency import WHISPER_TTFS_P99 from pipecat.services.stt_service import SegmentedSTTService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.time import time_now_iso8601 from pipecat.utils.tracing.service_decorators import traced_stt +@dataclass +class BaseWhisperSTTSettings(STTSettings): + """Settings for BaseWhisperSTTService. + + Parameters: + prompt: Optional text to guide the model's style or continue + a previous segment. + temperature: Sampling temperature between 0 and 1. + """ + + prompt: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + temperature: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + def language_to_whisper_language(language: Language) -> Optional[str]: """Maps pipecat Language enum to Whisper API language codes. @@ -105,60 +122,106 @@ class BaseWhisperSTTService(SegmentedSTTService): including metrics generation and error handling. """ + Settings = BaseWhisperSTTSettings + _settings: Settings + def __init__( self, *, - model: str, + model: Optional[str] = None, api_key: Optional[str] = None, base_url: Optional[str] = None, - language: Optional[Language] = Language.EN, + language: Optional[Language] = None, prompt: Optional[str] = None, temperature: Optional[float] = None, include_prob_metrics: bool = False, + push_empty_transcripts: bool = False, + settings: Optional[Settings] = None, + ttfs_p99_latency: Optional[float] = WHISPER_TTFS_P99, **kwargs, ): """Initialize the Whisper STT service. Args: model: Name of the Whisper model to use. + + .. deprecated:: 0.0.105 + Use ``settings=BaseWhisperSTTService.Settings(model=...)`` instead. + api_key: Service API key. Defaults to None. base_url: Service API base URL. Defaults to None. - language: Language of the audio input. Defaults to English. + language: Language of the audio input. + + .. deprecated:: 0.0.105 + Use ``settings=BaseWhisperSTTService.Settings(language=...)`` instead. + prompt: Optional text to guide the model's style or continue a previous segment. - temperature: Sampling temperature between 0 and 1. Defaults to 0.0. + + .. deprecated:: 0.0.105 + Use ``settings=BaseWhisperSTTService.Settings(prompt=...)`` instead. + + temperature: Sampling temperature between 0 and 1. + + .. deprecated:: 0.0.105 + Use ``settings=BaseWhisperSTTService.Settings(temperature=...)`` instead. + include_prob_metrics: If True, enables probability metrics in API response. Each service implements this differently (see child classes). Defaults to False. + push_empty_transcripts: - If true, allow empty `TranscriptionFrame` frames to be + pushed downstream instead of discarding them. This is intended for situations + where VAD fires even though the user did not speak. In these cases, it is + useful to know that nothing was transcribed so that the agent can resume + speaking, instead of waiting longer for a transcription. + Defaults to False. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. + ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. + Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to SegmentedSTTService. """ - super().__init__(**kwargs) - self.set_model_name(model) - self._client = self._create_client(api_key, base_url) - self._language = self.language_to_service_language(language or Language.EN) - self._prompt = prompt - self._temperature = temperature - self._include_prob_metrics = include_prob_metrics + # --- 1. Hardcoded defaults --- + default_settings = self.Settings( + model=None, + language=None, + prompt=None, + temperature=None, + ) - self._settings = { - "base_url": base_url, - "language": self._language, - "prompt": self._prompt, - "temperature": self._temperature, - } + # --- 2. Deprecated direct-arg overrides --- + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model + if language is not None: + self._warn_init_param_moved_to_settings("language", "language") + default_settings.language = language + if prompt is not None: + self._warn_init_param_moved_to_settings("prompt", "prompt") + default_settings.prompt = prompt + if temperature is not None: + self._warn_init_param_moved_to_settings("temperature", "temperature") + default_settings.temperature = temperature + + # --- 3. (no params object for this service) --- + + # --- 4. Settings delta (canonical API, always wins) --- + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + ttfs_p99_latency=ttfs_p99_latency, + settings=default_settings, + **kwargs, + ) + self._client = self._create_client(api_key, base_url) + self._include_prob_metrics = include_prob_metrics + self._push_empty_transcripts = push_empty_transcripts def _create_client(self, api_key: Optional[str], base_url: Optional[str]): return AsyncOpenAI(api_key=api_key, base_url=base_url) - async def set_model(self, model: str): - """Set the model name for transcription. - - Args: - model: The name of the model to use. - """ - self.set_model_name(model) - def can_generate_metrics(self) -> bool: - """Indicates whether this service can generate metrics. + """Whether this service can generate processing metrics. Returns: bool: True, as this service supports metric generation. @@ -176,15 +239,6 @@ class BaseWhisperSTTService(SegmentedSTTService): """ return language_to_whisper_language(language) - async def set_language(self, language: Language): - """Set the language for transcription. - - Args: - language: The Language enum value to use for transcription. - """ - logger.info(f"Switching STT language to: [{language}]") - self._language = self.language_to_service_language(language) - @traced_stt async def _handle_transcription( self, transcript: str, is_final: bool, language: Optional[Language] = None @@ -204,17 +258,18 @@ class BaseWhisperSTTService(SegmentedSTTService): """ try: await self.start_processing_metrics() - await self.start_ttfb_metrics() response = await self._transcribe(audio) - await self.stop_ttfb_metrics() await self.stop_processing_metrics() text = response.text.strip() - if text: - await self._handle_transcription(text, True, self._language) + if not text: + logger.warning("Received empty transcription from API") + + if text or self._push_empty_transcripts: + await self._handle_transcription(text, True, self._settings.language) logger.debug(f"Transcription: [{text}]") yield TranscriptionFrame( text, @@ -222,8 +277,6 @@ class BaseWhisperSTTService(SegmentedSTTService): time_now_iso8601(), result=response, ) - else: - logger.warning("Received empty transcription from API") except Exception as e: yield ErrorFrame(error=f"Unknown error occurred: {e}") diff --git a/src/pipecat/services/whisper/stt.py b/src/pipecat/services/whisper/stt.py index 27ba743ac..ac5d90c30 100644 --- a/src/pipecat/services/whisper/stt.py +++ b/src/pipecat/services/whisper/stt.py @@ -11,6 +11,7 @@ supporting both Faster Whisper and MLX Whisper backends for efficient inference. """ import asyncio +from dataclasses import dataclass, field from enum import Enum from typing import AsyncGenerator, Optional @@ -19,6 +20,7 @@ from loguru import logger from typing_extensions import TYPE_CHECKING, override from pipecat.frames.frames import ErrorFrame, Frame, TranscriptionFrame +from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven from pipecat.services.stt_service import SegmentedSTTService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.time import time_now_iso8601 @@ -33,7 +35,7 @@ if TYPE_CHECKING: raise Exception(f"Missing module: {e}") try: - import mlx_whisper + import mlx_whisper # noqa: F401 except ModuleNotFoundError as e: logger.error(f"Exception: {e}") logger.error("In order to use Whisper, you need to `pip install pipecat-ai[mlx-whisper]`.") @@ -172,6 +174,32 @@ def language_to_whisper_language(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=True) +@dataclass +class WhisperSTTSettings(STTSettings): + """Settings for WhisperSTTService. + + Parameters: + no_speech_prob: Probability threshold for filtering non-speech segments. + """ + + no_speech_prob: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + +@dataclass +class WhisperMLXSTTSettings(STTSettings): + """Settings for WhisperMLXSTTService. + + Parameters: + no_speech_prob: Probability threshold for filtering non-speech segments. + temperature: Sampling temperature (0.0-1.0). + engine: Whisper engine identifier. + """ + + no_speech_prob: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + temperature: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + engine: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN) + + class WhisperSTTService(SegmentedSTTService): """Class to transcribe audio with a locally-downloaded Whisper model. @@ -179,39 +207,80 @@ class WhisperSTTService(SegmentedSTTService): segments. It supports multiple languages and various model sizes. """ + Settings = WhisperSTTSettings + _settings: Settings + def __init__( self, *, - model: str | Model = Model.DISTIL_MEDIUM_EN, + model: Optional[str | Model] = None, device: str = "auto", compute_type: str = "default", - no_speech_prob: float = 0.4, - language: Language = Language.EN, + no_speech_prob: Optional[float] = None, + language: Optional[Language] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the Whisper STT service. Args: model: The Whisper model to use for transcription. Can be a Model enum or string. + + .. deprecated:: 0.0.105 + Use ``settings=WhisperSTTService.Settings(model=...)`` instead. + device: The device to run inference on ('cpu', 'cuda', or 'auto'). - compute_type: The compute type for inference ('default', 'int8', 'int8_float16', etc.). + Defaults to ``"auto"``. + compute_type: The compute type for inference ('default', 'int8', + 'int8_float16', etc.). Defaults to ``"default"``. no_speech_prob: Probability threshold for filtering out non-speech segments. + + .. deprecated:: 0.0.105 + Use ``settings=WhisperSTTService.Settings(no_speech_prob=...)`` instead. + language: The default language for transcription. + + .. deprecated:: 0.0.105 + Use ``settings=WhisperSTTService.Settings(language=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to SegmentedSTTService. """ - super().__init__(**kwargs) - self._device: str = device - self._compute_type = compute_type - self.set_model_name(model if isinstance(model, str) else model.value) - self._no_speech_prob = no_speech_prob - self._model: Optional[WhisperModel] = None + # --- 1. Hardcoded defaults --- + default_settings = self.Settings( + model=Model.DISTIL_MEDIUM_EN.value, + language=Language.EN, + no_speech_prob=0.4, + ) - self._settings = { - "language": language, - "device": self._device, - "compute_type": self._compute_type, - "no_speech_prob": self._no_speech_prob, - } + # --- 2. Deprecated direct-arg overrides --- + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model if isinstance(model, str) else model.value + if no_speech_prob is not None: + self._warn_init_param_moved_to_settings("no_speech_prob", "no_speech_prob") + default_settings.no_speech_prob = no_speech_prob + if language is not None: + self._warn_init_param_moved_to_settings("language", "language") + default_settings.language = language + + # --- 3. (no params object for this service) --- + + # --- 4. Settings delta (canonical API, always wins) --- + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + settings=default_settings, + **kwargs, + ) + + # Init-only inference config + self._device = device + self._compute_type = compute_type + + self._model: Optional[WhisperModel] = None self._load() @@ -234,15 +303,6 @@ class WhisperSTTService(SegmentedSTTService): """ return language_to_whisper_language(language) - async def set_language(self, language: Language): - """Set the language for transcription. - - Args: - language: The Language enum value to use for transcription. - """ - logger.info(f"Switching STT language to: [{language}]") - self._settings["language"] = language - def _load(self): """Loads the Whisper model. @@ -255,7 +315,7 @@ class WhisperSTTService(SegmentedSTTService): logger.debug("Loading Whisper model...") self._model = WhisperModel( - self.model_name, device=self._device, compute_type=self._compute_type + self._settings.model, device=self._device, compute_type=self._compute_type ) logger.debug("Loaded Whisper model") except ModuleNotFoundError as e: @@ -289,31 +349,28 @@ class WhisperSTTService(SegmentedSTTService): return await self.start_processing_metrics() - await self.start_ttfb_metrics() # Divide by 32768 because we have signed 16-bit data. audio_float = np.frombuffer(audio, dtype=np.int16).astype(np.float32) / 32768.0 - whisper_lang = self.language_to_service_language(self._settings["language"]) segments, _ = await asyncio.to_thread( - self._model.transcribe, audio_float, language=whisper_lang + self._model.transcribe, audio_float, language=self._settings.language ) text: str = "" for segment in segments: - if segment.no_speech_prob < self._no_speech_prob: + if segment.no_speech_prob < self._settings.no_speech_prob: text += f"{segment.text} " - await self.stop_ttfb_metrics() await self.stop_processing_metrics() if text: - await self._handle_transcription(text, True, self._settings["language"]) + await self._handle_transcription(text, True, self._settings.language) logger.debug(f"Transcription: [{text}]") yield TranscriptionFrame( text, self._user_id, time_now_iso8601(), - self._settings["language"], + self._settings.language, ) @@ -324,37 +381,81 @@ class WhisperSTTServiceMLX(WhisperSTTService): segments. It's optimized for Apple Silicon and supports multiple languages and quantizations. """ + Settings = WhisperMLXSTTSettings + _settings: Settings + def __init__( self, *, - model: str | MLXModel = MLXModel.TINY, - no_speech_prob: float = 0.6, - language: Language = Language.EN, - temperature: float = 0.0, + model: Optional[str | MLXModel] = None, + no_speech_prob: Optional[float] = None, + language: Optional[Language] = None, + temperature: Optional[float] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the MLX Whisper STT service. Args: model: The MLX Whisper model to use for transcription. Can be an MLXModel enum or string. + + .. deprecated:: 0.0.105 + Use ``settings=WhisperSTTServiceMLX.Settings(model=...)`` instead. + no_speech_prob: Probability threshold for filtering out non-speech segments. + + .. deprecated:: 0.0.105 + Use ``settings=WhisperSTTServiceMLX.Settings(no_speech_prob=...)`` instead. + language: The default language for transcription. + + .. deprecated:: 0.0.105 + Use ``settings=WhisperSTTServiceMLX.Settings(language=...)`` instead. + temperature: Temperature for sampling. Can be a float or tuple of floats. + + .. deprecated:: 0.0.105 + Use ``settings=WhisperSTTServiceMLX.Settings(temperature=...)`` instead. + + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to SegmentedSTTService. """ + # --- 1. Hardcoded defaults --- + default_settings = self.Settings( + model=MLXModel.TINY.value, + language=Language.EN, + no_speech_prob=0.6, + temperature=0.0, + engine="mlx", + ) + + # --- 2. Deprecated direct-arg overrides --- + if model is not None: + self._warn_init_param_moved_to_settings("model", "model") + default_settings.model = model if isinstance(model, str) else model.value + if no_speech_prob is not None: + self._warn_init_param_moved_to_settings("no_speech_prob", "no_speech_prob") + default_settings.no_speech_prob = no_speech_prob + if language is not None: + self._warn_init_param_moved_to_settings("language", "language") + default_settings.language = language + if temperature is not None: + self._warn_init_param_moved_to_settings("temperature", "temperature") + default_settings.temperature = temperature + + # --- 3. (no params object for this service) --- + + # --- 4. Settings delta (canonical API, always wins) --- + if settings is not None: + default_settings.apply_update(settings) + # Skip WhisperSTTService.__init__ and call its parent directly - SegmentedSTTService.__init__(self, **kwargs) - - self.set_model_name(model if isinstance(model, str) else model.value) - self._no_speech_prob = no_speech_prob - self._temperature = temperature - - self._settings = { - "language": language, - "no_speech_prob": self._no_speech_prob, - "temperature": self._temperature, - "engine": "mlx", - } + SegmentedSTTService.__init__( + self, + settings=default_settings, + **kwargs, + ) # No need to call _load() as MLX Whisper loads models on demand @@ -388,18 +489,16 @@ class WhisperSTTServiceMLX(WhisperSTTService): import mlx_whisper await self.start_processing_metrics() - await self.start_ttfb_metrics() # Divide by 32768 because we have signed 16-bit data. audio_float = np.frombuffer(audio, dtype=np.int16).astype(np.float32) / 32768.0 - whisper_lang = self.language_to_service_language(self._settings["language"]) chunk = await asyncio.to_thread( mlx_whisper.transcribe, audio_float, - path_or_hf_repo=self.model_name, - temperature=self._temperature, - language=whisper_lang, + path_or_hf_repo=self._settings.model, + temperature=self._settings.temperature, + language=self._settings.language, ) text: str = "" for segment in chunk.get("segments", []): @@ -407,23 +506,22 @@ class WhisperSTTServiceMLX(WhisperSTTService): if segment.get("compression_ratio", None) == 0.5555555555555556: continue - if segment.get("no_speech_prob", 0.0) < self._no_speech_prob: + if segment.get("no_speech_prob", 0.0) < self._settings.no_speech_prob: text += f"{segment.get('text', '')} " if len(text.strip()) == 0: text = None - await self.stop_ttfb_metrics() await self.stop_processing_metrics() if text: - await self._handle_transcription(text, True, self._settings["language"]) + await self._handle_transcription(text, True, self._settings.language) logger.debug(f"Transcription: [{text}]") yield TranscriptionFrame( text, self._user_id, time_now_iso8601(), - self._settings["language"], + self._settings.language, ) except Exception as e: diff --git a/src/pipecat/services/xtts/tts.py b/src/pipecat/services/xtts/tts.py index 2e43d828c..b164f8945 100644 --- a/src/pipecat/services/xtts/tts.py +++ b/src/pipecat/services/xtts/tts.py @@ -10,6 +10,7 @@ This module provides integration with Coqui XTTS streaming server for text-to-speech synthesis using local Docker deployment. """ +from dataclasses import dataclass from typing import Any, AsyncGenerator, Dict, Optional import aiohttp @@ -21,9 +22,8 @@ from pipecat.frames.frames import ( Frame, StartFrame, TTSAudioRawFrame, - TTSStartedFrame, - TTSStoppedFrame, ) +from pipecat.services.settings import TTSSettings from pipecat.services.tts_service import TTSService from pipecat.transcriptions.language import Language, resolve_language from pipecat.utils.tracing.service_decorators import traced_tts @@ -68,6 +68,13 @@ def language_to_xtts_language(language: Language) -> Optional[str]: return resolve_language(language, LANGUAGE_MAP, use_base_code=True) +@dataclass +class XTTSTTSSettings(TTSSettings): + """Settings for XTTSService.""" + + pass + + class XTTSService(TTSService): """Coqui XTTS text-to-speech service. @@ -76,33 +83,72 @@ class XTTSService(TTSService): studio speakers configuration. """ + Settings = XTTSTTSSettings + _settings: Settings + def __init__( self, *, - voice_id: str, + voice_id: Optional[str] = None, base_url: str, aiohttp_session: aiohttp.ClientSession, language: Language = Language.EN, sample_rate: Optional[int] = None, + settings: Optional[Settings] = None, **kwargs, ): """Initialize the XTTS service. Args: voice_id: ID of the voice/speaker to use for synthesis. + + .. deprecated:: 0.0.105 + Use ``settings=XTTSService.Settings(voice=...)`` instead. + base_url: Base URL of the XTTS streaming server. aiohttp_session: HTTP session for making requests to the server. language: Language for synthesis. Defaults to English. + + .. deprecated:: 0.0.106 + Use ``settings=XTTSService.Settings(language=...)`` instead. + sample_rate: Audio sample rate. If None, uses default. + settings: Runtime-updatable settings. When provided alongside deprecated + parameters, ``settings`` values take precedence. **kwargs: Additional arguments passed to parent TTSService. """ - super().__init__(sample_rate=sample_rate, **kwargs) + # 1. Initialize default_settings with hardcoded defaults + default_settings = self.Settings( + model=None, + voice=None, + language=Language.EN, + ) + + # 2. Apply direct init arg overrides (deprecated) + if voice_id is not None: + self._warn_init_param_moved_to_settings("voice_id", "voice") + default_settings.voice = voice_id + if language is not None: + self._warn_init_param_moved_to_settings("language", "language") + default_settings.language = language + + # 3. (No step 3, as there's no params object to apply) + + # 4. Apply settings delta (canonical API, always wins) + if settings is not None: + default_settings.apply_update(settings) + + super().__init__( + sample_rate=sample_rate, + push_start_frame=True, + push_stop_frames=True, + settings=default_settings, + **kwargs, + ) + + # Init-only fields (not runtime-updatable) + self._base_url = base_url - self._settings = { - "language": self.language_to_service_language(language), - "base_url": base_url, - } - self.set_voice(voice_id) self._studio_speakers: Optional[Dict[str, Any]] = None self._aiohttp_session = aiohttp_session @@ -138,7 +184,7 @@ class XTTSService(TTSService): if self._studio_speakers: return - async with self._aiohttp_session.get(self._settings["base_url"] + "/studio_speakers") as r: + async with self._aiohttp_session.get(self._base_url + "/studio_speakers") as r: if r.status != 200: text = await r.text() await self.push_error( @@ -148,11 +194,12 @@ class XTTSService(TTSService): self._studio_speakers = await r.json() @traced_tts - async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using XTTS streaming server. Args: text: The text to synthesize into speech. + context_id: The context ID for tracking audio frames. Yields: Frame: Audio frames containing the synthesized speech. @@ -163,21 +210,19 @@ class XTTSService(TTSService): logger.error(f"{self} no studio speakers available") return - embeddings = self._studio_speakers[self._voice_id] + embeddings = self._studio_speakers[self._settings.voice] - url = self._settings["base_url"] + "/tts_stream" + url = self._base_url + "/tts_stream" payload = { "text": text.replace(".", "").replace("*", ""), - "language": self._settings["language"], + "language": self._settings.language, "speaker_embedding": embeddings["speaker_embedding"], "gpt_cond_latent": embeddings["gpt_cond_latent"], "add_wav_header": False, "stream_chunk_size": 20, } - await self.start_ttfb_metrics() - async with self._aiohttp_session.post(url, json=payload) as r: if r.status != 200: text = await r.text() @@ -186,8 +231,6 @@ class XTTSService(TTSService): await self.start_tts_usage_metrics(text) - yield TTSStartedFrame() - CHUNK_SIZE = self.chunk_size buffer = bytearray() @@ -211,7 +254,9 @@ class XTTSService(TTSService): bytes(process_data), 24000, self.sample_rate ) # Create the frame with the resampled audio - frame = TTSAudioRawFrame(resampled_audio, self.sample_rate, 1) + frame = TTSAudioRawFrame( + resampled_audio, self.sample_rate, 1, context_id=context_id + ) yield frame # Process any remaining data in the buffer. @@ -219,7 +264,7 @@ class XTTSService(TTSService): resampled_audio = await self._resampler.resample( bytes(buffer), 24000, self.sample_rate ) - frame = TTSAudioRawFrame(resampled_audio, self.sample_rate, 1) + frame = TTSAudioRawFrame( + resampled_audio, self.sample_rate, 1, context_id=context_id + ) yield frame - - yield TTSStoppedFrame() diff --git a/src/pipecat/sync/base_notifier.py b/src/pipecat/sync/base_notifier.py index 474d50a8b..fb6e12732 100644 --- a/src/pipecat/sync/base_notifier.py +++ b/src/pipecat/sync/base_notifier.py @@ -8,8 +8,6 @@ import warnings -from pipecat.utils.sync.base_notifier import BaseNotifier - with warnings.catch_warnings(): warnings.simplefilter("always") warnings.warn( diff --git a/src/pipecat/sync/event_notifier.py b/src/pipecat/sync/event_notifier.py index 2a33cab73..6a6f6abbe 100644 --- a/src/pipecat/sync/event_notifier.py +++ b/src/pipecat/sync/event_notifier.py @@ -8,8 +8,6 @@ import warnings -from pipecat.utils.sync.event_notifier import EventNotifier - with warnings.catch_warnings(): warnings.simplefilter("always") warnings.warn( diff --git a/src/pipecat/tests/utils.py b/src/pipecat/tests/utils.py index 94b8cb1a4..ca18c4c4d 100644 --- a/src/pipecat/tests/utils.py +++ b/src/pipecat/tests/utils.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2024-2025 Daily +# Copyright (c) 2024-2026, Daily # # SPDX-License-Identifier: BSD 2-Clause License # @@ -123,9 +123,10 @@ class QueuedFrameProcessor(FrameProcessor): async def run_test( processor: FrameProcessor, *, - frames_to_send: Sequence[Frame], + enable_rtvi: bool = False, expected_down_frames: Optional[Sequence[type]] = None, expected_up_frames: Optional[Sequence[type]] = None, + frames_to_send: Sequence[Frame], ignore_start: bool = True, observers: Optional[List[BaseObserver]] = None, pipeline_params: Optional[PipelineParams] = None, @@ -139,9 +140,10 @@ async def run_test( Args: processor: The frame processor to test. - frames_to_send: Sequence of frames to send through the processor. + enable_rtvi: Whether RTVI should be enabled in this test. expected_down_frames: Expected frame types flowing downstream (optional). expected_up_frames: Expected frame types flowing upstream (optional). + frames_to_send: Sequence of frames to send through the processor. ignore_start: Whether to ignore StartFrames in frame validation. observers: Optional list of observers to attach to the pipeline. pipeline_params: Optional pipeline parameters. @@ -173,9 +175,10 @@ async def run_test( task = PipelineTask( pipeline, - params=pipeline_params, - observers=observers, cancel_on_idle_timeout=False, + enable_rtvi=enable_rtvi, + observers=observers, + params=pipeline_params, ) async def push_frames(): @@ -196,13 +199,13 @@ async def run_test( # # Down frames # - received_down_frames: Sequence[Frame] = [] - if expected_down_frames is not None: - while not received_down.empty(): - frame = await received_down.get() - if not isinstance(frame, EndFrame) or not send_end_frame: - received_down_frames.append(frame) + received_down_frames: list[Frame] = [] + while not received_down.empty(): + frame = await received_down.get() + if not isinstance(frame, EndFrame) or not send_end_frame: + received_down_frames.append(frame) + if expected_down_frames is not None: down_frames_printed = "[" for frame in received_down_frames: down_frames_printed += f"{frame.__class__.__name__}, " @@ -222,12 +225,12 @@ async def run_test( # # Up frames # - received_up_frames: Sequence[Frame] = [] - if expected_up_frames is not None: - while not received_up.empty(): - frame = await received_up.get() - received_up_frames.append(frame) + received_up_frames: list[Frame] = [] + while not received_up.empty(): + frame = await received_up.get() + received_up_frames.append(frame) + if expected_up_frames is not None: print("received UP frames =", received_up_frames) print("expected UP frames =", expected_up_frames) diff --git a/src/pipecat/transcriptions/language.py b/src/pipecat/transcriptions/language.py index a79a85166..1980590e3 100644 --- a/src/pipecat/transcriptions/language.py +++ b/src/pipecat/transcriptions/language.py @@ -631,13 +631,13 @@ def resolve_language( return result # Not in map - fall back with warning - lang_str = str(language.value) + lang_str = str(language) if use_base_code: # Extract base code (e.g., "en" from "en-US") base_code = lang_str.split("-")[0].lower() - logger.warning(f"Language {language.value} not verified. Using base code '{base_code}'.") + logger.warning(f"Language {language} not verified. Using base code '{base_code}'.") return base_code else: - logger.warning(f"Language {language.value} not verified. Using '{lang_str}'.") + logger.warning(f"Language {language} not verified. Using '{lang_str}'.") return lang_str diff --git a/src/pipecat/transports/base_input.py b/src/pipecat/transports/base_input.py index 31bc3e035..bcd457564 100644 --- a/src/pipecat/transports/base_input.py +++ b/src/pipecat/transports/base_input.py @@ -11,6 +11,7 @@ input processing, including VAD, turn analysis, and interruption management. """ import asyncio +import time from typing import Optional from loguru import logger @@ -77,6 +78,11 @@ class BaseInputTransport(FrameProcessor): # Track user speaking state for interruption logic self._user_speaking = False + # Last time a UserSpeakingFrame was pushed. + self._user_speaking_frame_time = 0 + # How often a UserSpeakingFrame should be pushed (value should be + # greater than the audio chunks to have any effect). + self._user_speaking_frame_period = 0.2 # Task to process incoming audio (VAD) and push audio frames downstream # if passthrough is enabled. @@ -138,6 +144,18 @@ class BaseInputTransport(FrameProcessor): DeprecationWarning, ) + if self._params.vad_analyzer: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Parameter 'vad_analyzer' is deprecated. Use `LLMUserAggregator`'s " + "`vad_analyzer` parameter, or `VADProcessor` if no `LLMUserAggregator` " + "is needed.", + DeprecationWarning, + ) + def enable_audio_in_stream_on_start(self, enabled: bool) -> None: """Enable or disable audio streaming on transport start. @@ -167,9 +185,23 @@ class BaseInputTransport(FrameProcessor): def vad_analyzer(self) -> Optional[VADAnalyzer]: """Get the Voice Activity Detection analyzer. + .. deprecated:: 0.0.101 + This method is deprecated and will be removed in a future version. + Use `LLMUserAggregator`'s new `vad_analyzer` parameter instead. + Returns: The VAD analyzer instance if configured, None otherwise. """ + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Method 'vad_analyzer' is deprecated. Use `LLMUserAggregator`'s new " + "`vad_analyzer` parameter instead.", + DeprecationWarning, + ) + return self._params.vad_analyzer @property @@ -206,6 +238,13 @@ class BaseInputTransport(FrameProcessor): self._sample_rate = self._params.audio_in_sample_rate or frame.audio_in_sample_rate + # Start audio filter. + if self._params.audio_in_filter: + await self._params.audio_in_filter.start(self._sample_rate) + + ################################################################### + # DEPRECATED. + # Configure VAD analyzer. if self._params.vad_analyzer: self._params.vad_analyzer.set_sample_rate(self._sample_rate) @@ -221,10 +260,7 @@ class BaseInputTransport(FrameProcessor): await self.broadcast_frame( SpeechControlParamsFrame, vad_params=vad_params, turn_params=turn_params ) - - # Start audio filter. - if self._params.audio_in_filter: - await self._params.audio_in_filter.start(self._sample_rate) + ################################################################### async def stop(self, frame: EndFrame): """Stop the input transport and cleanup resources. @@ -335,9 +371,11 @@ class BaseInputTransport(FrameProcessor): elif isinstance(frame, StopFrame): await self.push_frame(frame, direction) await self.pause(frame) + ################################################################### + # DEPRECATED. elif isinstance(frame, VADParamsUpdateFrame): - if self.vad_analyzer: - self.vad_analyzer.set_params(frame.params) + if self._params.vad_analyzer: + self._params.vad_analyzer.set_params(frame.params) await self.broadcast_frame( SpeechControlParamsFrame, vad_params=frame.params, @@ -345,6 +383,7 @@ class BaseInputTransport(FrameProcessor): if self._params.turn_analyzer else None, ) + ################################################################### elif isinstance(frame, FilterUpdateSettingsFrame) and self._params.audio_in_filter: await self._params.audio_in_filter.process_frame(frame) # Other frames @@ -367,63 +406,45 @@ class BaseInputTransport(FrameProcessor): await self.cancel_task(self._audio_task) self._audio_task = None - async def _vad_analyze(self, audio_frame: InputAudioRawFrame) -> VADState: - """Analyze audio frame for voice activity.""" - state = VADState.QUIET - if self.vad_analyzer: - state = await self.vad_analyzer.analyze_audio(audio_frame.audio) - return state - - async def _new_handle_vad( - self, audio_frame: InputAudioRawFrame, vad_state: VADState - ) -> VADState: - """Handle Voice Activity Detection results and generate appropriate frames.""" - new_vad_state = await self._vad_analyze(audio_frame) - if ( - new_vad_state != vad_state - and new_vad_state != VADState.STARTING - and new_vad_state != VADState.STOPPING - ): - if new_vad_state == VADState.SPEAKING: - await self.push_frame(VADUserStartedSpeakingFrame()) - elif new_vad_state == VADState.QUIET: - await self.push_frame(VADUserStoppedSpeakingFrame()) - - vad_state = new_vad_state - return vad_state - - async def _handle_vad(self, audio_frame: InputAudioRawFrame, vad_state: VADState) -> VADState: - """Handle Voice Activity Detection results and generate appropriate frames.""" - if self._params.turn_analyzer or self._deprecated_openaillmcontext: - return await self._deprecated_handle_vad(audio_frame, vad_state) - else: - return await self._new_handle_vad(audio_frame, vad_state) - async def _audio_task_handler(self): """Main audio processing task handler for VAD and turn analysis.""" vad_state: VADState = VADState.QUIET + # Skip timeout handling until the first audio frame arrives (e.g. client + # not yet connected). + audio_received = False while True: try: frame: InputAudioRawFrame = await asyncio.wait_for( self._audio_in_queue.get(), timeout=AUDIO_INPUT_TIMEOUT_SECS ) + # From now on, timeout should warn if there's no audio. + audio_received = True + # If an audio filter is available, run it before VAD. if self._params.audio_in_filter: frame.audio = await self._params.audio_in_filter.filter(frame.audio) + # Skip frames with no audio data (e.g. filter is buffering). + if not frame.audio: + self._audio_in_queue.task_done() + continue + + ################################################################### + # DEPRECATED. + # # Check VAD and push event if necessary. We just care about # changes from QUIET to SPEAKING and vice versa. previous_vad_state = vad_state if self._params.vad_analyzer: - vad_state = await self._handle_vad(frame, vad_state) + vad_state = await self._deprecated_handle_vad(frame, vad_state) - # DEPRECATED. if self._params.turn_analyzer: await self._deprecated_run_turn_analyzer(frame, vad_state, previous_vad_state) - if vad_state == VADState.SPEAKING: - await self.broadcast_frame(UserSpeakingFrame) + if self._params.vad_analyzer and vad_state == VADState.SPEAKING: + await self._deprecated_user_currently_speaking() + ################################################################### # Push audio downstream if passthrough is set. if self._params.audio_in_passthrough: @@ -431,6 +452,11 @@ class BaseInputTransport(FrameProcessor): self._audio_in_queue.task_done() except asyncio.TimeoutError: + if not audio_received: + continue + + ################################################################### + # DEPRECATED. if self._user_speaking: logger.warning( "Forcing VAD user stopped speaking due to timeout receiving audio frame!" @@ -442,7 +468,13 @@ class BaseInputTransport(FrameProcessor): if self._params.turn_analyzer: await self._deprecated_handle_user_interruption(VADState.QUIET) else: - await self.push_frame(VADUserStoppedSpeakingFrame()) + stop_secs = ( + self._params.vad_analyzer.params.stop_secs + if self._params.vad_analyzer + else 0.0 + ) + await self.push_frame(VADUserStoppedSpeakingFrame(stop_secs=stop_secs)) + ################################################################### # # DEPRECATED. @@ -451,6 +483,55 @@ class BaseInputTransport(FrameProcessor): # interruption strategies and turn analyzer are removed. # + async def _deprecated_vad_analyze(self, audio_frame: InputAudioRawFrame) -> VADState: + """Analyze audio frame for voice activity.""" + state = VADState.QUIET + if self._params.vad_analyzer: + state = await self._params.vad_analyzer.analyze_audio(audio_frame.audio) + return state + + async def _deprecated_new_handle_vad( + self, audio_frame: InputAudioRawFrame, vad_state: VADState + ) -> VADState: + """Handle Voice Activity Detection results and generate appropriate frames.""" + new_vad_state = await self._deprecated_vad_analyze(audio_frame) + if ( + new_vad_state != vad_state + and new_vad_state != VADState.STARTING + and new_vad_state != VADState.STOPPING + ): + if new_vad_state == VADState.SPEAKING: + start_secs = ( + self._params.vad_analyzer.params.start_secs + if self._params.vad_analyzer + else 0.0 + ) + await self.push_frame(VADUserStartedSpeakingFrame(start_secs=start_secs)) + elif new_vad_state == VADState.QUIET: + stop_secs = ( + self._params.vad_analyzer.params.stop_secs if self._params.vad_analyzer else 0.0 + ) + await self.push_frame(VADUserStoppedSpeakingFrame(stop_secs=stop_secs)) + + vad_state = new_vad_state + return vad_state + + async def _deprecated_handle_vad( + self, audio_frame: InputAudioRawFrame, vad_state: VADState + ) -> VADState: + """Handle Voice Activity Detection results and generate appropriate frames.""" + if self._params.turn_analyzer or self._deprecated_openaillmcontext: + return await self._deprecated_old_handle_vad(audio_frame, vad_state) + else: + return await self._deprecated_new_handle_vad(audio_frame, vad_state) + + async def _deprecated_user_currently_speaking(self): + """Handle user speaking frame.""" + diff_time = time.time() - self._user_speaking_frame_time + if diff_time >= self._user_speaking_frame_period: + await self.broadcast_frame(UserSpeakingFrame) + self._user_speaking_frame_time = time.time() + async def _deprecated_handle_bot_started_speaking(self, frame: BotStartedSpeakingFrame): """Update bot speaking state when bot starts speaking.""" self._bot_speaking = True @@ -478,7 +559,7 @@ class BaseInputTransport(FrameProcessor): # Make sure we notify about interruptions quickly out-of-band. if should_push_immediate_interruption and self._allow_interruptions: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() elif self.interruption_strategies and self._bot_speaking: logger.debug( "User started speaking while bot is speaking with interruption config - " @@ -490,11 +571,11 @@ class BaseInputTransport(FrameProcessor): await self.broadcast_frame(UserStoppedSpeakingFrame, emulated=emulated) - async def _deprecated_handle_vad( + async def _deprecated_old_handle_vad( self, audio_frame: InputAudioRawFrame, vad_state: VADState ) -> VADState: """Handle Voice Activity Detection results and generate appropriate frames.""" - new_vad_state = await self._vad_analyze(audio_frame) + new_vad_state = await self._deprecated_vad_analyze(audio_frame) if ( new_vad_state != vad_state and new_vad_state != VADState.STARTING @@ -510,11 +591,19 @@ class BaseInputTransport(FrameProcessor): or not self._params.turn_analyzer.speech_triggered ) if new_vad_state == VADState.SPEAKING: - await self.push_frame(VADUserStartedSpeakingFrame()) + start_secs = ( + self._params.vad_analyzer.params.start_secs + if self._params.vad_analyzer + else 0.0 + ) + await self.push_frame(VADUserStartedSpeakingFrame(start_secs=start_secs)) if can_create_user_frames: interruption_state = VADState.SPEAKING elif new_vad_state == VADState.QUIET: - await self.push_frame(VADUserStoppedSpeakingFrame()) + stop_secs = ( + self._params.vad_analyzer.params.stop_secs if self._params.vad_analyzer else 0.0 + ) + await self.push_frame(VADUserStoppedSpeakingFrame(stop_secs=stop_secs)) if can_create_user_frames: interruption_state = VADState.QUIET @@ -526,9 +615,7 @@ class BaseInputTransport(FrameProcessor): async def _deprecated_handle_end_of_turn(self): """Handle end-of-turn analysis and generate prediction results.""" - # Don't use self._params.turn_analyzer so we can keep showing one - # deprecation warning. - if self.turn_analyzer: + if self._params.turn_analyzer: state, prediction = await self._params.turn_analyzer.analyze_end_of_turn() await self._deprecated_handle_prediction_result(prediction) await self._deprecated_handle_end_of_turn_complete(state) diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index a19d65b75..01af97be8 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -44,12 +44,15 @@ from pipecat.frames.frames import ( StartFrame, SystemFrame, TTSAudioRawFrame, + TTSStoppedFrame, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.transports.base_transport import TransportParams from pipecat.utils.time import nanoseconds_to_seconds BOT_VAD_STOP_SECS = 0.35 +# Only used as a fallback +BOT_VAD_STOP_FALLBACK_SECS = 3 class BaseOutputTransport(FrameProcessor): @@ -237,6 +240,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. @@ -342,6 +357,8 @@ class BaseOutputTransport(FrameProcessor): await sender.handle_sync_frame(frame) elif isinstance(frame, MixerControlFrame): await sender.handle_mixer_control_frame(frame) + elif isinstance(frame, TTSStoppedFrame): + await sender.handle_sync_frame(frame) elif frame.pts: await sender.handle_timed_frame(frame) else: @@ -400,10 +417,12 @@ class BaseOutputTransport(FrameProcessor): # Indicates if the bot is currently speaking. self._bot_speaking = False + # Indicates if TTS audio has been received since the last stop. + self._tts_audio_received = False # Last time a BotSpeakingFrame was pushed. self._bot_speaking_frame_time = 0 # How often a BotSpeakingFrame should be pushed (value should be - # lower than the audio chunks). + # greater than the audio chunks to have any effect). self._bot_speaking_frame_period = 0.2 # Last time the bot actually spoke. self._bot_speech_last_time = 0 @@ -550,7 +569,11 @@ class BaseOutputTransport(FrameProcessor): if not self._params.video_out_enabled: return - if self._params.video_out_is_live and isinstance(frame, OutputImageRawFrame): + if isinstance(frame, OutputImageRawFrame) and frame.sync_with_audio: + # Route through the audio queue so the image is only + # displayed after all preceding audio has been sent. + await self._audio_queue.put(frame) + elif self._params.video_out_is_live and isinstance(frame, OutputImageRawFrame): await self._video_queue.put(frame) elif isinstance(frame, OutputImageRawFrame): await self._set_video_image(frame) @@ -613,6 +636,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) @@ -622,6 +650,7 @@ class BaseOutputTransport(FrameProcessor): return self._bot_speaking = False + self._tts_audio_received = False # Clean audio buffer (there could be tiny left overs if not multiple # to our output chunk size). @@ -635,6 +664,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) @@ -644,8 +678,7 @@ class BaseOutputTransport(FrameProcessor): diff_time = time.time() - self._bot_speaking_frame_time if diff_time >= self._bot_speaking_frame_period: - await self._transport.push_frame(BotSpeakingFrame()) - await self._transport.push_frame(BotSpeakingFrame(), FrameDirection.UPSTREAM) + await self._transport.broadcast_frame(BotSpeakingFrame) self._bot_speaking_frame_time = time.time() self._bot_speech_last_time = time.time() @@ -661,6 +694,9 @@ class BaseOutputTransport(FrameProcessor): async def _handle_bot_speech(self, frame: Frame): # TTS case. if isinstance(frame, TTSAudioRawFrame): + # We will only trigger bot stopped speaking based on the TTSStoppedFrame, + # if we have received audio from TTS + self._tts_audio_received = True await self._bot_currently_speaking() # Speech stream case. elif isinstance(frame, SpeechOutputAudioRawFrame): @@ -682,6 +718,14 @@ class BaseOutputTransport(FrameProcessor): await self._transport.send_message(frame) elif isinstance(frame, OutputDTMFFrame): await self._transport.write_dtmf(frame) + elif isinstance(frame, TTSStoppedFrame): + # We will only trigger bot stopped speaking based on the TTSStoppedFrame, + # if we have received audio from TTS + if self._tts_audio_received: + logger.debug("Bot stopped speaking based on TTSStoppedFrame") + await self._bot_stopped_speaking() + else: + await self._transport.write_transport_frame(frame) def _next_frame(self) -> AsyncGenerator[Frame, None]: """Generate the next frame for audio processing. @@ -699,7 +743,7 @@ class BaseOutputTransport(FrameProcessor): yield frame self._audio_queue.task_done() except asyncio.TimeoutError: - # Notify the bot stopped speaking upstream if necessary. + # Fallback: notify the bot stopped speaking upstream if necessary based on timeout. await self._bot_stopped_speaking() async def with_mixer(vad_stop_secs: float) -> AsyncGenerator[Frame, None]: @@ -714,7 +758,7 @@ class BaseOutputTransport(FrameProcessor): yield frame self._audio_queue.task_done() except asyncio.QueueEmpty: - # Notify the bot stopped speaking upstream if necessary. + # Fallback: notify the bot stopped speaking upstream if necessary based on timeout. diff_time = time.time() - last_frame_time if diff_time > vad_stop_secs: await self._bot_stopped_speaking() @@ -732,9 +776,9 @@ class BaseOutputTransport(FrameProcessor): await asyncio.sleep(0) if self._mixer: - return with_mixer(BOT_VAD_STOP_SECS) + return with_mixer(BOT_VAD_STOP_FALLBACK_SECS) else: - return without_mixer(BOT_VAD_STOP_SECS) + return without_mixer(BOT_VAD_STOP_FALLBACK_SECS) async def _send_silence(self, secs: int): if secs <= 0: diff --git a/src/pipecat/transports/base_transport.py b/src/pipecat/transports/base_transport.py index 13cba4b8b..a78e1046c 100644 --- a/src/pipecat/transports/base_transport.py +++ b/src/pipecat/transports/base_transport.py @@ -98,6 +98,7 @@ class TransportParams(BaseModel): video_out_bitrate: Video output bitrate in bits per second. video_out_framerate: Video output frame rate in FPS. video_out_color_format: Video output color format string. + video_out_codec: Preferred video codec for output (e.g., 'VP8', 'H264', 'H265'). video_out_destinations: List of video output destination identifiers. vad_enabled: Enable Voice Activity Detection (deprecated). @@ -112,6 +113,12 @@ class TransportParams(BaseModel): instead. vad_analyzer: Voice Activity Detection analyzer instance. + + .. deprecated:: 0.0.101 + The `vad_analyzer` parameter is deprecated. Use `LLMUserAggregator`'s + `vad_analyzer` parameter, or `VADProcessor` if no `LLMUserAggregator` + is needed. + turn_analyzer: Turn-taking analyzer instance for conversation management. .. deprecated:: 0.0.99 @@ -151,6 +158,7 @@ class TransportParams(BaseModel): video_out_bitrate: int = 800000 video_out_framerate: int = 30 video_out_color_format: str = "RGB" + video_out_codec: Optional[str] = None video_out_destinations: List[str] = Field(default_factory=list) vad_enabled: bool = False vad_audio_passthrough: bool = False diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index 147a9a111..2798f6d5e 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -15,21 +15,24 @@ 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 from loguru import logger from pydantic import BaseModel +from pipecat.audio.dtmf.types import KeypadEntry from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams from pipecat.frames.frames import ( + BotConnectedFrame, CancelFrame, - ControlFrame, + ClientConnectedFrame, + DataFrame, EndFrame, - ErrorFrame, Frame, InputAudioRawFrame, + InputDTMFFrame, InputTransportMessageFrame, InterimTranscriptionFrame, OutputAudioRawFrame, @@ -56,10 +59,11 @@ try: CallClient, CustomAudioSource, CustomAudioTrack, + CustomVideoSource, + CustomVideoTrack, Daily, EventHandler, VideoFrame, - VirtualCameraDevice, VirtualSpeakerDevice, ) from daily import LogLevel as DailyLogLevel @@ -184,34 +188,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): @@ -293,6 +307,44 @@ class DailyTranscriptionSettings(BaseModel): extra: Mapping[str, Any] = {"interim_results": True} +class DailyCustomVideoTrackParams(BaseModel): + """Configuration for a custom video track. + + If ``send_settings`` is not provided, the track will use the default video + publishing settings (framerate, bitrate, codec, etc.). + + Parameters: + width: Video width in pixels. + height: Video height in pixels. + color_format: Video color format (e.g., "RGB", "RGBA", "BGRA"). + send_settings: Optional Daily sendSettings dict for this track. + See https://reference-python.daily.co/types.html#videopublishingsettings + """ + + width: int = 1024 + height: int = 768 + color_format: str = "RGB" + send_settings: Optional[Dict[str, Any]] = None + + +class DailyCustomAudioTrackParams(BaseModel): + """Configuration for a custom audio track. + + If ``send_settings`` is not provided, the track will use the default audio + publishing settings (bitrate, channel config, etc.). + + Parameters: + sample_rate: Audio sample rate in Hz. Defaults to transport's output sample rate. + channels: Number of audio channels. + send_settings: Optional Daily sendSettings dict for this track. + See https://reference-python.daily.co/types.html#audiopublishingsettings + """ + + sample_rate: Optional[int] = None + channels: int = 1 + send_settings: Optional[Dict[str, Any]] = None + + class DailyParams(TransportParams): """Configuration parameters for Daily transport. @@ -300,8 +352,10 @@ class DailyParams(TransportParams): api_url: Daily API base URL. api_key: Daily API authentication key. audio_in_user_tracks: Receive users' audio in separate tracks - dialin_settings: Optional settings for dial-in functionality. camera_out_enabled: Whether to enable the main camera output track. + custom_audio_track_params: Per-destination configuration for custom audio tracks. + custom_video_track_params: Per-destination configuration for custom video tracks. + dialin_settings: Optional settings for dial-in functionality. microphone_out_enabled: Whether to enable the main microphone track. transcription_enabled: Whether to enable speech transcription. transcription_settings: Configuration for transcription service. @@ -310,8 +364,10 @@ class DailyParams(TransportParams): api_url: str = "https://api.daily.co/v1" api_key: str = "" audio_in_user_tracks: bool = True - dialin_settings: Optional[DailyDialinSettings] = None camera_out_enabled: bool = True + custom_audio_track_params: Optional[Mapping[str, DailyCustomAudioTrackParams]] = None + custom_video_track_params: Optional[Mapping[str, DailyCustomVideoTrackParams]] = None + dialin_settings: Optional[DailyDialinSettings] = None microphone_out_enabled: bool = True transcription_enabled: bool = False transcription_settings: DailyTranscriptionSettings = DailyTranscriptionSettings() @@ -340,6 +396,7 @@ class DailyCallbacks(BaseModel): on_dialout_stopped: Called when dial-out is stopped. on_dialout_error: Called when dial-out encounters an error. on_dialout_warning: Called when dial-out has a warning. + on_dtmf_event: Called when a DTMF tone happens. on_participant_joined: Called when a participant joins. on_participant_left: Called when a participant leaves. on_participant_updated: Called when participant info is updated. @@ -370,6 +427,7 @@ class DailyCallbacks(BaseModel): on_dialout_stopped: Callable[[Any], Awaitable[None]] on_dialout_error: Callable[[Any], Awaitable[None]] on_dialout_warning: Callable[[Any], Awaitable[None]] + on_dtmf_event: Callable[[Any], Awaitable[None]] on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]] on_participant_updated: Callable[[Mapping[str, Any]], Awaitable[None]] @@ -419,6 +477,19 @@ class DailyAudioTrack: track: CustomAudioTrack +@dataclass +class DailyVideoTrack: + """Container for Daily video track components. + + Parameters: + source: The custom video source for the track. + track: The custom video track instance. + """ + + source: CustomVideoSource + track: CustomVideoTrack + + # This is just a type alias for the errors returned by daily-python. Right now # they are just a string. CallClientError = str @@ -502,19 +573,17 @@ 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 self._out_sample_rate = 0 - self._camera: Optional[VirtualCameraDevice] = None self._speaker: Optional[VirtualSpeakerDevice] = None + self._camera_track: Optional[DailyVideoTrack] = None self._microphone_track: Optional[DailyAudioTrack] = None self._custom_audio_tracks: Dict[str, DailyAudioTrack] = {} - - def _camera_name(self): - """Generate a unique virtual camera name for this client instance.""" - return f"camera-{self}" + self._custom_video_tracks: Dict[str, DailyVideoTrack] = {} def _speaker_name(self): """Generate a unique virtual speaker name for this client instance.""" @@ -568,7 +637,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( @@ -612,8 +682,29 @@ class DailyTransportClient(EventHandler): Args: destination: The destination identifier to register. """ - self._custom_audio_tracks[destination] = await self.add_custom_audio_track(destination) - self._client.update_publishing({"customAudio": {destination: True}}) + params = (self._params.custom_audio_track_params or {}).get(destination) + self._custom_audio_tracks[destination] = await self.add_custom_audio_track( + destination, params=params + ) + publishing: Dict[str, Any] = {"customAudio": {destination: True}} + if params and params.send_settings: + publishing["customAudio"][destination] = {"sendSettings": params.send_settings} + self._client.update_publishing(publishing) + + async def register_video_destination(self, destination: str): + """Register a custom video destination for multi-track output. + + Args: + destination: The destination identifier to register. + """ + params = (self._params.custom_video_track_params or {}).get(destination) + self._custom_video_tracks[destination] = await self.add_custom_video_track( + destination, params=params + ) + publishing: Dict[str, Any] = {"customVideo": {destination: True}} + if params and params.send_settings: + publishing["customVideo"][destination] = {"sendSettings": params.send_settings} + self._client.update_publishing(publishing) async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool: """Write an audio frame to the appropriate audio track. @@ -644,7 +735,7 @@ class DailyTransportClient(EventHandler): return num_frames > 0 async def write_video_frame(self, frame: OutputImageRawFrame) -> bool: - """Write a video frame to the camera device. + """Write a video frame to the appropriate video track. Args: frame: The image frame to write. @@ -652,10 +743,20 @@ class DailyTransportClient(EventHandler): Returns: True if the video frame was written successfully, False otherwise. """ - if not frame.transport_destination and self._camera: - self._camera.write_frame(frame.image) + destination = frame.transport_destination + video_source: Optional[CustomVideoSource] = None + if not destination and self._camera_track: + video_source = self._camera_track.source + elif destination and destination in self._custom_video_tracks: + track = self._custom_video_tracks[destination] + video_source = track.source + + if video_source: + video_source.write_frame(frame.image) return True - return False + else: + logger.warning(f"{self} unable to write video frames to destination [{destination}]") + return False async def setup(self, setup: FrameProcessorSetup): """Setup the client with task manager and event queues. @@ -720,13 +821,14 @@ class DailyTransportClient(EventHandler): self._callback_task_handler(self._video_queue), f"{self}::video_callback_task", ) - if self._params.video_out_enabled and not self._camera: - self._camera = Daily.create_camera_device( - self._camera_name(), - width=self._params.video_out_width, - height=self._params.video_out_height, - color_format=self._params.video_out_color_format, + if self._params.video_out_enabled and not self._camera_track: + video_source = CustomVideoSource( + self._params.video_out_width, + self._params.video_out_height, + self._params.video_out_color_format, ) + video_track = CustomVideoTrack(video_source) + self._camera_track = DailyVideoTrack(source=video_source, track=video_track) if self._params.audio_out_enabled and not self._microphone_track: audio_source = CustomAudioSource(self._out_sample_rate, self._params.audio_out_channels) @@ -760,11 +862,17 @@ class DailyTransportClient(EventHandler): # Increment leave counter if we successfully joined. self._leave_counter += 1 - logger.info(f"Joined {self._room_url}") + participant_id = data.get("participants", {}).get("local", {}).get("id") + meeting_id = data.get("meetingSession", {}).get("id") + logger.info( + f"Joined {self._room_url}. Participant ID: {participant_id}, Meeting ID: {meeting_id}" + ) 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) @@ -790,7 +898,11 @@ class DailyTransportClient(EventHandler): "camera": { "isEnabled": camera_enabled, "settings": { - "deviceId": self._camera_name(), + "customTrack": { + "id": self._camera_track.track.id + if self._camera_track + else "no-camera-track" + } }, }, "microphone": { @@ -808,6 +920,11 @@ class DailyTransportClient(EventHandler): "camera": { "sendSettings": { "maxQuality": "low", + **( + {"preferredCodec": self._params.video_out_codec} + if self._params.video_out_codec + else {} + ), "encodings": { "low": { "maxBitrate": self._params.video_out_bitrate, @@ -850,6 +967,8 @@ class DailyTransportClient(EventHandler): # Remove any custom tracks, if any. for track_name, _ in self._custom_audio_tracks.items(): await self.remove_custom_audio_track(track_name) + for track_name, _ in self._custom_video_tracks.items(): + await self.remove_custom_video_track(track_name) error = await self._leave() if not error: @@ -1019,10 +1138,7 @@ class DailyTransportClient(EventHandler): return "Transcription can't be started without a room token" future = self._get_event_loop().create_future() - self._client.start_transcription( - settings=self._params.transcription_settings.model_dump(exclude_none=True), - completion=completion_callback(future), - ) + self._client.start_transcription(settings=settings, completion=completion_callback(future)) return await future async def stop_transcription(self) -> Optional[CallClientError]: @@ -1149,18 +1265,26 @@ class DailyTransportClient(EventHandler): color_format=color_format, ) - async def add_custom_audio_track(self, track_name: str) -> DailyAudioTrack: + async def add_custom_audio_track( + self, + track_name: str, + params: Optional[DailyCustomAudioTrackParams] = None, + ) -> DailyAudioTrack: """Add a custom audio track for multi-stream output. Args: track_name: Name for the custom audio track. + params: Optional per-track configuration for sample rate, channels, and sendSettings. Returns: The created DailyAudioTrack instance. """ future = self._get_event_loop().create_future() - audio_source = CustomAudioSource(self._out_sample_rate, 1) + sample_rate = params.sample_rate if params and params.sample_rate else self._out_sample_rate + channels = params.channels if params else 1 + + audio_source = CustomAudioSource(sample_rate, channels) audio_track = CustomAudioTrack(audio_source) @@ -1193,6 +1317,56 @@ class DailyTransportClient(EventHandler): ) return await future + async def add_custom_video_track( + self, + track_name: str, + params: Optional[DailyCustomVideoTrackParams] = None, + ) -> DailyVideoTrack: + """Add a custom video track for multi-stream output. + + Args: + track_name: Name for the custom video track. + params: Optional per-track configuration for dimensions, color format, and sendSettings. + + Returns: + The created DailyVideoTrack instance. + """ + future = self._get_event_loop().create_future() + + width = params.width if params else self._params.video_out_width + height = params.height if params else self._params.video_out_height + color_format = params.color_format if params else self._params.video_out_color_format + + video_source = CustomVideoSource(width, height, color_format) + + video_track = CustomVideoTrack(video_source) + + self._client.add_custom_video_track( + track_name=track_name, + video_track=video_track, + completion=completion_callback(future), + ) + + await future + + return DailyVideoTrack(source=video_source, track=video_track) + + async def remove_custom_video_track(self, track_name: str) -> Optional[CallClientError]: + """Remove a custom video track. + + Args: + track_name: Name of the custom video track to remove. + + Returns: + error: An error description or None. + """ + future = self._get_event_loop().create_future() + self._client.remove_custom_video_track( + track_name=track_name, + completion=completion_callback(future), + ) + return await future + async def update_transcription( self, participants=None, instance_id=None ) -> Optional[CallClientError]: @@ -1390,6 +1564,14 @@ class DailyTransportClient(EventHandler): """ self._call_event_callback(self._callbacks.on_dialout_warning, data) + def on_dtmf_event(self, data: Any): + """Handle incoming DTMF events. + + Args: + data: DTMF data. + """ + self._call_event_callback(self._callbacks.on_dtmf_event, data) + def on_participant_joined(self, participant): """Handle participant joined events. @@ -1443,7 +1625,6 @@ class DailyTransportClient(EventHandler): Args: message: Error message. """ - logger.error(f"Transcription error: {message}") self._call_event_callback(self._callbacks.on_transcription_error, message) def on_transcription_message(self, message): @@ -1533,6 +1714,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: @@ -1725,8 +1912,9 @@ class DailyInputTransport(BaseInputTransport): message: The message data to send. sender: ID of the message sender. """ - frame = DailyInputTransportMessageFrame(message=message, participant_id=sender) - await self.push_frame(frame) + await self.broadcast_frame( + DailyInputTransportMessageFrame, message=message, participant_id=sender + ) # # Audio in @@ -1844,7 +2032,6 @@ class DailyInputTransport(BaseInputTransport): format=video_frame.color_format, text=request_frame.text if request_frame else None, append_to_context=request_frame.append_to_context if request_frame else None, - # Deprecated fields below. request=request_frame, ) frame.transport_source = video_source @@ -1938,18 +2125,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 ): @@ -1960,7 +2135,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. @@ -1968,7 +2143,7 @@ class DailyOutputTransport(BaseOutputTransport): Args: destination: The destination identifier to register. """ - logger.warning(f"{self} registering video destinations is not supported yet") + await self._client.register_video_destination(destination) async def register_audio_destination(self, destination: str): """Register an audio output destination. @@ -2003,6 +2178,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. @@ -2031,6 +2225,63 @@ 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_connected: Called when the bot connects to the room (alias for + on_joined). Args: (data: dict) + - on_left: Called when the bot leaves the room. + - on_before_leave: [sync] Called just before the bot leaves the room. + - on_error: Called when a transport error occurs. Args: (error: str) + - 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__( @@ -2074,6 +2325,7 @@ class DailyTransport(BaseTransport): on_dialout_stopped=self._on_dialout_stopped, on_dialout_error=self._on_dialout_error, on_dialout_warning=self._on_dialout_warning, + on_dtmf_event=self._on_dtmf_event, on_participant_joined=self._on_participant_joined, on_participant_left=self._on_participant_left, on_participant_updated=self._on_participant_updated, @@ -2097,6 +2349,7 @@ class DailyTransport(BaseTransport): # Register supported handlers. The user will only be able to register # these handlers. self._register_event_handler("on_active_speaker_changed") + self._register_event_handler("on_connected") self._register_event_handler("on_joined") self._register_event_handler("on_left") self._register_event_handler("on_error") @@ -2114,6 +2367,7 @@ class DailyTransport(BaseTransport): self._register_event_handler("on_dialout_stopped") self._register_event_handler("on_dialout_error") self._register_event_handler("on_dialout_warning") + self._register_event_handler("on_dtmf_event") self._register_event_handler("on_first_participant_joined") self._register_event_handler("on_participant_joined") self._register_event_handler("on_participant_left") @@ -2484,10 +2738,15 @@ class DailyTransport(BaseTransport): if self._params.transcription_enabled: # We report an error because we are starting transcription # internally and if it fails we need to know. - error = await self.start_transcription(self._params.transcription_settings) + settings = self._params.transcription_settings.model_dump(exclude_none=True) + error = await self.start_transcription(settings) if error: await self._on_error(f"Unable to start transcription: {error}") await self._call_event_handler("on_joined", data) + # Also call on_connected for compatibility with other transports + await self._call_event_handler("on_connected", data) + if self._input: + await self._input.push_frame(BotConnectedFrame()) async def _on_left(self): """Handle room left events.""" @@ -2569,46 +2828,65 @@ class DailyTransport(BaseTransport): async def _on_dialin_connected(self, data): """Handle dial-in connected events.""" + logger.debug(f"{self} dial-in connected: {data}") await self._call_event_handler("on_dialin_connected", data) async def _on_dialin_ready(self, sip_endpoint): """Handle dial-in ready events.""" + logger.debug(f"{self} dial-in ready: {sip_endpoint}") if self._params.dialin_settings: await self._handle_dialin_ready(sip_endpoint) await self._call_event_handler("on_dialin_ready", sip_endpoint) async def _on_dialin_stopped(self, data): """Handle dial-in stopped events.""" + logger.debug(f"{self} dial-in stopped: {data}") await self._call_event_handler("on_dialin_stopped", data) async def _on_dialin_error(self, data): """Handle dial-in error events.""" + logger.error(f"{self} dial-in error: {data}") await self._call_event_handler("on_dialin_error", data) async def _on_dialin_warning(self, data): """Handle dial-in warning events.""" + logger.warning(f"{self} dial-in warning: {data}") await self._call_event_handler("on_dialin_warning", data) async def _on_dialout_answered(self, data): """Handle dial-out answered events.""" + logger.debug(f"{self} dial-out answered: {data}") await self._call_event_handler("on_dialout_answered", data) async def _on_dialout_connected(self, data): """Handle dial-out connected events.""" + logger.debug(f"{self} dial-out connected: {data}") await self._call_event_handler("on_dialout_connected", data) async def _on_dialout_stopped(self, data): """Handle dial-out stopped events.""" + logger.debug(f"{self} dial-out stopped: {data}") await self._call_event_handler("on_dialout_stopped", data) async def _on_dialout_error(self, data): """Handle dial-out error events.""" + logger.error(f"{self} dial-out error: {data}") await self._call_event_handler("on_dialout_error", data) async def _on_dialout_warning(self, data): """Handle dial-out warning events.""" + logger.warning(f"{self} dial-out warning: {data}") await self._call_event_handler("on_dialout_warning", data) + async def _on_dtmf_event(self, data): + """Handle incoming DTMF events.""" + logger.debug(f"{self} DTMF event: {data}") + await self._call_event_handler("on_dtmf_event", data) + + if self._input: + frame = InputDTMFFrame(button=KeypadEntry(data["tone"])) + await self._input.push_frame(frame) + async def _on_participant_joined(self, participant): """Handle participant joined events.""" id = participant["id"] @@ -2626,6 +2904,8 @@ class DailyTransport(BaseTransport): await self._call_event_handler("on_participant_joined", participant) # Also call on_client_connected for compatibility with other transports await self._call_event_handler("on_client_connected", participant) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_participant_left(self, participant, reason): """Handle participant left events.""" @@ -2637,6 +2917,7 @@ class DailyTransport(BaseTransport): async def _on_participant_updated(self, participant): """Handle participant updated events.""" + logger.trace(f"{self} participant updated: {participant}") await self._call_event_handler("on_participant_updated", participant) async def _on_transcription_message(self, message: Mapping[str, Any]) -> None: @@ -2677,20 +2958,25 @@ class DailyTransport(BaseTransport): async def _on_transcription_stopped(self, stopped_by, stopped_by_error): """Handle transcription stopped events.""" + logger.debug(f"{self} transcription stopped by: {stopped_by} (error: {stopped_by_error})") await self._call_event_handler("on_transcription_stopped", stopped_by, stopped_by_error) async def _on_transcription_error(self, message): """Handle transcription error events.""" + logger.error(f"{self} transcription error: {message}") await self._call_event_handler("on_transcription_error", message) async def _on_recording_started(self, status): """Handle recording started events.""" + logger.debug(f"{self} recording started: {status}") await self._call_event_handler("on_recording_started", status) async def _on_recording_stopped(self, stream_id): """Handle recording stopped events.""" + logger.debug(f"{self} recording stopped (id: {stream_id})") await self._call_event_handler("on_recording_stopped", stream_id) async def _on_recording_error(self, stream_id, message): """Handle recording error events.""" + logger.error(f"{self} recording error (id: {stream_id}): {message}") await self._call_event_handler("on_recording_error", stream_id, message) diff --git a/src/pipecat/transports/daily/utils.py b/src/pipecat/transports/daily/utils.py index bc4c4fa9d..8c7526357 100644 --- a/src/pipecat/transports/daily/utils.py +++ b/src/pipecat/transports/daily/utils.py @@ -10,11 +10,11 @@ Methods that wrap the Daily API to create rooms, check room URLs, and get meetin """ import time -from typing import Dict, List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional from urllib.parse import urlparse import aiohttp -from pydantic import BaseModel, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError class DailyRoomSipParams(BaseModel): @@ -77,7 +77,7 @@ class TranscriptionBucketConfig(BaseModel): allow_api_access: bool = False -class DailyRoomProperties(BaseModel, extra="allow"): +class DailyRoomProperties(BaseModel): """Properties for configuring a Daily room. Reference: https://docs.daily.co/reference/rest-api/rooms/create-room#properties @@ -89,7 +89,7 @@ class DailyRoomProperties(BaseModel, extra="allow"): enable_emoji_reactions: Whether emoji reactions are enabled. eject_at_room_exp: Whether to remove participants when room expires. enable_dialout: Whether SIP dial-out is enabled. - enable_recording: Recording settings ('cloud', 'local', 'raw-tracks'). + enable_recording: Recording settings ('cloud', 'cloud-audio-only', 'local', 'raw-tracks'). enable_transcription_storage: Whether transcription storage is enabled. geo: Geographic region for room. max_participants: Maximum number of participants allowed in the room. @@ -100,20 +100,22 @@ class DailyRoomProperties(BaseModel, extra="allow"): start_video_off: Whether video is off by default. """ + model_config = ConfigDict(extra="allow") + exp: Optional[float] = None enable_chat: bool = False enable_prejoin_ui: bool = False enable_emoji_reactions: bool = False eject_at_room_exp: bool = False enable_dialout: Optional[bool] = None - enable_recording: Optional[Literal["cloud", "local", "raw-tracks"]] = None + enable_recording: Optional[Literal["cloud", "cloud-audio-only", "local", "raw-tracks"]] = None enable_transcription_storage: Optional[bool] = None geo: Optional[str] = None max_participants: Optional[int] = None recordings_bucket: Optional[RecordingsBucketConfig] = None transcription_bucket: Optional[TranscriptionBucketConfig] = None sip: Optional[DailyRoomSipParams] = None - sip_uri: Optional[dict] = None + sip_uri: Optional[Dict[str, Any]] = None start_video_off: bool = False @property @@ -183,7 +185,7 @@ class DailyMeetingTokenProperties(BaseModel): enable_screenshare: If True, the user will be able to share their screen. start_video_off: If True, the user's video will be turned off when they join the room. start_audio_off: If True, the user's audio will be turned off when they join the room. - enable_recording: Recording settings for the token. Must be one of 'cloud', 'local' or 'raw-tracks'. + enable_recording: Recording settings for the token. Must be one of 'cloud', 'cloud-audio-only', 'local' or 'raw-tracks'. enable_prejoin_ui: If True, the user will see the prejoin UI before joining the room. start_cloud_recording: Start cloud recording when the user joins the room. permissions: Specifies the initial default permissions for a non-meeting-owner participant. @@ -200,10 +202,10 @@ class DailyMeetingTokenProperties(BaseModel): enable_screenshare: Optional[bool] = None start_video_off: Optional[bool] = None start_audio_off: Optional[bool] = None - enable_recording: Optional[Literal["cloud", "local", "raw-tracks"]] = None + enable_recording: Optional[Literal["cloud", "cloud-audio-only", "local", "raw-tracks"]] = None enable_prejoin_ui: Optional[bool] = None start_cloud_recording: Optional[bool] = None - permissions: Optional[dict] = None + permissions: Optional[Dict[str, Any]] = None class DailyMeetingTokenParams(BaseModel): diff --git a/src/pipecat/transports/heygen/transport.py b/src/pipecat/transports/heygen/transport.py index 2ff1c7d56..d79d0080e 100644 --- a/src/pipecat/transports/heygen/transport.py +++ b/src/pipecat/transports/heygen/transport.py @@ -23,9 +23,11 @@ from loguru import logger from pipecat.frames.frames import ( AudioRawFrame, + BotConnectedFrame, BotStartedSpeakingFrame, BotStoppedSpeakingFrame, CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, @@ -289,6 +291,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__( @@ -328,6 +341,7 @@ class HeyGenTransport(BaseTransport): session_request=session_request, service_type=service_type, callbacks=HeyGenCallbacks( + on_connected=self._on_connected, on_participant_connected=self._on_participant_connected, on_participant_disconnected=self._on_participant_disconnected, ), @@ -338,9 +352,16 @@ class HeyGenTransport(BaseTransport): # Register supported handlers. The user will only be able to register # these handlers. + self._register_event_handler("on_connected") self._register_event_handler("on_client_connected") self._register_event_handler("on_client_disconnected") + async def _on_connected(self): + """Handle bot connected to LiveKit room.""" + await self._call_event_handler("on_connected") + if self._input: + await self._input.push_frame(BotConnectedFrame()) + async def _on_participant_disconnected(self, participant_id: str): logger.debug(f"HeyGen participant {participant_id} disconnected") if participant_id != "heygen": @@ -376,6 +397,8 @@ class HeyGenTransport(BaseTransport): async def _on_client_connected(self, participant: Any): """Handle client connected events.""" await self._call_event_handler("on_client_connected", participant) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_client_disconnected(self, participant: Any): """Handle client disconnected events.""" diff --git a/src/pipecat/transports/lemonslice/__init__.py b/src/pipecat/transports/lemonslice/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pipecat/transports/lemonslice/api.py b/src/pipecat/transports/lemonslice/api.py new file mode 100644 index 000000000..cac341d7d --- /dev/null +++ b/src/pipecat/transports/lemonslice/api.py @@ -0,0 +1,110 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""LemonSlice API utilities for session management. + +This module provides helper classes for interacting with the LemonSlice API, +including session creation and termination. +""" + +from typing import Any, Optional + +import aiohttp +from loguru import logger + + +class LemonSliceApi: + """Helper class for interacting with the LemonSlice API. + + Provides methods for creating and managing sessions with LemonSlice avatars. + """ + + LEMONSLICE_URL = "https://lemonslice.com/api/liveai/sessions" + + def __init__(self, api_key: str, session: aiohttp.ClientSession): + """Initialize the LemonSliceApi client. + + Args: + api_key: LemonSlice API key for authentication. + session: An aiohttp session for making HTTP requests. + """ + self._api_key = api_key + self._session = session + self._headers = {"Content-Type": "application/json", "x-api-key": self._api_key} + + async def create_session( + self, + *, + agent_image_url: Optional[str] = None, + agent_id: Optional[str] = None, + agent_prompt: Optional[str] = None, + idle_timeout: Optional[int] = None, + daily_room_url: Optional[str] = None, + daily_token: Optional[str] = None, + properties: Optional[dict[str, Any]] = None, + ) -> dict: + """Create a new session with the specified agent_id or agent_image_url. + + Args: + agent_image_url: The URL to an agent image. Provide either agent_id or agent_image_url. + agent_id: ID of a LemonSlice agent. Provide either agent_id or agent_image_url. + agent_prompt: A high-level system prompt that subtly influences the avatar’s movements, expressions, and emotional demeanor. + idle_timeout: Idle timeout in seconds. + daily_room_url: Daily room URL to use for the session. + daily_token: Daily token for authenticating with the room. + properties: Additional properties to pass to the session. + + Returns: + Dictionary containing session_id, room_url, and control_url. + + Raises: + ValueError: If neither agent_id nor agent_image_url is provided. + """ + if not agent_id and not agent_image_url: + # Fallback to a default agent if none is provided + logger.debug("No agent_id or agent_image_url provided, using default agent") + agent_id = "agent_080308d8b6e99f47" + if agent_id and agent_image_url: + raise ValueError("Provide exactly one of agent_id or agent_image_url, not both") + + logger.debug( + f"Creating LemonSlice session: agent_id={agent_id}, agent_image_url={agent_image_url}" + ) + payload: dict[str, object] = {"transport_type": "daily"} + if agent_id is not None: + payload["agent_id"] = agent_id + if agent_image_url is not None: + payload["agent_image_url"] = agent_image_url + if agent_prompt is not None: + payload["agent_prompt"] = agent_prompt + if idle_timeout is not None: + payload["idle_timeout"] = idle_timeout + properties_dict: dict[str, Any] = dict(properties) if properties else {} + if daily_room_url is not None: + properties_dict["daily_url"] = daily_room_url + if daily_token is not None: + properties_dict["daily_token"] = daily_token + if properties_dict: + payload["properties"] = properties_dict + async with self._session.post( + self.LEMONSLICE_URL, headers=self._headers, json=payload + ) as r: + r.raise_for_status() + response = await r.json() + logger.debug(f"Created LemonSlice session: {response}") + return response + + async def end_session(self, session_id: str, control_url: str): + """End an existing session. + + Args: + session_id: ID of the session to end. + control_url: The control URL from the create_session response. + """ + payload = {"event": "terminate"} + async with self._session.post(control_url, headers=self._headers, json=payload) as r: + r.raise_for_status() + logger.debug(f"Ended LemonSlice session {session_id}") diff --git a/src/pipecat/transports/lemonslice/transport.py b/src/pipecat/transports/lemonslice/transport.py new file mode 100644 index 000000000..6a6894167 --- /dev/null +++ b/src/pipecat/transports/lemonslice/transport.py @@ -0,0 +1,790 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""LemonSlice transport for Pipecat. + +This module adds LemonSlice avatars to Daily rooms, enabling +real-time voice conversations with synchronized avatars. +""" + +from functools import partial +from typing import Any, Awaitable, Callable, Mapping, Optional + +import aiohttp +from daily.daily import AudioData +from loguru import logger +from pydantic import BaseModel + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + CancelFrame, + EndFrame, + Frame, + InputAudioRawFrame, + InterruptionFrame, + OutputAudioRawFrame, + OutputTransportMessageFrame, + OutputTransportMessageUrgentFrame, + StartFrame, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup +from pipecat.transports.base_input import BaseInputTransport +from pipecat.transports.base_output import BaseOutputTransport +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import ( + DailyCallbacks, + DailyParams, + DailyTransportClient, +) +from pipecat.transports.lemonslice.api import LemonSliceApi + + +class LemonSliceNewSessionRequest(BaseModel): + """Request model for creating a new LemonSlice session. + + Parameters: + agent_image_url: URL to an agent image. Provide either agent_id or agent_image_url. + agent_id: ID of a LemonSlice agent. Provide either agent_id or agent_image_url. + agent_prompt: A high-level system prompt that subtly influences the avatar's movements, + expressions, and emotional demeanor. + idle_timeout: Idle timeout in seconds. + daily_room_url: Daily room URL to use for the session. + daily_token: Daily token for authenticating with the room. + lemonslice_properties: Additional properties to pass to the session. + """ + + agent_image_url: Optional[str] = None + agent_id: Optional[str] = None + agent_prompt: Optional[str] = None + idle_timeout: Optional[int] = None + daily_room_url: Optional[str] = None + daily_token: Optional[str] = None + lemonslice_properties: Optional[dict] = None + + +class LemonSliceCallbacks(BaseModel): + """Callback handlers for LemonSlice events. + + Parameters: + on_participant_joined: Called when a participant joins the conversation. + on_participant_left: Called when a participant leaves the conversation. + """ + + on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]] + on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]] + + +class LemonSliceParams(DailyParams): + """Configuration parameters for the LemonSlice transport. + + Parameters: + audio_in_enabled: Whether to enable audio input from participants. + audio_out_enabled: Whether to enable audio output to participants. + microphone_out_enabled: Whether to enable microphone output track. + """ + + audio_in_enabled: bool = True + audio_out_enabled: bool = True + microphone_out_enabled: bool = False + + +class LemonSliceTransportClient: + """Transport client that integrates Pipecat with the LemonSlice platform. + + A transport client that integrates a Pipecat Bot with the LemonSlice platform by managing + conversation sessions using the LemonSlice API. + + This client uses `LemonSliceApi` to interact with the LemonSlice backend. LemonSlice either provides + a room URL where the avatar is already present, or adds the LemonSlice avatar to a Daily room + the user supplies. + """ + + def __init__( + self, + *, + bot_name: str, + params: LemonSliceParams = LemonSliceParams(), + callbacks: LemonSliceCallbacks, + api_key: str, + session_request: Optional[LemonSliceNewSessionRequest] = None, + session: aiohttp.ClientSession, + ) -> None: + """Initialize the LemonSlice transport client. + + Args: + bot_name: The name of the Pipecat bot instance. + params: Optional parameters for LemonSlice operation. + callbacks: Callback handlers for LemonSlice-related events. + api_key: API key for authenticating with LemonSlice API. + session_request: Optional session creation parameters. If not provided, a default + agent will be used. + session: The aiohttp session for making async HTTP requests. + """ + self._bot_name = bot_name + self._api = LemonSliceApi(api_key, session) + self._session_request = session_request or LemonSliceNewSessionRequest() + self._session_id: Optional[str] = None + self._control_url: Optional[str] = None + self._daily_transport_client: Optional[DailyTransportClient] = None + self._callbacks = callbacks + self._params = params + + async def _initialize(self) -> str: + """Initialize the conversation and return the room URL.""" + response = await self._api.create_session( + agent_image_url=self._session_request.agent_image_url, + agent_id=self._session_request.agent_id, + agent_prompt=self._session_request.agent_prompt, + idle_timeout=self._session_request.idle_timeout, + daily_room_url=self._session_request.daily_room_url, + daily_token=self._session_request.daily_token, + properties=self._session_request.lemonslice_properties, + ) + self._session_id = response["session_id"] + self._control_url = response["control_url"] + return response["room_url"] + + async def setup(self, setup: FrameProcessorSetup): + """Setup the client and initialize the conversation. + + Args: + setup: The frame processor setup configuration. + """ + if self._session_id is not None: + logger.debug(f"Session ID already defined: {self._session_id}") + return + try: + room_url = await self._initialize() + daily_callbacks = DailyCallbacks( + on_active_speaker_changed=partial( + self._on_handle_callback, "on_active_speaker_changed" + ), + on_joined=self._on_joined, + on_left=self._on_left, + on_before_leave=partial(self._on_handle_callback, "on_before_leave"), + on_error=partial(self._on_handle_callback, "on_error"), + on_app_message=partial(self._on_handle_callback, "on_app_message"), + on_call_state_updated=partial(self._on_handle_callback, "on_call_state_updated"), + on_client_connected=partial(self._on_handle_callback, "on_client_connected"), + on_client_disconnected=partial(self._on_handle_callback, "on_client_disconnected"), + on_dialin_connected=partial(self._on_handle_callback, "on_dialin_connected"), + on_dialin_ready=partial(self._on_handle_callback, "on_dialin_ready"), + on_dialin_stopped=partial(self._on_handle_callback, "on_dialin_stopped"), + on_dialin_error=partial(self._on_handle_callback, "on_dialin_error"), + on_dialin_warning=partial(self._on_handle_callback, "on_dialin_warning"), + on_dialout_answered=partial(self._on_handle_callback, "on_dialout_answered"), + on_dialout_connected=partial(self._on_handle_callback, "on_dialout_connected"), + on_dialout_stopped=partial(self._on_handle_callback, "on_dialout_stopped"), + on_dialout_error=partial(self._on_handle_callback, "on_dialout_error"), + on_dialout_warning=partial(self._on_handle_callback, "on_dialout_warning"), + on_participant_joined=self._callbacks.on_participant_joined, + on_participant_left=self._callbacks.on_participant_left, + on_participant_updated=partial(self._on_handle_callback, "on_participant_updated"), + on_transcription_message=partial( + self._on_handle_callback, "on_transcription_message" + ), + on_recording_started=partial(self._on_handle_callback, "on_recording_started"), + on_recording_stopped=partial(self._on_handle_callback, "on_recording_stopped"), + on_recording_error=partial(self._on_handle_callback, "on_recording_error"), + on_transcription_stopped=partial( + self._on_handle_callback, "on_transcription_stopped" + ), + on_transcription_error=partial(self._on_handle_callback, "on_transcription_error"), + ) + self._daily_transport_client = DailyTransportClient( + room_url, None, self._bot_name, self._params, daily_callbacks, "LemonSlicePipecat" + ) + await self._daily_transport_client.setup(setup) + except Exception as e: + logger.error(f"Failed to setup LemonSliceTransportClient: {e}") + if self._session_id and self._control_url: + await self._api.end_session(self._session_id, self._control_url) + self._session_id = None + self._control_url = None + raise + + async def cleanup(self): + """Cleanup client resources.""" + try: + if self._daily_transport_client: + await self._daily_transport_client.cleanup() + except Exception as e: + logger.error(f"Exception during cleanup: {e}") + + async def _on_joined(self, data): + """Handle joined event.""" + logger.debug("LemonSliceTransportClient joined!") + + async def _on_left(self): + """Handle left event.""" + logger.debug("LemonSliceTransportClient left!") + + async def _on_handle_callback(self, event_name, *args, **kwargs): + """Handle generic callback events.""" + logger.trace(f"[Callback] {event_name} called with args={args}, kwargs={kwargs}") + + async def get_bot_name(self) -> str: + """Get the name of the LemonSlice participant. + + Returns: + The name of the LemonSlice participant. + """ + return "LemonSlice" + + async def start(self, frame: StartFrame): + """Start the client and join the room. + + Args: + frame: The start frame containing initialization parameters. + """ + await self._daily_transport_client.start(frame) + await self._daily_transport_client.join() + + async def stop(self): + """Stop the client and end the conversation.""" + await self._daily_transport_client.leave() + if self._session_id and self._control_url: + await self._api.end_session(self._session_id, self._control_url) + self._session_id = None + self._control_url = None + + async def capture_participant_video( + self, + participant_id: str, + callback: Callable, + framerate: int = 30, + video_source: str = "camera", + color_format: str = "RGB", + ): + """Capture video from a participant. + + Args: + participant_id: ID of the participant to capture video from. + callback: Callback function to handle video frames. + framerate: Desired framerate for video capture. + video_source: Video source to capture from. + color_format: Color format for video frames. + """ + await self._daily_transport_client.capture_participant_video( + participant_id, callback, framerate, video_source, color_format + ) + + async def capture_participant_audio( + self, + participant_id: str, + callback: Callable, + audio_source: str = "microphone", + sample_rate: int = 16000, + callback_interval_ms: int = 20, + ): + """Capture audio from a participant. + + Args: + participant_id: ID of the participant to capture audio from. + callback: Callback function to handle audio data. + audio_source: Audio source to capture from. + sample_rate: Desired sample rate for audio capture. + callback_interval_ms: Interval between audio callbacks in milliseconds. + """ + await self._daily_transport_client.capture_participant_audio( + participant_id, callback, audio_source, sample_rate, callback_interval_ms + ) + + async def send_message( + self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame + ): + """Send a message to participants. + + Args: + frame: The message frame to send. + """ + await self._daily_transport_client.send_message(frame) + + @property + def out_sample_rate(self) -> int: + """Get the output sample rate. + + Returns: + The output sample rate in Hz. + """ + return self._daily_transport_client.out_sample_rate + + @property + def in_sample_rate(self) -> int: + """Get the input sample rate. + + Returns: + The input sample rate in Hz. + """ + return self._daily_transport_client.in_sample_rate + + async def send_interrupt_message(self) -> None: + """Send an interrupt message to the LemonSlice session.""" + logger.debug("Sending interrupt message") + transport_frame = OutputTransportMessageUrgentFrame( + message={ + "event": "interrupt", + "session_id": self._session_id, + } + ) + await self.send_message(transport_frame) + + async def send_response_started_message(self) -> None: + """Send a response_started message to the LemonSlice session.""" + logger.trace("Sending response_started message") + transport_frame = OutputTransportMessageUrgentFrame( + message={ + "event": "response_started", + "session_id": self._session_id, + } + ) + await self.send_message(transport_frame) + + async def send_response_finished_message(self) -> None: + """Send a response_finished message to the LemonSlice session.""" + logger.trace("Sending response_finished message") + transport_frame = OutputTransportMessageUrgentFrame( + message={ + "event": "response_finished", + "session_id": self._session_id, + } + ) + await self.send_message(transport_frame) + + async def update_subscriptions(self, participant_settings=None, profile_settings=None): + """Update subscription settings for participants. + + Args: + participant_settings: Per-participant subscription settings. + profile_settings: Global subscription profile settings. + """ + if not self._daily_transport_client: + return + + await self._daily_transport_client.update_subscriptions( + participant_settings=participant_settings, profile_settings=profile_settings + ) + + async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool: + """Write an audio frame to the transport. + + Args: + frame: The audio frame to write. + + Returns: + True if the audio frame was written successfully, False otherwise. + """ + if not self._daily_transport_client: + return False + + return await self._daily_transport_client.write_audio_frame(frame) + + async def register_audio_destination(self, destination: str): + """Register an audio destination for output. + + Args: + destination: The destination identifier to register. + """ + if not self._daily_transport_client: + return + + await self._daily_transport_client.register_audio_destination(destination) + + +class LemonSliceInputTransport(BaseInputTransport): + """Input transport for receiving audio and events from LemonSlice. + + Handles incoming audio streams from participants and manages audio capture + from the Daily room connected to LemonSlice. + """ + + def __init__( + self, + client: LemonSliceTransportClient, + params: TransportParams, + **kwargs, + ): + """Initialize the LemonSlice input transport. + + Args: + client: The LemonSlice transport client instance. + params: Transport configuration parameters. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(params, **kwargs) + self._client = client + self._params = params + # Whether we have seen a StartFrame already. + self._initialized = False + + async def setup(self, setup: FrameProcessorSetup): + """Setup the input transport. + + Args: + setup: The frame processor setup configuration. + """ + await super().setup(setup) + await self._client.setup(setup) + + async def cleanup(self): + """Cleanup input transport resources.""" + await super().cleanup() + await self._client.cleanup() + + async def start(self, frame: StartFrame): + """Start the input transport. + + Args: + frame: The start frame containing initialization parameters. + """ + await super().start(frame) + + if self._initialized: + return + + self._initialized = True + + await self._client.start(frame) + await self.set_transport_ready(frame) + + async def stop(self, frame: EndFrame): + """Stop the input transport. + + Args: + frame: The end frame signaling transport shutdown. + """ + await super().stop(frame) + await self._client.stop() + + async def cancel(self, frame: CancelFrame): + """Cancel the input transport. + + Args: + frame: The cancel frame signaling immediate cancellation. + """ + await super().cancel(frame) + await self._client.stop() + + async def start_capturing_audio(self, participant): + """Start capturing audio from a participant. + + Args: + participant: The participant to capture audio from. + """ + if self._params.audio_in_enabled: + logger.debug( + f"LemonSliceTransportClient start capturing audio for participant {participant['id']}" + ) + await self._client.capture_participant_audio( + participant_id=participant["id"], + callback=self._on_participant_audio_data, + sample_rate=self._client.in_sample_rate, + ) + + async def _on_participant_audio_data( + self, participant_id: str, audio: AudioData, audio_source: str + ): + """Handle received participant audio data. + + Args: + participant_id: ID of the participant who sent the audio. + audio: The audio data from the participant. + audio_source: The source of the audio (e.g., microphone). + """ + frame = InputAudioRawFrame( + audio=audio.audio_frames, + sample_rate=audio.sample_rate, + num_channels=audio.num_channels, + ) + frame.transport_source = audio_source + await self.push_audio_frame(frame) + + +class LemonSliceOutputTransport(BaseOutputTransport): + """Output transport for sending audio and events to LemonSlice. + + Handles outgoing audio streams to participants and manages the custom + audio track expected by the LemonSlice platform. + """ + + def __init__( + self, + client: LemonSliceTransportClient, + params: TransportParams, + **kwargs, + ): + """Initialize the LemonSlice output transport. + + Args: + client: The LemonSlice transport client instance. + params: Transport configuration parameters. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(params, **kwargs) + self._client = client + self._params = params + + # Whether we have seen a StartFrame already. + self._initialized = False + # This is the custom track destination expected by LemonSlice + self._transport_destination: Optional[str] = "stream" + + async def setup(self, setup: FrameProcessorSetup): + """Setup the output transport. + + Args: + setup: The frame processor setup configuration. + """ + await super().setup(setup) + await self._client.setup(setup) + + async def cleanup(self): + """Cleanup output transport resources.""" + await super().cleanup() + await self._client.cleanup() + + async def start(self, frame: StartFrame): + """Start the output transport. + + Args: + frame: The start frame containing initialization parameters. + """ + await super().start(frame) + + if self._initialized: + return + + self._initialized = True + + await self._client.start(frame) + + if self._transport_destination: + await self._client.register_audio_destination(self._transport_destination) + + await self.set_transport_ready(frame) + + async def stop(self, frame: EndFrame): + """Stop the output transport. + + Args: + frame: The end frame signaling transport shutdown. + """ + await super().stop(frame) + await self._client.stop() + + async def cancel(self, frame: CancelFrame): + """Cancel the output transport. + + Args: + frame: The cancel frame signaling immediate cancellation. + """ + await super().cancel(frame) + await self._client.stop() + + async def send_message( + self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame + ): + """Send a message to participants. + + Args: + frame: The message frame to send. + """ + logger.trace(f"LemonSliceTransport sending message {frame}") + await self._client.send_message(frame) + + async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): + """Push a frame to the next processor in the pipeline. + + Args: + frame: The frame to push. + direction: The direction to push the frame. + """ + # The BotStartedSpeakingFrame and BotStoppedSpeakingFrame are created inside BaseOutputTransport + # This is a workaround, so we can more reliably be aware when the bot has started or stopped speaking + if direction == FrameDirection.DOWNSTREAM: + if isinstance(frame, BotStartedSpeakingFrame): + await self._handle_response_started() + if isinstance(frame, BotStoppedSpeakingFrame): + await self._handle_response_finished() + await super().push_frame(frame, direction) + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames and handle interruptions. + + Args: + frame: The frame to process. + direction: The direction of frame flow in the pipeline. + """ + await super().process_frame(frame, direction) + if isinstance(frame, InterruptionFrame): + await self._handle_interruptions() + + async def _handle_interruptions(self): + """Handle interruption events by sending interrupt message.""" + await self._client.send_interrupt_message() + + async def _handle_response_started(self): + """Handle bot started speaking events by sending response_started message.""" + await self._client.send_response_started_message() + + async def _handle_response_finished(self): + """Handle tts response stopped events by sending response_finished message.""" + await self._client.send_response_finished_message() + + async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool: + """Write an audio frame to the LemonSlice transport. + + Args: + frame: The audio frame to write. + + Returns: + True if the audio frame was written successfully, False otherwise. + """ + # This is the custom track destination expected by LemonSlice + frame.transport_destination = self._transport_destination + return await self._client.write_audio_frame(frame) + + async def register_audio_destination(self, destination: str): + """Register an audio destination. + + Args: + destination: The destination identifier to register. + """ + await self._client.register_audio_destination(destination) + + +class LemonSliceTransport(BaseTransport): + """Transport implementation to add a LemonSlice avatar to Daily calls. + + When used, the Pipecat bot joins the same virtual room as the LemonSlice Avatar and the user. + This is achieved by using `LemonSliceTransportClient`, which initiates the conversation via + `LemonSliceApi` and obtains a room URL that all participants connect to. + + Event handlers available: + + - on_client_connected(transport, participant): Participant connected to the session + - on_client_disconnected(transport, participant): Participant disconnected from the session + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, participant): + ... + """ + + def __init__( + self, + bot_name: str, + session: aiohttp.ClientSession, + api_key: str, + session_request: Optional[LemonSliceNewSessionRequest] = None, + params: LemonSliceParams = LemonSliceParams(), + input_name: Optional[str] = None, + output_name: Optional[str] = None, + ): + """Initialize the LemonSlice transport. + + Args: + bot_name: The name of the Pipecat bot. + session: aiohttp session used for async HTTP requests. + api_key: LemonSlice API key for authentication. + session_request: Optional session creation parameters. If not provided, a default + agent will be used. + params: Optional LemonSlice-specific configuration parameters. + input_name: Optional name for the input transport. + output_name: Optional name for the output transport. + """ + super().__init__(input_name=input_name, output_name=output_name) + self._params = params + + callbacks = LemonSliceCallbacks( + on_participant_joined=self._on_participant_joined, + on_participant_left=self._on_participant_left, + ) + self._client = LemonSliceTransportClient( + bot_name=bot_name, + callbacks=callbacks, + api_key=api_key, + session_request=session_request, + session=session, + params=params, + ) + self._input: Optional[LemonSliceInputTransport] = None + self._output: Optional[LemonSliceOutputTransport] = None + self._lemonslice_participant_id = None + + # Register supported handlers. The user will only be able to register + # these handlers. + self._register_event_handler("on_client_connected") + self._register_event_handler("on_client_disconnected") + + async def _on_participant_left(self, participant, reason): + """Handle participant left events.""" + ls_bot_name = await self._client.get_bot_name() + if participant.get("info", {}).get("userName", "") != ls_bot_name: + await self._on_client_disconnected(participant) + + async def _on_participant_joined(self, participant): + """Handle participant joined events.""" + ls_bot_name = await self._client.get_bot_name() + + # Ignore the LemonSlice bot's microphone + if participant.get("info", {}).get("userName", "") == ls_bot_name: + self._lemonslice_participant_id = participant["id"] + else: + await self._on_client_connected(participant) + if self._lemonslice_participant_id: + logger.debug(f"Ignoring {self._lemonslice_participant_id}'s microphone") + await self.update_subscriptions( + participant_settings={ + self._lemonslice_participant_id: { + "media": {"microphone": "unsubscribed"}, + } + } + ) + if self._input: + await self._input.start_capturing_audio(participant) + + async def update_subscriptions(self, participant_settings=None, profile_settings=None): + """Update subscription settings for participants. + + Args: + participant_settings: Per-participant subscription settings. + profile_settings: Global subscription profile settings. + """ + await self._client.update_subscriptions( + participant_settings=participant_settings, + profile_settings=profile_settings, + ) + + def input(self) -> FrameProcessor: + """Get the input transport for receiving media and events. + + Returns: + The LemonSlice input transport instance. + """ + if not self._input: + self._input = LemonSliceInputTransport(client=self._client, params=self._params) + return self._input + + def output(self) -> FrameProcessor: + """Get the output transport for sending media and events. + + Returns: + The LemonSlice output transport instance. + """ + if not self._output: + self._output = LemonSliceOutputTransport(client=self._client, params=self._params) + return self._output + + async def _on_client_connected(self, participant: Any): + """Handle client connected events.""" + await self._call_event_handler("on_client_connected", participant) + + async def _on_client_disconnected(self, participant: Any): + """Handle client disconnected events.""" + await self._call_event_handler("on_client_disconnected", participant) diff --git a/src/pipecat/transports/livekit/transport.py b/src/pipecat/transports/livekit/transport.py index 10a7d8d5d..18163cf8e 100644 --- a/src/pipecat/transports/livekit/transport.py +++ b/src/pipecat/transports/livekit/transport.py @@ -23,7 +23,9 @@ from pipecat.audio.utils import create_stream_resampler from pipecat.audio.vad.vad_analyzer import VADAnalyzer from pipecat.frames.frames import ( AudioRawFrame, + BotConnectedFrame, CancelFrame, + ClientConnectedFrame, EndFrame, ImageRawFrame, OutputAudioRawFrame, @@ -386,7 +388,16 @@ class LiveKitTransportClient: await self._audio_source.capture_frame(audio_frame) return True except Exception as e: - logger.error(f"Error publishing audio: {e}") + # When using an audio mixer, the base output transport's + # with_mixer() generator continuously yields frames (mixed with + # background audio) even when no TTS audio is queued. During + # interruptions, the audio task is cancelled and recreated, but + # there is a brief window where the native LiveKit AudioSource + # rejects capture_frame() with an InvalidState error. This is a + # transient condition — the mixer will produce a new frame within + # milliseconds, so we silently drop these frames. + if "InvalidState" not in str(e): + logger.error(f"Error publishing audio: {e}") return False def get_participants(self) -> List[str]: @@ -539,11 +550,14 @@ class LiveKitTransportClient: elif track.kind == rtc.TrackKind.KIND_VIDEO: logger.info(f"Video track subscribed: {track.sid} from participant {participant.sid}") self._video_tracks[participant.sid] = track - video_stream = rtc.VideoStream(track) - self._task_manager.create_task( - self._process_video_stream(video_stream, participant.sid), - f"{self}::_process_video_stream", - ) + # Only process video stream if video input is enabled to prevent + # unbounded queue growth when there is no consumer for video frames. + if self._params.video_in_enabled: + video_stream = rtc.VideoStream(track) + self._task_manager.create_task( + self._process_video_stream(video_stream, participant.sid), + f"{self}::_process_video_stream", + ) await self._callbacks.on_video_track_subscribed(participant.sid) async def _async_on_track_unsubscribed( @@ -947,6 +961,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__( @@ -1093,6 +1142,8 @@ class LiveKitTransport(BaseTransport): async def _on_connected(self): """Handle room connected events.""" await self._call_event_handler("on_connected") + if self._input: + await self._input.push_frame(BotConnectedFrame()) async def _on_disconnected(self): """Handle room disconnected events.""" @@ -1105,6 +1156,8 @@ class LiveKitTransport(BaseTransport): async def _on_participant_connected(self, participant_id: str): """Handle participant connected events.""" await self._call_event_handler("on_participant_connected", participant_id) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_participant_disconnected(self, participant_id: str): """Handle participant disconnected events.""" @@ -1200,7 +1253,7 @@ class LiveKitTransport(BaseTransport): async def _on_call_state_updated(self, state: str): """Handle call state update events.""" - await self._call_event_handler("on_call_state_updated", self, state) + await self._call_event_handler("on_call_state_updated", state) async def _on_first_participant_joined(self, participant_id: str): """Handle first participant joined events.""" diff --git a/src/pipecat/transports/smallwebrtc/connection.py b/src/pipecat/transports/smallwebrtc/connection.py index 5a5a1450d..3f0d6a9ee 100644 --- a/src/pipecat/transports/smallwebrtc/connection.py +++ b/src/pipecat/transports/smallwebrtc/connection.py @@ -14,6 +14,7 @@ for real-time communication applications. import asyncio import json import time +import uuid from typing import Any, List, Literal, Optional, Union from loguru import logger @@ -23,7 +24,6 @@ from pipecat.utils.base_object import BaseObject try: from aiortc import ( - MediaStreamTrack, RTCConfiguration, RTCIceServer, RTCPeerConnection, @@ -41,6 +41,11 @@ AUDIO_TRANSCEIVER_INDEX = 0 VIDEO_TRANSCEIVER_INDEX = 1 SCREEN_VIDEO_TRANSCEIVER_INDEX = 2 +# Maximum number of messages to queue while the data channel is not yet open. +MAX_MESSAGE_QUEUE_SIZE = 50 +# Seconds to wait for the data channel to open after the peer connection is established. +DATA_CHANNEL_TIMEOUT_SECS = 10 + class TrackStatusMessage(BaseModel): """Message for updating track enabled/disabled status. @@ -278,13 +283,16 @@ class SmallWebRTCConnection(BaseObject): self._answer: Optional[RTCSessionDescription] = None self._pc = RTCPeerConnection(rtc_config) - self._pc_id = self.name + self._pc_id = f"{self.name}-{uuid.uuid4().hex}" self._setup_listeners() self._data_channel = None self._renegotiation_in_progress = False self._last_received_time = None + self._outgoing_messages_queue = [] + self._data_channel_enabled = True self._pending_app_messages = [] self._connecting_timeout_task = None + self._data_channel_timeout_task = None def _setup_listeners(self): """Set up event listeners for the peer connection.""" @@ -297,6 +305,7 @@ class SmallWebRTCConnection(BaseObject): @channel.on("open") async def on_open(): logger.debug("Data channel is open!") + self._flush_message_queue() @channel.on("message") async def on_message(message): @@ -499,9 +508,12 @@ class SmallWebRTCConnection(BaseObject): self._track_map.clear() if self._pc: await self._pc.close() + self._outgoing_messages_queue.clear() + self._data_channel_enabled = True self._pending_app_messages.clear() self._track_map = {} self._cancel_monitoring_connecting_state() + self._cancel_data_channel_timeout() def get_answer(self): """Get the SDP answer for the current connection. @@ -550,6 +562,44 @@ class SmallWebRTCConnection(BaseObject): self._connecting_timeout_task.cancel() self._connecting_timeout_task = None + def _start_data_channel_timeout(self) -> None: + """Start a timeout to detect if the data channel fails to open after connection. + + Schedules a background task that fires ``DATA_CHANNEL_TIMEOUT_SECS`` seconds after + the peer connection reaches the *connected* state. If the data channel has not + opened by then, the queued messages are discarded, a warning is logged, and future + calls to :meth:`send_app_message` will silently drop messages instead of queuing + them (fall-back to "discard" mode). + + The task is automatically cancelled when the data channel opens successfully (see + :meth:`_flush_message_queue`) or when the connection is closed (see + :meth:`_close`). + """ + + async def timeout_handler(): + await asyncio.sleep(DATA_CHANNEL_TIMEOUT_SECS) + if not self._data_channel or self._data_channel.readyState != "open": + logger.warning( + f"Data channel not established within {DATA_CHANNEL_TIMEOUT_SECS}s after " + "connection. Clearing message queue and disabling future queueing." + ) + self._outgoing_messages_queue.clear() + self._data_channel_enabled = False + + self._data_channel_timeout_task = asyncio.create_task(timeout_handler()) + + def _cancel_data_channel_timeout(self) -> None: + """Cancel the data-channel open timeout task, if any. + + Should be called when the data channel opens successfully (the timeout is no longer + needed) or when the connection is being torn down. If the task is still pending it + will be cancelled and the reference cleared. + """ + if self._data_channel_timeout_task and not self._data_channel_timeout_task.done(): + logger.debug("Cancelling the data channel timeout task") + self._data_channel_timeout_task.cancel() + self._data_channel_timeout_task = None + async def _handle_new_connection_state(self): """Handle changes in the peer connection state.""" state = self._pc.connectionState @@ -558,6 +608,9 @@ class SmallWebRTCConnection(BaseObject): else: self._cancel_monitoring_connecting_state() + if state == "connected" and not self._data_channel_timeout_task: + self._start_data_channel_timeout() + if state == "connected" and not self._connect_invoked: # We are going to wait until the pipeline is ready before triggering the event return @@ -657,15 +710,47 @@ class SmallWebRTCConnection(BaseObject): def send_app_message(self, message: Any): """Send an application message through the data channel. + If the data channel is open the message is sent immediately. Otherwise, + the message is placed in an in-memory queue so it can be flushed once the + channel opens, subject to the following constraints: + + * Queueing is only attempted when ``_data_channel_enabled`` is ``True``. It is + set to ``False`` when the data-channel open timeout fires (see + :meth:`_start_data_channel_timeout`), after which messages are silently + discarded. + * The queue will not grow beyond ``MAX_MESSAGE_QUEUE_SIZE`` entries. + Messages that arrive when the queue is full are discarded with a warning. + Args: message: The message to send (will be JSON serialized). """ json_message = json.dumps(message) if self._data_channel and self._data_channel.readyState == "open": self._data_channel.send(json_message) + elif self._data_channel_enabled: + if len(self._outgoing_messages_queue) < MAX_MESSAGE_QUEUE_SIZE: + logger.debug("Data channel not ready, queuing message") + self._outgoing_messages_queue.append(json_message) + else: + logger.warning( + f"Message queue is full ({MAX_MESSAGE_QUEUE_SIZE} messages). Discarding message." + ) else: # The client might choose never to create a data channel. - logger.trace("Data channel not ready, discarding message!") + logger.trace("Data channel unavailable and queueing disabled. Discarding message.") + + def _flush_message_queue(self): + """Flush all queued messages through the now-open data channel. + + Called when the data channel transitions to the *open* state. Cancels + the data-channel open timeout (it is no longer needed) and sends every + message that was buffered while the channel was unavailable. + """ + self._cancel_data_channel_timeout() + logger.debug("Data channel is open, flushing queued messages") + while self._outgoing_messages_queue: + message = self._outgoing_messages_queue.pop(0) + self._data_channel.send(message) def ask_to_renegotiate(self): """Request renegotiation of the WebRTC connection.""" diff --git a/src/pipecat/transports/smallwebrtc/transport.py b/src/pipecat/transports/smallwebrtc/transport.py index 6b6a14829..36f883278 100644 --- a/src/pipecat/transports/smallwebrtc/transport.py +++ b/src/pipecat/transports/smallwebrtc/transport.py @@ -23,6 +23,7 @@ from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, @@ -233,9 +234,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): @@ -336,6 +336,7 @@ class SmallWebRTCClient: format="RGB", ) image_frame.transport_source = video_source + image_frame.pts = frame.pts del frame # free original VideoFrame del image_bytes # reference kept in image_frame @@ -374,33 +375,25 @@ 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, - ) - 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 del pcm_bytes # reference kept in audio_frame yield audio_frame @@ -447,6 +440,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.""" @@ -680,7 +674,6 @@ class SmallWebRTCInputTransport(BaseInputTransport): format=video_frame.format, text=request_text, append_to_context=add_to_context, - # Deprecated fields below. request=request_frame, ) image_frame.transport_source = video_source @@ -699,8 +692,7 @@ class SmallWebRTCInputTransport(BaseInputTransport): message: The application message to process. """ logger.debug(f"Received app message inside SmallWebRTCInputTransport {message}") - frame = InputTransportMessageFrame(message=message) - await self.push_frame(frame) + await self.broadcast_frame(InputTransportMessageFrame, message=message) # Add this method similar to DailyInputTransport.request_participant_image async def request_participant_image(self, frame: UserImageRequestFrame): @@ -873,6 +865,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__( @@ -961,6 +965,8 @@ class SmallWebRTCTransport(BaseTransport): async def _on_client_connected(self, webrtc_connection): """Handle client connection events.""" await self._call_event_handler("on_client_connected", webrtc_connection) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_client_disconnected(self, webrtc_connection): """Handle client disconnection events.""" diff --git a/src/pipecat/transports/tavus/transport.py b/src/pipecat/transports/tavus/transport.py index 8e91e2713..872e6eefc 100644 --- a/src/pipecat/transports/tavus/transport.py +++ b/src/pipecat/transports/tavus/transport.py @@ -21,7 +21,9 @@ from loguru import logger from pydantic import BaseModel from pipecat.frames.frames import ( + BotConnectedFrame, CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, @@ -132,10 +134,12 @@ class TavusCallbacks(BaseModel): """Callback handlers for Tavus events. Parameters: + on_joined: Called when the bot joins the Daily room. on_participant_joined: Called when a participant joins the conversation. on_participant_left: Called when a participant leaves the conversation. """ + on_joined: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]] @@ -237,6 +241,7 @@ class TavusTransportClient: on_dialout_stopped=partial(self._on_handle_callback, "on_dialout_stopped"), on_dialout_error=partial(self._on_handle_callback, "on_dialout_error"), on_dialout_warning=partial(self._on_handle_callback, "on_dialout_warning"), + on_dtmf_event=partial(self._on_handle_callback, "on_dtmf_event"), on_participant_joined=self._callbacks.on_participant_joined, on_participant_left=self._callbacks.on_participant_left, on_participant_updated=partial(self._on_handle_callback, "on_participant_updated"), @@ -270,6 +275,7 @@ class TavusTransportClient: async def _on_joined(self, data): """Handle joined event.""" logger.debug("TavusTransportClient joined!") + await self._callbacks.on_joined(data) async def _on_left(self): """Handle left event.""" @@ -519,7 +525,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 +667,18 @@ 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_connected(transport, data): Bot connected to the room + - on_client_connected(transport, participant): Participant connected to the session + - on_client_disconnected(transport, participant): Participant disconnected from the session + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, participant): + ... """ def __init__( @@ -691,6 +709,7 @@ class TavusTransport(BaseTransport): self._params = params callbacks = TavusCallbacks( + on_joined=self._on_joined, on_participant_joined=self._on_participant_joined, on_participant_left=self._on_participant_left, ) @@ -709,9 +728,16 @@ class TavusTransport(BaseTransport): # Register supported handlers. The user will only be able to register # these handlers. + self._register_event_handler("on_connected") self._register_event_handler("on_client_connected") self._register_event_handler("on_client_disconnected") + async def _on_joined(self, data): + """Handle bot joined room event.""" + await self._call_event_handler("on_connected", data) + if self._input: + await self._input.push_frame(BotConnectedFrame()) + async def _on_participant_left(self, participant, reason): """Handle participant left events.""" persona_name = await self._client.get_persona_name() @@ -775,6 +801,8 @@ class TavusTransport(BaseTransport): async def _on_client_connected(self, participant: Any): """Handle client connected events.""" await self._call_event_handler("on_client_connected", participant) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_client_disconnected(self, participant: Any): """Handle client disconnected events.""" diff --git a/src/pipecat/transports/websocket/client.py b/src/pipecat/transports/websocket/client.py index 2123a2c4d..b5b99ee97 100644 --- a/src/pipecat/transports/websocket/client.py +++ b/src/pipecat/transports/websocket/client.py @@ -27,6 +27,7 @@ from pipecat.frames.frames import ( EndFrame, Frame, InputAudioRawFrame, + InputTransportMessageFrame, OutputAudioRawFrame, OutputTransportMessageFrame, OutputTransportMessageUrgentFrame, @@ -50,6 +51,7 @@ class WebsocketClientParams(TransportParams): """ add_wav_header: bool = True + additional_headers: Optional[dict[str, str]] = None serializer: Optional[FrameSerializer] = None @@ -130,7 +132,11 @@ class WebsocketClientSession: return try: - self._websocket = await websocket_connect(uri=self._uri, open_timeout=10) + self._websocket = await websocket_connect( + uri=self._uri, + open_timeout=10, + additional_headers=self._params.additional_headers, + ) self._client_task = self.task_manager.create_task( self._client_task_handler(), f"{self._transport_name}::WebsocketClientSession::_client_task_handler", @@ -293,6 +299,8 @@ class WebsocketClientInputTransport(BaseInputTransport): return if isinstance(frame, InputAudioRawFrame) and self._params.audio_in_enabled: await self.push_audio_frame(frame) + elif isinstance(frame, InputTransportMessageFrame): + await self.broadcast_frame(InputTransportMessageFrame, message=frame.message) else: await self.push_frame(frame) @@ -463,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 1bcc59e8b..0fde2b9ae 100644 --- a/src/pipecat/transports/websocket/fastapi.py +++ b/src/pipecat/transports/websocket/fastapi.py @@ -23,9 +23,11 @@ from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, + InputTransportMessageFrame, InterruptionFrame, OutputAudioRawFrame, OutputTransportMessageFrame, @@ -56,11 +58,14 @@ class FastAPIWebsocketParams(TransportParams): add_wav_header: Whether to add WAV headers to audio frames. serializer: Frame serializer for encoding/decoding messages. session_timeout: Session timeout in seconds, None for no timeout. + fixed_audio_packet_size: Optional fixed-size packetization for raw PCM audio payloads. + Useful when the remote WebSocket media endpoint requires strict audio framing. """ add_wav_header: bool = False serializer: Optional[FrameSerializer] = None session_timeout: Optional[int] = None + fixed_audio_packet_size: Optional[int] = None class FastAPIWebsocketCallbacks(BaseModel): @@ -256,6 +261,7 @@ class FastAPIWebsocketInputTransport(BaseInputTransport): if not self._monitor_websocket_task and self._params.session_timeout: self._monitor_websocket_task = self.create_task(self._monitor_websocket()) await self._client.trigger_client_connected() + await self.push_frame(ClientConnectedFrame()) if not self._receive_task: self._receive_task = self.create_task(self._receive_messages()) await self.set_transport_ready(frame) @@ -308,6 +314,8 @@ class FastAPIWebsocketInputTransport(BaseInputTransport): if isinstance(frame, InputAudioRawFrame): await self.push_audio_frame(frame) + elif isinstance(frame, InputTransportMessageFrame): + await self.broadcast_frame(InputTransportMessageFrame, message=frame.message) else: await self.push_frame(frame) except Exception as e: @@ -360,6 +368,14 @@ class FastAPIWebsocketOutputTransport(BaseOutputTransport): self._send_interval = 0 self._next_send_time = 0 + # Buffer for optional protocol-level audio packetization. + # Some serializers may emit arbitrarily sized raw PCM payloads, while + # certain downstream transports or media endpoints require audio to be + # sent in fixed-size frames. When `params.fixed_audio_packet_size` is set, + # this buffer accumulates outgoing audio until a full packet can be + # emitted, preserving any remainder for subsequent sends. + self._audio_send_buffer = bytearray() + # Whether we have seen a StartFrame already. self._initialized = False @@ -417,6 +433,10 @@ class FastAPIWebsocketOutputTransport(BaseOutputTransport): await super().process_frame(frame, direction) if isinstance(frame, InterruptionFrame): + # Drop any partially buffered audio to avoid replaying stale PCM + if self._params.fixed_audio_packet_size: + self._audio_send_buffer.clear() + await self._write_frame(frame) self._next_send_time = 0 @@ -480,6 +500,21 @@ class FastAPIWebsocketOutputTransport(BaseOutputTransport): try: payload = await self._params.serializer.serialize(frame) if payload: + # Optional protocol-level audio packetization: + # If a downstream WebSocket media endpoint requires fixed-size PCM frames, + # configure params.fixed_audio_packet_size (e.g. 640 for 20ms @ 16kHz PCM16 mono). + packet_bytes = self._params.fixed_audio_packet_size + + if packet_bytes and isinstance(payload, (bytes, bytearray)): + self._audio_send_buffer.extend(bytes(payload)) + + # Send only full frames; keep remainder for the next call. + while len(self._audio_send_buffer) >= packet_bytes: + chunk = bytes(self._audio_send_buffer[:packet_bytes]) + del self._audio_send_buffer[:packet_bytes] + await self._client.send(chunk) + return + await self._client.send(payload) except Exception as e: logger.error(f"{self} exception sending data: {e.__class__.__name__} ({e})") @@ -501,6 +536,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 a31ac5487..fa3645d37 100644 --- a/src/pipecat/transports/websocket/server.py +++ b/src/pipecat/transports/websocket/server.py @@ -22,9 +22,11 @@ from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, + InputTransportMessageFrame, InterruptionFrame, OutputAudioRawFrame, OutputTransportMessageFrame, @@ -214,6 +216,8 @@ class WebsocketServerInputTransport(BaseInputTransport): if isinstance(frame, InputAudioRawFrame): await self.push_audio_frame(frame) + elif isinstance(frame, InputTransportMessageFrame): + await self.broadcast_frame(InputTransportMessageFrame, message=frame.message) else: await self.push_frame(frame) except Exception as e: @@ -417,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__( @@ -487,6 +504,8 @@ class WebsocketServerTransport(BaseTransport): if self._output: await self._output.set_client_connection(websocket) await self._call_event_handler("on_client_connected", websocket) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) else: logger.error("A WebsocketServerTransport output is missing in the pipeline") diff --git a/src/pipecat/turns/mute/__init__.py b/src/pipecat/turns/mute/__init__.py index d452ce19e..c82b10ae7 100644 --- a/src/pipecat/turns/mute/__init__.py +++ b/src/pipecat/turns/mute/__init__.py @@ -4,10 +4,29 @@ # SPDX-License-Identifier: BSD 2-Clause License # -from pipecat.turns.mute.always_user_mute_strategy import AlwaysUserMuteStrategy -from pipecat.turns.mute.base_user_mute_strategy import BaseUserMuteStrategy -from pipecat.turns.mute.first_speech_user_mute_strategy import FirstSpeechUserMuteStrategy -from pipecat.turns.mute.function_call_user_mute_strategy import FunctionCallUserMuteStrategy -from pipecat.turns.mute.mute_until_first_bot_complete_user_mute_strategy import ( +import warnings + +from pipecat.turns.user_mute.always_user_mute_strategy import AlwaysUserMuteStrategy +from pipecat.turns.user_mute.base_user_mute_strategy import BaseUserMuteStrategy +from pipecat.turns.user_mute.first_speech_user_mute_strategy import FirstSpeechUserMuteStrategy +from pipecat.turns.user_mute.function_call_user_mute_strategy import FunctionCallUserMuteStrategy +from pipecat.turns.user_mute.mute_until_first_bot_complete_user_mute_strategy import ( MuteUntilFirstBotCompleteUserMuteStrategy, ) + +with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Types in pipecat.turns.mute are deprecated. " + "Please use the equivalent types from pipecat.turns.user_mute instead.", + DeprecationWarning, + stacklevel=2, + ) + +__all__ = [ + "AlwaysUserMuteStrategy", + "BaseUserMuteStrategy", + "FirstSpeechUserMuteStrategy", + "FunctionCallUserMuteStrategy", + "MuteUntilFirstBotCompleteUserMuteStrategy", +] diff --git a/src/pipecat/turns/types.py b/src/pipecat/turns/types.py new file mode 100644 index 000000000..5ebdf20a3 --- /dev/null +++ b/src/pipecat/turns/types.py @@ -0,0 +1,24 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Shared result type for user turn strategy frame processing.""" + +from enum import Enum + + +class ProcessFrameResult(Enum): + """Result of processing a frame in a user turn strategy. + + Controls whether the strategy loop in the controller continues to the + next strategy or stops early. + + Attributes: + CONTINUE: Continue to the next strategy in the loop. + STOP: Stop evaluating further strategies for this frame. + """ + + CONTINUE = "continue" + STOP = "stop" diff --git a/src/pipecat/turns/user_idle_controller.py b/src/pipecat/turns/user_idle_controller.py new file mode 100644 index 000000000..b3b7e8074 --- /dev/null +++ b/src/pipecat/turns/user_idle_controller.py @@ -0,0 +1,157 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""This module defines a controller for managing user idle detection.""" + +import asyncio +from typing import Optional + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + Frame, + FunctionCallCancelFrame, + FunctionCallResultFrame, + FunctionCallsStartedFrame, + UserIdleTimeoutUpdateFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, +) +from pipecat.utils.asyncio.task_manager import BaseTaskManager +from pipecat.utils.base_object import BaseObject + + +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 after the bot finishes + speaking. The timer starts when BotStoppedSpeakingFrame is received and is + cancelled when someone starts speaking again (UserStartedSpeakingFrame or + BotStartedSpeakingFrame). + + 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: + + - on_user_turn_idle: Emitted when the user has been idle for the timeout period. + + Example:: + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + # Handle user idle - send reminder, prompt, etc. + ... + """ + + def __init__( + self, + *, + 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__() + + self._user_idle_timeout = user_idle_timeout + + self._task_manager: Optional[BaseTaskManager] = 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) + + @property + def task_manager(self) -> BaseTaskManager: + """Returns the configured task manager.""" + if not self._task_manager: + raise RuntimeError(f"{self} user idle controller was not properly setup") + return self._task_manager + + async def setup(self, task_manager: BaseTaskManager): + """Initialize the controller with the given task manager. + + Args: + task_manager: The task manager to be associated with this instance. + """ + self._task_manager = task_manager + + async def cleanup(self): + """Cleanup the controller.""" + await super().cleanup() + await self._cancel_idle_timer() + + async def process_frame(self, frame: Frame): + """Process an incoming frame to track user activity state. + + Args: + frame: The frame to be processed. + """ + if isinstance(frame, UserIdleTimeoutUpdateFrame): + self._user_idle_timeout = frame.timeout + if self._user_idle_timeout <= 0: + await self._cancel_idle_timer() + return + + if isinstance(frame, BotStoppedSpeakingFrame): + # Only start the timer if the user isn't mid-turn and no function + # calls are pending. + # + # 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): + 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 _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", + ) + + 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 + + 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_mute/__init__.py b/src/pipecat/turns/user_mute/__init__.py new file mode 100644 index 000000000..14c5fea42 --- /dev/null +++ b/src/pipecat/turns/user_mute/__init__.py @@ -0,0 +1,21 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +from .always_user_mute_strategy import AlwaysUserMuteStrategy +from .base_user_mute_strategy import BaseUserMuteStrategy +from .first_speech_user_mute_strategy import FirstSpeechUserMuteStrategy +from .function_call_user_mute_strategy import FunctionCallUserMuteStrategy +from .mute_until_first_bot_complete_user_mute_strategy import ( + MuteUntilFirstBotCompleteUserMuteStrategy, +) + +__all__ = [ + "AlwaysUserMuteStrategy", + "BaseUserMuteStrategy", + "FirstSpeechUserMuteStrategy", + "FunctionCallUserMuteStrategy", + "MuteUntilFirstBotCompleteUserMuteStrategy", +] diff --git a/src/pipecat/turns/mute/always_user_mute_strategy.py b/src/pipecat/turns/user_mute/always_user_mute_strategy.py similarity index 93% rename from src/pipecat/turns/mute/always_user_mute_strategy.py rename to src/pipecat/turns/user_mute/always_user_mute_strategy.py index 584b8165b..981c6bb74 100644 --- a/src/pipecat/turns/mute/always_user_mute_strategy.py +++ b/src/pipecat/turns/user_mute/always_user_mute_strategy.py @@ -7,7 +7,7 @@ """User mute strategy that always mutes the user while the bot is speaking.""" from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame, Frame -from pipecat.turns.mute.base_user_mute_strategy import BaseUserMuteStrategy +from pipecat.turns.user_mute.base_user_mute_strategy import BaseUserMuteStrategy class AlwaysUserMuteStrategy(BaseUserMuteStrategy): diff --git a/src/pipecat/turns/mute/base_user_mute_strategy.py b/src/pipecat/turns/user_mute/base_user_mute_strategy.py similarity index 100% rename from src/pipecat/turns/mute/base_user_mute_strategy.py rename to src/pipecat/turns/user_mute/base_user_mute_strategy.py diff --git a/src/pipecat/turns/mute/first_speech_user_mute_strategy.py b/src/pipecat/turns/user_mute/first_speech_user_mute_strategy.py similarity index 96% rename from src/pipecat/turns/mute/first_speech_user_mute_strategy.py rename to src/pipecat/turns/user_mute/first_speech_user_mute_strategy.py index 1ce4c64f5..9349edd11 100644 --- a/src/pipecat/turns/mute/first_speech_user_mute_strategy.py +++ b/src/pipecat/turns/user_mute/first_speech_user_mute_strategy.py @@ -7,7 +7,7 @@ """User mute strategy that mutes the user only during the bot’s first speech.""" from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame, Frame -from pipecat.turns.mute.base_user_mute_strategy import BaseUserMuteStrategy +from pipecat.turns.user_mute.base_user_mute_strategy import BaseUserMuteStrategy class FirstSpeechUserMuteStrategy(BaseUserMuteStrategy): diff --git a/src/pipecat/turns/mute/function_call_user_mute_strategy.py b/src/pipecat/turns/user_mute/function_call_user_mute_strategy.py similarity index 95% rename from src/pipecat/turns/mute/function_call_user_mute_strategy.py rename to src/pipecat/turns/user_mute/function_call_user_mute_strategy.py index ef83de271..f50f31bd4 100644 --- a/src/pipecat/turns/mute/function_call_user_mute_strategy.py +++ b/src/pipecat/turns/user_mute/function_call_user_mute_strategy.py @@ -14,7 +14,7 @@ from pipecat.frames.frames import ( FunctionCallResultFrame, FunctionCallsStartedFrame, ) -from pipecat.turns.mute.base_user_mute_strategy import BaseUserMuteStrategy +from pipecat.turns.user_mute.base_user_mute_strategy import BaseUserMuteStrategy class FunctionCallUserMuteStrategy(BaseUserMuteStrategy): diff --git a/src/pipecat/turns/mute/mute_until_first_bot_complete_user_mute_strategy.py b/src/pipecat/turns/user_mute/mute_until_first_bot_complete_user_mute_strategy.py similarity index 94% rename from src/pipecat/turns/mute/mute_until_first_bot_complete_user_mute_strategy.py rename to src/pipecat/turns/user_mute/mute_until_first_bot_complete_user_mute_strategy.py index 6ac5d62a9..c19499e07 100644 --- a/src/pipecat/turns/mute/mute_until_first_bot_complete_user_mute_strategy.py +++ b/src/pipecat/turns/user_mute/mute_until_first_bot_complete_user_mute_strategy.py @@ -7,7 +7,7 @@ """User mute strategy that mutes the user until the bot completes its first speech.""" from pipecat.frames.frames import BotStoppedSpeakingFrame, Frame -from pipecat.turns.mute.base_user_mute_strategy import BaseUserMuteStrategy +from pipecat.turns.user_mute.base_user_mute_strategy import BaseUserMuteStrategy class MuteUntilFirstBotCompleteUserMuteStrategy(BaseUserMuteStrategy): @@ -51,6 +51,5 @@ class MuteUntilFirstBotCompleteUserMuteStrategy(BaseUserMuteStrategy): return not self._first_speech_handled async def _handle_bot_stopped_speaking(self, frame: BotStoppedSpeakingFrame): - self._bot_speaking = False if not self._first_speech_handled: self._first_speech_handled = True diff --git a/src/pipecat/turns/user_start/__init__.py b/src/pipecat/turns/user_start/__init__.py index 3108e1c9a..94d12708d 100644 --- a/src/pipecat/turns/user_start/__init__.py +++ b/src/pipecat/turns/user_start/__init__.py @@ -4,15 +4,19 @@ # SPDX-License-Identifier: BSD 2-Clause License # -from pipecat.turns.user_start.base_user_turn_start_strategy import ( - BaseUserTurnStartStrategy, - UserTurnStartedParams, -) -from pipecat.turns.user_start.external_user_turn_start_strategy import ExternalUserTurnStartStrategy -from pipecat.turns.user_start.min_words_user_turn_start_strategy import ( - MinWordsUserTurnStartStrategy, -) -from pipecat.turns.user_start.transcription_user_turn_start_strategy import ( - TranscriptionUserTurnStartStrategy, -) -from pipecat.turns.user_start.vad_user_turn_start_strategy import VADUserTurnStartStrategy +from .base_user_turn_start_strategy import BaseUserTurnStartStrategy, UserTurnStartedParams +from .external_user_turn_start_strategy import ExternalUserTurnStartStrategy +from .min_words_user_turn_start_strategy import MinWordsUserTurnStartStrategy +from .transcription_user_turn_start_strategy import TranscriptionUserTurnStartStrategy +from .vad_user_turn_start_strategy import VADUserTurnStartStrategy +from .wake_phrase_user_turn_start_strategy import WakePhraseUserTurnStartStrategy + +__all__ = [ + "BaseUserTurnStartStrategy", + "ExternalUserTurnStartStrategy", + "MinWordsUserTurnStartStrategy", + "TranscriptionUserTurnStartStrategy", + "UserTurnStartedParams", + "VADUserTurnStartStrategy", + "WakePhraseUserTurnStartStrategy", +] diff --git a/src/pipecat/turns/user_start/base_user_turn_start_strategy.py b/src/pipecat/turns/user_start/base_user_turn_start_strategy.py index 25f5c8303..fd928a8a7 100644 --- a/src/pipecat/turns/user_start/base_user_turn_start_strategy.py +++ b/src/pipecat/turns/user_start/base_user_turn_start_strategy.py @@ -11,6 +11,7 @@ from typing import Optional, Type from pipecat.frames.frames import Frame from pipecat.processors.frame_processor import FrameDirection +from pipecat.turns.types import ProcessFrameResult from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.base_object import BaseObject @@ -76,6 +77,7 @@ class BaseUserTurnStartStrategy(BaseObject): self._register_event_handler("on_push_frame", sync=True) self._register_event_handler("on_broadcast_frame", sync=True) self._register_event_handler("on_user_turn_started", sync=True) + self._register_event_handler("on_reset_aggregation", sync=True) @property def task_manager(self) -> BaseTaskManager: @@ -100,7 +102,7 @@ class BaseUserTurnStartStrategy(BaseObject): """Reset the strategy to its initial state.""" pass - async def process_frame(self, frame: Frame): + async def process_frame(self, frame: Frame) -> ProcessFrameResult: """Process an incoming frame. Subclasses should override this to implement logic that decides whether @@ -108,6 +110,10 @@ class BaseUserTurnStartStrategy(BaseObject): Args: frame: The frame to be processed. + + Returns: + A ProcessFrameResult indicating the outcome. Subclasses that return + None are treated as CONTINUE for backward compatibility. """ pass @@ -138,3 +144,7 @@ class BaseUserTurnStartStrategy(BaseObject): enable_user_speaking_frames=self._enable_user_speaking_frames, ), ) + + async def trigger_reset_aggregation(self): + """Trigger the `on_reset_aggregation` event.""" + await self._call_event_handler("on_reset_aggregation") diff --git a/src/pipecat/turns/user_start/external_user_turn_start_strategy.py b/src/pipecat/turns/user_start/external_user_turn_start_strategy.py index 9a85cad6d..1031a6b46 100644 --- a/src/pipecat/turns/user_start/external_user_turn_start_strategy.py +++ b/src/pipecat/turns/user_start/external_user_turn_start_strategy.py @@ -7,6 +7,7 @@ """User turn start strategy triggered by externally emitted frames.""" from pipecat.frames.frames import Frame, UserStartedSpeakingFrame +from pipecat.turns.types import ProcessFrameResult from pipecat.turns.user_start.base_user_turn_start_strategy import BaseUserTurnStartStrategy @@ -27,13 +28,17 @@ class ExternalUserTurnStartStrategy(BaseUserTurnStartStrategy): """ super().__init__(enable_interruptions=False, enable_user_speaking_frames=False, **kwargs) - async def process_frame(self, frame: Frame): + async def process_frame(self, frame: Frame) -> ProcessFrameResult: """Process an incoming frame to detect user turn start. Args: frame: The frame to be analyzed. - """ - await super().process_frame(frame) + Returns: + STOP if a user started speaking frame was received, CONTINUE otherwise. + """ if isinstance(frame, UserStartedSpeakingFrame): await self.trigger_user_turn_started() + return ProcessFrameResult.STOP + + return ProcessFrameResult.CONTINUE diff --git a/src/pipecat/turns/user_start/min_words_user_turn_start_strategy.py b/src/pipecat/turns/user_start/min_words_user_turn_start_strategy.py index 1f156cef9..10bcfcdad 100644 --- a/src/pipecat/turns/user_start/min_words_user_turn_start_strategy.py +++ b/src/pipecat/turns/user_start/min_words_user_turn_start_strategy.py @@ -15,6 +15,7 @@ from pipecat.frames.frames import ( InterimTranscriptionFrame, TranscriptionFrame, ) +from pipecat.turns.types import ProcessFrameResult from pipecat.turns.user_start.base_user_turn_start_strategy import BaseUserTurnStartStrategy @@ -41,15 +42,13 @@ class MinWordsUserTurnStartStrategy(BaseUserTurnStartStrategy): self._min_words = min_words self._use_interim = use_interim self._bot_speaking = False - self._text = "" async def reset(self): """Reset the strategy to its initial state.""" await super().reset() - self._text = "" self._bot_speaking = False - async def process_frame(self, frame: Frame): + async def process_frame(self, frame: Frame) -> ProcessFrameResult: """Process an incoming frame to detect the start of a user turn. This method updates internal state based on transcription frames and @@ -57,17 +56,20 @@ class MinWordsUserTurnStartStrategy(BaseUserTurnStartStrategy): Args: frame: The frame to be analyzed. - """ - await super().process_frame(frame) + Returns: + STOP if the minimum word count was reached, CONTINUE otherwise. + """ if isinstance(frame, BotStartedSpeakingFrame): await self._handle_bot_started_speaking(frame) elif isinstance(frame, BotStoppedSpeakingFrame): await self._handle_bot_stopped_speaking(frame) elif isinstance(frame, TranscriptionFrame): - await self._handle_transcription(frame) + return await self._handle_transcription(frame) elif isinstance(frame, InterimTranscriptionFrame) and self._use_interim: - await self._handle_interim_transcription(frame) + return await self._handle_transcription(frame) + + return ProcessFrameResult.CONTINUE async def _handle_bot_started_speaking(self, frame: BotStartedSpeakingFrame): """Handle bot started speaking frame. @@ -89,42 +91,31 @@ class MinWordsUserTurnStartStrategy(BaseUserTurnStartStrategy): """ self._bot_speaking = False - async def _handle_transcription(self, frame: TranscriptionFrame): - """Handle a completed transcription frame and check word count. + async def _handle_transcription( + self, frame: TranscriptionFrame | InterimTranscriptionFrame + ) -> ProcessFrameResult: + """Handle a transcription frame and check word count. Args: frame: The transcription frame to be processed. - """ - self._text += frame.text - min_words = self._min_words if self._bot_speaking else 1 - - word_count = len(self._text.split()) - should_trigger = word_count >= min_words - - logger.debug( - f"{self} should_trigger={should_trigger} num_spoken_words={word_count} " - f"min_words={min_words} bot_speaking={self._bot_speaking}" - ) - - if should_trigger: - await self.trigger_user_turn_started() - - async def _handle_interim_transcription(self, frame: InterimTranscriptionFrame): - """Handle an interim transcription frame and check word count. - - Args: - frame: The interim transcription frame to be processed. + Returns: + STOP if the minimum word count was reached, CONTINUE otherwise. """ min_words = self._min_words if self._bot_speaking else 1 word_count = len(frame.text.split()) should_trigger = word_count >= min_words + is_interim = isinstance(frame, InterimTranscriptionFrame) logger.debug( - f"{self} interim=True should_trigger={should_trigger} num_spoken_words={word_count} " - f"min_words={min_words} bot_speaking={self._bot_speaking}" + f"{self} should_trigger={should_trigger} num_spoken_words={word_count} " + f"min_words={min_words} bot_speaking={self._bot_speaking} interim_transcription={is_interim}" ) if should_trigger: await self.trigger_user_turn_started() + return ProcessFrameResult.STOP + await self.trigger_reset_aggregation() + + return ProcessFrameResult.CONTINUE diff --git a/src/pipecat/turns/user_start/transcription_user_turn_start_strategy.py b/src/pipecat/turns/user_start/transcription_user_turn_start_strategy.py index b69b127ea..34a3e83e6 100644 --- a/src/pipecat/turns/user_start/transcription_user_turn_start_strategy.py +++ b/src/pipecat/turns/user_start/transcription_user_turn_start_strategy.py @@ -7,6 +7,7 @@ """User turn start strategy based on transcriptions.""" from pipecat.frames.frames import Frame, InterimTranscriptionFrame, TranscriptionFrame +from pipecat.turns.types import ProcessFrameResult from pipecat.turns.user_start.base_user_turn_start_strategy import BaseUserTurnStartStrategy @@ -25,15 +26,20 @@ class TranscriptionUserTurnStartStrategy(BaseUserTurnStartStrategy): super().__init__(**kwargs) self._use_interim = use_interim - async def process_frame(self, frame: Frame): + async def process_frame(self, frame: Frame) -> ProcessFrameResult: """Process an incoming frame to detect the start of a user turn. Args: frame: The frame to be processed. - """ - await super().process_frame(frame) + Returns: + STOP if a transcription was received, CONTINUE otherwise. + """ if isinstance(frame, InterimTranscriptionFrame) and self._use_interim: await self.trigger_user_turn_started() + return ProcessFrameResult.STOP elif isinstance(frame, TranscriptionFrame): await self.trigger_user_turn_started() + return ProcessFrameResult.STOP + + return ProcessFrameResult.CONTINUE diff --git a/src/pipecat/turns/user_start/vad_user_turn_start_strategy.py b/src/pipecat/turns/user_start/vad_user_turn_start_strategy.py index 4bdf48594..2bc3875e0 100644 --- a/src/pipecat/turns/user_start/vad_user_turn_start_strategy.py +++ b/src/pipecat/turns/user_start/vad_user_turn_start_strategy.py @@ -7,6 +7,7 @@ """User turn start strategy based on VAD events.""" from pipecat.frames.frames import Frame, VADUserStartedSpeakingFrame +from pipecat.turns.types import ProcessFrameResult from pipecat.turns.user_start.base_user_turn_start_strategy import BaseUserTurnStartStrategy @@ -18,13 +19,17 @@ class VADUserTurnStartStrategy(BaseUserTurnStartStrategy): """ - async def process_frame(self, frame: Frame): + async def process_frame(self, frame: Frame) -> ProcessFrameResult: """Process an incoming frame to detect user turn start. Args: frame: The frame to be analyzed. - """ - await super().process_frame(frame) + Returns: + STOP if the user started speaking, CONTINUE otherwise. + """ if isinstance(frame, VADUserStartedSpeakingFrame): await self.trigger_user_turn_started() + return ProcessFrameResult.STOP + + return ProcessFrameResult.CONTINUE diff --git a/src/pipecat/turns/user_start/wake_phrase_user_turn_start_strategy.py b/src/pipecat/turns/user_start/wake_phrase_user_turn_start_strategy.py new file mode 100644 index 000000000..bcc069ad7 --- /dev/null +++ b/src/pipecat/turns/user_start/wake_phrase_user_turn_start_strategy.py @@ -0,0 +1,281 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""User turn start strategy that gates interaction behind wake phrase detection.""" + +import asyncio +import enum +import re +from typing import List, Optional + +from loguru import logger + +from pipecat.frames.frames import ( + BotSpeakingFrame, + Frame, + TranscriptionFrame, + UserSpeakingFrame, + VADUserStartedSpeakingFrame, +) +from pipecat.turns.types import ProcessFrameResult +from pipecat.turns.user_start.base_user_turn_start_strategy import BaseUserTurnStartStrategy +from pipecat.utils.asyncio.task_manager import BaseTaskManager + + +class _WakeState(enum.Enum): + """Internal state for wake phrase detection.""" + + IDLE = "idle" + AWAKE = "awake" + + +class WakePhraseUserTurnStartStrategy(BaseUserTurnStartStrategy): + """User turn start strategy that requires a wake phrase before interaction. + + Blocks subsequent strategies until a wake phrase is detected in a final + transcription. After detection, allows interaction for a configurable + timeout period before requiring the wake phrase again. Use + ``single_activation=True`` to require the wake phrase before every turn. + + This strategy should be placed first in the start strategies list. + + Event handlers available: + + - on_wake_phrase_detected: Called when a wake phrase is matched. + - on_wake_phrase_timeout: Called when the inactivity timeout expires + (timeout mode only). + + Example:: + + # Timeout mode (default): wake phrase unlocks interaction for 10s + strategy = WakePhraseUserTurnStartStrategy( + phrases=["hey pipecat", "ok pipecat"], + timeout=10.0, + ) + + # Single activation: wake phrase required before every turn + strategy = WakePhraseUserTurnStartStrategy( + phrases=["hey pipecat"], + single_activation=True, + ) + + @strategy.event_handler("on_wake_phrase_detected") + async def on_wake_phrase_detected(strategy, phrase): + ... + + @strategy.event_handler("on_wake_phrase_timeout") + async def on_wake_phrase_timeout(strategy): + ... + + Args: + phrases: List of wake phrases to detect. + timeout: Inactivity timeout in seconds before returning to IDLE. + In timeout mode, the timer resets on activity (user, bot speech). + In single activation mode, acts as a keepalive window — the strategy + stays AWAKE for this duration after wake phrase detection, allowing + the current turn to complete before returning to IDLE. + single_activation: If True, the wake phrase is required before every + turn. The strategy returns to IDLE after each turn completes. + **kwargs: Additional keyword arguments passed to parent. + """ + + def __init__( + self, + *, + phrases: List[str], + timeout: float = 10.0, + single_activation: bool = False, + **kwargs, + ): + """Initialize the wake phrase user turn start strategy. + + Args: + phrases: List of wake phrases to detect. + timeout: Inactivity timeout in seconds before returning to IDLE. + In timeout mode, the timer resets on activity. In single activation + mode, acts as a keepalive window after wake phrase detection. + single_activation: If True, the wake phrase is required before every + turn. The strategy returns to IDLE after each turn completes. + **kwargs: Additional keyword arguments passed to parent. + """ + super().__init__(**kwargs) + self._phrases = phrases + self._timeout = timeout + self._single_activation = single_activation + + self._patterns: List[re.Pattern] = [] + for phrase in phrases: + pattern = re.compile( + r"\b" + r"\s*".join(re.escape(word) for word in phrase.split()) + r"\b", + re.IGNORECASE, + ) + self._patterns.append(pattern) + + self._state = _WakeState.IDLE + self._accumulated_text = "" + + self._timeout_event = asyncio.Event() + self._timeout_task: Optional[asyncio.Task] = None + + self._register_event_handler("on_wake_phrase_detected") + self._register_event_handler("on_wake_phrase_timeout") + + @property + def state(self) -> _WakeState: + """Returns the current wake state.""" + return self._state + + async def setup(self, task_manager: BaseTaskManager): + """Initialize the strategy with the given task manager. + + Args: + task_manager: The task manager to be associated with this instance. + """ + await super().setup(task_manager) + if not self._timeout_task: + self._timeout_task = self.task_manager.create_task( + self._timeout_task_handler(), + f"{self}::_timeout_task_handler", + ) + + async def cleanup(self): + """Cleanup the strategy.""" + await super().cleanup() + if self._timeout_task: + await self.task_manager.cancel_task(self._timeout_task) + self._timeout_task = None + + async def reset(self): + """Reset the strategy. + + In timeout mode, preserves state and refreshes timeout since reset + means a turn started (activity). In single activation mode, does + nothing — the keepalive timeout (started when the wake phrase was + detected) handles the transition back to IDLE. + """ + await super().reset() + if self._state == _WakeState.AWAKE: + if not self._single_activation: + self._refresh_timeout() + + async def process_frame(self, frame: Frame) -> ProcessFrameResult: + """Process an incoming frame for wake phrase detection or passthrough. + + Args: + frame: The frame to be processed. + + Returns: + STOP when the wake phrase is detected or when in IDLE state + (blocks subsequent strategies), CONTINUE when in AWAKE state + (allows subsequent strategies to proceed). + """ + await super().process_frame(frame) + + if self._state == _WakeState.IDLE: + return await self._process_idle(frame) + else: + return await self._process_awake(frame) + + async def _process_idle(self, frame: Frame) -> ProcessFrameResult: + """Process a frame while in IDLE state. + + Only final ``TranscriptionFrame`` instances are checked for wake phrase + matches. When a match is found, a user turn start is triggered. + Transcription frames that don't match have their text cleared so that + pre-wake-phrase speech is not added to the LLM context. All frames + return STOP to block subsequent strategies. + """ + if isinstance(frame, TranscriptionFrame): + if self._check_wake_phrase(frame.text): + await self.trigger_user_turn_started() + return ProcessFrameResult.STOP + await self.trigger_reset_aggregation() + + return ProcessFrameResult.STOP + + async def _process_awake(self, frame: Frame) -> ProcessFrameResult: + """Process a frame while in AWAKE state. + + Refreshes the timeout on activity frames (timeout mode only). Returns + CONTINUE so subsequent strategies can process the frame. + """ + if not self._single_activation: + if isinstance(frame, (UserSpeakingFrame, BotSpeakingFrame)): + self._refresh_timeout() + elif isinstance(frame, TranscriptionFrame): + self._refresh_timeout() + elif isinstance(frame, VADUserStartedSpeakingFrame): + self._refresh_timeout() + + return ProcessFrameResult.CONTINUE + + @staticmethod + def _strip_punctuation(text: str) -> str: + """Strip punctuation from text, keeping only letters, digits, and whitespace.""" + return re.sub(r"[^\w\s]", "", text) + + def _check_wake_phrase(self, text: str) -> bool: + """Check if the accumulated text contains a wake phrase. + + Punctuation is stripped before matching so that STT output like + "Hey, Pipecat!" still matches the phrase "hey pipecat". + + Args: + text: New transcription text to append and check. + + Returns: + True if a wake phrase was found, False otherwise. + """ + self._accumulated_text += " " + self._strip_punctuation(text) + # Cap accumulated text to prevent unbounded growth. + if len(self._accumulated_text) > 250: + self._accumulated_text = self._accumulated_text[-250:] + + for i, pattern in enumerate(self._patterns): + if pattern.search(self._accumulated_text): + phrase = self._phrases[i] + logger.debug(f"{self} wake phrase detected: {phrase!r}") + self._transition_to_awake(phrase) + return True + + return False + + def _transition_to_awake(self, phrase: str): + """Transition from IDLE to AWAKE state.""" + self._state = _WakeState.AWAKE + self._accumulated_text = "" + self._refresh_timeout() + self.task_manager.create_task( + self._call_event_handler("on_wake_phrase_detected", phrase), + f"{self}::on_wake_phrase_detected", + ) + + def _transition_to_idle(self): + """Transition from AWAKE to IDLE state.""" + logger.debug(f"{self} wake phrase timeout, returning to IDLE") + self._state = _WakeState.IDLE + self._accumulated_text = "" + self.task_manager.create_task( + self._call_event_handler("on_wake_phrase_timeout"), + f"{self}::on_wake_phrase_timeout", + ) + + def _refresh_timeout(self): + """Refresh the inactivity timeout.""" + self._timeout_event.set() + + async def _timeout_task_handler(self): + """Background task that monitors inactivity timeout.""" + while True: + try: + await asyncio.wait_for( + self._timeout_event.wait(), + timeout=self._timeout, + ) + self._timeout_event.clear() + except asyncio.TimeoutError: + if self._state == _WakeState.AWAKE: + self._transition_to_idle() diff --git a/src/pipecat/turns/user_stop/__init__.py b/src/pipecat/turns/user_stop/__init__.py index f54e00b7e..7ff676744 100644 --- a/src/pipecat/turns/user_stop/__init__.py +++ b/src/pipecat/turns/user_stop/__init__.py @@ -4,14 +4,15 @@ # SPDX-License-Identifier: BSD 2-Clause License # -from pipecat.turns.user_stop.base_user_turn_stop_strategy import ( - BaseUserTurnStopStrategy, - UserTurnStoppedParams, -) -from pipecat.turns.user_stop.external_user_turn_stop_strategy import ExternalUserTurnStopStrategy -from pipecat.turns.user_stop.transcription_user_turn_stop_strategy import ( - TranscriptionUserTurnStopStrategy, -) -from pipecat.turns.user_stop.turn_analyzer_user_turn_stop_strategy import ( - TurnAnalyzerUserTurnStopStrategy, -) +from .base_user_turn_stop_strategy import BaseUserTurnStopStrategy, UserTurnStoppedParams +from .external_user_turn_stop_strategy import ExternalUserTurnStopStrategy +from .speech_timeout_user_turn_stop_strategy import SpeechTimeoutUserTurnStopStrategy +from .turn_analyzer_user_turn_stop_strategy import TurnAnalyzerUserTurnStopStrategy + +__all__ = [ + "BaseUserTurnStopStrategy", + "ExternalUserTurnStopStrategy", + "SpeechTimeoutUserTurnStopStrategy", + "UserTurnStoppedParams", + "TurnAnalyzerUserTurnStopStrategy", +] diff --git a/src/pipecat/turns/user_stop/base_user_turn_stop_strategy.py b/src/pipecat/turns/user_stop/base_user_turn_stop_strategy.py index c0042f902..7c493475e 100644 --- a/src/pipecat/turns/user_stop/base_user_turn_stop_strategy.py +++ b/src/pipecat/turns/user_stop/base_user_turn_stop_strategy.py @@ -11,6 +11,7 @@ from typing import Optional, Type from pipecat.frames.frames import Frame from pipecat.processors.frame_processor import FrameDirection +from pipecat.turns.types import ProcessFrameResult from pipecat.utils.asyncio.task_manager import BaseTaskManager from pipecat.utils.base_object import BaseObject @@ -89,7 +90,7 @@ class BaseUserTurnStopStrategy(BaseObject): """Reset the strategy to its initial state.""" pass - async def process_frame(self, frame: Frame): + async def process_frame(self, frame: Frame) -> ProcessFrameResult: """Process an incoming frame to decide whether the user stopped speaking. Subclasses should override this to implement logic that decides whether @@ -97,6 +98,10 @@ class BaseUserTurnStopStrategy(BaseObject): Args: frame: The frame to be analyzed. + + Returns: + A ProcessFrameResult indicating the outcome. Subclasses that return + None are treated as CONTINUE for backward compatibility. """ pass diff --git a/src/pipecat/turns/user_stop/external_user_turn_stop_strategy.py b/src/pipecat/turns/user_stop/external_user_turn_stop_strategy.py index 58e037fbf..4ffa3360b 100644 --- a/src/pipecat/turns/user_stop/external_user_turn_stop_strategy.py +++ b/src/pipecat/turns/user_stop/external_user_turn_stop_strategy.py @@ -16,6 +16,7 @@ from pipecat.frames.frames import ( UserStartedSpeakingFrame, UserStoppedSpeakingFrame, ) +from pipecat.turns.types import ProcessFrameResult from pipecat.turns.user_stop.base_user_turn_stop_strategy import BaseUserTurnStopStrategy from pipecat.utils.asyncio.task_manager import BaseTaskManager @@ -69,7 +70,7 @@ class ExternalUserTurnStopStrategy(BaseUserTurnStopStrategy): await self.task_manager.cancel_task(self._task) self._task = None - async def process_frame(self, frame: Frame): + async def process_frame(self, frame: Frame) -> ProcessFrameResult: """Process an incoming frame to update strategy state. Updates internal transcription text and VAD state. The user end turn @@ -78,6 +79,8 @@ class ExternalUserTurnStopStrategy(BaseUserTurnStopStrategy): Args: frame: The frame to be analyzed. + Returns: + Always returns CONTINUE so subsequent stop strategies are evaluated. """ if isinstance(frame, UserStartedSpeakingFrame): await self._handle_user_started_speaking(frame) @@ -88,6 +91,8 @@ class ExternalUserTurnStopStrategy(BaseUserTurnStopStrategy): elif isinstance(frame, TranscriptionFrame): await self._handle_transcription(frame) + return ProcessFrameResult.CONTINUE + async def _handle_user_started_speaking(self, _: UserStartedSpeakingFrame): """Handle when the external service indicates the user is speaking.""" self._user_speaking = True diff --git a/src/pipecat/turns/user_stop/speech_timeout_user_turn_stop_strategy.py b/src/pipecat/turns/user_stop/speech_timeout_user_turn_stop_strategy.py new file mode 100644 index 000000000..341a4c1e3 --- /dev/null +++ b/src/pipecat/turns/user_stop/speech_timeout_user_turn_stop_strategy.py @@ -0,0 +1,210 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Speech timeout-based user turn stop strategy.""" + +import asyncio +import time +from typing import Optional + +from pipecat.frames.frames import ( + Frame, + STTMetadataFrame, + TranscriptionFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, +) +from pipecat.turns.types import ProcessFrameResult +from pipecat.turns.user_stop.base_user_turn_stop_strategy import BaseUserTurnStopStrategy +from pipecat.utils.asyncio.task_manager import BaseTaskManager + + +class SpeechTimeoutUserTurnStopStrategy(BaseUserTurnStopStrategy): + """User turn stop strategy that uses a configurable timeout to determine if the user is done speaking. + + After the user stops speaking (detected by VAD), this strategy waits for a + configurable timeout before triggering the end of the user's turn. The + timeout accounts for two factors: + + - user_speech_timeout: Time to wait for the user to potentially say more + after they pause. + - stt_timeout: The P99 time for the STT service to return a transcription + after the user stops speaking, adjusted by the VAD stop_secs. + + For services that support finalization (TranscriptionFrame.finalized=True), + the turn can be triggered immediately once the finalized transcript is + received and the user resume speaking timeout has elapsed. + """ + + def __init__(self, *, user_speech_timeout: float = 0.6, **kwargs): + """Initialize the speech timeout-based user turn stop strategy. + + Args: + user_speech_timeout: Time to wait for the user to potentially + say more after they pause speaking. Defaults to 0.6 seconds. + **kwargs: Additional keyword arguments. + """ + super().__init__(**kwargs) + self._user_speech_timeout = user_speech_timeout + self._stt_timeout: float = 0.0 # STT P99 latency from STTMetadataFrame + self._stop_secs: float = 0.0 # VAD stop_secs from VADUserStoppedSpeakingFrame + + self._text = "" + self._vad_user_speaking = False + self._transcript_finalized = False + self._vad_stopped_time: Optional[float] = None + self._timeout_task: Optional[asyncio.Task] = None + + async def reset(self): + """Reset the strategy to its initial state.""" + await super().reset() + self._text = "" + self._vad_user_speaking = False + self._transcript_finalized = False + self._vad_stopped_time = None + if self._timeout_task: + await self.task_manager.cancel_task(self._timeout_task) + self._timeout_task = None + + async def setup(self, task_manager: BaseTaskManager): + """Initialize the strategy with the given task manager. + + Args: + task_manager: The task manager to be associated with this instance. + """ + await super().setup(task_manager) + + async def cleanup(self): + """Cleanup the strategy.""" + await super().cleanup() + if self._timeout_task: + await self.task_manager.cancel_task(self._timeout_task) + self._timeout_task = None + + async def process_frame(self, frame: Frame) -> ProcessFrameResult: + """Process an incoming frame to update strategy state. + + Updates internal transcription text and VAD state. The user end turn + will be triggered when appropriate based on the collected frames. + + Args: + frame: The frame to be analyzed. + + Returns: + Always returns CONTINUE so subsequent stop strategies are evaluated. + """ + if isinstance(frame, STTMetadataFrame): + self._stt_timeout = frame.ttfs_p99_latency + elif isinstance(frame, VADUserStartedSpeakingFrame): + await self._handle_vad_user_started_speaking(frame) + elif isinstance(frame, VADUserStoppedSpeakingFrame): + await self._handle_vad_user_stopped_speaking(frame) + elif isinstance(frame, TranscriptionFrame): + await self._handle_transcription(frame) + + return ProcessFrameResult.CONTINUE + + async def _handle_vad_user_started_speaking(self, _: VADUserStartedSpeakingFrame): + """Handle when the VAD indicates the user is speaking.""" + self._vad_user_speaking = True + self._transcript_finalized = False + self._vad_stopped_time = None + # Cancel any pending timeout + if self._timeout_task: + await self.task_manager.cancel_task(self._timeout_task) + self._timeout_task = None + + async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame): + """Handle when the VAD indicates the user has stopped speaking.""" + self._vad_user_speaking = False + self._stop_secs = frame.stop_secs + self._vad_stopped_time = frame.timestamp + + # Start the timeout task + timeout = self._calculate_timeout() + self._timeout_task = self.task_manager.create_task( + self._timeout_handler(timeout), f"{self}::_timeout_handler" + ) + + async def _handle_transcription(self, frame: TranscriptionFrame): + """Handle user transcription.""" + self._text += frame.text + if frame.finalized: + self._transcript_finalized = True + # For finalized transcripts, check if we can trigger early + await self._maybe_trigger_user_turn_stopped() + + # Fallback: handle transcripts when no VAD stop was received. + # This handles edge cases where transcripts arrive without VAD firing. + # _vad_stopped_time is None means VAD stopped hasn't been received yet. + # In fallback mode, reset timeout on each transcript to wait for inactivity. + if not self._vad_user_speaking and self._vad_stopped_time is None: + # Cancel existing fallback timeout if any + if self._timeout_task: + await self.task_manager.cancel_task(self._timeout_task) + timeout = self._calculate_timeout() + self._timeout_task = self.task_manager.create_task( + self._timeout_handler(timeout), f"{self}::_timeout_handler" + ) + + def _calculate_timeout(self) -> float: + """Calculate the timeout value based on current state. + + Returns: + The timeout in seconds to wait after VAD stopped speaking. + """ + # Adjust STT timeout by VAD stop_secs since that time has already elapsed + effective_stt_wait = max(0, self._stt_timeout - self._stop_secs) + + # If transcript is already finalized, we don't need to wait for STT + if self._transcript_finalized: + return self._user_speech_timeout + + return max(effective_stt_wait, self._user_speech_timeout) + + async def _timeout_handler(self, timeout: float): + """Wait for the timeout then trigger user turn stopped if conditions met. + + Args: + timeout: The timeout in seconds to wait. + """ + try: + await asyncio.sleep(timeout) + except asyncio.CancelledError: + return + finally: + self._timeout_task = None + + await self._maybe_trigger_user_turn_stopped() + + async def _maybe_trigger_user_turn_stopped(self): + """Trigger user turn stopped if conditions are met. + + Conditions: + - User is not currently speaking + - We have transcription text + - Either the timeout has elapsed OR we have a finalized transcript + and user_speech_timeout has elapsed + """ + if self._vad_user_speaking or not self._text: + return + + # For finalized transcripts, check if user_speech_timeout has elapsed. + # If elapsed, trigger user turn stopped immediately. Else, wait for user resume + # speaking timeout. + if self._transcript_finalized and self._vad_stopped_time is not None: + elapsed = time.time() - self._vad_stopped_time + if elapsed >= self._user_speech_timeout: + # Cancel any remaining timeout since we're triggering now + if self._timeout_task: + await self.task_manager.cancel_task(self._timeout_task) + self._timeout_task = None + await self.trigger_user_turn_stopped() + return + + # For non-finalized, only trigger if timeout task has completed + if self._timeout_task is None: + await self.trigger_user_turn_stopped() diff --git a/src/pipecat/turns/user_stop/transcription_user_turn_stop_strategy.py b/src/pipecat/turns/user_stop/transcription_user_turn_stop_strategy.py index 5e037e6f7..a57aaad6d 100644 --- a/src/pipecat/turns/user_stop/transcription_user_turn_stop_strategy.py +++ b/src/pipecat/turns/user_stop/transcription_user_turn_stop_strategy.py @@ -4,124 +4,28 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Transcription time-based user turn stop strategy.""" +"""Transcription-based user turn stop strategy (deprecated). -import asyncio -from typing import Optional +.. deprecated:: 0.0.102 + This module is deprecated. Please use + ``pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy.SpeechTimeoutUserTurnStopStrategy`` + instead. +""" -from pipecat.frames.frames import ( - Frame, - InterimTranscriptionFrame, - TranscriptionFrame, - VADUserStartedSpeakingFrame, - VADUserStoppedSpeakingFrame, +import warnings + +from pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy import ( + SpeechTimeoutUserTurnStopStrategy, ) -from pipecat.turns.user_stop.base_user_turn_stop_strategy import BaseUserTurnStopStrategy -from pipecat.utils.asyncio.task_manager import BaseTaskManager +with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "TranscriptionUserTurnStopStrategy is deprecated. " + "Please use SpeechTimeoutUserTurnStopStrategy from " + "pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy instead.", + DeprecationWarning, + stacklevel=2, + ) -class TranscriptionUserTurnStopStrategy(BaseUserTurnStopStrategy): - """User turn stop strategy based on transcriptions. - - This strategy assumes the user stops speaking once a transcription has been - received. It handles multiple or delayed transcription frames gracefully. - - """ - - def __init__(self, *, timeout: float = 0.5, **kwargs): - """Initialize the transcription-based user turn stop strategy. - - Args: - timeout: A short delay used internally to handle consecutive or - slightly delayed transcriptions. - **kwargs: Additional keyword arguments. - """ - super().__init__(**kwargs) - self._timeout = timeout - self._text = "" - self._vad_user_speaking = False - self._seen_interim_results = False - self._event = asyncio.Event() - self._task: Optional[asyncio.Task] = None - - async def reset(self): - """Reset the strategy to its initial state.""" - await super().reset() - self._text = "" - self._vad_user_speaking = False - self._seen_interim_results = False - self._event.clear() - - async def setup(self, task_manager: BaseTaskManager): - """Initialize the strategy with the given task manager. - - Args: - task_manager: The task manager to be associated with this instance. - """ - await super().setup(task_manager) - self._task = task_manager.create_task(self._task_handler(), f"{self}::_task_handler") - - async def cleanup(self): - """Cleanup the strategy.""" - await super().cleanup() - if self._task: - await self.task_manager.cancel_task(self._task) - self._task = None - - async def process_frame(self, frame: Frame): - """Process an incoming frame to update strategy state. - - Updates internal transcription text and VAD state. The user end turn - will be triggered when appropriate based on the collected frames. - - Args: - frame: The frame to be analyzed. - - """ - if isinstance(frame, VADUserStartedSpeakingFrame): - await self._handle_vad_user_started_speaking(frame) - elif isinstance(frame, VADUserStoppedSpeakingFrame): - await self._handle_vad_user_stopped_speaking(frame) - elif isinstance(frame, InterimTranscriptionFrame): - await self._handle_interim_transcription(frame) - elif isinstance(frame, TranscriptionFrame): - await self._handle_transcription(frame) - - async def _handle_vad_user_started_speaking(self, _: VADUserStartedSpeakingFrame): - """Handle when the VAD indicates the user is speaking.""" - self._vad_user_speaking = True - - async def _handle_vad_user_stopped_speaking(self, _: VADUserStoppedSpeakingFrame): - """Handle when the VAD indicates the user has stopped speaking.""" - self._vad_user_speaking = False - await self._maybe_trigger_user_turn_stopped() - - async def _handle_interim_transcription(self, frame: InterimTranscriptionFrame): - self._seen_interim_results = True - - async def _handle_transcription(self, frame: TranscriptionFrame): - """Handle user transcription.""" - self._text += frame.text - # We just got a final result, so let's reset interim results. - self._seen_interim_results = False - # Reset aggregation timer. - self._event.set() - - async def _task_handler(self): - """Asynchronously monitor transcriptions and trigger user end turn when ready. - - If transcription text exists and the user is not currently speaking, - triggers the user end turn. Handles multiple or delayed transcriptions - gracefully. - - """ - while True: - try: - await asyncio.wait_for(self._event.wait(), timeout=self._timeout) - self._event.clear() - except asyncio.TimeoutError: - await self._maybe_trigger_user_turn_stopped() - - async def _maybe_trigger_user_turn_stopped(self): - if not self._vad_user_speaking and not self._seen_interim_results and self._text: - await self.trigger_user_turn_stopped() +TranscriptionUserTurnStopStrategy = SpeechTimeoutUserTurnStopStrategy diff --git a/src/pipecat/turns/user_stop/turn_analyzer_user_turn_stop_strategy.py b/src/pipecat/turns/user_stop/turn_analyzer_user_turn_stop_strategy.py index b21177dc4..a0df2efbb 100644 --- a/src/pipecat/turns/user_stop/turn_analyzer_user_turn_stop_strategy.py +++ b/src/pipecat/turns/user_stop/turn_analyzer_user_turn_stop_strategy.py @@ -10,48 +10,56 @@ import asyncio from typing import Optional from pipecat.audio.turn.base_turn_analyzer import BaseTurnAnalyzer, EndOfTurnState -from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams from pipecat.frames.frames import ( Frame, InputAudioRawFrame, - InterimTranscriptionFrame, MetricsFrame, SpeechControlParamsFrame, StartFrame, + STTMetadataFrame, TranscriptionFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) from pipecat.metrics.metrics import MetricsData +from pipecat.turns.types import ProcessFrameResult from pipecat.turns.user_stop.base_user_turn_stop_strategy import BaseUserTurnStopStrategy from pipecat.utils.asyncio.task_manager import BaseTaskManager class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy): - """User turn stop strategy using a turn detection model to detect end of user turn. + """User turn stop strategy that uses a turn detection model to determine if the user is done speaking. - This strategy uses the turn detection models to determine when the user has - finished speaking, combining audio, VAD, and transcription frames. Once the - turn is considered complete, the user end of turn is triggered. + This strategy feeds audio, VAD, and transcription frames to a turn + detection model (``BaseTurnAnalyzer``) that predicts when the user has + finished their turn. Once the model indicates the turn is complete, the + strategy waits for a final transcription before triggering the end of + the user's turn. + For services that support finalization (TranscriptionFrame.finalized=True), + the turn can be triggered immediately once the finalized transcript is + received. Otherwise, an STT timeout (adjusted by VAD stop_secs) is used + as a fallback. """ - def __init__(self, *, turn_analyzer: BaseTurnAnalyzer, timeout: float = 0.5, **kwargs): + def __init__(self, *, turn_analyzer: BaseTurnAnalyzer, **kwargs): """Initialize the user turn stop strategy. Args: turn_analyzer: The turn detection analyzer instance to detect end of user turn. - timeout: Short delay used internally to handle frame timing and event triggering. **kwargs: Additional keyword arguments. """ super().__init__(**kwargs) self._turn_analyzer = turn_analyzer - self._timeout = timeout + self._stt_timeout: float = 0.0 # STT P99 latency from STTMetadataFrame + self._stop_secs: float = 0.0 # VAD stop_secs from VADUserStoppedSpeakingFrame + self._text = "" self._turn_complete = False self._vad_user_speaking = False - self._event = asyncio.Event() - self._task: Optional[asyncio.Task] = None + self._vad_stopped_time: Optional[float] = None # Track when VAD stopped was received + self._transcript_finalized = False + self._timeout_task: Optional[asyncio.Task] = None async def reset(self): """Reset the strategy to its initial state.""" @@ -59,7 +67,11 @@ class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy): self._text = "" self._turn_complete = False self._vad_user_speaking = False - self._event.clear() + self._vad_stopped_time = None + self._transcript_finalized = False + if self._timeout_task: + await self.task_manager.cancel_task(self._timeout_task) + self._timeout_task = None async def setup(self, task_manager: BaseTaskManager): """Initialize the strategy with the given task manager. @@ -68,28 +80,30 @@ class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy): task_manager: The task manager to be associated with this instance. """ await super().setup(task_manager) - self._task = task_manager.create_task(self._task_handler(), f"{self}::_task_handler") async def cleanup(self): """Cleanup the strategy.""" await super().cleanup() await self._turn_analyzer.cleanup() - if self._task: - await self.task_manager.cancel_task(self._task) - self._task = None + if self._timeout_task: + await self.task_manager.cancel_task(self._timeout_task) + self._timeout_task = None - async def process_frame(self, frame: Frame): + async def process_frame(self, frame: Frame) -> ProcessFrameResult: """Process an incoming frame to update the turn analyzer and strategy state. Args: frame: The frame to be analyzed. + + Returns: + Always returns CONTINUE so subsequent stop strategies are evaluated. """ await super().process_frame(frame) if isinstance(frame, StartFrame): await self._start(frame) - elif isinstance(frame, SpeechControlParamsFrame): - await self._handle_speech_control_params(frame) + elif isinstance(frame, STTMetadataFrame): + self._stt_timeout = frame.ttfs_p99_latency elif isinstance(frame, VADUserStartedSpeakingFrame): await self._handle_vad_user_started_speaking(frame) elif isinstance(frame, VADUserStoppedSpeakingFrame): @@ -98,43 +112,47 @@ class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy): await self._handle_input_audio(frame) elif isinstance(frame, TranscriptionFrame): await self._handle_transcription(frame) - elif isinstance(frame, InterimTranscriptionFrame): - await self._handle_interim_transcription(frame) + + return ProcessFrameResult.CONTINUE async def _start(self, frame: StartFrame): """Process the start frame to configure the turn analyzer.""" self._turn_analyzer.set_sample_rate(frame.audio_in_sample_rate) await self.broadcast_frame(SpeechControlParamsFrame, turn_params=self._turn_analyzer.params) - async def _handle_speech_control_params(self, frame: SpeechControlParamsFrame): - """Sync Smart Turn pre-speech buffering with VAD start delay. - - `VADUserStartedSpeakingFrame` is emitted only once VAD has confirmed speech - (after `vad_params.start_secs`). Smart Turn should still include the initial - audio collected during that confirmation window, so we let the analyzer know - when this value has changed. - """ - self._turn_analyzer.update_vad_start_secs(frame.vad_params.start_secs) - async def _handle_input_audio(self, frame: InputAudioRawFrame): """Handle input audio to check if the turn is completed.""" state = self._turn_analyzer.append_audio(frame.audio, self._vad_user_speaking) - # If at this point the model says the turn is complete it will be due to - # a timeout, so we mark turn as complete and we trigger the user end of - # turn. + # Streaming analyzers (e.g. KrispVivaTurn) detect turn completion + # frame-by-frame inside append_audio, so COMPLETE is returned here + # rather than in analyze_end_of_turn. Batch analyzers (BaseSmartTurn) + # return COMPLETE here only on a silence timeout. In either case we + # consume and push metrics immediately while they're fresh. if state == EndOfTurnState.COMPLETE: + _, prediction = await self._turn_analyzer.analyze_end_of_turn() + await self._handle_prediction_result(prediction) self._turn_complete = True await self._maybe_trigger_user_turn_stopped() - async def _handle_vad_user_started_speaking(self, _: VADUserStartedSpeakingFrame): + async def _handle_vad_user_started_speaking(self, frame: VADUserStartedSpeakingFrame): """Handle when the VAD indicates the user is speaking.""" + # Sync Smart Turn pre-speech buffering with VAD start delay + self._turn_analyzer.update_vad_start_secs(frame.start_secs) self._turn_complete = False self._vad_user_speaking = True + self._vad_stopped_time = None + self._transcript_finalized = False + # Cancel any pending timeout + if self._timeout_task: + await self.task_manager.cancel_task(self._timeout_task) + self._timeout_task = None - async def _handle_vad_user_stopped_speaking(self, _: VADUserStoppedSpeakingFrame): + async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame): """Handle when the VAD indicates the user has stopped speaking.""" self._vad_user_speaking = False + self._stop_secs = frame.stop_secs + self._vad_stopped_time = frame.timestamp state, prediction = await self._turn_analyzer.analyze_end_of_turn() await self._handle_prediction_result(prediction) @@ -143,41 +161,76 @@ class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy): # wait for transcriptions. self._turn_complete = state == EndOfTurnState.COMPLETE - # Reset transcription timeout. - self._event.set() + # Start the STT timeout (adjusted by VAD stop_secs since that time already elapsed) + timeout = max(0, self._stt_timeout - self._stop_secs) + self._timeout_task = self.task_manager.create_task( + self._timeout_handler(timeout), f"{self}::_timeout_handler" + ) async def _handle_transcription(self, frame: TranscriptionFrame): """Handle user transcription.""" # We don't really care about the content. self._text = frame.text - # Reset transcription timeout. - self._event.set() + if frame.finalized: + self._transcript_finalized = True + # For finalized transcripts, trigger immediately if turn is complete + await self._maybe_trigger_user_turn_stopped() - async def _handle_interim_transcription(self, frame: InterimTranscriptionFrame): - """Handle user interim transcription.""" - # Reset transcription timeout. - self._event.set() + # Fallback: handle transcripts when no VAD stop was received. + # This handles edge cases where transcripts arrive without VAD firing. + # _vad_stopped_time is None means VAD stopped hasn't been received yet. + # In fallback mode, reset timeout on each transcript to wait for inactivity. + if not self._vad_user_speaking and self._vad_stopped_time is None: + # Cancel existing fallback timeout if any + if self._timeout_task: + await self.task_manager.cancel_task(self._timeout_task) + # Without VAD/turn analyzer data, assume turn is complete + self._turn_complete = True + timeout = max(0, self._stt_timeout - self._stop_secs) + self._timeout_task = self.task_manager.create_task( + self._timeout_handler(timeout), f"{self}::_timeout_handler" + ) async def _handle_prediction_result(self, result: Optional[MetricsData]): """Handle a prediction result event from the turn analyzer.""" if result: await self.push_frame(MetricsFrame(data=[result])) - async def _task_handler(self): - """Asynchronously monitor events and trigger user end of turn when appropriate. - - If we have not received a transcription in the specified amount of time - (and we initially received one) and the turn analyzer said the turn is - done, then the user is done speaking. + async def _timeout_handler(self, timeout: float): + """Wait for the timeout then trigger user turn stopped if conditions met. + Args: + timeout: The timeout in seconds to wait. """ - while True: - try: - await asyncio.wait_for(self._event.wait(), timeout=self._timeout) - self._event.clear() - except asyncio.TimeoutError: - await self._maybe_trigger_user_turn_stopped() + try: + await asyncio.sleep(timeout) + except asyncio.CancelledError: + return + finally: + self._timeout_task = None + + await self._maybe_trigger_user_turn_stopped() async def _maybe_trigger_user_turn_stopped(self): - if self._text and self._turn_complete: + """Trigger user turn stopped if conditions are met. + + Conditions: + - We have transcription text + - Turn analyzer indicates turn is complete + - Either the timeout has elapsed OR we have a finalized transcript + """ + if not self._text or not self._turn_complete: + return + + # For finalized transcripts, trigger immediately + if self._transcript_finalized: + # Cancel any remaining timeout since we're triggering now + if self._timeout_task: + await self.task_manager.cancel_task(self._timeout_task) + self._timeout_task = None + await self.trigger_user_turn_stopped() + return + + # For non-finalized, only trigger if timeout task has completed + if self._timeout_task is None: await self.trigger_user_turn_stopped() diff --git a/src/pipecat/turns/user_turn_completion_mixin.py b/src/pipecat/turns/user_turn_completion_mixin.py new file mode 100644 index 000000000..40ae551b4 --- /dev/null +++ b/src/pipecat/turns/user_turn_completion_mixin.py @@ -0,0 +1,439 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Mixin for adding turn completion detection to LLM services. + +This mixin enables LLM services to detect and process turn completion markers +(COMPLETE/INCOMPLETE) in LLM responses, allowing for smarter conversation flow +where the LLM can indicate whether the user's input was complete or if they +were interrupted mid-thought. +""" + +import asyncio +from dataclasses import dataclass +from typing import Literal, Optional + +from loguru import logger + +from pipecat.frames.frames import ( + Frame, + InterruptionFrame, + LLMFullResponseEndFrame, + LLMMessagesAppendFrame, + LLMRunFrame, + LLMTextFrame, +) +from pipecat.processors.frame_processor import FrameDirection + +# Turn completion markers +USER_TURN_COMPLETE_MARKER = "✓" +USER_TURN_INCOMPLETE_SHORT_MARKER = "○" # Short wait - user likely continues soon +USER_TURN_INCOMPLETE_LONG_MARKER = "◐" # Long wait - user needs more time + +# Default prompts for incomplete timeouts +DEFAULT_INCOMPLETE_SHORT_PROMPT = """The user paused briefly. Generate a brief, natural prompt to encourage them to continue. + +IMPORTANT: You MUST respond with ✓ followed by your message. Do NOT output ○ or ◐ - the user has already been given time to continue. + +Your response should: +- Be contextually relevant to what was just discussed +- Sound natural and conversational +- Be very concise (1 sentence max) +- Gently prompt them to continue + +Example format: ✓ Go ahead, I'm listening. + +Generate your ✓ response now.""" + +DEFAULT_INCOMPLETE_LONG_PROMPT = """The user has been quiet for a while. Generate a friendly check-in message. + +IMPORTANT: You MUST respond with ✓ followed by your message. Do NOT output ○ or ◐ - the user has already been given plenty of time. + +Your response should: +- Acknowledge they might be thinking or busy +- Offer to help or continue when ready +- Be warm and understanding +- Be brief (1 sentence) + +Example format: ✓ No rush! Let me know when you're ready to continue. + +Generate your ✓ response now.""" + +# System prompt instructions for turn completion that can be appended to any base prompt +USER_TURN_COMPLETION_INSTRUCTIONS = """ +CRITICAL INSTRUCTION - MANDATORY RESPONSE FORMAT: +Every single response MUST begin with a turn completion indicator. This is not optional. + +TURN COMPLETION DECISION FRAMEWORK: +Ask yourself: "Has the user provided enough information for me to give a meaningful, substantive response?" + +Mark as COMPLETE (✓) when: +- The user has answered your question with actual content +- The user has made a complete request or statement +- The user has provided all necessary information for you to respond meaningfully +- The conversation can naturally progress to your substantive response + +Mark as INCOMPLETE SHORT (○) when the user will likely continue soon: +- The user was clearly cut off mid-sentence or mid-word +- The user is in the middle of a thought that got interrupted +- Brief technical interruption (they'll resume in a few seconds) + +Mark as INCOMPLETE LONG (◐) when the user needs more time: +- The user explicitly asks for time: "let me think", "give me a minute", "hold on" +- The user is clearly pondering or deliberating: "hmm", "well...", "that's a good question" +- The user acknowledged but hasn't answered yet: "That's interesting..." +- The response feels like a preamble before the actual answer + +RESPOND in one of these three formats: +1. If COMPLETE: `✓` followed by a space and your full substantive response +2. If INCOMPLETE SHORT: ONLY the character `○` (user will continue in a few seconds) +3. If INCOMPLETE LONG: ONLY the character `◐` (user needs more time to think) + +KEY INSIGHT: Grammatically complete ≠ conversationally complete +- "That's a really good question." is grammatically complete but conversationally incomplete (use ◐) +- "I'd go to Japan because I love" is mid-sentence (use ○) + +EXAMPLES: + +You ask: "Where would you travel?" +User: "I'd go to Japan because I love" +→ `○` +(Cut off mid-sentence - they'll continue in seconds) + +You ask: "Where would you travel?" +User: "That's a good question. Let me think..." +→ `◐` +(User is deliberating - give them time) + +You ask: "Where would you travel?" +User: "Hmm, hold on a second." +→ `◐` +(User explicitly asked for time) + +You ask: "Where would you travel?" +User: "I'd go to Japan because I love the culture." +→ `✓ Japan is a wonderful choice! The blend of ancient traditions and modern innovation is truly unique. Have you been before?` +(Complete answer - give full response) + +User: "I need help with" +→ `○` +(Cut off mid-request - they'll finish soon) + +User: "Well, let me think about that for a moment." +→ `◐` +(User needs time to think) + +User: "Can you help me book a flight to New York next week?" +→ `✓ I'd be happy to help you with that! Let me gather some information...` +(Complete request - provide full response) + +User: "Give me a minute to gather my thoughts." +→ `◐` +(User explicitly asked for time) + +FORMAT REQUIREMENTS: +- ALWAYS use single-character indicators: `✓` (complete), `○` (short wait), or `◐` (long wait) +- For COMPLETE: `✓` followed by a space and your full response +- For INCOMPLETE: ONLY the single character (`○` or `◐`) with absolutely nothing else +- Your turn indicator must be the very first character in your response + +Remember: Focus on conversational completeness and how long the user might need. Was it a mid-sentence cutoff (○) or do they need time to think (◐)?""" + + +@dataclass +class UserTurnCompletionConfig: + """Configuration for turn completion behavior. + + Attributes: + instructions: Custom instructions for turn completion. If not provided, + uses default USER_TURN_COMPLETION_INSTRUCTIONS. + incomplete_short_timeout: Seconds to wait after short incomplete (○) before prompting. + incomplete_long_timeout: Seconds to wait after long incomplete (◐) before prompting. + incomplete_short_prompt: Custom prompt when short timeout expires. + incomplete_long_prompt: Custom prompt when long timeout expires. + """ + + instructions: Optional[str] = None + incomplete_short_timeout: float = 5.0 + incomplete_long_timeout: float = 10.0 + incomplete_short_prompt: Optional[str] = None + incomplete_long_prompt: Optional[str] = None + + @property + def completion_instructions(self) -> str: + """Turn completion instructions, using default if not set.""" + return self.instructions or USER_TURN_COMPLETION_INSTRUCTIONS + + @property + def short_prompt(self) -> str: + """Short incomplete prompt, using default if not set.""" + return self.incomplete_short_prompt or DEFAULT_INCOMPLETE_SHORT_PROMPT + + @property + def long_prompt(self) -> str: + """Long incomplete prompt, using default if not set.""" + return self.incomplete_long_prompt or DEFAULT_INCOMPLETE_LONG_PROMPT + + +class UserTurnCompletionLLMServiceMixin: + """Mixin that adds turn completion detection to LLM services. + + This mixin provides methods to push LLM text with turn completion detection. + It processes turn completion markers to enable smarter conversation flow: + + - ✓ (COMPLETE): Push response normally + - ○ (INCOMPLETE SHORT): Suppress response, wait ~5s, then prompt + - ◐ (INCOMPLETE LONG): Suppress response, wait ~15s, then prompt + + When incomplete timeouts expire, the mixin automatically prompts the LLM + with a contextual follow-up message to re-engage the user. + + Usage: + The LLM service controls when to use turn completion by calling + _push_turn_text instead of push_frame: + + # With turn completion: + if self._filter_incomplete_user_turns: + await self._push_turn_text(chunk.text) + else: + await self.push_frame(LLMTextFrame(chunk.text)) + + The mixin requires that the base class has a `push_frame` method compatible + with FrameProcessor's signature. + """ + + def __init__(self, *args, **kwargs): + """Initialize the turn completion mixin. + + Args: + *args: Positional arguments passed to parent class. + **kwargs: Keyword arguments passed to parent class. + """ + super().__init__(*args, **kwargs) + self._turn_text_buffer = "" + # Safety mechanism: True when incomplete is detected. While the prompt + # instructs the LLM to output ONLY the marker for incomplete turns, this flag + # ensures graceful degradation if the LLM disobeys and outputs additional text. + self._turn_suppressed = False + self._turn_complete_found = False # True when ✓ (COMPLETE) is detected + + # Timeout handling + self._user_turn_completion_config = UserTurnCompletionConfig() + self._incomplete_timeout_task: Optional[asyncio.Task] = None + self._incomplete_type: Optional[Literal["short", "long"]] = None + + def set_user_turn_completion_config(self, config: UserTurnCompletionConfig): + """Set the turn completion configuration. + + Args: + config: The turn completion configuration. + """ + self._user_turn_completion_config = config + + async def _start_incomplete_timeout(self, incomplete_type: Literal["short", "long"]): + """Start a timeout task for incomplete turn handling. + + Args: + incomplete_type: Either "short" or "long" to determine timeout duration. + """ + # Cancel any existing timeout + await self._cancel_incomplete_timeout() + + self._incomplete_type = incomplete_type + + if incomplete_type == "short": + timeout = self._user_turn_completion_config.incomplete_short_timeout + else: + timeout = self._user_turn_completion_config.incomplete_long_timeout + + logger.debug(f"Starting {incomplete_type} incomplete timeout ({timeout}s)") + self._incomplete_timeout_task = self.create_task( + self._incomplete_timeout_handler(incomplete_type, timeout), + f"_incomplete_timeout_{incomplete_type}", + ) + + async def _cancel_incomplete_timeout(self): + """Cancel any pending incomplete timeout task.""" + if self._incomplete_timeout_task and not self._incomplete_timeout_task.done(): + logger.debug("Cancelling incomplete timeout") + await self.cancel_task(self._incomplete_timeout_task) + self._incomplete_timeout_task = None + self._incomplete_type = None + + async def _incomplete_timeout_handler( + self, incomplete_type: Literal["short", "long"], timeout: float + ): + """Handle incomplete timeout expiration. + + Args: + incomplete_type: Either "short" or "long". + timeout: The timeout duration in seconds. + """ + try: + await asyncio.sleep(timeout) + + # Timeout expired - reset state before prompting LLM + logger.info(f"Incomplete {incomplete_type} timeout expired, prompting LLM") + await self._turn_reset() + self._incomplete_timeout_task = None + self._incomplete_type = None + + # Get the appropriate prompt + if incomplete_type == "short": + prompt = self._user_turn_completion_config.short_prompt + else: + prompt = self._user_turn_completion_config.long_prompt + + # Push through pipeline to trigger LLM response + await self.push_frame( + LLMMessagesAppendFrame(messages=[{"role": "system", "content": prompt}]) + ) + await self.push_frame(LLMRunFrame()) + + except asyncio.CancelledError: + # Timeout was cancelled (user spoke or interruption) + pass + + async def _turn_reset(self): + """Reset turn completion state between responses. + + Call this at the end of each LLM response to clear buffered text and reset state. + If no marker was found, pushes the buffered text to avoid losing content. + + Note: This does NOT cancel pending incomplete timeouts. Timeouts are only + cancelled on InterruptionFrame (when user speaks). + """ + # Check if no marker was found in this response + marker_found = self._turn_suppressed or self._turn_complete_found + if not marker_found and self._turn_text_buffer: + # Graceful degradation: push the buffered text so it's not lost + logger.warning( + f"{self}: filter_incomplete_user_turns is enabled but LLM response did not " + f"contain turn completion markers (✓/○/◐). Pushing text anyway. " + "The system prompt may be missing turn completion instructions." + ) + await self.push_frame(LLMTextFrame(self._turn_text_buffer)) + + self._turn_text_buffer = "" + self._turn_suppressed = False + self._turn_complete_found = False + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames, handling turn completion state resets. + + Args: + frame: The frame to process. + direction: The direction of frame processing. + """ + # Handle interruptions by cancelling timeout and resetting state + if isinstance(frame, InterruptionFrame): + await self._cancel_incomplete_timeout() + await self._turn_reset() + + # Pass frame to parent + await super().process_frame(frame, direction) + + async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): + """Push a frame downstream, resetting turn state at end of each LLM response. + + ``LLMFullResponseEndFrame`` is generated by the LLM service itself (pushed, + not received), so it must be handled here rather than in ``process_frame``. + + Args: + frame: The frame to push downstream. + direction: The direction of frame flow. Defaults to downstream. + """ + if isinstance(frame, LLMFullResponseEndFrame): + await self._turn_reset() + + await super().push_frame(frame, direction) + + async def _push_turn_text(self, text: str): + """Push LLM text with turn completion detection. + + This method should be used instead of `push_frame(LLMTextFrame(text))` when + turn completion is enabled. It will: + 1. Detect turn markers (✓, ○, or ◐) + 2. When ○ (SHORT) is found: suppress text, start short timeout + 3. When ◐ (LONG) is found: suppress text, start long timeout + 4. When ✓ (COMPLETE) is found: push all text with marker marked as skip_tts + 5. After marker detected: all subsequent text flows through immediately + + Args: + text: The text content from the LLM to push. + """ + # If we've already detected incomplete, suppress all remaining text. + # This is a safety mechanism in case the LLM disobeys the prompt and outputs + # additional text after the marker (e.g., "○ Please continue..."). + if self._turn_suppressed: + return + + # If ✓ (COMPLETE) was already found, push text immediately without buffering + if self._turn_complete_found: + await self.push_frame(LLMTextFrame(text)) + return + + # Add text to buffer + self._turn_text_buffer += text + + # Check for incomplete markers (○ short, ◐ long) + # These indicate the user was cut off or needs time - we suppress the bot's + # response and start a timeout to re-prompt later. + incomplete_type: Optional[Literal["short", "long"]] = None + if USER_TURN_INCOMPLETE_SHORT_MARKER in self._turn_text_buffer: + incomplete_type = "short" + elif USER_TURN_INCOMPLETE_LONG_MARKER in self._turn_text_buffer: + incomplete_type = "long" + + if incomplete_type: + marker = ( + USER_TURN_INCOMPLETE_SHORT_MARKER + if incomplete_type == "short" + else USER_TURN_INCOMPLETE_LONG_MARKER + ) + logger.debug( + f"INCOMPLETE {incomplete_type.upper()} ({marker}) detected, suppressing text" + ) + self._turn_suppressed = True + + # Push the marker with skip_tts=True so it's added to context (maintains + # conversation continuity per prompt instructions) but not spoken by TTS + frame = LLMTextFrame(self._turn_text_buffer) + frame.skip_tts = True + await self.push_frame(frame) + + self._turn_text_buffer = "" + await self._start_incomplete_timeout(incomplete_type) + return + + # Check for ✓ (COMPLETE) marker - user's turn was complete, respond normally + if USER_TURN_COMPLETE_MARKER in self._turn_text_buffer: + logger.debug(f"COMPLETE ({USER_TURN_COMPLETE_MARKER}) detected, pushing buffered text") + + # Split buffer at the marker to handle cases where marker and text + # arrive in the same chunk (e.g., "✓ Hello!" from some LLMs) + marker_pos = self._turn_text_buffer.index(USER_TURN_COMPLETE_MARKER) + marker_end = marker_pos + len(USER_TURN_COMPLETE_MARKER) + + # Push the marker with skip_tts=True - adds to context but not spoken + marker_text = self._turn_text_buffer[:marker_end] + frame = LLMTextFrame(marker_text) + frame.skip_tts = True + await self.push_frame(frame) + + # Push remaining text after marker as normal speech + remaining_text = self._turn_text_buffer[marker_end:] + if remaining_text: + # Strip leading space after marker if present (✓ Hello -> Hello) + if remaining_text.startswith(" "): + remaining_text = remaining_text[1:] + if remaining_text: + await self.push_frame(LLMTextFrame(remaining_text)) + + # Mark complete - all subsequent text flows through immediately + self._turn_text_buffer = "" + self._turn_complete_found = True + return diff --git a/src/pipecat/turns/user_turn_controller.py b/src/pipecat/turns/user_turn_controller.py index a9b267ae0..9abed3932 100644 --- a/src/pipecat/turns/user_turn_controller.py +++ b/src/pipecat/turns/user_turn_controller.py @@ -11,12 +11,19 @@ from typing import Optional, Type from pipecat.frames.frames import ( Frame, + InterimTranscriptionFrame, TranscriptionFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection -from pipecat.turns.user_start import BaseUserTurnStartStrategy, UserTurnStartedParams +from pipecat.turns.types import ProcessFrameResult +from pipecat.turns.user_start import ( + BaseUserTurnStartStrategy, + UserTurnStartedParams, +) from pipecat.turns.user_stop import BaseUserTurnStopStrategy, UserTurnStoppedParams from pipecat.turns.user_turn_strategies import UserTurnStrategies from pipecat.utils.asyncio.task_manager import BaseTaskManager @@ -80,7 +87,7 @@ class UserTurnController(BaseObject): self._task_manager: Optional[BaseTaskManager] = None - self._vad_user_speaking = False + self._user_speaking = False self._user_turn = False self._user_turn_stop_timeout_event = asyncio.Event() @@ -91,6 +98,7 @@ class UserTurnController(BaseObject): self._register_event_handler("on_user_turn_started", sync=True) self._register_event_handler("on_user_turn_stopped", sync=True) self._register_event_handler("on_user_turn_stop_timeout", sync=True) + self._register_event_handler("on_reset_aggregation", sync=True) @property def task_manager(self) -> BaseTaskManager: @@ -146,18 +154,26 @@ class UserTurnController(BaseObject): frame: The frame to be processed. """ - if isinstance(frame, VADUserStartedSpeakingFrame): + if isinstance(frame, UserStartedSpeakingFrame): + await self._handle_user_started_speaking(frame) + elif isinstance(frame, UserStoppedSpeakingFrame): + await self._handle_user_stopped_speaking(frame) + elif isinstance(frame, VADUserStartedSpeakingFrame): await self._handle_vad_user_started_speaking(frame) elif isinstance(frame, VADUserStoppedSpeakingFrame): await self._handle_vad_user_stopped_speaking(frame) - elif isinstance(frame, TranscriptionFrame): + elif isinstance(frame, (TranscriptionFrame, InterimTranscriptionFrame)): await self._handle_transcription(frame) for strategy in self._user_turn_strategies.start or []: - await strategy.process_frame(frame) + result = await strategy.process_frame(frame) + if result == ProcessFrameResult.STOP: + break for strategy in self._user_turn_strategies.stop or []: - await strategy.process_frame(frame) + result = await strategy.process_frame(frame) + if result == ProcessFrameResult.STOP: + break async def _setup_strategies(self): for s in self._user_turn_strategies.start or []: @@ -165,6 +181,7 @@ class UserTurnController(BaseObject): s.add_event_handler("on_push_frame", self._on_push_frame) s.add_event_handler("on_broadcast_frame", self._on_broadcast_frame) s.add_event_handler("on_user_turn_started", self._on_user_turn_started) + s.add_event_handler("on_reset_aggregation", self._on_reset_aggregation) for s in self._user_turn_strategies.stop or []: await s.setup(self.task_manager) @@ -179,20 +196,32 @@ class UserTurnController(BaseObject): for s in self._user_turn_strategies.stop or []: await s.cleanup() + async def _handle_user_started_speaking(self, frame: UserStartedSpeakingFrame): + self._user_speaking = True + + # The user started talking, let's reset the user turn timeout. + self._user_turn_stop_timeout_event.set() + + async def _handle_user_stopped_speaking(self, frame: UserStoppedSpeakingFrame): + self._user_speaking = False + + # The user stopped talking, let's reset the user turn timeout. + self._user_turn_stop_timeout_event.set() + async def _handle_vad_user_started_speaking(self, frame: VADUserStartedSpeakingFrame): - self._vad_user_speaking = True + self._user_speaking = True # The user started talking, let's reset the user turn timeout. self._user_turn_stop_timeout_event.set() async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame): - self._vad_user_speaking = False + self._user_speaking = False # The user stopped talking, let's reset the user turn timeout. self._user_turn_stop_timeout_event.set() - async def _handle_transcription(self, frame: TranscriptionFrame): - # We have creceived a transcription, let's reset the user turn timeout. + async def _handle_transcription(self, frame: TranscriptionFrame | InterimTranscriptionFrame): + # We have received a transcription, let's reset the user turn timeout. self._user_turn_stop_timeout_event.set() async def _on_push_frame( @@ -223,6 +252,9 @@ class UserTurnController(BaseObject): ): await self._trigger_user_turn_stop(strategy, params) + async def _on_reset_aggregation(self, strategy: BaseUserTurnStartStrategy): + await self._call_event_handler("on_reset_aggregation", strategy) + async def _trigger_user_turn_start( self, strategy: Optional[BaseUserTurnStartStrategy], params: UserTurnStartedParams ): @@ -233,6 +265,14 @@ class UserTurnController(BaseObject): self._user_turn = True self._user_turn_stop_timeout_event.set() + # Reset all user turn start strategies to start fresh. + for s in self._user_turn_strategies.start or []: + await s.reset() + + # Reset all user turn stop strategies to start fresh for the new turn. + for s in self._user_turn_strategies.stop or []: + await s.reset() + await self._call_event_handler("on_user_turn_started", strategy, params) async def _trigger_user_turn_stop( @@ -260,7 +300,7 @@ class UserTurnController(BaseObject): ) self._user_turn_stop_timeout_event.clear() except asyncio.TimeoutError: - if self._user_turn and not self._vad_user_speaking: + if self._user_turn and not self._user_speaking: await self._call_event_handler("on_user_turn_stop_timeout") await self._trigger_user_turn_stop( None, UserTurnStoppedParams(enable_user_speaking_frames=True) diff --git a/src/pipecat/turns/user_turn_processor.py b/src/pipecat/turns/user_turn_processor.py index 33ac078c2..85bc658dd 100644 --- a/src/pipecat/turns/user_turn_processor.py +++ b/src/pipecat/turns/user_turn_processor.py @@ -19,6 +19,7 @@ from pipecat.frames.frames import ( UserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.turns.user_idle_controller import UserIdleController from pipecat.turns.user_start import BaseUserTurnStartStrategy, UserTurnStartedParams from pipecat.turns.user_stop import BaseUserTurnStopStrategy, UserTurnStoppedParams from pipecat.turns.user_turn_controller import UserTurnController @@ -38,6 +39,7 @@ class UserTurnProcessor(FrameProcessor): - on_user_turn_started: Emitted when a user turn starts. - on_user_turn_stopped: Emitted when a user turn stops. - on_user_turn_stop_timeout: Emitted if no stop strategy triggers before timeout. + - on_user_turn_idle: Emitted when the user has been idle for the configured timeout. Example:: @@ -53,6 +55,10 @@ class UserTurnProcessor(FrameProcessor): async def on_user_turn_stop_timeout(processor): ... + @processor.event_handler("on_user_turn_idle") + async def on_user_turn_idle(processor): + ... + """ def __init__( @@ -60,6 +66,7 @@ class UserTurnProcessor(FrameProcessor): *, user_turn_strategies: Optional[UserTurnStrategies] = None, user_turn_stop_timeout: float = 5.0, + user_idle_timeout: float = 0, **kwargs, ): """Initialize the user turn processor. @@ -68,6 +75,10 @@ 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: 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. """ super().__init__(**kwargs) @@ -75,6 +86,7 @@ class UserTurnProcessor(FrameProcessor): self._register_event_handler("on_user_turn_started") self._register_event_handler("on_user_turn_stopped") self._register_event_handler("on_user_turn_stop_timeout") + self._register_event_handler("on_user_turn_idle") self._user_turn_controller = UserTurnController( user_turn_strategies=user_turn_strategies or UserTurnStrategies(), @@ -92,6 +104,9 @@ class UserTurnProcessor(FrameProcessor): "on_user_turn_stop_timeout", self._on_user_turn_stop_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) + async def cleanup(self): """Clean up processor resources.""" await super().cleanup() @@ -129,8 +144,11 @@ class UserTurnProcessor(FrameProcessor): await self._user_turn_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) + await self._user_idle_controller.setup(self.task_manager) async def _stop(self, frame: EndFrame): await self._cleanup() @@ -140,6 +158,7 @@ class UserTurnProcessor(FrameProcessor): async def _cleanup(self): await self._user_turn_controller.cleanup() + await self._user_idle_controller.cleanup() async def _on_push_frame( self, controller, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM @@ -155,13 +174,15 @@ class UserTurnProcessor(FrameProcessor): strategy: BaseUserTurnStartStrategy, params: UserTurnStartedParams, ): - logger.debug(f"{self}: User started speaking (user turn start strategy: {strategy})") + logger.debug(f"{self}: User started speaking (strategy: {strategy})") 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() + await self.broadcast_interruption() await self._call_event_handler("on_user_turn_started", strategy) @@ -171,12 +192,17 @@ class UserTurnProcessor(FrameProcessor): strategy: BaseUserTurnStopStrategy, params: UserTurnStoppedParams, ): - logger.debug(f"{self}: User stopped speaking (user turn stop strategy: {strategy})") + logger.debug(f"{self}: User stopped speaking (strategy: {strategy})") 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): await self._call_event_handler("on_user_turn_stop_timeout") + + async def _on_user_turn_idle(self, controller): + await self._call_event_handler("on_user_turn_idle") diff --git a/src/pipecat/turns/user_turn_strategies.py b/src/pipecat/turns/user_turn_strategies.py index eab64377a..5789c6328 100644 --- a/src/pipecat/turns/user_turn_strategies.py +++ b/src/pipecat/turns/user_turn_strategies.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from typing import List, Optional +from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.turns.user_start import ( BaseUserTurnStartStrategy, ExternalUserTurnStartStrategy, @@ -18,10 +19,35 @@ from pipecat.turns.user_start import ( from pipecat.turns.user_stop import ( BaseUserTurnStopStrategy, ExternalUserTurnStopStrategy, - TranscriptionUserTurnStopStrategy, + TurnAnalyzerUserTurnStopStrategy, ) +def default_user_turn_start_strategies() -> List[BaseUserTurnStartStrategy]: + """Return the default user turn start strategies. + + Returns ``[VADUserTurnStartStrategy, TranscriptionUserTurnStartStrategy]``. + Useful when building a custom strategy list that extends the defaults. + + Example:: + + start_strategies = [ + WakePhraseUserTurnStartStrategy(phrases=["hey pipecat"]), + *default_user_turn_start_strategies(), + ] + """ + return [VADUserTurnStartStrategy(), TranscriptionUserTurnStartStrategy()] + + +def default_user_turn_stop_strategies() -> List[BaseUserTurnStopStrategy]: + """Return the default user turn stop strategies. + + Returns ``[TurnAnalyzerUserTurnStopStrategy(LocalSmartTurnAnalyzerV3)]``. + Useful when building a custom strategy list that extends the defaults. + """ + return [TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] + + @dataclass class UserTurnStrategies: """Container for user turn start and stop strategies. @@ -29,7 +55,7 @@ class UserTurnStrategies: If no strategies are specified, the following defaults are used: start: [VADUserTurnStartStrategy, TranscriptionUserTurnStartStrategy] - stop: [TranscriptionUserTurnStopStrategy] + stop: [TurnAnalyzerUserTurnStopStrategy(LocalSmartTurnAnalyzerV3)] Attributes: start: A list of user turn start strategies used to detect when @@ -44,9 +70,9 @@ class UserTurnStrategies: def __post_init__(self): if not self.start: - self.start = [VADUserTurnStartStrategy(), TranscriptionUserTurnStartStrategy()] + self.start = default_user_turn_start_strategies() if not self.stop: - self.stop = [TranscriptionUserTurnStopStrategy()] + self.stop = default_user_turn_stop_strategies() @dataclass diff --git a/src/pipecat/utils/context/__init__.py b/src/pipecat/utils/context/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pipecat/utils/context/llm_context_summarization.py b/src/pipecat/utils/context/llm_context_summarization.py new file mode 100644 index 000000000..259d0bae5 --- /dev/null +++ b/src/pipecat/utils/context/llm_context_summarization.py @@ -0,0 +1,582 @@ +# +# Copyright (c) 2024–2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Utility for context summarization in LLM services. + +This module provides reusable functionality for automatically compressing conversation +context when token limits are reached, enabling efficient long-running conversations. +""" + +import warnings +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, List, Optional + +if TYPE_CHECKING: + from pipecat.services.llm_service import LLMService + +from loguru import logger + +from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage + +# Fallback timeout (seconds) used when summarization_timeout is None. +DEFAULT_SUMMARIZATION_TIMEOUT = 120.0 + +# Token estimation constants +CHARS_PER_TOKEN = 4 # Industry-standard heuristic: 1 token ≈ 4 characters +TOKEN_OVERHEAD_PER_MESSAGE = 10 # Estimated structural overhead per message +IMAGE_TOKEN_ESTIMATE = 500 # Rough estimate for image content +SUMMARY_TOKEN_BUFFER = 0.8 # Keep summary at 80% of available space for safety +MIN_SUMMARY_TOKENS = 100 # Minimum tokens to allocate for summary + +DEFAULT_SUMMARIZATION_PROMPT = """You are summarizing a conversation between a user and an AI assistant. + +Your task: +1. Create a concise summary that preserves: + - Key facts, decisions, and agreements + - Important context needed to continue the conversation + - User preferences and requirements mentioned + - Any unresolved questions or action items + +2. Format: + - Use clear, factual statements + - Group related information + - Prioritize information likely to be referenced later + - Keep the summary concise to fit within the specified token budget + +3. Omit: + - Greetings and small talk + - Redundant information + - Tangential discussions that were resolved + +The conversation transcript follows. Generate only the summary, no other text.""" + + +@dataclass +class LLMContextSummaryConfig: + """Configuration for summary generation parameters. + + Contains settings that control how a summary is generated. Used by both + automatic and manual summarization modes. + + Parameters: + target_context_tokens: Maximum token size for the generated summary. + This value is passed directly to the LLM as the max_tokens parameter + when generating the summary. Should be sized appropriately to allow + the summary plus recent preserved messages to fit within reasonable + context limits. + min_messages_after_summary: Number of recent messages to preserve + uncompressed after each summarization. These messages maintain + immediate conversational context. + summarization_prompt: Custom prompt for the LLM to use when generating + summaries. If None, uses DEFAULT_SUMMARIZATION_PROMPT. + summary_message_template: Template for formatting the summary when + injected into context. Must contain ``{summary}`` as a placeholder + for the generated summary text. Allows applications to wrap the + summary in custom delimiters (e.g., XML tags) so that system + prompts can distinguish summaries from live conversation. + llm: Optional separate LLM service for generating summaries. When set, + summarization requests are sent to this service instead of the + pipeline's primary LLM. Useful for routing summarization to a + cheaper/faster model (e.g., Gemini Flash) while keeping an + expensive model for conversation. If None, uses the pipeline LLM. + summarization_timeout: Maximum time in seconds to wait for the LLM to + generate a summary. If the call exceeds this timeout, summarization + is aborted with an error and future summarizations are unblocked. + """ + + target_context_tokens: int = 6000 + min_messages_after_summary: int = 4 + summarization_prompt: Optional[str] = None + summary_message_template: str = "Conversation summary: {summary}" + llm: Optional["LLMService"] = None + summarization_timeout: float = DEFAULT_SUMMARIZATION_TIMEOUT + + def __post_init__(self): + """Validate configuration parameters.""" + if self.target_context_tokens <= 0: + raise ValueError("target_context_tokens must be positive") + if self.min_messages_after_summary < 0: + raise ValueError("min_messages_after_summary must be non-negative") + + @property + def summary_prompt(self) -> str: + """Get the summarization prompt to use. + + Returns: + The custom prompt if set, otherwise the default summarization prompt. + """ + return self.summarization_prompt or DEFAULT_SUMMARIZATION_PROMPT + + +@dataclass +class LLMAutoContextSummarizationConfig: + """Configuration for automatic context summarization. + + Controls when conversation context is automatically compressed and how + that summary is generated. Summarization is triggered when either the + token limit or the unsummarized message count threshold is exceeded. + + At least one of ``max_context_tokens`` and ``max_unsummarized_messages`` + must be set. Set the other to ``None`` to disable that threshold. + + Parameters: + max_context_tokens: Maximum allowed context size in tokens. When this + limit is reached, summarization is triggered to compress the context. + The tokens are calculated using the industry-standard approximation + of 1 token ≈ 4 characters. Set to ``None`` to disable token-based + triggering. + max_unsummarized_messages: Maximum number of new messages that can + accumulate since the last summary before triggering a new + summarization. This ensures regular compression even if token + limits are not reached. Set to ``None`` to disable message-count + triggering. + summary_config: Configuration for summary generation parameters + (prompt, token budget, messages to keep). If not provided, uses + default ``LLMContextSummaryConfig`` values. + """ + + max_context_tokens: Optional[int] = 8000 + max_unsummarized_messages: Optional[int] = 20 + summary_config: LLMContextSummaryConfig = field(default_factory=LLMContextSummaryConfig) + + def __post_init__(self): + """Validate configuration parameters.""" + if self.max_context_tokens is None and self.max_unsummarized_messages is None: + raise ValueError( + "At least one of max_context_tokens and max_unsummarized_messages must be set" + ) + if self.max_context_tokens is not None and self.max_context_tokens <= 0: + raise ValueError("max_context_tokens must be positive") + if self.max_unsummarized_messages is not None and self.max_unsummarized_messages < 1: + raise ValueError("max_unsummarized_messages must be at least 1") + + # Auto-adjust target_context_tokens if it exceeds max_context_tokens + if ( + self.max_context_tokens is not None + and self.summary_config.target_context_tokens > self.max_context_tokens + ): + # Use 80% of max_context_tokens as a reasonable default + self.summary_config.target_context_tokens = int(self.max_context_tokens * 0.8) + + +@dataclass +class LLMContextSummarizationConfig: + """Configuration for context summarization behavior. + + .. deprecated:: 0.0.104 + Use :class:`LLMAutoContextSummarizationConfig` with a nested + :class:`LLMContextSummaryConfig` instead:: + + LLMAutoContextSummarizationConfig( + max_context_tokens=8000, + max_unsummarized_messages=20, + summary_config=LLMContextSummaryConfig( + target_context_tokens=6000, + min_messages_after_summary=4, + ), + ) + + Parameters: + max_context_tokens: Maximum allowed context size in tokens. + Set to ``None`` to disable token-based triggering. + target_context_tokens: Maximum token size for the generated summary. + max_unsummarized_messages: Maximum new messages before triggering summarization. + Set to ``None`` to disable message-count triggering. + min_messages_after_summary: Number of recent messages to preserve. + summarization_prompt: Custom prompt for summary generation. + """ + + max_context_tokens: Optional[int] = 8000 + target_context_tokens: int = 6000 + max_unsummarized_messages: Optional[int] = 20 + min_messages_after_summary: int = 4 + summarization_prompt: Optional[str] = None + summary_message_template: str = "Conversation summary: {summary}" + llm: Optional["LLMService"] = None + summarization_timeout: float = DEFAULT_SUMMARIZATION_TIMEOUT + + def __post_init__(self): + """Validate configuration parameters.""" + warnings.warn( + "LLMContextSummarizationConfig is deprecated. " + "Use LLMAutoContextSummarizationConfig with a nested LLMContextSummaryConfig instead.", + DeprecationWarning, + stacklevel=2, + ) + if self.max_context_tokens is None and self.max_unsummarized_messages is None: + raise ValueError( + "At least one of max_context_tokens and max_unsummarized_messages must be set" + ) + if self.max_context_tokens is not None and self.max_context_tokens <= 0: + raise ValueError("max_context_tokens must be positive") + if self.target_context_tokens <= 0: + raise ValueError("target_context_tokens must be positive") + + # Auto-adjust target_context_tokens if it exceeds max_context_tokens + if ( + self.max_context_tokens is not None + and self.target_context_tokens > self.max_context_tokens + ): + # Use 80% of max_context_tokens as a reasonable default + self.target_context_tokens = int(self.max_context_tokens * 0.8) + + if self.max_unsummarized_messages is not None and self.max_unsummarized_messages < 1: + raise ValueError("max_unsummarized_messages must be at least 1") + if self.min_messages_after_summary < 0: + raise ValueError("min_messages_after_summary must be positive") + + @property + def summary_prompt(self) -> str: + """Get the summarization prompt to use. + + Returns: + The custom prompt if set, otherwise the default summarization prompt. + """ + return self.summarization_prompt or DEFAULT_SUMMARIZATION_PROMPT + + def to_auto_config(self) -> LLMAutoContextSummarizationConfig: + """Convert to the new :class:`LLMAutoContextSummarizationConfig`. + + Returns: + An equivalent ``LLMAutoContextSummarizationConfig`` instance. + """ + return LLMAutoContextSummarizationConfig( + max_context_tokens=self.max_context_tokens, + max_unsummarized_messages=self.max_unsummarized_messages, + summary_config=LLMContextSummaryConfig( + target_context_tokens=self.target_context_tokens, + min_messages_after_summary=self.min_messages_after_summary, + summarization_prompt=self.summarization_prompt, + summary_message_template=self.summary_message_template, + llm=self.llm, + summarization_timeout=self.summarization_timeout, + ), + ) + + +@dataclass +class LLMMessagesToSummarize: + """Result of get_messages_to_summarize operation. + + Parameters: + messages: Messages to include in the summary + last_summarized_index: Index of the last message being summarized + """ + + messages: List[dict] + last_summarized_index: int + + +class LLMContextSummarizationUtil: + """Utility providing context summarization capabilities for LLM processing. + + This utility enables automatic conversation context compression when token + limits are reached. It provides functionality for both aggregators + (which decide when to summarize) and LLM services (which generate summaries). + + Key features: + - Token estimation using character-count heuristics (chars // 4) + - Smart message selection (preserves system messages and recent context) + - Function call awareness (avoids summarizing incomplete tool interactions) + - Flexible transcript formatting for summarization + - Maximum summary token calculation with safety buffers + + Usage: + Use the static methods directly on the class: + + tokens = LLMContextSummarizationUtil.estimate_context_tokens(context) + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 4) + transcript = LLMContextSummarizationUtil.format_messages_for_summary(messages) + + Note: + Token estimation uses the industry-standard heuristic of 1 token ≈ 4 characters. + """ + + @staticmethod + def estimate_tokens(text: str) -> int: + """Estimate token count for text using character count heuristic. + + Uses the industry-standard approximation of 1 token ≈ 4 characters. + This works well across different content types (prose, code, etc.) + and languages. + + Note: + For more accurate token counts, use the model's official tokenizer. + This is a rough estimate suitable for threshold checks and budgeting. + + Args: + text: Text to estimate tokens for + + Returns: + Estimated token count (characters // 4) + """ + if not text: + return 0 + return len(text) // CHARS_PER_TOKEN + + @staticmethod + def estimate_context_tokens(context: LLMContext) -> int: + """Estimate total token count for a context. + + Calculates an approximate token count by analyzing all messages, + including text content, tool calls, and structural overhead. + + Args: + context: LLM context to estimate. + + Returns: + Estimated total token count including: + - Message content (text, images) + - Tool calls and their arguments + - Tool results + - Structural overhead (TOKEN_OVERHEAD_PER_MESSAGE per message) + """ + total = 0 + + for message in context.messages: + # LLMSpecificMessage holds service-specific data (e.g. thinking blocks, + # thought signatures). Skipping them here for now. + if isinstance(message, LLMSpecificMessage): + continue + + # Role and structure overhead + total += TOKEN_OVERHEAD_PER_MESSAGE + + # Message content + content = message.get("content", "") + if isinstance(content, str): + total += LLMContextSummarizationUtil.estimate_tokens(content) + elif isinstance(content, list): + for item in content: + if isinstance(item, dict): + item_type = item.get("type", "") + # Text content + if item_type == "text": + total += LLMContextSummarizationUtil.estimate_tokens( + item.get("text", "") + ) + # Image content + elif item_type in ("image_url", "image"): + # Images are expensive, rough estimate + total += IMAGE_TOKEN_ESTIMATE + + # Tool calls + if "tool_calls" in message: + tool_calls = message["tool_calls"] + if isinstance(tool_calls, list): + for tool_call in tool_calls: + if isinstance(tool_call, dict): + func = tool_call.get("function", {}) + if isinstance(func, dict): + total += LLMContextSummarizationUtil.estimate_tokens( + func.get("name", "") + func.get("arguments", "") + ) + + # Tool call ID + if "tool_call_id" in message: + total += TOKEN_OVERHEAD_PER_MESSAGE + + return total + + @staticmethod + def _get_earliest_function_call_not_resolved_in_range( + messages: List[dict], start_idx: int, summary_end: int + ) -> int: + """Find the earliest message index with incomplete function calls. + + Scans messages from ``start_idx`` up to (but not including) + ``summary_end`` to identify tool calls whose responses either don't + exist yet or fall in the kept portion of the context (>= summary_end). + This prevents summarizing tool call requests when their responses would + remain in the kept context as orphans, which the OpenAI API rejects. + + Args: + messages: List of messages to check. + start_idx: Index to start checking from. + summary_end: Exclusive upper bound for the scan (the first kept + message index). Only tool responses within this range count as + completing a call; responses beyond it are treated as absent, + leaving the call "in progress". + + Returns: + Index of first message with function call in progress, or -1 if all + function calls are complete within the scanned range. + """ + # Track tool call IDs mapped to their message index + pending_tool_calls: dict[str, int] = {} + + for i in range(start_idx, summary_end): + msg = messages[i] + # LLMSpecificMessage instances (e.g. thinking blocks) never carry tool_call or + # tool_call_id fields, so they cannot affect the pending-call tracking. Skipping + # them avoids an AttributeError. + if isinstance(msg, LLMSpecificMessage): + continue + + role = msg.get("role") + + # Check for tool calls in assistant messages + if role == "assistant" and "tool_calls" in msg: + tool_calls = msg.get("tool_calls", []) + if isinstance(tool_calls, list): + for tool_call in tool_calls: + if isinstance(tool_call, dict): + tool_call_id = tool_call.get("id") + if tool_call_id: + pending_tool_calls[tool_call_id] = i + + # Check for tool results + if role == "tool": + tool_call_id = msg.get("tool_call_id") + if tool_call_id and tool_call_id in pending_tool_calls: + pending_tool_calls.pop(tool_call_id) + + # If we have pending tool calls, return the earliest index + if pending_tool_calls: + return min(pending_tool_calls.values()) + + return -1 + + @staticmethod + def get_messages_to_summarize( + context: LLMContext, min_messages_to_keep: int + ) -> LLMMessagesToSummarize: + """Determine which messages should be included in summarization. + + Intelligently selects messages for summarization while preserving: + - The first system message (defines assistant behavior) + - The last N messages (maintains immediate conversation context) + - Incomplete function call sequences (preserves tool interaction integrity) + + Args: + context: The LLM context containing all messages. + min_messages_to_keep: Number of recent messages to exclude from + summarization. + + Returns: + LLMMessagesToSummarize containing the messages to summarize and the + index of the last message included. + """ + messages = context.messages + if len(messages) <= min_messages_to_keep: + return LLMMessagesToSummarize(messages=[], last_summarized_index=-1) + + # Find first system message index. LLMSpecificMessage instances are excluded because + # they are not dict-like and never represent a system message; they hold + # service-specific metadata (e.g. thinking blocks) that is always paired with a + # standard message. + first_system_index = next( + ( + i + for i, msg in enumerate(messages) + if not isinstance(msg, LLMSpecificMessage) and msg.get("role") == "system" + ), + -1, + ) + + # Messages to summarize are between first system and recent messages + # We exclude the first system message itself + if first_system_index >= 0: + summary_start = first_system_index + 1 + else: + summary_start = 0 + + # Get messages to keep (last N messages) + summary_end = len(messages) - min_messages_to_keep + + if summary_start >= summary_end: + return LLMMessagesToSummarize(messages=[], last_summarized_index=-1) + + # Check for function calls in progress in the range we want to summarize + function_call_start = ( + LLMContextSummarizationUtil._get_earliest_function_call_not_resolved_in_range( + messages, summary_start, summary_end + ) + ) + if function_call_start >= 0 and function_call_start < summary_end: + # Stop summarization before the function call + logger.debug( + f"ContextSummarization: Found function call in progress at index {function_call_start}, " + f"stopping summary before it (was going to summarize up to {summary_end})" + ) + # Count how many messages we're skipping + skipped_messages = summary_end - function_call_start + summary_end = function_call_start + if skipped_messages > 0: + logger.info( + f"ContextSummarization: Skipping {skipped_messages} messages with " + f"function calls in progress (will summarize after results are available)" + ) + + if summary_start >= summary_end: + return LLMMessagesToSummarize(messages=[], last_summarized_index=-1) + + messages_to_summarize = messages[summary_start:summary_end] + last_summarized_index = summary_end - 1 + + return LLMMessagesToSummarize( + messages=messages_to_summarize, last_summarized_index=last_summarized_index + ) + + @staticmethod + def format_messages_for_summary(messages: List[dict]) -> str: + """Format messages as a transcript for summarization. + + Args: + messages: Messages to format + + Returns: + Formatted transcript string + """ + transcript_parts = [] + + for msg in messages: + # LLMSpecificMessage holds service-specific internal data (e.g. Anthropic thinking + # blocks, Gemini thought signatures). This data is not meaningful as plain text for + # a summarization transcript, and the summarizer LLM would not know how to interpret + # it. The conversational content of those turns is already captured by the + # accompanying standard assistant message. + if isinstance(msg, LLMSpecificMessage): + continue + + role = msg.get("role", "unknown") + content = msg.get("content", "") + + # Handle different content types + if isinstance(content, str): + text = content + elif isinstance(content, list): + text_parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + text_parts.append(item.get("text", "")) + text = " ".join(text_parts) + else: + text = str(content) + + if text: + # Capitalize role for readability + formatted_role = role.upper() + transcript_parts.append(f"{formatted_role}: {text}") + + # Include tool calls if present + if "tool_calls" in msg: + tool_calls = msg.get("tool_calls", []) + if isinstance(tool_calls, list): + for tool_call in tool_calls: + if isinstance(tool_call, dict): + func = tool_call.get("function", {}) + if isinstance(func, dict): + name = func.get("name", "unknown") + args = func.get("arguments", "") + transcript_parts.append(f"TOOL_CALL: {name}({args})") + + # Include tool results + if role == "tool": + tool_call_id = msg.get("tool_call_id", "unknown") + transcript_parts.append(f"TOOL_RESULT[{tool_call_id}]: {text}") + + return "\n\n".join(transcript_parts) diff --git a/src/pipecat/utils/env.py b/src/pipecat/utils/env.py new file mode 100644 index 000000000..cb66a5400 --- /dev/null +++ b/src/pipecat/utils/env.py @@ -0,0 +1,53 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Environment variable helpers. + +This module provides small, centralized parsing helpers for environment variables. +""" + +from __future__ import annotations + +import os + + +class InvalidEnvVarValueError(ValueError): + """Raised when an environment variable value cannot be parsed.""" + + def __init__(self, name: str, value: str, expected: str): + """Initialize an InvalidEnvVarValueError.""" + super().__init__(f"Invalid value for env var {name!r}: {value!r}. Expected {expected}.") + self.name = name + self.value = value + self.expected = expected + + +def env_truthy(name: str, default: bool = False) -> bool: + """Interpret an environment variable as a boolean. + + - If the variable is **not set**, returns `default`. + - If the variable is set to a recognized boolean string, returns the parsed value. + - Otherwise, raises `InvalidEnvVarValueError`. + + Recognized values (case-insensitive, whitespace ignored): + - Truthy: "1", "true", "yes", "y", "on" + - Falsy: "0", "false", "no", "n", "off", "" + """ + raw = os.getenv(name) + if raw is None: + return default + + val = raw.strip().lower() + if val in {"1", "true", "yes", "y", "on"}: + return True + if val in {"0", "false", "no", "n", "off", ""}: + return False + + raise InvalidEnvVarValueError( + name=name, + value=raw, + expected="true or false", + ) diff --git a/src/pipecat/utils/string.py b/src/pipecat/utils/string.py index 3a5d69cad..20fcdb2e0 100644 --- a/src/pipecat/utils/string.py +++ b/src/pipecat/utils/string.py @@ -89,6 +89,17 @@ SENTENCE_ENDING_PUNCTUATION: FrozenSet[str] = frozenset( } ) +# Latin punctuation that NLTK handles well — these need NLTK's disambiguation +# because "." can appear in abbreviations, decimals, etc. +_LATIN_SENTENCE_ENDING_PUNCTUATION: FrozenSet[str] = frozenset({".", "!", "?", ";", "…"}) + +# Non-Latin sentence-ending punctuation that is always unambiguous and never needs +# NLTK's disambiguation logic. Used as a fallback when NLTK doesn't support the +# language (e.g., Japanese, Chinese, Korean, Hindi, Arabic). +UNAMBIGUOUS_SENTENCE_ENDING_PUNCTUATION: FrozenSet[str] = ( + SENTENCE_ENDING_PUNCTUATION - _LATIN_SENTENCE_ENDING_PUNCTUATION +) + StartEndTags = Tuple[str, str] @@ -144,7 +155,17 @@ def match_endofsentence(text: str) -> int: # common for text to be single words, so we need to ensure # sentence-ending punctuation is present. if len(sentences) == 1 and first_sentence == text: - return len(text) if text and text[-1] in SENTENCE_ENDING_PUNCTUATION else 0 + if text and text[-1] in SENTENCE_ENDING_PUNCTUATION: + return len(text) + # Fallback for languages not supported by NLTK (e.g., Japanese, Chinese, + # Korean, Hindi, Arabic). NLTK returned the entire text as a single + # sentence, and the last character is not sentence-ending punctuation + # (it's a lookahead character). Scan for unambiguous non-Latin sentence- + # ending punctuation that doesn't need NLTK's disambiguation. + for i, ch in enumerate(text): + if ch in UNAMBIGUOUS_SENTENCE_ENDING_PUNCTUATION: + return i + 1 + return 0 # If there are multiple sentences, the first one is complete by definition # (NLTK found a boundary, so there must be proper punctuation) diff --git a/src/pipecat/utils/text/base_text_aggregator.py b/src/pipecat/utils/text/base_text_aggregator.py index 13691d9cd..2b050fcb7 100644 --- a/src/pipecat/utils/text/base_text_aggregator.py +++ b/src/pipecat/utils/text/base_text_aggregator.py @@ -21,6 +21,7 @@ class AggregationType(str, Enum): """Built-in aggregation strings.""" SENTENCE = "sentence" + TOKEN = "token" WORD = "word" def __str__(self): @@ -66,6 +67,25 @@ class BaseTextAggregator(ABC): logic, text manipulation behavior, and state management for interruptions. """ + def __init__(self, *, aggregation_type: AggregationType = AggregationType.SENTENCE): + """Initialize the base text aggregator. + + Args: + aggregation_type: The aggregation strategy to use. SENTENCE buffers + text until sentence boundaries are detected, TOKEN passes text + through immediately, and WORD buffers until word boundaries. + """ + self._aggregation_type = AggregationType(aggregation_type) + + @property + def aggregation_type(self) -> AggregationType: + """Get the aggregation type for this aggregator. + + Returns: + The aggregation type. + """ + return self._aggregation_type + @property @abstractmethod def text(self) -> Aggregation: diff --git a/src/pipecat/utils/text/pattern_pair_aggregator.py b/src/pipecat/utils/text/pattern_pair_aggregator.py index bfaf9291b..835bb8591 100644 --- a/src/pipecat/utils/text/pattern_pair_aggregator.py +++ b/src/pipecat/utils/text/pattern_pair_aggregator.py @@ -96,8 +96,11 @@ class PatternPairAggregator(SimpleTextAggregator): Creates an empty aggregator with no patterns or handlers registered. Text buffering and pattern detection will begin when text is aggregated. + + Args: + **kwargs: Additional arguments passed to SimpleTextAggregator (e.g. aggregation_type). """ - super().__init__() + super().__init__(**kwargs) self._patterns = {} self._handlers = {} self._last_processed_position = 0 # Track where we last checked for complete patterns @@ -146,7 +149,7 @@ class PatternPairAggregator(SimpleTextAggregator): Returns: Self for method chaining. """ - if type in [AggregationType.SENTENCE, AggregationType.WORD]: + if type in [AggregationType.SENTENCE, AggregationType.WORD, AggregationType.TOKEN]: raise ValueError( f"The aggregation type '{type}' is reserved for default behavior and can not be used for custom patterns." ) @@ -321,6 +324,9 @@ class PatternPairAggregator(SimpleTextAggregator): and uses the parent's lookahead logic for sentence detection when no patterns are active. + In TOKEN mode, pattern detection still works but non-pattern text is + yielded as TOKEN aggregations instead of waiting for sentence boundaries. + Args: text: Text to aggregate. @@ -370,18 +376,35 @@ class PatternPairAggregator(SimpleTextAggregator): # boundaries when a pattern begins (e.g., "Here is code ..." yields "Here is code") result = self._text[: pattern_start[0]] self._text = self._text[pattern_start[0] :] - yield PatternMatch( - content=result.strip(), type=AggregationType.SENTENCE, full_match=result + agg_type = ( + AggregationType.TOKEN + if self._aggregation_type == AggregationType.TOKEN + else AggregationType.SENTENCE ) + yield PatternMatch(content=result.strip(), type=agg_type, full_match=result) continue - # Use parent's lookahead logic for sentence detection - aggregation = await super()._check_sentence_with_lookahead(char) - if aggregation: - # Convert to PatternMatch for consistency with return type + if self._aggregation_type != AggregationType.TOKEN: + # Use parent's lookahead logic for sentence detection + aggregation = await super()._check_sentence_with_lookahead(char) + if aggregation: + # Convert to PatternMatch for consistency with return type + yield PatternMatch( + content=aggregation.text, + type=aggregation.type, + full_match=aggregation.text, + ) + + # In TOKEN mode, yield any accumulated text after processing all chars, + # but only if there's no incomplete pattern being buffered. + if self._aggregation_type == AggregationType.TOKEN and self._text: + if self._match_start_of_pattern(self._text) is None: yield PatternMatch( - content=aggregation.text, type=aggregation.type, full_match=aggregation.text + content=self._text, + type=AggregationType.TOKEN, + full_match=self._text, ) + self._text = "" async def handle_interruption(self): """Handle interruptions by clearing the buffer and pattern state. diff --git a/src/pipecat/utils/text/simple_text_aggregator.py b/src/pipecat/utils/text/simple_text_aggregator.py index b0cc698a9..b5b179fcf 100644 --- a/src/pipecat/utils/text/simple_text_aggregator.py +++ b/src/pipecat/utils/text/simple_text_aggregator.py @@ -25,11 +25,15 @@ class SimpleTextAggregator(BaseTextAggregator): most straightforward implementation of text aggregation for TTS processing. """ - def __init__(self): + def __init__(self, **kwargs): """Initialize the simple text aggregator. Creates an empty text buffer ready to begin accumulating text tokens. + + Args: + **kwargs: Additional arguments passed to BaseTextAggregator (e.g. aggregation_type). """ + super().__init__(**kwargs) self._text = "" self._needs_lookahead: bool = False @@ -43,19 +47,25 @@ class SimpleTextAggregator(BaseTextAggregator): return Aggregation(text=self._text.strip(" "), type=AggregationType.SENTENCE) async def aggregate(self, text: str) -> AsyncIterator[Aggregation]: - """Aggregate text and yield completed sentences. + """Aggregate text and yield completed aggregations. - Processes the input text character-by-character. When sentence-ending - punctuation is detected, it waits for non-whitespace lookahead before - calling NLTK. This prevents false positives like "$29." being detected - as a sentence when it's actually "$29.95". + In SENTENCE mode, processes the input text character-by-character. When + sentence-ending punctuation is detected, it waits for non-whitespace + lookahead before calling NLTK. + + In TOKEN mode, yields the text immediately without buffering. Args: text: Text to aggregate. Yields: - Complete sentences as Aggregation objects. + Aggregation objects (sentences in SENTENCE mode, tokens in TOKEN mode). """ + if self._aggregation_type == AggregationType.TOKEN: + if text: + yield Aggregation(text=text, type=AggregationType.TOKEN) + return + # Process text character by character for char in text: self._text += char @@ -114,11 +124,15 @@ class SimpleTextAggregator(BaseTextAggregator): """Flush any remaining text in the buffer. Returns any text remaining in the buffer. This is called at the end - of a stream to ensure all text is processed. + of a stream to ensure all text is processed. In TOKEN mode, returns + None since tokens are yielded immediately. Returns: - Any remaining text as a sentence, or None if buffer is empty. + Any remaining text as a sentence, or None if buffer is empty or in TOKEN mode. """ + if self._aggregation_type == AggregationType.TOKEN: + return None + if self._text: # Return whatever we have in the buffer result = self._text diff --git a/src/pipecat/utils/text/skip_tags_aggregator.py b/src/pipecat/utils/text/skip_tags_aggregator.py index 1212bd34d..1b6a7f156 100644 --- a/src/pipecat/utils/text/skip_tags_aggregator.py +++ b/src/pipecat/utils/text/skip_tags_aggregator.py @@ -31,14 +31,15 @@ class SkipTagsAggregator(SimpleTextAggregator): identified and that content within tags is never split at sentence boundaries. """ - def __init__(self, tags: Sequence[StartEndTags]): + def __init__(self, tags: Sequence[StartEndTags], **kwargs): """Initialize the skip tags aggregator. Args: tags: Sequence of StartEndTags objects defining the tag pairs that should prevent sentence boundary detection. + **kwargs: Additional arguments passed to SimpleTextAggregator (e.g. aggregation_type). """ - super().__init__() + super().__init__(**kwargs) self._tags = tags self._current_tag: Optional[StartEndTags] = None self._current_tag_index: int = 0 @@ -50,13 +51,33 @@ class SkipTagsAggregator(SimpleTextAggregator): uses the parent's lookahead logic for sentence detection when not inside tags. + In TOKEN mode, text is passed through immediately unless we're inside + a tag, in which case we buffer until the closing tag is found. + Args: text: Text to aggregate. Yields: Aggregation objects containing text up to a sentence boundary, - marked as SENTENCE type. + marked as SENTENCE type (or TOKEN type in TOKEN mode). """ + if self._aggregation_type == AggregationType.TOKEN: + # In TOKEN mode, process chars for tag tracking but yield the + # full input as a single token when not inside a tag. + for char in text: + self._text += char + + # Update tag state + (self._current_tag, self._current_tag_index) = parse_start_end_tags( + self._text, self._tags, self._current_tag, self._current_tag_index + ) + + # After processing all chars: if not inside a tag, yield accumulated text + if not self._current_tag and self._text: + yield Aggregation(text=self._text, type=AggregationType.TOKEN) + self._text = "" + return + # Process text character by character for char in text: self._text += char 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_attributes.py b/src/pipecat/utils/tracing/service_attributes.py index c8471a03b..e5cf4a83f 100644 --- a/src/pipecat/utils/tracing/service_attributes.py +++ b/src/pipecat/utils/tracing/service_attributes.py @@ -17,26 +17,28 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional if TYPE_CHECKING: from opentelemetry.trace import Span + from pipecat.services.settings import ServiceSettings + from pipecat.utils.tracing.setup import is_tracing_available if is_tracing_available(): from opentelemetry.trace import Span -def _get_gen_ai_system_from_service_name(service_name: str) -> str: - """Extract the standardized gen_ai.system value from a service class name. +def _get_provider_name_from_service_name(service_name: str) -> str: + """Extract the standardized gen_ai.provider.name value from a service class name. Source: - https://opentelemetry.io/docs/specs/semconv/attributes-registry/gen-ai/#gen-ai-system + https://opentelemetry.io/docs/specs/semconv/attributes-registry/gen-ai/ Uses standard OTel names where possible, with special case mappings for service names that don't follow the pattern. Args: - service_name: The service class name to extract system name from. + service_name: The service class name to extract provider name from. Returns: - The standardized gen_ai.system value. + The standardized gen_ai.provider.name value. """ SPECIAL_CASE_MAPPINGS = { # AWS @@ -68,7 +70,7 @@ def add_tts_span_attributes( model: str, voice_id: str, text: Optional[str] = None, - settings: Optional[Dict[str, Any]] = None, + settings: Optional["ServiceSettings"] = None, character_count: Optional[int] = None, operation_name: str = "tts", ttfb: Optional[float] = None, @@ -89,7 +91,7 @@ def add_tts_span_attributes( **kwargs: Additional attributes to add. """ # Add standard attributes - span.set_attribute("gen_ai.system", service_name.replace("TTSService", "").lower()) + span.set_attribute("gen_ai.provider.name", service_name.replace("TTSService", "").lower()) span.set_attribute("gen_ai.request.model", model) span.set_attribute("gen_ai.operation.name", operation_name) span.set_attribute("gen_ai.output.type", "speech") @@ -105,9 +107,9 @@ def add_tts_span_attributes( if ttfb is not None: span.set_attribute("metrics.ttfb", ttfb) - # Add settings if provided + # Use given_fields() defensively in case a service doesn't initialize all settings. if settings: - for key, value in settings.items(): + for key, value in settings.given_fields().items(): if isinstance(value, (str, int, float, bool)): span.set_attribute(f"settings.{key}", value) @@ -126,7 +128,7 @@ def add_stt_span_attributes( is_final: Optional[bool] = None, language: Optional[str] = None, user_id: Optional[str] = None, - settings: Optional[Dict[str, Any]] = None, + settings: Optional["ServiceSettings"] = None, vad_enabled: bool = False, ttfb: Optional[float] = None, **kwargs, @@ -148,7 +150,7 @@ def add_stt_span_attributes( **kwargs: Additional attributes to add. """ # Add standard attributes - span.set_attribute("gen_ai.system", service_name.replace("STTService", "").lower()) + span.set_attribute("gen_ai.provider.name", service_name.replace("STTService", "").lower()) span.set_attribute("gen_ai.request.model", model) span.set_attribute("gen_ai.operation.name", operation_name) span.set_attribute("vad_enabled", vad_enabled) @@ -169,9 +171,9 @@ def add_stt_span_attributes( if ttfb is not None: span.set_attribute("metrics.ttfb", ttfb) - # Add settings if provided + # Use given_fields() defensively in case a service doesn't initialize all settings. if settings: - for key, value in settings.items(): + for key, value in settings.given_fields().items(): if isinstance(value, (str, int, float, bool)): span.set_attribute(f"settings.{key}", value) @@ -191,7 +193,7 @@ def add_llm_span_attributes( tools: Optional[str] = None, tool_count: Optional[int] = None, tool_choice: Optional[str] = None, - system: Optional[str] = None, + system_instructions: Optional[str] = None, parameters: Optional[Dict[str, Any]] = None, extra_parameters: Optional[Dict[str, Any]] = None, ttfb: Optional[float] = None, @@ -209,14 +211,14 @@ def add_llm_span_attributes( tools: JSON-serialized tools configuration. tool_count: Number of tools available. tool_choice: Tool selection configuration. - system: System message. + system_instructions: System instructions. parameters: Service parameters. extra_parameters: Additional parameters. ttfb: Time to first byte in seconds. **kwargs: Additional attributes to add. """ # Add standard attributes - span.set_attribute("gen_ai.system", _get_gen_ai_system_from_service_name(service_name)) + span.set_attribute("gen_ai.provider.name", _get_provider_name_from_service_name(service_name)) span.set_attribute("gen_ai.request.model", model) span.set_attribute("gen_ai.operation.name", "chat") span.set_attribute("gen_ai.output.type", "text") @@ -238,8 +240,8 @@ def add_llm_span_attributes( if tool_choice: span.set_attribute("tool_choice", tool_choice) - if system: - span.set_attribute("system", system) + if system_instructions: + span.set_attribute("gen_ai.system_instructions", system_instructions) if ttfb is not None: span.set_attribute("metrics.ttfb", ttfb) @@ -282,7 +284,7 @@ def add_gemini_live_span_attributes( voice_id: Optional[str] = None, language: Optional[str] = None, modalities: Optional[str] = None, - settings: Optional[Dict[str, Any]] = None, + settings: Optional["ServiceSettings"] = None, tools: Optional[List[Dict]] = None, tools_serialized: Optional[str] = None, transcript: Optional[str] = None, @@ -311,7 +313,7 @@ def add_gemini_live_span_attributes( **kwargs: Additional attributes to add. """ # Add standard attributes - span.set_attribute("gen_ai.system", "gcp.gemini") + span.set_attribute("gen_ai.provider.name", "gcp.gemini") span.set_attribute("gen_ai.request.model", model) span.set_attribute("gen_ai.operation.name", operation_name) span.set_attribute("service.operation", operation_name) @@ -357,9 +359,9 @@ def add_gemini_live_span_attributes( if tools_serialized: span.set_attribute("tools.definitions", tools_serialized) - # Add settings if provided + # Use given_fields() defensively in case a service doesn't initialize all settings. if settings: - for key, value in settings.items(): + for key, value in settings.given_fields().items(): if isinstance(value, (str, int, float, bool)): span.set_attribute(f"settings.{key}", value) elif key == "vad" and value: @@ -412,7 +414,7 @@ def add_openai_realtime_span_attributes( **kwargs: Additional attributes to add. """ # Add standard attributes - span.set_attribute("gen_ai.system", "openai") + span.set_attribute("gen_ai.provider.name", "openai") span.set_attribute("gen_ai.request.model", model) span.set_attribute("gen_ai.operation.name", operation_name) span.set_attribute("service.operation", operation_name) diff --git a/src/pipecat/utils/tracing/service_decorators.py b/src/pipecat/utils/tracing/service_decorators.py index 22274ae8b..cc353e1a3 100644 --- a/src/pipecat/utils/tracing/service_decorators.py +++ b/src/pipecat/utils/tracing/service_decorators.py @@ -33,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 @@ -43,6 +42,25 @@ T = TypeVar("T") R = TypeVar("R") +def _get_model_name(service) -> str: + """Get the model name from a service instance. + + This is a bit of a mess — there were multiple places a model name could live. + Soon, self._settings should be the only source of truth about model name. + In fact...it might already be the case, but juuuuust to be safe, we'll + check all the places we used to store it. + """ + return ( + # Some services store an API-response-provided detailed "full" name, + # which is distinct from the user-provided model name + getattr(service, "_full_model_name", None) + or getattr(getattr(service, "_settings", None), "model", None) + or getattr(service, "model_name", None) + or getattr(service, "_model_name", None) + or "unknown" + ) + + def _noop_decorator(func): """No-op fallback decorator when tracing is unavailable. @@ -55,10 +73,24 @@ 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). - This looks for the service span that was created when the service was initialized. + This looks for the service span that was created when the service was initialized, + or falls back to the conversation context if available. Args: self: The service instance. @@ -69,11 +101,18 @@ 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) - # If we can't find a stored span, default to current 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 + + # Last resort: use current context (may create orphan spans) return context_api.get_current() @@ -98,14 +137,14 @@ def _add_token_usage_to_span(span, token_usage): and token_usage["cache_read_input_tokens"] is not None ): span.set_attribute( - "gen_ai.usage.cache_read_input_tokens", token_usage["cache_read_input_tokens"] + "gen_ai.usage.cache_read.input_tokens", token_usage["cache_read_input_tokens"] ) if ( "cache_creation_input_tokens" in token_usage and token_usage["cache_creation_input_tokens"] is not None ): span.set_attribute( - "gen_ai.usage.cache_creation_input_tokens", + "gen_ai.usage.cache_creation.input_tokens", token_usage["cache_creation_input_tokens"], ) if "reasoning_tokens" in token_usage and token_usage["reasoning_tokens"] is not None: @@ -120,11 +159,11 @@ def _add_token_usage_to_span(span, token_usage): # Add cached token metrics for LLMTokenUsage object cache_read_tokens = getattr(token_usage, "cache_read_input_tokens", None) if cache_read_tokens is not None: - span.set_attribute("gen_ai.usage.cache_read_input_tokens", cache_read_tokens) + span.set_attribute("gen_ai.usage.cache_read.input_tokens", cache_read_tokens) cache_creation_tokens = getattr(token_usage, "cache_creation_input_tokens", None) if cache_creation_tokens is not None: - span.set_attribute("gen_ai.usage.cache_creation_input_tokens", cache_creation_tokens) + span.set_attribute("gen_ai.usage.cache_creation.input_tokens", cache_creation_tokens) reasoning_tokens = getattr(token_usage, "reasoning_tokens", None) if reasoning_tokens is not None: @@ -176,20 +215,20 @@ 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") with tracer.start_as_current_span(span_name, context=parent_context) as span: try: + settings = getattr(self, "_settings", None) add_tts_span_attributes( span=span, service_name=service_class_name, - model=getattr(self, "model_name", "unknown"), - voice_id=getattr(self, "_voice_id", "unknown"), + model=_get_model_name(self), + voice_id=getattr(settings, "voice", "unknown"), text=text, - settings=getattr(self, "_settings", {}), + settings=settings, character_count=len(text), operation_name="tts", cartesia_version=getattr(self, "_cartesia_version", None), @@ -211,19 +250,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 @@ -232,16 +273,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 @@ -274,17 +317,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") @@ -298,12 +340,12 @@ def traced_stt(func: Optional[Callable] = None, *, name: Optional[str] = None) - ) # Use settings from the service if available - settings = getattr(self, "_settings", {}) + settings = getattr(self, "_settings", None) add_stt_span_attributes( span=current_span, service_name=service_class_name, - model=getattr(self, "model_name") or settings.get("model", "unknown"), + model=_get_model_name(self), transcript=transcript, is_final=is_final, language=str(language) if language else None, @@ -314,14 +356,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 @@ -356,17 +400,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") @@ -459,33 +502,54 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - # Handle system message for different services system_message = None - if hasattr(context, "system"): + if isinstance(context, LLMContext): + # settings.system_instruction takes priority (matches service behavior) + if hasattr(self, "_settings") and getattr( + self._settings, "system_instruction", None + ): + system_message = self._settings.system_instruction + else: + # Fall back to extracting from context messages + ctx_messages = context.get_messages() + if ctx_messages: + first = ctx_messages[0] + if ( + isinstance(first, dict) + and first.get("role") == "system" + ): + content = first.get("content") + if isinstance(content, str): + system_message = content + elif isinstance(content, list): + system_message = " ".join( + part.get("text", "") + for part in content + if isinstance(part, dict) + and part.get("type") == "text" + ) + elif hasattr(context, "system"): system_message = context.system elif hasattr(context, "system_message"): system_message = context.system_message - elif hasattr(self, "_system_instruction"): - system_message = self._system_instruction - # Get settings from the service + # Use given_fields() defensively in case a service doesn't + # initialize all settings. params = {} if hasattr(self, "_settings"): - for key, value in self._settings.items(): - if key == "extra": + for key, value in self._settings.given_fields().items(): + # system_instruction is already captured as the + # "system_instructions" span attribute above. + if key == "system_instruction": continue - # Add value directly if it's a basic type if isinstance(value, (int, float, bool, str)): params[key] = value - elif value is None or ( - hasattr(value, "__name__") and value.__name__ == "NOT_GIVEN" - ): + elif value is None: params[key] = "NOT_GIVEN" # Add all available attributes to the span attribute_kwargs = { "service_name": service_class_name, - "model": getattr( - self, getattr(self, "_full_model_name", "model_name"), "unknown" - ), + "model": _get_model_name(self), "stream": True, # Most LLM services use streaming "parameters": params, } @@ -497,7 +561,7 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - attribute_kwargs["tools"] = serialized_tools attribute_kwargs["tool_count"] = tool_count if system_message: - attribute_kwargs["system"] = system_message + attribute_kwargs["system_instructions"] = system_message # Add all gathered attributes to the span add_llm_span_attributes(span=current_span, **attribute_kwargs) @@ -507,6 +571,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 @@ -532,8 +597,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 @@ -565,17 +631,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") @@ -584,17 +649,15 @@ def traced_gemini_live(operation: str) -> Callable: ) as current_span: try: # Base service attributes - model_name = getattr( - self, "model_name", getattr(self, "_model_name", "unknown") - ) + model_name = _get_model_name(self) voice_id = getattr(self, "_voice_id", None) language_code = getattr(self, "_language_code", None) - settings = getattr(self, "_settings", {}) + settings = getattr(self, "_settings", None) # Get modalities if available modalities = None - if hasattr(self, "_settings") and "modalities" in self._settings: - modality_obj = self._settings["modalities"] + if settings and hasattr(settings, "modalities"): + modality_obj = settings.modalities if hasattr(modality_obj, "value"): modalities = modality_obj.value else: @@ -830,6 +893,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 @@ -840,8 +904,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 @@ -870,17 +935,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") @@ -889,9 +953,7 @@ def traced_openai_realtime(operation: str) -> Callable: ) as current_span: try: # Base service attributes - model_name = getattr( - self, "model_name", getattr(self, "_model_name", "unknown") - ) + model_name = _get_model_name(self) # Operation-specific attribute collection operation_attrs = {} @@ -1052,6 +1114,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 @@ -1062,8 +1125,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 a76d9bb8f..83c2bcdc2 100644 --- a/src/pipecat/utils/tracing/turn_trace_observer.py +++ b/src/pipecat/utils/tracing/turn_trace_observer.py @@ -15,11 +15,12 @@ from typing import TYPE_CHECKING, Dict, Optional from loguru import logger +from pipecat.frames.frames import StartFrame from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.observers.turn_tracking_observer import TurnTrackingObserver -from pipecat.utils.tracing.conversation_context_provider import ConversationContextProvider +from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver 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: @@ -44,20 +45,26 @@ class TurnTraceObserver(BaseObserver): def __init__( self, turn_tracker: TurnTrackingObserver, + 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. Args: turn_tracker: The turn tracking observer to monitor. + 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"] = {} @@ -68,26 +75,45 @@ class TurnTraceObserver(BaseObserver): self._conversation_id = conversation_id self._additional_span_attributes = additional_span_attributes or {} - if turn_tracker: + @turn_tracker.event_handler("on_turn_started") + async def on_turn_started(tracker, turn_number): + await self._handle_turn_started(turn_number) - @turn_tracker.event_handler("on_turn_started") - async def on_turn_started(tracker, turn_number): - await self._handle_turn_started(turn_number) + @turn_tracker.event_handler("on_turn_ended") + async def on_turn_ended(tracker, turn_number, duration, was_interrupted): + await self._handle_turn_ended(turn_number, duration, was_interrupted) - @turn_tracker.event_handler("on_turn_ended") - async def on_turn_ended(tracker, turn_number, duration, was_interrupted): - await self._handle_turn_ended(turn_number, duration, was_interrupted) + @latency_tracker.event_handler("on_latency_measured") + async def on_latency_measured(tracker, latency_seconds): + await self._handle_latency_measured(latency_seconds) + + async def _handle_latency_measured(self, latency_seconds: float): + """Handle latency measurement events. + + Called when the latency tracker measures user-to-bot latency. + Adds the latency as an attribute to the current turn span. + + Args: + latency_seconds: The measured latency in seconds. + """ + if self._current_span and is_tracing_available(): + self._current_span.set_attribute("turn.user_bot_latency_seconds", latency_seconds) + logger.debug( + f"Turn {self._current_turn_number} user-bot latency: {latency_seconds:.3f}s" + ) async def on_push_frame(self, data: FramePushed): """Process a frame without modifying it. - This observer doesn't need to process individual frames as it - relies on turn start/end events from the turn tracker. + Handles StartFrame to begin conversation tracing early, ensuring + that any spans created before Turn 1 (e.g., from flow initialization) + are properly attached to the conversation trace. Args: data: The frame push event data. """ - pass + if isinstance(data.frame, StartFrame) and not self._conversation_span: + self.start_conversation_tracing(self._conversation_id) def start_conversation_tracing(self, conversation_id: Optional[str] = None): """Start a new conversation span. @@ -99,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 @@ -116,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 ) @@ -137,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: @@ -147,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 @@ -159,16 +182,13 @@ class TurnTraceObserver(BaseObserver): if not is_tracing_available() or not self._tracer: return - # If this is the first turn and no conversation span exists yet, - # start the conversation tracing (will generate ID if needed) if turn_number == 1 and not self._conversation_span: self.start_conversation_tracing(self._conversation_id) # 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) @@ -185,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}") @@ -206,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/genesys/__init__.py b/tests/genesys/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/genesys/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/genesys/conftest.py b/tests/genesys/conftest.py new file mode 100644 index 000000000..a0cf1588f --- /dev/null +++ b/tests/genesys/conftest.py @@ -0,0 +1,192 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Pytest fixtures for Genesys AudioHook serializer tests. + +These fixtures provide sample AudioHook protocol messages for testing +the GenesysAudioHookSerializer. They are scoped to this directory only. +""" + +import pytest + + +@pytest.fixture +def sample_open_message(): + """Sample AudioHook open message from Genesys.""" + return { + "version": "2", + "type": "open", + "seq": 1, + "id": "test-session-123", + "parameters": { + "conversationId": "conv-456", + "participant": { + "ani": "+1234567890", + "dnis": "+0987654321", + }, + "media": [ + { + "type": "audio", + "format": "PCMU", + "channels": ["external"], + "rate": 8000, + } + ], + }, + } + + +@pytest.fixture +def sample_open_message_with_input_variables(): + """Sample AudioHook open message with custom inputVariables from Genesys.""" + return { + "version": "2", + "type": "open", + "seq": 1, + "id": "test-session-123", + "parameters": { + "conversationId": "conv-456", + "participant": { + "ani": "+1234567890", + "dnis": "+0987654321", + }, + "media": [ + { + "type": "audio", + "format": "PCMU", + "channels": ["external"], + "rate": 8000, + } + ], + "inputVariables": { + "customer_id": "cust-789", + "queue_name": "billing", + "priority": "high", + "language": "es-ES", + }, + }, + } + + +@pytest.fixture +def sample_ping_message(): + """Sample AudioHook ping message.""" + return { + "version": "2", + "type": "ping", + "seq": 5, + "id": "test-session-123", + "position": "PT10.5S", + } + + +@pytest.fixture +def sample_close_message(): + """Sample AudioHook close message from Genesys.""" + return { + "version": "2", + "type": "close", + "seq": 10, + "id": "test-session-123", + "position": "PT30.0S", + "parameters": { + "reason": "disconnect", + }, + } + + +@pytest.fixture +def sample_pause_message(): + """Sample AudioHook pause message.""" + return { + "version": "2", + "type": "pause", + "seq": 7, + "id": "test-session-123", + "position": "PT15.0S", + "parameters": { + "reason": "hold", + }, + } + + +@pytest.fixture +def sample_update_message(): + """Sample AudioHook update message.""" + return { + "version": "2", + "type": "update", + "seq": 8, + "id": "test-session-123", + "position": "PT20.0S", + "parameters": { + "participant": { + "ani": "+1234567890", + "dnis": "+0987654321", + "name": "John Doe", + }, + }, + } + + +@pytest.fixture +def sample_error_message(): + """Sample AudioHook error message.""" + return { + "version": "2", + "type": "error", + "seq": 9, + "id": "test-session-123", + "parameters": { + "code": 500, + "message": "Internal server error", + }, + } + + +@pytest.fixture +def sample_dtmf_message(): + """Sample AudioHook DTMF message.""" + return { + "version": "2", + "type": "dtmf", + "seq": 6, + "id": "test-session-123", + "position": "PT12.0S", + "parameters": { + "digit": "5", + }, + } + + +@pytest.fixture +def sample_dtmf_star_message(): + """Sample AudioHook DTMF message with star key.""" + return { + "version": "2", + "type": "dtmf", + "seq": 6, + "id": "test-session-123", + "position": "PT12.0S", + "parameters": { + "digit": "*", + }, + } + + +@pytest.fixture +def sample_dtmf_hash_message(): + """Sample AudioHook DTMF message with hash key.""" + return { + "version": "2", + "type": "dtmf", + "seq": 6, + "id": "test-session-123", + "position": "PT12.0S", + "parameters": { + "digit": "#", + }, + } diff --git a/tests/genesys/test_genesys_serializer.py b/tests/genesys/test_genesys_serializer.py new file mode 100644 index 000000000..1f63ae94e --- /dev/null +++ b/tests/genesys/test_genesys_serializer.py @@ -0,0 +1,376 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Tests for the Genesys AudioHook serializer.""" + +import json + +import pytest + +from pipecat.frames.frames import InputDTMFFrame, OutputTransportMessageUrgentFrame +from pipecat.serializers.genesys import AudioHookChannel, GenesysAudioHookSerializer + + +class TestGenesysAudioHookSerializer: + """Tests for GenesysAudioHookSerializer.""" + + # ==================== Initialization Tests ==================== + + def test_create_serializer_default_params(self): + """Test creating serializer with default parameters.""" + serializer = GenesysAudioHookSerializer() + + # session_id is auto-generated as UUID + assert serializer.session_id != "" + assert len(serializer.session_id) == 36 # UUID format + assert serializer.is_open is False + assert serializer.is_paused is False + + def test_create_serializer_with_custom_params(self): + """Test creating serializer with custom parameters.""" + params = GenesysAudioHookSerializer.InputParams( + channel=AudioHookChannel.BOTH, + sample_rate=16000, + supported_languages=["es-ES", "en-US"], + selected_language="es-ES", + start_paused=True, + ) + serializer = GenesysAudioHookSerializer(params=params) + + assert serializer.session_id != "" + + # ==================== Response Creation Tests ==================== + + def test_create_opened_response(self): + """Test creating an opened response message.""" + serializer = GenesysAudioHookSerializer() + + msg = serializer.create_opened_response() + + assert msg["type"] == "opened" + assert msg["version"] == "2" + assert msg["id"] == serializer.session_id + assert "parameters" in msg + assert serializer.is_open is True + + def test_create_opened_response_with_languages(self): + """Test creating an opened response with language options.""" + serializer = GenesysAudioHookSerializer() + + msg = serializer.create_opened_response( + supported_languages=["es", "en", "fr"], + selected_language="es", + ) + + assert msg["parameters"]["supportedLanguages"] == ["es", "en", "fr"] + assert msg["parameters"]["selectedLanguage"] == "es" + + def test_create_pong_response(self): + """Test creating a pong response message.""" + serializer = GenesysAudioHookSerializer() + + msg = serializer.create_pong_response() + + assert msg["type"] == "pong" + assert msg["id"] == serializer.session_id + assert msg["parameters"] == {} + + def test_create_closed_response(self): + """Test creating a closed response message.""" + serializer = GenesysAudioHookSerializer() + serializer._is_open = True + + msg = serializer.create_closed_response() + + assert msg["type"] == "closed" + assert serializer.is_open is False + assert msg["parameters"] == {} # Empty parameters when no output_variables + + def test_create_closed_response_with_output_variables(self): + """Test creating a closed response with custom output variables.""" + serializer = GenesysAudioHookSerializer() + serializer._is_open = True + + msg = serializer.create_closed_response( + output_variables={ + "intent": "billing_inquiry", + "customer_verified": True, + "summary": "Customer asked about their bill", + } + ) + + assert msg["type"] == "closed" + assert msg["parameters"]["outputVariables"]["intent"] == "billing_inquiry" + assert msg["parameters"]["outputVariables"]["customer_verified"] is True + assert msg["parameters"]["outputVariables"]["summary"] == "Customer asked about their bill" + + def test_create_resumed_response(self): + """Test creating a resumed response message.""" + serializer = GenesysAudioHookSerializer() + serializer._is_paused = True + + msg = serializer.create_resumed_response() + + assert msg["type"] == "resumed" + assert serializer.is_paused is False + + def test_create_disconnect_message(self): + """Test creating a disconnect message.""" + serializer = GenesysAudioHookSerializer() + + msg = serializer.create_disconnect_message( + reason="completed", + action="transfer", + ) + + assert msg["type"] == "disconnect" + assert msg["parameters"]["reason"] == "completed" + assert msg["parameters"]["outputVariables"]["action"] == "transfer" + + def test_create_disconnect_message_with_output_variables(self): + """Test creating a disconnect message with custom output variables.""" + serializer = GenesysAudioHookSerializer() + + msg = serializer.create_disconnect_message( + reason="completed", + action="finished", + output_variables={"result": "success", "code": "123"}, + ) + + assert msg["parameters"]["outputVariables"]["result"] == "success" + assert msg["parameters"]["outputVariables"]["code"] == "123" + + def test_create_error_message(self): + """Test creating an error message.""" + serializer = GenesysAudioHookSerializer() + + msg = serializer.create_error_message( + code=500, + message="Internal error", + retryable=True, + ) + + assert msg["type"] == "error" + assert msg["parameters"]["code"] == 500 + assert msg["parameters"]["message"] == "Internal error" + assert msg["parameters"]["retryable"] is True + + # ==================== Message Handling Tests ==================== + + @pytest.mark.asyncio + async def test_handle_open_message(self, sample_open_message): + """Test handling an open message returns opened frame.""" + serializer = GenesysAudioHookSerializer() + + result = await serializer.deserialize(json.dumps(sample_open_message)) + + # Now returns OutputTransportMessageUrgentFrame with opened response + assert isinstance(result, OutputTransportMessageUrgentFrame) + assert result.message["type"] == "opened" + assert serializer.session_id == "test-session-123" + assert serializer.conversation_id == "conv-456" + + @pytest.mark.asyncio + async def test_handle_open_message_extracts_participant(self, sample_open_message): + """Test that open message extracts participant info.""" + serializer = GenesysAudioHookSerializer() + + await serializer.deserialize(json.dumps(sample_open_message)) + + assert serializer.participant is not None + assert serializer.participant["ani"] == "+1234567890" + assert serializer.participant["dnis"] == "+0987654321" + + @pytest.mark.asyncio + async def test_handle_open_message_uses_params(self, sample_open_message): + """Test that open message uses InputParams for response.""" + params = GenesysAudioHookSerializer.InputParams( + supported_languages=["es-ES", "en-US"], + selected_language="es-ES", + start_paused=True, + ) + serializer = GenesysAudioHookSerializer(params=params) + + result = await serializer.deserialize(json.dumps(sample_open_message)) + + assert isinstance(result, OutputTransportMessageUrgentFrame) + assert result.message["parameters"]["supportedLanguages"] == ["es-ES", "en-US"] + assert result.message["parameters"]["selectedLanguage"] == "es-ES" + assert result.message["parameters"]["startPaused"] is True + + @pytest.mark.asyncio + async def test_handle_open_message_extracts_input_variables( + self, sample_open_message_with_input_variables + ): + """Test that open message extracts inputVariables from Genesys.""" + serializer = GenesysAudioHookSerializer() + + await serializer.deserialize(json.dumps(sample_open_message_with_input_variables)) + + assert serializer.input_variables is not None + assert serializer.input_variables["customer_id"] == "cust-789" + assert serializer.input_variables["queue_name"] == "billing" + assert serializer.input_variables["priority"] == "high" + assert serializer.input_variables["language"] == "es-ES" + + @pytest.mark.asyncio + async def test_handle_ping_message(self, sample_ping_message): + """Test handling a ping message returns pong frame.""" + serializer = GenesysAudioHookSerializer() + + result = await serializer.deserialize(json.dumps(sample_ping_message)) + + assert isinstance(result, OutputTransportMessageUrgentFrame) + assert result.message["type"] == "pong" + + @pytest.mark.asyncio + async def test_handle_close_message(self, sample_close_message): + """Test handling a close message returns closed frame.""" + serializer = GenesysAudioHookSerializer() + serializer._is_open = True + + result = await serializer.deserialize(json.dumps(sample_close_message)) + + assert isinstance(result, OutputTransportMessageUrgentFrame) + assert result.message["type"] == "closed" + assert serializer.is_open is False + + @pytest.mark.asyncio + async def test_handle_close_message_includes_output_variables(self, sample_close_message): + """Test that close response includes output variables when set.""" + serializer = GenesysAudioHookSerializer() + serializer._is_open = True + + # Set output variables before close + serializer.set_output_variables( + {"intent": "support", "resolved": True, "transfer_to": "agent_queue"} + ) + + result = await serializer.deserialize(json.dumps(sample_close_message)) + + assert isinstance(result, OutputTransportMessageUrgentFrame) + assert result.message["type"] == "closed" + assert result.message["parameters"]["outputVariables"]["intent"] == "support" + assert result.message["parameters"]["outputVariables"]["resolved"] is True + assert result.message["parameters"]["outputVariables"]["transfer_to"] == "agent_queue" + + # ==================== Output Variables Tests ==================== + + def test_set_output_variables(self): + """Test setting output variables.""" + serializer = GenesysAudioHookSerializer() + + assert serializer.output_variables is None + + serializer.set_output_variables({"intent": "billing", "score": 0.95}) + + assert serializer.output_variables is not None + assert serializer.output_variables["intent"] == "billing" + assert serializer.output_variables["score"] == 0.95 + + def test_set_output_variables_overwrites(self): + """Test that setting output variables overwrites previous values.""" + serializer = GenesysAudioHookSerializer() + + serializer.set_output_variables({"first": "value"}) + serializer.set_output_variables({"second": "value"}) + + assert "first" not in serializer.output_variables + assert serializer.output_variables["second"] == "value" + + @pytest.mark.asyncio + async def test_handle_pause_message(self, sample_pause_message): + """Test handling a pause message.""" + serializer = GenesysAudioHookSerializer() + serializer._is_open = True + + result = await serializer.deserialize(json.dumps(sample_pause_message)) + + assert result is None # Pause is handled internally + assert serializer.is_paused is True + + @pytest.mark.asyncio + async def test_handle_update_message(self, sample_update_message): + """Test handling an update message.""" + serializer = GenesysAudioHookSerializer() + + result = await serializer.deserialize(json.dumps(sample_update_message)) + + assert result is None # Update is handled internally + assert serializer.participant is not None + assert serializer.participant["name"] == "John Doe" + + @pytest.mark.asyncio + async def test_handle_error_message(self, sample_error_message): + """Test handling an error message.""" + serializer = GenesysAudioHookSerializer() + + result = await serializer.deserialize(json.dumps(sample_error_message)) + + assert result is None # Error is logged but returns None + + # ==================== DTMF Tests ==================== + + @pytest.mark.asyncio + async def test_handle_dtmf_digit(self, sample_dtmf_message): + """Test handling a DTMF digit message.""" + serializer = GenesysAudioHookSerializer() + + result = await serializer.deserialize(json.dumps(sample_dtmf_message)) + + assert isinstance(result, InputDTMFFrame) + assert result.button.value == "5" + + @pytest.mark.asyncio + async def test_handle_dtmf_star(self, sample_dtmf_star_message): + """Test handling a DTMF star (*) message.""" + serializer = GenesysAudioHookSerializer() + + result = await serializer.deserialize(json.dumps(sample_dtmf_star_message)) + + assert isinstance(result, InputDTMFFrame) + assert result.button.value == "*" + + @pytest.mark.asyncio + async def test_handle_dtmf_hash(self, sample_dtmf_hash_message): + """Test handling a DTMF hash (#) message.""" + serializer = GenesysAudioHookSerializer() + + result = await serializer.deserialize(json.dumps(sample_dtmf_hash_message)) + + assert isinstance(result, InputDTMFFrame) + assert result.button.value == "#" + + @pytest.mark.asyncio + async def test_handle_dtmf_empty_digit(self): + """Test handling a DTMF message without digit.""" + serializer = GenesysAudioHookSerializer() + + dtmf_msg = { + "version": "2", + "type": "dtmf", + "seq": 6, + "id": "test-session-123", + "parameters": {}, + } + + result = await serializer.deserialize(json.dumps(dtmf_msg)) + + assert result is None # No digit provided + + # ==================== Sequence Number Tests ==================== + + def test_sequence_numbers_increment(self): + """Test that sequence numbers increment correctly.""" + serializer = GenesysAudioHookSerializer() + + response1 = serializer.create_pong_response() + response2 = serializer.create_pong_response() + response3 = serializer.create_pong_response() + + assert response1["seq"] == 1 + assert response2["seq"] == 2 + assert response3["seq"] == 3 diff --git a/tests/integration/test_integration_unified_function_calling.py b/tests/integration/test_integration_unified_function_calling.py index d017c55ac..a282ba611 100644 --- a/tests/integration/test_integration_unified_function_calling.py +++ b/tests/integration/test_integration_unified_function_calling.py @@ -5,7 +5,6 @@ # import os -from unittest.mock import AsyncMock import pytest from dotenv import load_dotenv @@ -89,7 +88,7 @@ async def test_unified_function_calling_openai(): @pytest.mark.skipif(os.getenv("GOOGLE_API_KEY") is None, reason="GOOGLE_API_KEY is not set") @pytest.mark.asyncio async def test_unified_function_calling_gemini(): - llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY"), model="gemini-2.0-flash-001") + llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY")) # This will fail if an exception is raised await _test_llm_function_calling(llm) @@ -97,8 +96,6 @@ async def test_unified_function_calling_gemini(): @pytest.mark.skipif(os.getenv("ANTHROPIC_API_KEY") is None, reason="ANTHROPIC_API_KEY is not set") @pytest.mark.asyncio async def test_unified_function_calling_anthropic(): - llm = AnthropicLLMService( - api_key=os.getenv("ANTHROPIC_API_KEY"), model="claude-3-5-sonnet-20240620" - ) + llm = AnthropicLLMService(api_key=os.getenv("ANTHROPIC_API_KEY")) # This will fail if an exception is raised await _test_llm_function_calling(llm) diff --git a/tests/test_aggregators.py b/tests/test_aggregators.py index 73ccf6b04..6563d324c 100644 --- a/tests/test_aggregators.py +++ b/tests/test_aggregators.py @@ -74,3 +74,7 @@ class TestGatedAggregator(unittest.IsolatedAsyncioTestCase): frames_to_send=frames_to_send, expected_down_frames=expected_down_frames, ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_aic_filter.py b/tests/test_aic_filter.py new file mode 100644 index 000000000..b04054180 --- /dev/null +++ b/tests/test_aic_filter.py @@ -0,0 +1,831 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import time +import unittest +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import numpy as np + +# Check if aic_sdk is available +aic_sdk: Any +try: + import aic_sdk + + HAS_AIC_SDK = True +except ImportError: + aic_sdk = None + HAS_AIC_SDK = False + +# Module path for patching +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.""" + + def __init__(self): + self.processor_ctx = MockProcessorContext() + self.vad_ctx = MockVadContext() + + def get_processor_context(self): + return self.processor_ctx + + def get_vad_context(self): + return self.vad_ctx + + async def process_async(self, audio_array): + # Return a copy of the input (simulating passthrough) + return audio_array.copy() + + +class MockProcessorContext: + """A lightweight mock for AIC ProcessorContext.""" + + def __init__(self): + self.parameters_set: list[tuple] = [] + self.reset_called = False + self._output_delay = 0 + + def get_output_delay(self): + return self._output_delay + + def set_parameter(self, param, value): + self.parameters_set.append((param, value)) + + def reset(self): + self.reset_called = True + + +class UnsupportedEnhancementProcessorContext(MockProcessorContext): + """Processor context mock that rejects EnhancementLevel updates.""" + + def __init__(self, enhancement_parameter, error_type): + super().__init__() + self._enhancement_parameter = enhancement_parameter + self._error_type = error_type + self.enhancement_attempts = 0 + + def set_parameter(self, param, value): + if param == self._enhancement_parameter: + self.enhancement_attempts += 1 + raise self._error_type("EnhancementLevel out of range") + super().set_parameter(param, value) + + +class MockVadContext: + """A lightweight mock for AIC VadContext.""" + + def __init__(self, speech_detected: bool = False): + self.speech_detected = speech_detected + self.parameters_set: list[tuple] = [] + + def is_speech_detected(self) -> bool: + return self.speech_detected + + def set_parameter(self, param, value): + self.parameters_set.append((param, value)) + + +class MockModel: + """A lightweight mock for AIC Model.""" + + def __init__(self, model_id: str = "test-model"): + self._model_id = model_id + self._optimal_num_frames = 160 + self._optimal_sample_rate = 16000 + + def get_optimal_num_frames(self, sample_rate: int): + """Return optimal number of frames for the given sample rate.""" + return self._optimal_num_frames + + def get_id(self): + return self._model_id + + def get_optimal_sample_rate(self): + return self._optimal_sample_rate + + +@unittest.skipUnless(HAS_AIC_SDK, "aic-sdk not installed") +class TestAICFilter(unittest.IsolatedAsyncioTestCase): + """Test suite for AICFilter audio filter using real aic_sdk types.""" + + @classmethod + def setUpClass(cls): + """Import AICFilter after confirming aic_sdk is available.""" + 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): + """Set up test fixtures before each test method.""" + self.mock_model = MockModel() + self.mock_processor = MockProcessor() + + def _create_filter_with_mocks(self, **kwargs): + """Create an AICFilter with mocked SDK components.""" + filter_kwargs = { + "license_key": "test-key", + "model_id": "test-model", + } + filter_kwargs.update(kwargs) + with patch(f"{AIC_FILTER_MODULE}.set_sdk_id"): + return self.AICFilter(**filter_kwargs) + + 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}.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, cache_key)) + mock_config_cls.optimal.return_value = MagicMock() + await filter_instance.start(sample_rate) + + async def test_initialization_requires_model_id_or_path(self): + """Test filter initialization fails without model_id or model_path.""" + with patch(f"{AIC_FILTER_MODULE}.set_sdk_id"): + with self.assertRaises(ValueError) as context: + self.AICFilter(license_key="test-key") + + self.assertIn("model_id", str(context.exception)) + self.assertIn("model_path", str(context.exception)) + + async def test_initialization_with_model_id(self): + """Test filter initialization with model_id.""" + filter_instance = self._create_filter_with_mocks() + + self.assertEqual(filter_instance._license_key, "test-key") + self.assertEqual(filter_instance._model_id, "test-model") + self.assertIsNone(filter_instance._model_path) + self.assertIsNone(filter_instance._enhancement_level) + self.assertFalse(filter_instance._bypass) + + async def test_initialization_with_model_path(self): + """Test filter initialization with model_path.""" + model_path = Path("/tmp/test.aicmodel") + filter_instance = self._create_filter_with_mocks(model_id=None, model_path=model_path) + + self.assertEqual(filter_instance._model_path, model_path) + self.assertIsNone(filter_instance._model_id) + + async def test_initialization_with_custom_download_dir(self): + """Test filter initialization with custom model_download_dir.""" + download_dir = Path("/custom/cache") + filter_instance = self._create_filter_with_mocks(model_download_dir=download_dir) + + self.assertEqual(filter_instance._model_download_dir, download_dir) + + async def test_initialization_with_valid_enhancement_level(self): + """Test filter initialization with a valid enhancement_level.""" + filter_instance = self._create_filter_with_mocks(enhancement_level=0.75) + + self.assertEqual(filter_instance._enhancement_level, 0.75) + + async def test_initialization_with_none_enhancement_level(self): + """Test filter initialization with enhancement_level set to None.""" + filter_instance = self._create_filter_with_mocks(enhancement_level=None) + + self.assertIsNone(filter_instance._enhancement_level) + + async def test_initialization_invalid_enhancement_level_raises(self): + """Test initialization rejects enhancement_level outside 0.0..1.0.""" + with patch(f"{AIC_FILTER_MODULE}.set_sdk_id"): + with self.assertRaises(ValueError) as context: + self.AICFilter( + license_key="test-key", + model_id="test-model", + enhancement_level=1.5, + ) + self.assertIn("enhancement_level", str(context.exception)) + + async def test_start_with_model_path(self): + """Test starting filter with a local model path.""" + model_path = Path("/tmp/test.aicmodel") + filter_instance = self._create_filter_with_mocks(model_id=None, model_path=model_path) + + 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, "path:/tmp/test.aicmodel") + ) + mock_config_cls.optimal.return_value = MagicMock() + + await filter_instance.start(16000) + + 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 uses manager (download happens in manager).""" + 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", return_value=self.mock_processor), + ): + 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_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): + """Test that start creates processor with correct config.""" + 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", return_value=self.mock_processor + ) as mock_processor_cls, + ): + 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) + + mock_config_cls.optimal.assert_called_once() + mock_processor_cls.assert_called_once() + self.assertIsNotNone(filter_instance._processor_ctx) + self.assertIsNotNone(filter_instance._vad_ctx) + + async def test_start_applies_initial_bypass_parameter(self): + """Test that start applies bypass parameter.""" + filter_instance = self._create_filter_with_mocks() + await self._start_filter_with_mocks(filter_instance) + + bypass_params = [ + (p, v) + for p, v in self.mock_processor.processor_ctx.parameters_set + if p == aic_sdk.ProcessorParameter.Bypass + ] + self.assertTrue(len(bypass_params) > 0) + self.assertEqual(bypass_params[-1][1], 0.0) + + async def test_start_applies_enhancement_level_when_supported(self): + """Test that start applies enhancement_level when supported by model.""" + filter_instance = self._create_filter_with_mocks(enhancement_level=0.65) + await self._start_filter_with_mocks(filter_instance) + + enhancement_params = [ + (p, v) + for p, v in self.mock_processor.processor_ctx.parameters_set + if p == aic_sdk.ProcessorParameter.EnhancementLevel + ] + self.assertTrue(len(enhancement_params) > 0) + self.assertEqual(enhancement_params[-1][1], 0.65) + + async def test_start_ignores_enhancement_level_when_unsupported(self): + """Test unsupported enhancement_level logs warning and keeps filter ready.""" + filter_instance = self._create_filter_with_mocks(enhancement_level=0.65) + + with patch(f"{AIC_FILTER_MODULE}.ParameterOutOfRangeError", ValueError): + unsupported_ctx = UnsupportedEnhancementProcessorContext( + aic_sdk.ProcessorParameter.EnhancementLevel, ValueError + ) + self.mock_processor.processor_ctx = unsupported_ctx + await self._start_filter_with_mocks(filter_instance) + + self.assertTrue(filter_instance._aic_ready) + self.assertIsNone(filter_instance._enhancement_level) + self.assertEqual(unsupported_ctx.enhancement_attempts, 1) + + async def test_start_does_not_set_enhancement_level_when_none(self): + """Test start does not attempt enhancement_level when not configured.""" + filter_instance = self._create_filter_with_mocks(enhancement_level=None) + with patch(f"{AIC_FILTER_MODULE}.logger.debug") as mock_debug: + await self._start_filter_with_mocks(filter_instance) + + enhancement_params = [ + (p, v) + for p, v in self.mock_processor.processor_ctx.parameters_set + if p == aic_sdk.ProcessorParameter.EnhancementLevel + ] + self.assertEqual(enhancement_params, []) + self.assertTrue( + any("default behavior" in str(call.args[0]) for call in mock_debug.call_args_list) + ) + + async def test_stop_cleans_up_resources(self): + """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 + + 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): + """Test that stop can be called safely without start.""" + filter_instance = self._create_filter_with_mocks() + + # 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_skips_unsupported_enhancement_level_after_first_attempt(self): + """Test unsupported enhancement_level is attempted once and then skipped.""" + filter_instance = self._create_filter_with_mocks(enhancement_level=0.9) + + with patch(f"{AIC_FILTER_MODULE}.ParameterOutOfRangeError", ValueError): + unsupported_ctx = UnsupportedEnhancementProcessorContext( + aic_sdk.ProcessorParameter.EnhancementLevel, ValueError + ) + self.mock_processor.processor_ctx = unsupported_ctx + + await self._start_filter_with_mocks(filter_instance) + await filter_instance.stop() + await self._start_filter_with_mocks(filter_instance) + + self.assertEqual(unsupported_ctx.enhancement_attempts, 1) + + async def test_process_frame_disable_sets_bypass(self): + """Test disable frame toggles bypass.""" + filter_instance = self._create_filter_with_mocks(enhancement_level=0.7) + await self._start_filter_with_mocks(filter_instance) + await filter_instance.process_frame(self.FilterEnableFrame(enable=False)) + + enhancement_params = [ + (p, v) + for p, v in self.mock_processor.processor_ctx.parameters_set + if p == aic_sdk.ProcessorParameter.EnhancementLevel + ] + bypass_params = [ + (p, v) + for p, v in self.mock_processor.processor_ctx.parameters_set + if p == aic_sdk.ProcessorParameter.Bypass + ] + + self.assertTrue(filter_instance._bypass) + self.assertEqual(enhancement_params[-1][1], 0.7) + self.assertEqual(bypass_params[-1][1], 1.0) + + async def test_process_frame_enable_restores_configured_enhancement(self): + """Test enable frame restores configured enhancement level.""" + filter_instance = self._create_filter_with_mocks(enhancement_level=0.7) + await self._start_filter_with_mocks(filter_instance) + + await filter_instance.process_frame(self.FilterEnableFrame(enable=False)) + await filter_instance.process_frame(self.FilterEnableFrame(enable=True)) + + enhancement_params = [ + (p, v) + for p, v in self.mock_processor.processor_ctx.parameters_set + if p == aic_sdk.ProcessorParameter.EnhancementLevel + ] + bypass_params = [ + (p, v) + for p, v in self.mock_processor.processor_ctx.parameters_set + if p == aic_sdk.ProcessorParameter.Bypass + ] + self.assertFalse(filter_instance._bypass) + self.assertEqual(enhancement_params[-1][1], 0.7) + self.assertEqual(bypass_params[-1][1], 0.0) + + async def test_filter_when_not_ready(self): + """Test that filter returns audio unchanged when not ready.""" + filter_instance = self._create_filter_with_mocks() + # Don't call start() + + input_audio = b"\x00\x01\x02\x03" + output_audio = await filter_instance.filter(input_audio) + + self.assertEqual(output_audio, input_audio) + + async def test_filter_with_incomplete_frame(self): + """Test filtering audio with incomplete frame data.""" + filter_instance = self._create_filter_with_mocks() + await self._start_filter_with_mocks(filter_instance) + + # Create audio data for less than one frame (100 samples = 200 bytes) + samples = np.random.randint(-32768, 32767, size=100, dtype=np.int16) + input_audio = samples.tobytes() + + output_audio = await filter_instance.filter(input_audio) + + # Should return empty bytes since no complete frame + self.assertEqual(output_audio, b"") + + async def test_filter_with_complete_frame(self): + """Test filtering audio with exactly one complete frame.""" + filter_instance = self._create_filter_with_mocks() + await self._start_filter_with_mocks(filter_instance) + + # Create audio data for exactly one frame (160 samples = 320 bytes) + samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16) + input_audio = samples.tobytes() + + output_audio = await filter_instance.filter(input_audio) + + self.assertIsInstance(output_audio, bytes) + self.assertEqual(len(output_audio), len(input_audio)) + + async def test_filter_with_multiple_frames(self): + """Test filtering audio with multiple complete frames.""" + filter_instance = self._create_filter_with_mocks() + await self._start_filter_with_mocks(filter_instance) + + # Create audio data for 3 complete frames (480 samples = 960 bytes) + samples = np.random.randint(-32768, 32767, size=480, dtype=np.int16) + input_audio = samples.tobytes() + + output_audio = await filter_instance.filter(input_audio) + + self.assertEqual(len(output_audio), len(input_audio)) + + async def test_filter_with_buffering(self): + """Test that filter properly buffers incomplete frames.""" + filter_instance = self._create_filter_with_mocks() + await self._start_filter_with_mocks(filter_instance) + + # First call: Send 100 samples (incomplete frame) + samples1 = np.random.randint(-32768, 32767, size=100, dtype=np.int16) + input_audio1 = samples1.tobytes() + output_audio1 = await filter_instance.filter(input_audio1) + + self.assertEqual(output_audio1, b"") + self.assertEqual(len(filter_instance._audio_buffer), 200) + + # Second call: Send 60 more samples (now we have 160 total = 1 complete frame) + samples2 = np.random.randint(-32768, 32767, size=60, dtype=np.int16) + input_audio2 = samples2.tobytes() + output_audio2 = await filter_instance.filter(input_audio2) + + self.assertEqual(len(output_audio2), 320) + self.assertEqual(len(filter_instance._audio_buffer), 0) + + async def test_filter_with_partial_buffering(self): + """Test that filter keeps remainder in buffer after processing.""" + filter_instance = self._create_filter_with_mocks() + await self._start_filter_with_mocks(filter_instance) + + # Send 250 samples (1 complete frame + 90 samples remainder) + samples = np.random.randint(-32768, 32767, size=250, dtype=np.int16) + input_audio = samples.tobytes() + + output_audio = await filter_instance.filter(input_audio) + + self.assertEqual(len(output_audio), 320) # 1 frame + self.assertEqual(len(filter_instance._audio_buffer), 180) # 90 samples * 2 bytes + + async def test_get_vad_context_before_start(self): + """Test that get_vad_context raises before start.""" + filter_instance = self._create_filter_with_mocks() + + with self.assertRaises(RuntimeError) as context: + filter_instance.get_vad_context() + + self.assertIn("not initialized", str(context.exception)) + + async def test_get_vad_context_after_start(self): + """Test that get_vad_context returns context after start.""" + filter_instance = self._create_filter_with_mocks() + await self._start_filter_with_mocks(filter_instance) + + vad_ctx = filter_instance.get_vad_context() + + self.assertEqual(vad_ctx, self.mock_processor.vad_ctx) + + async def test_create_vad_analyzer(self): + """Test create_vad_analyzer returns analyzer with factory.""" + filter_instance = self._create_filter_with_mocks() + + analyzer = filter_instance.create_vad_analyzer() + + self.assertIsNotNone(analyzer) + # Factory should be set + self.assertIsNotNone(analyzer._vad_context_factory) + + async def test_create_vad_analyzer_with_params(self): + """Test create_vad_analyzer with custom parameters.""" + filter_instance = self._create_filter_with_mocks() + + analyzer = filter_instance.create_vad_analyzer( + speech_hold_duration=0.1, + minimum_speech_duration=0.05, + sensitivity=8.0, + ) + + self.assertEqual(analyzer._pending_speech_hold_duration, 0.1) + self.assertEqual(analyzer._pending_minimum_speech_duration, 0.05) + self.assertEqual(analyzer._pending_sensitivity, 8.0) + + async def test_multiple_start_stop_cycles(self): + """Test multiple start/stop cycles.""" + filter_instance = self._create_filter_with_mocks() + + for sample_rate in [16000, 24000, 48000]: + # Create fresh mock processor for each cycle + self.mock_processor = MockProcessor() + await self._start_filter_with_mocks(filter_instance, sample_rate) + self.assertTrue(filter_instance._aic_ready) + self.assertEqual(filter_instance._sample_rate, sample_rate) + + await filter_instance.stop() + self.assertFalse(filter_instance._aic_ready) + + async def test_concurrent_filter_calls(self): + """Test that concurrent filter calls are handled safely.""" + filter_instance = self._create_filter_with_mocks() + await self._start_filter_with_mocks(filter_instance) + + samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16) + input_audio = samples.tobytes() + + async def filter_audio(): + return await filter_instance.filter(input_audio) + + tasks = [filter_audio() for _ in range(10)] + results = await asyncio.gather(*tasks) + + self.assertEqual(len(results), 10) + for result in results: + self.assertIsInstance(result, bytes) + + async def test_concurrent_filter_no_buffer_resize_error(self): + """Regression: concurrent filter() must not raise BufferError. + + When process_async yields to the event loop, a second filter() call + runs and calls _audio_buffer.extend(). If the first call still holds + a memoryview on the bytearray, extend() raises: + + BufferError: Existing exports of data: object cannot be re-sized + + The fix snapshots the needed data into immutable bytes and trims the + buffer *before* any await, so no memoryview is held across yield + points. + """ + filter_instance = self._create_filter_with_mocks() + + # Make process_async yield to the event loop so concurrent filter() + # calls can interleave and attempt _audio_buffer.extend(). + async def yielding_process_async(audio_array): + await asyncio.sleep(0) + return audio_array.copy() + + self.mock_processor.process_async = yielding_process_async + await self._start_filter_with_mocks(filter_instance) + + samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16) + input_audio = samples.tobytes() + + async def filter_audio(): + return await filter_instance.filter(input_audio) + + # 20 concurrent calls to reliably trigger the interleaving. + tasks = [filter_audio() for _ in range(20)] + results = await asyncio.gather(*tasks) + + for result in results: + self.assertIsInstance(result, bytes) + + async def test_stop_during_filter_no_buffer_resize_error(self): + """Regression: stop() during filter() must not raise BufferError. + + When filter() holds a memoryview on _audio_buffer across an await + (process_async), a concurrent stop() that calls + _audio_buffer.clear() raises: + + BufferError: Existing exports of data: object cannot be re-sized + + The fix removes the memoryview by snapshotting data into immutable + bytes before any await. + """ + filter_instance = self._create_filter_with_mocks() + + # Gate so stop() waits until filter() is inside process_async. + processing_started = asyncio.Event() + + async def yielding_process_async(audio_array): + processing_started.set() + await asyncio.sleep(0) # yield — stop() runs here + return audio_array.copy() + + self.mock_processor.process_async = yielding_process_async + await self._start_filter_with_mocks(filter_instance) + + # Exactly one complete frame so the loop runs once. + samples = np.random.randint(-32768, 32767, size=160, dtype=np.int16) + input_audio = samples.tobytes() + + async def stop_after_filter_enters_process_async(): + await processing_started.wait() + await filter_instance.stop() + + # filter() enters process_async → yields → stop() calls clear() + filter_result, _ = await asyncio.gather( + filter_instance.filter(input_audio), + stop_after_filter_enters_process_async(), + ) + self.assertIsInstance(filter_result, bytes) + + async def test_buffer_cleared_on_stop(self): + """Test that audio buffer is cleared when stopping.""" + filter_instance = self._create_filter_with_mocks() + await self._start_filter_with_mocks(filter_instance) + + # Add incomplete frame to buffer + samples = np.random.randint(-32768, 32767, size=100, dtype=np.int16) + input_audio = samples.tobytes() + await filter_instance.filter(input_audio) + + # Verify buffer has data + self.assertGreater(len(filter_instance._audio_buffer), 0) + + # Stop should clear buffer + await filter_instance.stop() + self.assertEqual(len(filter_instance._audio_buffer), 0) + + async def test_set_sdk_id_called_on_init(self): + """Test that set_sdk_id is called during initialization.""" + with patch(f"{AIC_FILTER_MODULE}.set_sdk_id") as mock_set_sdk_id: + self.AICFilter(license_key="test-key", model_id="test-model") + + mock_set_sdk_id.assert_called_once_with(6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_aic_vad.py b/tests/test_aic_vad.py new file mode 100644 index 000000000..5028da46b --- /dev/null +++ b/tests/test_aic_vad.py @@ -0,0 +1,322 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import unittest + +# Check if aic_sdk is available +try: + import aic_sdk + + HAS_AIC_SDK = True +except ImportError: + HAS_AIC_SDK = False + + +@unittest.skipUnless(HAS_AIC_SDK, "aic-sdk not installed") +class TestAICVADAnalyzer(unittest.IsolatedAsyncioTestCase): + """Test suite for AICVADAnalyzer using real aic_sdk.""" + + @classmethod + def setUpClass(cls): + """Import AICVADAnalyzer after confirming aic_sdk is available.""" + from pipecat.audio.vad.aic_vad import AICVADAnalyzer + + cls.AICVADAnalyzer = AICVADAnalyzer + + def test_initialization_without_factory(self): + """Test analyzer initialization without a factory.""" + analyzer = self.AICVADAnalyzer() + + self.assertIsNone(analyzer._vad_context_factory) + self.assertIsNone(analyzer._vad_ctx) + # Fixed params should be set + self.assertEqual(analyzer._params.confidence, 0.5) + self.assertEqual(analyzer._params.start_secs, 0.0) + self.assertEqual(analyzer._params.stop_secs, 0.0) + self.assertEqual(analyzer._params.min_volume, 0.0) + + def test_initialization_with_factory(self): + """Test analyzer initialization with a factory.""" + # Create a mock VAD context for testing + mock_vad_ctx = MockVadContext() + factory = lambda: mock_vad_ctx + analyzer = self.AICVADAnalyzer(vad_context_factory=factory) + + self.assertIsNotNone(analyzer._vad_context_factory) + + def test_initialization_with_vad_params(self): + """Test analyzer initialization with VAD parameters.""" + analyzer = self.AICVADAnalyzer( + speech_hold_duration=0.1, + minimum_speech_duration=0.05, + sensitivity=8.0, + ) + + self.assertEqual(analyzer._pending_speech_hold_duration, 0.1) + self.assertEqual(analyzer._pending_minimum_speech_duration, 0.05) + self.assertEqual(analyzer._pending_sensitivity, 8.0) + + def test_bind_vad_context_factory(self): + """Test binding a factory post-construction.""" + mock_vad_ctx = MockVadContext() + analyzer = self.AICVADAnalyzer() + factory = lambda: mock_vad_ctx + + analyzer.bind_vad_context_factory(factory) + + self.assertEqual(analyzer._vad_context_factory, factory) + # Should have attempted to initialize + self.assertEqual(analyzer._vad_ctx, mock_vad_ctx) + + def test_bind_vad_context_factory_applies_params(self): + """Test that binding factory applies pending VAD params.""" + mock_vad_ctx = MockVadContext() + analyzer = self.AICVADAnalyzer( + speech_hold_duration=0.1, + minimum_speech_duration=0.05, + sensitivity=8.0, + ) + factory = lambda: mock_vad_ctx + + analyzer.bind_vad_context_factory(factory) + + # Verify parameters were applied + self.assertIn( + (aic_sdk.VadParameter.SpeechHoldDuration, 0.1), + mock_vad_ctx.parameters_set, + ) + self.assertIn( + (aic_sdk.VadParameter.MinimumSpeechDuration, 0.05), + mock_vad_ctx.parameters_set, + ) + self.assertIn( + (aic_sdk.VadParameter.Sensitivity, 8.0), + mock_vad_ctx.parameters_set, + ) + + def test_set_sample_rate(self): + """Test setting sample rate.""" + analyzer = self.AICVADAnalyzer() + + analyzer.set_sample_rate(16000) + + self.assertEqual(analyzer._sample_rate, 16000) + + def test_set_sample_rate_with_init_sample_rate(self): + """Test that init_sample_rate takes precedence.""" + # Create analyzer and manually set _init_sample_rate + analyzer = self.AICVADAnalyzer() + analyzer._init_sample_rate = 48000 + + analyzer.set_sample_rate(16000) + + # init_sample_rate should take precedence + self.assertEqual(analyzer._sample_rate, 48000) + + def test_set_sample_rate_triggers_context_init(self): + """Test that set_sample_rate attempts context initialization.""" + mock_vad_ctx = MockVadContext() + factory = lambda: mock_vad_ctx + analyzer = self.AICVADAnalyzer(vad_context_factory=factory) + + analyzer.set_sample_rate(16000) + + self.assertEqual(analyzer._vad_ctx, mock_vad_ctx) + + def test_num_frames_required_with_sample_rate(self): + """Test num_frames_required returns correct value.""" + analyzer = self.AICVADAnalyzer() + analyzer.set_sample_rate(16000) + + frames = analyzer.num_frames_required() + + # 10ms at 16kHz = 160 frames + self.assertEqual(frames, 160) + + def test_num_frames_required_different_sample_rates(self): + """Test num_frames_required for different sample rates.""" + analyzer = self.AICVADAnalyzer() + + test_cases = [ + (8000, 80), # 10ms at 8kHz + (16000, 160), # 10ms at 16kHz + (24000, 240), # 10ms at 24kHz + (48000, 480), # 10ms at 48kHz + ] + + for sample_rate, expected_frames in test_cases: + analyzer.set_sample_rate(sample_rate) + frames = analyzer.num_frames_required() + self.assertEqual(frames, expected_frames, f"Failed for {sample_rate}Hz") + + def test_num_frames_required_no_sample_rate(self): + """Test num_frames_required returns default when no sample rate.""" + analyzer = self.AICVADAnalyzer() + + frames = analyzer.num_frames_required() + + # Default is 160 + self.assertEqual(frames, 160) + + def test_voice_confidence_no_context(self): + """Test voice_confidence returns 0.0 when no context.""" + analyzer = self.AICVADAnalyzer() + + confidence = analyzer.voice_confidence(b"\x00" * 320) + + self.assertEqual(confidence, 0.0) + + def test_voice_confidence_speech_detected(self): + """Test voice_confidence returns 1.0 when speech detected.""" + mock_vad_ctx = MockVadContext(speech_detected=True) + factory = lambda: mock_vad_ctx + analyzer = self.AICVADAnalyzer(vad_context_factory=factory) + analyzer.set_sample_rate(16000) + + confidence = analyzer.voice_confidence(b"\x00" * 320) + + self.assertEqual(confidence, 1.0) + + def test_voice_confidence_no_speech(self): + """Test voice_confidence returns 0.0 when no speech.""" + mock_vad_ctx = MockVadContext(speech_detected=False) + factory = lambda: mock_vad_ctx + analyzer = self.AICVADAnalyzer(vad_context_factory=factory) + analyzer.set_sample_rate(16000) + + confidence = analyzer.voice_confidence(b"\x00" * 320) + + self.assertEqual(confidence, 0.0) + + def test_voice_confidence_handles_exception(self): + """Test voice_confidence handles exceptions gracefully.""" + mock_vad_ctx = MockVadContext(raise_on_detect=True) + factory = lambda: mock_vad_ctx + analyzer = self.AICVADAnalyzer(vad_context_factory=factory) + analyzer.set_sample_rate(16000) + + confidence = analyzer.voice_confidence(b"\x00" * 320) + + self.assertEqual(confidence, 0.0) + + def test_lazy_initialization(self): + """Test that VAD context is lazily initialized.""" + call_count = 0 + mock_vad_ctx = MockVadContext() + + def counting_factory(): + nonlocal call_count + call_count += 1 + return mock_vad_ctx + + analyzer = self.AICVADAnalyzer(vad_context_factory=counting_factory) + + # Factory not called yet + self.assertEqual(call_count, 0) + + # First call to voice_confidence triggers initialization + analyzer.voice_confidence(b"\x00" * 320) + self.assertEqual(call_count, 1) + + # Subsequent calls don't re-initialize + analyzer.voice_confidence(b"\x00" * 320) + analyzer.voice_confidence(b"\x00" * 320) + self.assertEqual(call_count, 1) + + def test_deferred_initialization_on_factory_failure(self): + """Test that initialization is deferred when factory fails.""" + call_count = 0 + mock_vad_ctx = MockVadContext(speech_detected=True) + + def failing_then_succeeding_factory(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise RuntimeError("Not ready yet") + return mock_vad_ctx + + analyzer = self.AICVADAnalyzer(vad_context_factory=failing_then_succeeding_factory) + + # First two calls fail, should return 0.0 + self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 0.0) + self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 0.0) + + # Third call succeeds + self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 1.0) + + def test_apply_vad_params_deferred_on_failure(self): + """Test that VAD param application handles exceptions.""" + mock_vad_ctx = MockVadContext(raise_on_set_param=True) + factory = lambda: mock_vad_ctx + + analyzer = self.AICVADAnalyzer( + vad_context_factory=factory, + speech_hold_duration=0.1, + ) + + # Should not raise, just log debug message + analyzer.bind_vad_context_factory(factory) + + # Context should still be set despite param failure + self.assertEqual(analyzer._vad_ctx, mock_vad_ctx) + + def test_apply_vad_params_only_set_values(self): + """Test that only specified VAD params are applied.""" + mock_vad_ctx = MockVadContext() + factory = lambda: mock_vad_ctx + analyzer = self.AICVADAnalyzer( + vad_context_factory=factory, + speech_hold_duration=0.1, + # minimum_speech_duration and sensitivity not set + ) + + analyzer.bind_vad_context_factory(factory) + + # Only SpeechHoldDuration should be set + self.assertEqual(len(mock_vad_ctx.parameters_set), 1) + self.assertIn( + (aic_sdk.VadParameter.SpeechHoldDuration, 0.1), + mock_vad_ctx.parameters_set, + ) + + def test_fixed_vad_params(self): + """Test that VAD uses fixed parameters.""" + analyzer = self.AICVADAnalyzer() + + # These are the fixed params for AIC VAD + self.assertEqual(analyzer._params.confidence, 0.5) + self.assertEqual(analyzer._params.start_secs, 0.0) + self.assertEqual(analyzer._params.stop_secs, 0.0) + self.assertEqual(analyzer._params.min_volume, 0.0) + + +class MockVadContext: + """A lightweight mock for AIC VadContext that mimics real behavior.""" + + def __init__( + self, + speech_detected: bool = False, + raise_on_detect: bool = False, + raise_on_set_param: bool = False, + ): + self.speech_detected = speech_detected + self.raise_on_detect = raise_on_detect + self.raise_on_set_param = raise_on_set_param + self.parameters_set: list[tuple] = [] + + def is_speech_detected(self) -> bool: + if self.raise_on_detect: + raise RuntimeError("VAD error") + return self.speech_detected + + def set_parameter(self, param, value): + if self.raise_on_set_param: + raise RuntimeError("Param error") + self.parameters_set.append((param, value)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_audio_buffer_processor.py b/tests/test_audio_buffer_processor.py index faebe4ef5..4d3c588bc 100644 --- a/tests/test_audio_buffer_processor.py +++ b/tests/test_audio_buffer_processor.py @@ -115,3 +115,7 @@ class TestAudioBufferProcessor(unittest.IsolatedAsyncioTestCase): self.assertEqual(merged_audio[6:8], bot_audio[2:4]) self.assertEqual(len(self.processor._user_audio_buffer), 0) self.assertEqual(len(self.processor._bot_audio_buffer), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_context_aggregators.py b/tests/test_context_aggregators.py index 17a24113e..37d36bfef 100644 --- a/tests/test_context_aggregators.py +++ b/tests/test_context_aggregators.py @@ -21,7 +21,6 @@ from pipecat.frames.frames import ( FunctionCallResultProperties, InterimTranscriptionFrame, InterruptionFrame, - InterruptionTaskFrame, LLMContextAssistantTimestampFrame, LLMContextFrame, LLMFullResponseEndFrame, @@ -567,7 +566,7 @@ class BaseTestUserContextAggregator: SleepFrame(), UserStoppedSpeakingFrame(), ] - expected_up_frames = [InterruptionTaskFrame] + expected_up_frames = [InterruptionFrame] expected_down_frames = [ BotStartedSpeakingFrame, UserStartedSpeakingFrame, @@ -1055,3 +1054,7 @@ class TestLLMAssistantAggregator( 0, "Hello Pipecat. Here's some code: ```python\nprint('Hello, World!')\n``` ```javascript\nconsole.log('Hello, World!');\n``` And some more: ```html\n
Hello, World!
\n``` Hope that helps!", ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_context_aggregators_universal.py b/tests/test_context_aggregators_universal.py index 6aa38d362..7d6f73f2d 100644 --- a/tests/test_context_aggregators_universal.py +++ b/tests/test_context_aggregators_universal.py @@ -12,6 +12,7 @@ from pipecat.frames.frames import ( FunctionCallFromLLM, FunctionCallResultFrame, FunctionCallsStartedFrame, + InterimTranscriptionFrame, InterruptionFrame, LLMContextAssistantTimestampFrame, LLMContextFrame, @@ -24,7 +25,10 @@ from pipecat.frames.frames import ( LLMThoughtEndFrame, LLMThoughtStartFrame, LLMThoughtTextFrame, + StartFrame, TranscriptionFrame, + TranslationFrame, + UserMuteStartedFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, @@ -40,8 +44,12 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMUserAggregatorParams, ) from pipecat.tests.utils import SleepFrame, run_test -from pipecat.turns.mute import FirstSpeechUserMuteStrategy, FunctionCallUserMuteStrategy -from pipecat.turns.user_stop import TranscriptionUserTurnStopStrategy +from pipecat.turns.user_mute import ( + FirstSpeechUserMuteStrategy, + FunctionCallUserMuteStrategy, + MuteUntilFirstBotCompleteUserMuteStrategy, +) +from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy from pipecat.turns.user_turn_strategies import UserTurnStrategies USER_TURN_STOP_TIMEOUT = 0.2 @@ -147,9 +155,38 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): ) assert context.messages[0]["content"] == "Hi there!" + async def test_llm_messages_update_does_not_inject_turn_completion_into_context(self): + context = LLMContext() + params = LLMUserAggregatorParams(filter_incomplete_user_turns=True) + pipeline = Pipeline([LLMUserAggregator(context, params=params)]) + + new_messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello!"}, + ] + frames_to_send = [LLMMessagesUpdateFrame(messages=new_messages)] + await run_test( + pipeline, + frames_to_send=frames_to_send, + ) + # Turn completion instructions are now set via system_instruction on the + # LLM service, not injected into context messages. + assert len(context.messages) == 2 + assert context.messages[0]["content"] == "You are a helpful assistant." + assert context.messages[1]["content"] == "Hello!" + async def test_default_user_turn_strategies(self): context = LLMContext() - user_aggregator = LLMUserAggregator(context) + user_aggregator = LLMUserAggregator( + context, + params=LLMUserAggregatorParams( + user_turn_strategies=UserTurnStrategies( + stop=[ + SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT) + ], + ), + ), + ) should_start = None should_stop = None @@ -173,6 +210,8 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): TranscriptionFrame(text="Hello!", user_id="", timestamp="now"), SleepFrame(), VADUserStoppedSpeakingFrame(), + # Wait for user_speech_timeout to elapse + SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1), ] expected_down_frames = [ VADUserStartedSpeakingFrame, @@ -241,7 +280,9 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): context, params=LLMUserAggregatorParams( user_turn_strategies=UserTurnStrategies( - stop=[TranscriptionUserTurnStopStrategy(timeout=TRANSCRIPTION_TIMEOUT)], + stop=[ + SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT) + ], ), user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT, ), @@ -270,13 +311,13 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): pipeline = Pipeline([user_aggregator]) + # Transcript arrives before VAD stop, then we wait for user_speech_timeout frames_to_send = [ VADUserStartedSpeakingFrame(), - VADUserStoppedSpeakingFrame(), - SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1), TranscriptionFrame(text="Hello!", user_id="", timestamp="now"), - SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1), - SleepFrame(sleep=TRANSCRIPTION_TIMEOUT), + VADUserStoppedSpeakingFrame(), + # Wait for user_speech_timeout (TRANSCRIPTION_TIMEOUT=0.1s) to elapse + SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05), ] await run_test( pipeline, @@ -344,6 +385,109 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase): # The user mute strategies should have muted the user. self.assertFalse(user_turn) + async def test_pending_transcription_emitted_on_end_frame(self): + """Pending user transcription should be emitted when EndFrame arrives.""" + context = LLMContext() + + user_aggregator = LLMUserAggregator(context) + + stop_messages = [] + + @user_aggregator.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(aggregator, strategy, message): + stop_messages.append((strategy, message)) + + pipeline = Pipeline([user_aggregator]) + + # Start turn and send transcription, but don't trigger normal turn stop + frames_to_send = [ + VADUserStartedSpeakingFrame(), + TranscriptionFrame(text="Hello!", user_id="", timestamp="now"), + # No VADUserStoppedSpeakingFrame - turn doesn't stop normally + # EndFrame will be sent by run_test, triggering emission + ] + await run_test(pipeline, frames_to_send=frames_to_send) + + # The pending transcription should be emitted on EndFrame + self.assertEqual(len(stop_messages), 1) + strategy, message = stop_messages[0] + 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, + ) + + async def test_interim_transcription_not_pushed_downstream(self): + """InterimTranscriptionFrame should be consumed and not pushed downstream.""" + context = LLMContext() + pipeline = Pipeline([LLMUserAggregator(context)]) + + frames_to_send = [ + InterimTranscriptionFrame(text="Hel", user_id="", timestamp="now"), + InterimTranscriptionFrame(text="Hello", user_id="", timestamp="now"), + ] + # The interim transcription triggers a user turn start via the default + # TranscriptionUserTurnStartStrategy, so we expect turn-related frames + # but NOT the InterimTranscriptionFrame itself. + expected_down_frames = [ + UserStartedSpeakingFrame, + InterruptionFrame, + ] + (down_frames, _) = await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + ) + self.assertFalse(any(isinstance(f, InterimTranscriptionFrame) for f in down_frames)) + + async def test_translation_not_pushed_downstream(self): + """TranslationFrame should be consumed and not pushed downstream.""" + context = LLMContext() + pipeline = Pipeline([LLMUserAggregator(context)]) + + frames_to_send = [ + TranslationFrame(text="Hola!", user_id="", timestamp="now", language="es"), + ] + # No downstream frames expected — translations are consumed. + await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=[], + ) + class TestLLMAssistantAggregator(unittest.IsolatedAsyncioTestCase): async def test_empty(self): @@ -512,3 +656,80 @@ class TestLLMAssistantAggregator(unittest.IsolatedAsyncioTestCase): ] await run_test(aggregator, frames_to_send=frames_to_send) self.assertEqual(thought_message.content, "I'm thinking!") + + async def test_pending_text_emitted_on_end_frame(self): + """Pending assistant text should be emitted when EndFrame arrives.""" + context = LLMContext() + + aggregator = LLMAssistantAggregator(context) + + stop_messages = [] + + @aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + stop_messages.append(message) + + # Start response and send text, but don't send LLMFullResponseEndFrame + frames_to_send = [ + LLMFullResponseStartFrame(), + LLMTextFrame("Hello from Pipecat!"), + # No LLMFullResponseEndFrame - response doesn't end normally + # EndFrame will be sent by run_test, triggering emission + ] + await run_test(aggregator, frames_to_send=frames_to_send) + + # The pending text should be emitted on EndFrame + self.assertEqual(len(stop_messages), 1) + self.assertEqual(stop_messages[0].content, "Hello from Pipecat!") + + async def test_turn_completion_markers_stripped_from_transcript(self): + """Turn completion markers should be stripped from assistant transcript.""" + from pipecat.turns.user_turn_completion_mixin import ( + USER_TURN_COMPLETE_MARKER, + USER_TURN_INCOMPLETE_SHORT_MARKER, + ) + + context = LLMContext() + aggregator = LLMAssistantAggregator(context) + + stop_messages = [] + + @aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage): + stop_messages.append(message) + + # Send text with a turn completion marker + frames_to_send = [ + LLMFullResponseStartFrame(), + LLMTextFrame(f"{USER_TURN_COMPLETE_MARKER} Hello from Pipecat!"), + LLMFullResponseEndFrame(), + ] + await run_test(aggregator, frames_to_send=frames_to_send) + + # The marker should be stripped from the transcript + self.assertEqual(len(stop_messages), 1) + self.assertEqual(stop_messages[0].content, "Hello from Pipecat!") + + # Test incomplete markers are also stripped + stop_messages.clear() + context2 = LLMContext() + aggregator2 = LLMAssistantAggregator(context2) + + @aggregator2.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped2(aggregator, message: AssistantTurnStoppedMessage): + stop_messages.append(message) + + frames_to_send = [ + LLMFullResponseStartFrame(), + LLMTextFrame(USER_TURN_INCOMPLETE_SHORT_MARKER), + LLMFullResponseEndFrame(), + ] + await run_test(aggregator2, frames_to_send=frames_to_send) + + # The incomplete marker should be stripped (resulting in empty content) + self.assertEqual(len(stop_messages), 1) + self.assertEqual(stop_messages[0].content, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_context_summarization.py b/tests/test_context_summarization.py new file mode 100644 index 000000000..26d26614e --- /dev/null +++ b/tests/test_context_summarization.py @@ -0,0 +1,1178 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Tests for context summarization feature.""" + +import asyncio +import unittest +from unittest.mock import AsyncMock + +from pipecat.frames.frames import LLMContextSummaryRequestFrame +from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage +from pipecat.services.llm_service import LLMService +from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, + LLMContextSummarizationConfig, + LLMContextSummarizationUtil, + LLMContextSummaryConfig, +) + + +class TestContextSummarizationMixin(unittest.TestCase): + """Tests for LLMContextSummarizationUtil.""" + + def test_estimate_tokens_simple_text(self): + """Test token estimation with simple text.""" + # Simple sentence: "Hello world" = 11 chars / 4 = 2.75 -> 2 tokens + tokens = LLMContextSummarizationUtil.estimate_tokens("Hello world") + self.assertEqual(tokens, 2) + + # More words: "This is a test message" = 22 chars / 4 = 5.5 -> 5 tokens + tokens = LLMContextSummarizationUtil.estimate_tokens("This is a test message") + self.assertEqual(tokens, 5) + + def test_estimate_tokens_empty(self): + """Test token estimation with empty text.""" + tokens = LLMContextSummarizationUtil.estimate_tokens("") + self.assertEqual(tokens, 0) + + def test_estimate_context_tokens(self): + """Test context token estimation.""" + context = LLMContext() + + # Empty context + self.assertEqual(LLMContextSummarizationUtil.estimate_context_tokens(context), 0) + + # Add messages + context.add_message({"role": "system", "content": "You are helpful"}) # ~4 words + context.add_message({"role": "user", "content": "Hello"}) # ~1 word + context.add_message({"role": "assistant", "content": "Hi there"}) # ~2 words + + # Each message has ~10 token overhead + # Total content: ~7 words * 1.3 = ~9 tokens + # Total overhead: 3 * 10 = 30 tokens + # Expected: ~39 tokens + total = LLMContextSummarizationUtil.estimate_context_tokens(context) + self.assertGreater(total, 30) # At least overhead + self.assertLess(total, 50) # Not too much + + def test_get_messages_to_summarize_basic(self): + """Test basic message extraction for summarization.""" + context = LLMContext() + + # Add messages + context.add_message({"role": "system", "content": "System prompt"}) + context.add_message({"role": "user", "content": "Message 1"}) + context.add_message({"role": "assistant", "content": "Response 1"}) + context.add_message({"role": "user", "content": "Message 2"}) + context.add_message({"role": "assistant", "content": "Response 2"}) + context.add_message({"role": "user", "content": "Message 3"}) + context.add_message({"role": "assistant", "content": "Response 3"}) + + # Keep last 2 messages + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 2) + + # Get first system message from context + first_system = None + for msg in context.messages: + if msg.get("role") == "system": + first_system = msg + break + + # Should get system message + self.assertIsNotNone(first_system) + self.assertEqual(first_system["content"], "System prompt") + + # Should get middle messages (indices 1-4) + self.assertEqual(len(result.messages), 4) + self.assertEqual(result.messages[0]["content"], "Message 1") + self.assertEqual(result.messages[-1]["content"], "Response 2") + + # Last index should be 4 (0-indexed) + self.assertEqual(result.last_summarized_index, 4) + + def test_get_messages_to_summarize_no_system(self): + """Test message extraction when there's no system message.""" + context = LLMContext() + + # Add messages without system prompt + context.add_message({"role": "user", "content": "Message 1"}) + context.add_message({"role": "assistant", "content": "Response 1"}) + context.add_message({"role": "user", "content": "Message 2"}) + context.add_message({"role": "assistant", "content": "Response 2"}) + + # Keep last 1 message + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 1) + + # Get first system message from context + first_system = None + for msg in context.messages: + if msg.get("role") == "system": + first_system = msg + break + + # Should have no system message + self.assertIsNone(first_system) + + # Should get first 3 messages + self.assertEqual(len(result.messages), 3) + self.assertEqual(result.last_summarized_index, 2) + + def test_get_messages_to_summarize_insufficient(self): + """Test when there aren't enough messages to summarize.""" + context = LLMContext() + + # Add only 2 messages + context.add_message({"role": "user", "content": "Message 1"}) + context.add_message({"role": "assistant", "content": "Response 1"}) + + # Try to keep 2 messages (same as total) + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 2) + + # Should return empty + self.assertEqual(len(result.messages), 0) + self.assertEqual(result.last_summarized_index, -1) + + def test_format_messages_for_summary(self): + """Test message formatting for summary.""" + + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there"}, + {"role": "user", "content": "How are you?"}, + ] + + transcript = LLMContextSummarizationUtil.format_messages_for_summary(messages) + + self.assertIn("USER: Hello", transcript) + self.assertIn("ASSISTANT: Hi there", transcript) + self.assertIn("USER: How are you?", transcript) + + def test_format_messages_with_list_content(self): + """Test formatting messages with list content.""" + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "First part"}, + {"type": "text", "text": "Second part"}, + ], + } + ] + + transcript = LLMContextSummarizationUtil.format_messages_for_summary(messages) + + self.assertIn("USER: First part Second part", transcript) + + +class TestLLMContextSummaryConfig(unittest.TestCase): + """Tests for LLMContextSummaryConfig.""" + + def test_default_config(self): + """Test default configuration values.""" + config = LLMContextSummaryConfig() + + self.assertEqual(config.target_context_tokens, 6000) + self.assertEqual(config.min_messages_after_summary, 4) + self.assertIsNone(config.summarization_prompt) + + def test_custom_config(self): + """Test custom configuration.""" + config = LLMContextSummaryConfig( + target_context_tokens=2000, + min_messages_after_summary=4, + summarization_prompt="Custom prompt", + ) + + self.assertEqual(config.target_context_tokens, 2000) + self.assertEqual(config.min_messages_after_summary, 4) + self.assertEqual(config.summary_prompt, "Custom prompt") + + def test_summary_prompt_property(self): + """Test summary_prompt property uses default when None.""" + config = LLMContextSummaryConfig() + self.assertIn("summarizing a conversation", config.summary_prompt.lower()) + + config_with_custom = LLMContextSummaryConfig(summarization_prompt="Custom") + self.assertEqual(config_with_custom.summary_prompt, "Custom") + + +class TestLLMAutoContextSummarizationConfig(unittest.TestCase): + """Tests for LLMAutoContextSummarizationConfig.""" + + def test_default_config(self): + """Test default configuration values.""" + config = LLMAutoContextSummarizationConfig() + + self.assertEqual(config.max_context_tokens, 8000) + self.assertEqual(config.max_unsummarized_messages, 20) + self.assertEqual(config.summary_config.target_context_tokens, 6000) + self.assertEqual(config.summary_config.min_messages_after_summary, 4) + + def test_custom_config(self): + """Test custom configuration.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=2500, + max_unsummarized_messages=15, + summary_config=LLMContextSummaryConfig( + target_context_tokens=2000, + min_messages_after_summary=4, + summarization_prompt="Custom prompt", + ), + ) + + self.assertEqual(config.max_context_tokens, 2500) + self.assertEqual(config.max_unsummarized_messages, 15) + self.assertEqual(config.summary_config.target_context_tokens, 2000) + self.assertEqual(config.summary_config.min_messages_after_summary, 4) + self.assertEqual(config.summary_config.summary_prompt, "Custom prompt") + + def test_target_tokens_auto_adjusted(self): + """Test that target_context_tokens is auto-adjusted when it exceeds max.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=1000, + summary_config=LLMContextSummaryConfig(target_context_tokens=9000), + ) + self.assertLessEqual(config.summary_config.target_context_tokens, config.max_context_tokens) + + def test_max_context_tokens_none(self): + """Test that max_context_tokens can be None when max_unsummarized_messages is set.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=None, + max_unsummarized_messages=20, + ) + self.assertIsNone(config.max_context_tokens) + self.assertEqual(config.max_unsummarized_messages, 20) + + def test_max_unsummarized_messages_none(self): + """Test that max_unsummarized_messages can be None when max_context_tokens is set.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=8000, + max_unsummarized_messages=None, + ) + self.assertEqual(config.max_context_tokens, 8000) + self.assertIsNone(config.max_unsummarized_messages) + + def test_both_none_raises(self): + """Test that setting both thresholds to None raises ValueError.""" + with self.assertRaises(ValueError) as cm: + LLMAutoContextSummarizationConfig( + max_context_tokens=None, + max_unsummarized_messages=None, + ) + self.assertIn("at least one", str(cm.exception).lower()) + + def test_target_tokens_not_auto_adjusted_when_max_none(self): + """Test that target_context_tokens is not auto-adjusted when max_context_tokens is None.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=None, + max_unsummarized_messages=10, + summary_config=LLMContextSummaryConfig(target_context_tokens=9000), + ) + # target_context_tokens should remain unchanged since there's no max to compare against + self.assertEqual(config.summary_config.target_context_tokens, 9000) + + +class TestLLMContextSummarizationConfigDeprecated(unittest.TestCase): + """Tests for deprecated LLMContextSummarizationConfig.""" + + def test_emits_deprecation_warning(self): + """Test that instantiating the deprecated config emits a DeprecationWarning.""" + with self.assertWarns(DeprecationWarning): + LLMContextSummarizationConfig() + + def test_to_auto_config(self): + """Test conversion to the new LLMAutoContextSummarizationConfig.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + old_config = LLMContextSummarizationConfig( + max_context_tokens=2500, + target_context_tokens=2000, + max_unsummarized_messages=15, + min_messages_after_summary=4, + summarization_prompt="Custom", + ) + + new_config = old_config.to_auto_config() + + self.assertIsInstance(new_config, LLMAutoContextSummarizationConfig) + self.assertEqual(new_config.max_context_tokens, 2500) + self.assertEqual(new_config.max_unsummarized_messages, 15) + self.assertEqual(new_config.summary_config.target_context_tokens, 2000) + self.assertEqual(new_config.summary_config.min_messages_after_summary, 4) + self.assertEqual(new_config.summary_config.summarization_prompt, "Custom") + + +class TestFunctionCallHandling(unittest.TestCase): + """Tests for function call handling in summarization.""" + + def test_function_call_in_progress_not_summarized(self): + """Test that messages with function calls in progress are not summarized.""" + context = LLMContext() + + # Add messages including a function call without result + context.add_message({"role": "system", "content": "System prompt"}) + context.add_message({"role": "user", "content": "What time is it?"}) + context.add_message( + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": {"name": "get_time", "arguments": "{}"}, + } + ], + } + ) + # No tool result yet - function call is in progress + context.add_message({"role": "user", "content": "Latest message"}) + + # Try to keep last 1 message + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 1) + + # Should only get the first user message, stopping before the function call + self.assertEqual(len(result.messages), 1) + self.assertEqual(result.messages[0]["content"], "What time is it?") + self.assertEqual(result.last_summarized_index, 1) + + def test_completed_function_call_can_be_summarized(self): + """Test that completed function calls can be summarized.""" + context = LLMContext() + + # Add messages including a complete function call sequence + context.add_message({"role": "system", "content": "System prompt"}) + context.add_message({"role": "user", "content": "What time is it?"}) + context.add_message( + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": {"name": "get_time", "arguments": "{}"}, + } + ], + } + ) + # Tool result completes the function call + context.add_message( + {"role": "tool", "tool_call_id": "call_123", "content": '{"time": "10:30 AM"}'} + ) + context.add_message({"role": "assistant", "content": "It's 10:30 AM"}) + context.add_message({"role": "user", "content": "Latest message"}) + + # Try to keep last 1 message + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 1) + + # Should get all messages except the last one (complete function call is included) + self.assertEqual(len(result.messages), 4) + self.assertEqual(result.messages[0]["content"], "What time is it?") + self.assertEqual(result.messages[-1]["content"], "It's 10:30 AM") + self.assertEqual(result.last_summarized_index, 4) + + def test_multiple_function_calls_in_progress(self): + """Test handling of multiple function calls in progress.""" + context = LLMContext() + + # Add messages with multiple function calls + context.add_message({"role": "system", "content": "System prompt"}) + context.add_message({"role": "user", "content": "Message 1"}) + context.add_message({"role": "assistant", "content": "Response 1"}) + context.add_message({"role": "user", "content": "What's the time and date?"}) + context.add_message( + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_time", + "type": "function", + "function": {"name": "get_time", "arguments": "{}"}, + }, + { + "id": "call_date", + "type": "function", + "function": {"name": "get_date", "arguments": "{}"}, + }, + ], + } + ) + # Only one tool result - other call still in progress + context.add_message( + {"role": "tool", "tool_call_id": "call_time", "content": '{"time": "10:30 AM"}'} + ) + context.add_message({"role": "user", "content": "Latest message"}) + + # Try to keep last 1 message + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 1) + + # Should stop before the function call that's in progress + # Messages to summarize: indices 1, 2, 3 (stops before index 4 where incomplete call is) + self.assertEqual(len(result.messages), 3) + self.assertEqual(result.last_summarized_index, 3) + + def test_multiple_completed_function_calls(self): + """Test that multiple completed function calls can be summarized.""" + context = LLMContext() + + # Add messages with multiple completed function calls + context.add_message({"role": "system", "content": "System prompt"}) + context.add_message({"role": "user", "content": "What's the time and date?"}) + context.add_message( + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_time", + "type": "function", + "function": {"name": "get_time", "arguments": "{}"}, + }, + { + "id": "call_date", + "type": "function", + "function": {"name": "get_date", "arguments": "{}"}, + }, + ], + } + ) + # Both tool results provided + context.add_message( + {"role": "tool", "tool_call_id": "call_time", "content": '{"time": "10:30 AM"}'} + ) + context.add_message( + { + "role": "tool", + "tool_call_id": "call_date", + "content": '{"date": "January 1, 2024"}', + } + ) + context.add_message({"role": "assistant", "content": "It's 10:30 AM on January 1, 2024"}) + context.add_message({"role": "user", "content": "Latest message"}) + + # Try to keep last 1 message + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 1) + + # Should get all messages except the last one (all function calls completed) + self.assertEqual(len(result.messages), 5) + self.assertEqual(result.last_summarized_index, 5) + + def test_sequential_function_calls_mixed_completion(self): + """Test sequential function calls with mixed completion states.""" + context = LLMContext() + + # Add messages with sequential function calls + context.add_message({"role": "system", "content": "System prompt"}) + + # First function call - completed + context.add_message({"role": "user", "content": "What time is it?"}) + context.add_message( + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "get_time", "arguments": "{}"}, + } + ], + } + ) + context.add_message( + {"role": "tool", "tool_call_id": "call_1", "content": '{"time": "10:30 AM"}'} + ) + context.add_message({"role": "assistant", "content": "It's 10:30 AM"}) + + # Second function call - in progress + context.add_message({"role": "user", "content": "What's the date?"}) + context.add_message( + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_2", + "type": "function", + "function": {"name": "get_date", "arguments": "{}"}, + } + ], + } + ) + # No result for call_2 yet + context.add_message({"role": "user", "content": "Latest message"}) + + # Try to keep last 1 message + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 1) + + # Should get messages up to and including the first completed function call + # but stop before the second function call that's in progress + # Messages to summarize: indices 1, 2, 3, 4, 5 (stops before index 6 where incomplete call is) + self.assertEqual(len(result.messages), 5) + self.assertEqual(result.messages[-1]["content"], "What's the date?") + self.assertEqual(result.last_summarized_index, 5) + + def test_function_call_formatting_in_transcript(self): + """Test that function calls are properly formatted in transcript.""" + + messages = [ + {"role": "user", "content": "What time is it?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": {"name": "get_time", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_123", "content": '{"time": "10:30 AM"}'}, + {"role": "assistant", "content": "It's 10:30 AM"}, + ] + + transcript = LLMContextSummarizationUtil.format_messages_for_summary(messages) + + # Check that function call is included + self.assertIn("TOOL_CALL: get_time({})", transcript) + # Check that tool result is included + self.assertIn('TOOL_RESULT[call_123]: {"time": "10:30 AM"}', transcript) + + def test_no_function_calls(self): + """Test that summarization works normally without function calls.""" + context = LLMContext() + + # Add normal conversation without function calls + context.add_message({"role": "system", "content": "System prompt"}) + context.add_message({"role": "user", "content": "Hello"}) + context.add_message({"role": "assistant", "content": "Hi"}) + context.add_message({"role": "user", "content": "How are you?"}) + context.add_message({"role": "assistant", "content": "I'm good"}) + context.add_message({"role": "user", "content": "Latest message"}) + + # Try to keep last 1 message + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 1) + + # Should get all messages except the last one + self.assertEqual(len(result.messages), 4) + self.assertEqual(result.last_summarized_index, 4) + + +class TestSummaryGenerationExceptions(unittest.IsolatedAsyncioTestCase): + """Tests for summary generation exception handling.""" + + async def test_generate_summary_raises_on_no_messages(self): + """Test that _generate_summary raises RuntimeError when there are no messages to summarize.""" + llm_service = LLMService() + context = LLMContext() + + # Add only one message (system), which isn't enough to summarize + context.add_message({"role": "system", "content": "System prompt"}) + + frame = LLMContextSummaryRequestFrame( + request_id="test", + context=context, + min_messages_to_keep=1, + target_context_tokens=1000, + summarization_prompt="Summarize this", + ) + + with self.assertRaises(RuntimeError) as cm: + await llm_service._generate_summary(frame) + + self.assertEqual(str(cm.exception), "No messages to summarize") + + async def test_generate_summary_raises_on_no_run_inference(self): + """Test that _generate_summary raises RuntimeError when run_inference is not implemented.""" + # Create a minimal LLM service - base class raises NotImplementedError + llm_service = LLMService() + + context = LLMContext() + context.add_message({"role": "user", "content": "Message 1"}) + context.add_message({"role": "assistant", "content": "Response 1"}) + context.add_message({"role": "user", "content": "Message 2"}) + + frame = LLMContextSummaryRequestFrame( + request_id="test", + context=context, + min_messages_to_keep=1, + target_context_tokens=1000, + summarization_prompt="Summarize this", + ) + + with self.assertRaises(RuntimeError) as cm: + await llm_service._generate_summary(frame) + + self.assertIn("does not implement run_inference", str(cm.exception)) + self.assertIn("LLMService", str(cm.exception)) + + async def test_generate_summary_raises_on_empty_response(self): + """Test that _generate_summary raises RuntimeError when LLM returns empty summary.""" + llm_service = LLMService() + # Mock run_inference to return None + llm_service.run_inference = AsyncMock(return_value=None) + + context = LLMContext() + context.add_message({"role": "user", "content": "Message 1"}) + context.add_message({"role": "assistant", "content": "Response 1"}) + context.add_message({"role": "user", "content": "Message 2"}) + + frame = LLMContextSummaryRequestFrame( + request_id="test", + context=context, + min_messages_to_keep=1, + target_context_tokens=1000, + summarization_prompt="Summarize this", + ) + + with self.assertRaises(RuntimeError) as cm: + await llm_service._generate_summary(frame) + + self.assertEqual(str(cm.exception), "LLM returned empty summary") + + async def test_generate_summary_task_handles_exceptions(self): + """Test that _generate_summary_task properly handles exceptions from _generate_summary.""" + llm_service = LLMService() + + # Mock broadcast_frame to capture the result + broadcast_calls = [] + + async def mock_broadcast(frame_class, **kwargs): + broadcast_calls.append((frame_class, kwargs)) + + llm_service.broadcast_frame = mock_broadcast + + # Mock push_error + llm_service.push_error = AsyncMock() + + context = LLMContext() + context.add_message({"role": "system", "content": "System prompt"}) + + frame = LLMContextSummaryRequestFrame( + request_id="test_123", + context=context, + min_messages_to_keep=1, + target_context_tokens=1000, + summarization_prompt="Summarize this", + ) + + # Execute the task + await llm_service._generate_summary_task(frame) + + # Verify broadcast_frame was called with error + self.assertEqual(len(broadcast_calls), 1) + frame_class, kwargs = broadcast_calls[0] + self.assertEqual(kwargs["request_id"], "test_123") + self.assertEqual(kwargs["summary"], "") + self.assertEqual(kwargs["last_summarized_index"], -1) + self.assertEqual( + kwargs["error"], "Error generating context summary: No messages to summarize" + ) + + # Verify push_error was called + llm_service.push_error.assert_called_once() + + async def test_generate_summary_success(self): + """Test that _generate_summary returns successfully with valid input.""" + llm_service = LLMService() + # Mock run_inference to return a summary + llm_service.run_inference = AsyncMock(return_value="This is a summary of the conversation") + + context = LLMContext() + context.add_message({"role": "user", "content": "Message 1"}) + context.add_message({"role": "assistant", "content": "Response 1"}) + context.add_message({"role": "user", "content": "Message 2"}) + + frame = LLMContextSummaryRequestFrame( + request_id="test", + context=context, + min_messages_to_keep=1, + target_context_tokens=1000, + summarization_prompt="Summarize this", + ) + + summary, last_index = await llm_service._generate_summary(frame) + + self.assertEqual(summary, "This is a summary of the conversation") + self.assertGreater(last_index, -1) + self.assertEqual(last_index, 1) # Should be the index of the last summarized message + + async def test_generate_summary_task_timeout(self): + """Test that _generate_summary_task handles timeout correctly.""" + llm_service = LLMService() + + # Mock _generate_summary to hang + async def slow_summary(frame): + await asyncio.sleep(10) + return ("summary", 1) + + llm_service._generate_summary = slow_summary + + broadcast_calls = [] + + async def mock_broadcast(frame_class, **kwargs): + broadcast_calls.append((frame_class, kwargs)) + + llm_service.broadcast_frame = mock_broadcast + llm_service.push_error = AsyncMock() + + context = LLMContext() + context.add_message({"role": "user", "content": "Message 1"}) + context.add_message({"role": "assistant", "content": "Response 1"}) + context.add_message({"role": "user", "content": "Message 2"}) + + frame = LLMContextSummaryRequestFrame( + request_id="timeout_test", + context=context, + min_messages_to_keep=1, + target_context_tokens=1000, + summarization_prompt="Summarize this", + summarization_timeout=0.1, # Very short timeout + ) + + await llm_service._generate_summary_task(frame) + + # Should have broadcast an error result + self.assertEqual(len(broadcast_calls), 1) + _, kwargs = broadcast_calls[0] + self.assertEqual(kwargs["request_id"], "timeout_test") + self.assertEqual(kwargs["summary"], "") + self.assertEqual(kwargs["last_summarized_index"], -1) + # error is None for timeout path (push_error is called instead) + self.assertIsNone(kwargs["error"]) + + # push_error should have been called with timeout message + llm_service.push_error.assert_called_once() + call_args = llm_service.push_error.call_args + error_msg = call_args.kwargs.get("error_msg") or call_args.args[0] + self.assertIn("timed out", error_msg) + + +class TestDedicatedLLMSummarization(unittest.IsolatedAsyncioTestCase): + """Tests for dedicated LLM summarization in LLMContextSummarizer.""" + + async def asyncSetUp(self): + from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams + + self.task_manager = TaskManager() + self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop())) + + def _create_context_and_config(self, dedicated_llm): + """Create a context with enough messages and a config with a dedicated LLM.""" + context = LLMContext() + for i in range(10): + context.add_message( + {"role": "user", "content": f"Test message {i} that adds tokens to context."} + ) + + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, # Very low to trigger easily + summary_config=LLMContextSummaryConfig( + llm=dedicated_llm, + summarization_timeout=5.0, + ), + ) + return context, config + + async def test_dedicated_llm_success(self): + """Test that dedicated LLM generates summary and applies result.""" + from pipecat.processors.aggregators.llm_context_summarizer import LLMContextSummarizer + + dedicated_llm = LLMService() + dedicated_llm._generate_summary = AsyncMock(return_value=("Dedicated summary", 5)) + + context, config = self._create_context_and_config(dedicated_llm) + original_message_count = len(context.messages) + summarizer = LLMContextSummarizer(context=context, config=config) + await summarizer.setup(self.task_manager) + + # Track whether on_request_summarization event fires (it should NOT) + event_fired = False + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal event_fired + event_fired = True + + # Trigger summarization via LLM response start + from pipecat.frames.frames import LLMFullResponseStartFrame + + await summarizer.process_frame(LLMFullResponseStartFrame()) + + # Wait for the background task to complete + await asyncio.sleep(0.1) + + # The event should NOT have fired (dedicated LLM handles it internally) + self.assertFalse(event_fired) + + # Verify the dedicated LLM was called + dedicated_llm._generate_summary.assert_called_once() + + # Verify summary was applied to context (message count should decrease) + self.assertLess(len(context.messages), original_message_count) + + # Verify summary message is present + summary_messages = [ + msg for msg in context.messages if "Conversation summary:" in msg.get("content", "") + ] + self.assertEqual(len(summary_messages), 1) + self.assertIn("Dedicated summary", summary_messages[0]["content"]) + + await summarizer.cleanup() + + async def test_dedicated_llm_timeout(self): + """Test that dedicated LLM timeout produces error and clears state.""" + from pipecat.processors.aggregators.llm_context_summarizer import LLMContextSummarizer + + dedicated_llm = LLMService() + + async def slow_summary(frame): + await asyncio.sleep(10) + return ("summary", 1) + + dedicated_llm._generate_summary = slow_summary + + context, config = self._create_context_and_config(dedicated_llm) + config.summary_config.summarization_timeout = 0.1 # Very short timeout + summarizer = LLMContextSummarizer(context=context, config=config) + await summarizer.setup(self.task_manager) + + original_message_count = len(context.messages) + + # Trigger summarization + from pipecat.frames.frames import LLMFullResponseStartFrame + + await summarizer.process_frame(LLMFullResponseStartFrame()) + + # Wait for the background task to complete (timeout + some buffer) + await asyncio.sleep(0.3) + + # Context should be unchanged (timeout = error = no summary applied) + self.assertEqual(len(context.messages), original_message_count) + + # Summarization state should be cleared so new requests can be made + self.assertFalse(summarizer._summarization_in_progress) + + await summarizer.cleanup() + + async def test_dedicated_llm_exception(self): + """Test that dedicated LLM exceptions produce error and clear state.""" + from pipecat.processors.aggregators.llm_context_summarizer import LLMContextSummarizer + + dedicated_llm = LLMService() + dedicated_llm._generate_summary = AsyncMock( + side_effect=RuntimeError("LLM connection failed") + ) + + context, config = self._create_context_and_config(dedicated_llm) + summarizer = LLMContextSummarizer(context=context, config=config) + await summarizer.setup(self.task_manager) + + original_message_count = len(context.messages) + + # Trigger summarization + from pipecat.frames.frames import LLMFullResponseStartFrame + + await summarizer.process_frame(LLMFullResponseStartFrame()) + + # Wait for the background task to complete + await asyncio.sleep(0.1) + + # Context should be unchanged (exception = error = no summary applied) + self.assertEqual(len(context.messages), original_message_count) + + # Summarization state should be cleared + self.assertFalse(summarizer._summarization_in_progress) + + await summarizer.cleanup() + + async def test_dedicated_llm_does_not_emit_event(self): + """Test that summarizer does NOT emit on_request_summarization when dedicated LLM is set.""" + from pipecat.processors.aggregators.llm_context_summarizer import LLMContextSummarizer + + dedicated_llm = LLMService() + dedicated_llm._generate_summary = AsyncMock(return_value=("Summary", 1)) + + context, config = self._create_context_and_config(dedicated_llm) + summarizer = LLMContextSummarizer(context=context, config=config) + await summarizer.setup(self.task_manager) + + event_fired = False + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal event_fired + event_fired = True + + from pipecat.frames.frames import LLMFullResponseStartFrame + + await summarizer.process_frame(LLMFullResponseStartFrame()) + await asyncio.sleep(0.1) + + self.assertFalse(event_fired) + + await summarizer.cleanup() + + async def test_no_dedicated_llm_emits_event(self): + """Test that summarizer emits on_request_summarization when no dedicated LLM.""" + from pipecat.processors.aggregators.llm_context_summarizer import LLMContextSummarizer + + context = LLMContext() + for i in range(10): + context.add_message( + {"role": "user", "content": f"Test message {i} that adds tokens to context."} + ) + + config = LLMAutoContextSummarizationConfig(max_context_tokens=50) + summarizer = LLMContextSummarizer(context=context, config=config) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + from pipecat.frames.frames import LLMFullResponseStartFrame + + await summarizer.process_frame(LLMFullResponseStartFrame()) + + self.assertIsNotNone(request_frame) + self.assertIsInstance(request_frame, LLMContextSummaryRequestFrame) + + await summarizer.cleanup() + + +class TestOrphanedToolResponseDetection(unittest.TestCase): + """Tests that tool responses in the kept range are treated as orphans. + + The scan in _get_function_calls_in_progress_index is bounded by summary_end, + so a tool response that falls in the kept portion (>= summary_end) never + resolves its matching tool call. This ensures the assistant+tool_calls + message and all its responses stay together in the kept range. + """ + + def test_tool_response_in_kept_range_is_treated_as_orphan(self): + """Tool response in the kept range causes the tool call to be kept too.""" + context = LLMContext() + context.add_message({"role": "system", "content": "System prompt"}) # idx 0 + context.add_message({"role": "user", "content": "Hello"}) # idx 1 + context.add_message( # idx 2: assistant with tool_call + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "fn", "arguments": "{}"}, + } + ], + } + ) + context.add_message( + {"role": "tool", "tool_call_id": "call_1", "content": "result"} + ) # idx 3 (kept) + context.add_message({"role": "user", "content": "Thanks"}) # idx 4 (kept) + + # Keep 2: summary_end=3. The tool response at idx 3 is outside the scan + # range → call_1 stays pending → boundary moves back to idx 2. + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 2) + self.assertEqual(result.last_summarized_index, 1) + self.assertEqual(result.messages[-1]["content"], "Hello") + + def test_tool_response_in_summarized_range_is_not_orphan(self): + """Tool response within the summarized range correctly resolves its call.""" + context = LLMContext() + context.add_message({"role": "system", "content": "System prompt"}) # idx 0 + context.add_message({"role": "user", "content": "Hello"}) # idx 1 + context.add_message( # idx 2: assistant with tool_call + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "fn", "arguments": "{}"}, + } + ], + } + ) + context.add_message( + {"role": "tool", "tool_call_id": "call_1", "content": "result"} + ) # idx 3 + context.add_message({"role": "assistant", "content": "Done"}) # idx 4 + context.add_message({"role": "user", "content": "Thanks"}) # idx 5 (kept) + + # Keep 1: summary_end=5. Both the tool call (idx 2) and its response + # (idx 3) are within the scan range → resolved → no adjustment. + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 1) + self.assertEqual(result.last_summarized_index, 4) + self.assertEqual(len(result.messages), 4) + + def test_partial_responses_in_kept_range_moves_back(self): + """When only some tool responses are in the kept range the whole group is kept.""" + context = LLMContext() + context.add_message({"role": "system", "content": "System prompt"}) # idx 0 + context.add_message({"role": "user", "content": "Hello"}) # idx 1 + context.add_message( # idx 2: assistant with two tool_calls + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_a", + "type": "function", + "function": {"name": "fn_a", "arguments": "{}"}, + }, + { + "id": "call_b", + "type": "function", + "function": {"name": "fn_b", "arguments": "{}"}, + }, + ], + } + ) + context.add_message( + {"role": "tool", "tool_call_id": "call_a", "content": "result_a"} + ) # idx 3 + context.add_message( + {"role": "tool", "tool_call_id": "call_b", "content": "result_b"} + ) # idx 4 (kept) + context.add_message({"role": "user", "content": "Thanks"}) # idx 5 (kept) + + # Keep 2: summary_end=4. call_a is resolved (idx 3 is in scan range) but + # call_b's response (idx 4) is outside → call_b stays pending → + # function_call_start=2 → boundary moves back to idx 2. + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 2) + self.assertEqual(result.last_summarized_index, 1) + self.assertEqual(result.messages[-1]["content"], "Hello") + + def test_non_adjacent_orphan_in_kept_range_moves_back(self): + """Orphaned tool response deeper in the kept range (not at the boundary) is detected.""" + context = LLMContext() + context.add_message({"role": "system", "content": "System prompt"}) # idx 0 + context.add_message({"role": "user", "content": "Hello"}) # idx 1 + context.add_message( # idx 2: assistant with two tool_calls + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_a", + "type": "function", + "function": {"name": "fn_a", "arguments": "{}"}, + }, + { + "id": "call_b", + "type": "function", + "function": {"name": "fn_b", "arguments": "{}"}, + }, + ], + } + ) + context.add_message( + {"role": "tool", "tool_call_id": "call_a", "content": "result_a"} + ) # idx 3 + context.add_message({"role": "user", "content": "Intermediate"}) # idx 4 (kept) + context.add_message( + {"role": "tool", "tool_call_id": "call_b", "content": "result_b"} + ) # idx 5 (kept) — NOT adjacent to the boundary + context.add_message({"role": "user", "content": "Latest"}) # idx 6 (kept) + + # Keep 3: summary_end=4. call_b's response is at idx 5, two hops into + # the kept range. The scan stops at idx 4, so call_b is never resolved → + # function_call_start=2 → boundary moves back to idx 2. + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 3) + self.assertEqual(result.last_summarized_index, 1) + self.assertEqual(result.messages[-1]["content"], "Hello") + + +class TestLLMSpecificMessageHandling(unittest.TestCase): + """Tests that LLMSpecificMessage objects are correctly skipped in summarization.""" + + def test_estimate_context_tokens_skips_specific_messages(self): + """Test that estimate_context_tokens skips LLMSpecificMessage objects.""" + context = LLMContext() + context.add_message({"role": "user", "content": "Hello"}) + context.add_message(LLMSpecificMessage(llm="google", message={})) + context.add_message({"role": "assistant", "content": "Hi there"}) + + tokens_with_specific = LLMContextSummarizationUtil.estimate_context_tokens(context) + + context_without = LLMContext() + context_without.add_message({"role": "user", "content": "Hello"}) + context_without.add_message({"role": "assistant", "content": "Hi there"}) + tokens_without = LLMContextSummarizationUtil.estimate_context_tokens(context_without) + + self.assertEqual(tokens_with_specific, tokens_without) + + def test_get_messages_to_summarize_with_specific_messages(self): + """Test that get_messages_to_summarize handles LLMSpecificMessage objects.""" + context = LLMContext() + context.add_message({"role": "system", "content": "System prompt"}) + context.add_message(LLMSpecificMessage(llm="google", message={})) + context.add_message({"role": "user", "content": "Message 1"}) + context.add_message({"role": "assistant", "content": "Response 1"}) + context.add_message(LLMSpecificMessage(llm="google", message={})) + context.add_message({"role": "user", "content": "Message 2"}) + context.add_message({"role": "assistant", "content": "Response 2"}) + + result = LLMContextSummarizationUtil.get_messages_to_summarize(context, 2) + + self.assertEqual(len(result.messages), 4) + self.assertEqual(result.last_summarized_index, 4) + + def test_format_messages_skips_specific_messages(self): + """Test that format_messages_for_summary skips LLMSpecificMessage objects.""" + messages = [ + {"role": "user", "content": "Hello"}, + LLMSpecificMessage(llm="google", message={}), + {"role": "assistant", "content": "Hi there"}, + ] + + transcript = LLMContextSummarizationUtil.format_messages_for_summary(messages) + + self.assertIn("USER: Hello", transcript) + self.assertIn("ASSISTANT: Hi there", transcript) + + def test_function_call_tracking_skips_specific_messages(self): + """Test that _get_function_calls_in_progress_index skips LLMSpecificMessage.""" + messages = [ + {"role": "user", "content": "What time is it?"}, + LLMSpecificMessage(llm="google", message={}), + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": {"name": "get_time", "arguments": "{}"}, + } + ], + }, + LLMSpecificMessage(llm="google", message={}), + {"role": "tool", "tool_call_id": "call_123", "content": '{"time": "10:30 AM"}'}, + ] + + result = LLMContextSummarizationUtil._get_earliest_function_call_not_resolved_in_range( + messages, 0, len(messages) + ) + self.assertEqual(result, -1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_daily_transport_service.py b/tests/test_daily_transport_service.py index 5ebf28b83..117c859ce 100644 --- a/tests/test_daily_transport_service.py +++ b/tests/test_daily_transport_service.py @@ -90,3 +90,7 @@ class TestDailyTransport(unittest.IsolatedAsyncioTestCase): camera.write_frame.assert_called_with(b"test") mic.write_frames.assert_called() """ + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deepgram_stt.py b/tests/test_deepgram_stt.py new file mode 100644 index 000000000..eb8036237 --- /dev/null +++ b/tests/test_deepgram_stt.py @@ -0,0 +1,51 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import io + +import pytest +from loguru import logger + +from pipecat.services.deepgram.stt import _derive_deepgram_urls + + +@pytest.mark.parametrize( + "base_url, expected_ws, expected_http", + [ + # Secure schemes + ("wss://mydeepgram.com", "wss://mydeepgram.com", "https://mydeepgram.com"), + ("https://mydeepgram.com", "wss://mydeepgram.com", "https://mydeepgram.com"), + # Insecure schemes (air-gapped deployments) + ("ws://mydeepgram.com", "ws://mydeepgram.com", "http://mydeepgram.com"), + ("http://mydeepgram.com", "ws://mydeepgram.com", "http://mydeepgram.com"), + # Bare hostname defaults to secure + ("mydeepgram.com", "wss://mydeepgram.com", "https://mydeepgram.com"), + # With port + ("ws://localhost:8080", "ws://localhost:8080", "http://localhost:8080"), + ("wss://localhost:443", "wss://localhost:443", "https://localhost:443"), + ("localhost:8080", "wss://localhost:8080", "https://localhost:8080"), + # With path + ("wss://host/v1/listen", "wss://host/v1/listen", "https://host/v1/listen"), + ("http://host/v1/listen", "ws://host/v1/listen", "http://host/v1/listen"), + ], +) +def test_derive_deepgram_urls(base_url, expected_ws, expected_http): + ws_url, http_url = _derive_deepgram_urls(base_url) + assert ws_url == expected_ws + assert http_url == expected_http + + +def test_derive_deepgram_urls_unknown_scheme_warns(): + sink = io.StringIO() + handler_id = logger.add(sink, format="{message}") + try: + ws_url, http_url = _derive_deepgram_urls("ftp://mydeepgram.com") + # Falls back to secure + assert ws_url == "wss://mydeepgram.com" + assert http_url == "https://mydeepgram.com" + assert "Unrecognized scheme" in sink.getvalue() + finally: + logger.remove(handler_id) diff --git a/tests/test_dtmf_aggregator.py b/tests/test_dtmf_aggregator.py index dd9f4d835..827720b9c 100644 --- a/tests/test_dtmf_aggregator.py +++ b/tests/test_dtmf_aggregator.py @@ -240,3 +240,7 @@ class TestDTMFAggregator(unittest.IsolatedAsyncioTestCase): ] self.assertEqual(len(transcription_frames), 1) self.assertEqual(transcription_frames[0].text, "DTMF: 0123456789*#") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_filters.py b/tests/test_filters.py index 1d56fab38..564ba4a08 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -14,10 +14,12 @@ from pipecat.frames.frames import ( UserStartedSpeakingFrame, UserStoppedSpeakingFrame, ) +from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.filters.frame_filter import FrameFilter from pipecat.processors.filters.function_filter import FunctionFilter from pipecat.processors.filters.identity_filter import IdentityFilter from pipecat.processors.filters.wake_check_filter import WakeCheckFilter +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.tests.utils import run_test @@ -93,6 +95,98 @@ class TestFunctionFilter(unittest.IsolatedAsyncioTestCase): expected_down_frames=expected_down_frames, ) + async def test_no_direction_filters_both_directions(self): + """When direction is None, frames in both directions are filtered.""" + + class UpstreamPusher(FrameProcessor): + """Pushes a TextFrame upstream when it receives a system frame.""" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + await self.push_frame(frame, direction) + if isinstance(frame, UserStartedSpeakingFrame): + await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM) + + async def block_text(frame: Frame): + return not isinstance(frame, TextFrame) + + # direction=None: filter applies in both directions. The downstream + # TextFrame is blocked and the upstream TextFrame pushed by + # UpstreamPusher is also blocked. + filter = FunctionFilter(filter=block_text, direction=None) + pipeline = Pipeline([filter, UpstreamPusher()]) + frames_to_send = [ + TextFrame(text="Hello!"), + UserStartedSpeakingFrame(), + ] + expected_down_frames = [UserStartedSpeakingFrame] + expected_up_frames = [] + await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + expected_up_frames=expected_up_frames, + ) + + async def test_downstream_direction_passes_upstream(self): + """When direction is DOWNSTREAM, upstream frames pass through unfiltered.""" + + class UpstreamPusher(FrameProcessor): + """Pushes a TextFrame upstream when it receives a system frame.""" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + await self.push_frame(frame, direction) + if isinstance(frame, UserStartedSpeakingFrame): + await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM) + + async def block_text(frame: Frame): + return not isinstance(frame, TextFrame) + + # direction=DOWNSTREAM: filter only applies downstream, so the + # upstream TextFrame pushed by UpstreamPusher passes through. + filter = FunctionFilter(filter=block_text) + pipeline = Pipeline([filter, UpstreamPusher()]) + frames_to_send = [UserStartedSpeakingFrame()] + expected_down_frames = [UserStartedSpeakingFrame] + expected_up_frames = [TextFrame] + await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + expected_up_frames=expected_up_frames, + ) + + async def test_upstream_direction_passes_downstream(self): + """When direction is UPSTREAM, downstream frames pass through unfiltered.""" + + class UpstreamPusher(FrameProcessor): + """Pushes a TextFrame upstream when it receives a system frame.""" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + await self.push_frame(frame, direction) + if isinstance(frame, UserStartedSpeakingFrame): + await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM) + + async def block_text(frame: Frame): + return not isinstance(frame, TextFrame) + + # direction=UPSTREAM: filter only applies upstream, so the + # downstream TextFrame passes through but the upstream TextFrame + # pushed by UpstreamPusher is blocked. + filter = FunctionFilter(filter=block_text, direction=FrameDirection.UPSTREAM) + pipeline = Pipeline([filter, UpstreamPusher()]) + frames_to_send = [TextFrame(text="Hello!"), UserStartedSpeakingFrame()] + expected_down_frames = [UserStartedSpeakingFrame, TextFrame] + expected_up_frames = [] + await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + expected_up_frames=expected_up_frames, + ) + class TestWakeCheckFilter(unittest.IsolatedAsyncioTestCase): async def test_no_wake_word(self): @@ -118,3 +212,7 @@ class TestWakeCheckFilter(unittest.IsolatedAsyncioTestCase): expected_down_frames=expected_down_frames, ) assert received_down[-1].text == "Phrase 1" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_frame_processor.py b/tests/test_frame_processor.py index b3a11ab43..a875741e3 100644 --- a/tests/test_frame_processor.py +++ b/tests/test_frame_processor.py @@ -6,7 +6,8 @@ import asyncio import unittest -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import List from pipecat.frames.frames import ( DataFrame, @@ -14,16 +15,29 @@ from pipecat.frames.frames import ( Frame, InterruptionFrame, OutputTransportMessageUrgentFrame, + StopFrame, SystemFrame, TextFrame, UninterruptibleFrame, ) from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.filters.identity_filter import IdentityFilter -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.processors.frame_processor import ( + FrameDirection, + FrameProcessor, +) from pipecat.tests.utils import SleepFrame, run_test +@dataclass +class BroadcastTestFrame(DataFrame): + """Test frame with init fields for broadcast testing.""" + + text: str = "" + value: int = 0 + items: List[str] = field(default_factory=list) + + class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): async def test_before_after_events(self): identity = IdentityFilter() @@ -69,50 +83,38 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): assert before_push_called assert after_push_called - async def test_interruption_and_wait(self): - class DelayFrameProcessor(FrameProcessor): - """This processors just gives time to the event loop to change - between tasks. Otherwise things happen to fast.""" - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - await asyncio.sleep(0.1) - await self.push_frame(frame, direction) + async def test_broadcast_interruption(self): + """Test that broadcast_interruption() pushes InterruptionFrame both + directions and allows subsequent code to run.""" class InterruptFrameProcessor(FrameProcessor): async def process_frame(self, frame: Frame, direction: FrameDirection): await super().process_frame(frame, direction) if isinstance(frame, TextFrame): - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self.push_frame(OutputTransportMessageUrgentFrame(message=frame.text)) else: await self.push_frame(frame, direction) - pipeline = Pipeline([DelayFrameProcessor(), InterruptFrameProcessor()]) + pipeline = Pipeline([InterruptFrameProcessor()]) frames_to_send = [ - # Just a random interruption to make sure we don't clear anything - # before the actual `InterruptionTaskFrame` interruption. - InterruptionFrame(), - # This will generate an `InterruptionTaskFrame` and will wait for an - # `InterruptionFrame`. TextFrame(text="Hello from Pipecat!"), - # Just give time for everything to complete. SleepFrame(sleep=0.5), - EndFrame(), ] expected_down_frames = [ - InterruptionFrame, InterruptionFrame, OutputTransportMessageUrgentFrame, - EndFrame, + ] + expected_up_frames = [ + InterruptionFrame, ] await run_test( pipeline, frames_to_send=frames_to_send, expected_down_frames=expected_down_frames, - send_end_frame=False, + expected_up_frames=expected_up_frames, ) async def test_interruptible_frames(self): @@ -186,3 +188,292 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): frames_to_send=frames_to_send, expected_down_frames=expected_down_frames, ) + + async def test_broadcast_frame(self): + """Test that broadcast_frame creates two separate frames with fresh IDs.""" + downstream_frames: List[Frame] = [] + upstream_frames: List[Frame] = [] + + class BroadcastTestProcessor(FrameProcessor): + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + if isinstance(frame, TextFrame): + await self.broadcast_frame( + BroadcastTestFrame, text="hello", value=42, items=["a", "b"] + ) + else: + await self.push_frame(frame, direction) + + class CaptureProcessor(FrameProcessor): + def __init__(self, capture_list: List[Frame], direction: FrameDirection): + super().__init__() + self._capture_list = capture_list + self._capture_direction = direction + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + if direction == self._capture_direction and isinstance(frame, BroadcastTestFrame): + self._capture_list.append(frame) + await self.push_frame(frame, direction) + + up_capture = CaptureProcessor(upstream_frames, FrameDirection.UPSTREAM) + broadcaster = BroadcastTestProcessor() + down_capture = CaptureProcessor(downstream_frames, FrameDirection.DOWNSTREAM) + + pipeline = Pipeline([up_capture, broadcaster, down_capture]) + + frames_to_send = [TextFrame(text="trigger")] + expected_down_frames = [BroadcastTestFrame] + expected_up_frames = [BroadcastTestFrame] + + await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + expected_up_frames=expected_up_frames, + ) + + # Verify we got one frame in each direction + self.assertEqual(len(downstream_frames), 1) + self.assertEqual(len(upstream_frames), 1) + + down_frame = downstream_frames[0] + up_frame = upstream_frames[0] + + # Verify the frames have different IDs (they are separate instances) + self.assertNotEqual(down_frame.id, up_frame.id) + + # Verify the frames have the correct field values + self.assertEqual(down_frame.text, "hello") + self.assertEqual(down_frame.value, 42) + self.assertEqual(down_frame.items, ["a", "b"]) + self.assertEqual(up_frame.text, "hello") + self.assertEqual(up_frame.value, 42) + self.assertEqual(up_frame.items, ["a", "b"]) + + # Verify the items lists are shared references (no deep copy) + self.assertIs(down_frame.items, up_frame.items) + + async def test_broadcast_frame_instance(self): + """Test that broadcast_frame_instance shallow-copies all fields except id and name.""" + downstream_frames: List[Frame] = [] + upstream_frames: List[Frame] = [] + original_frame: List[Frame] = [] + + class BroadcastInstanceTestProcessor(FrameProcessor): + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + if isinstance(frame, BroadcastTestFrame): + # Set some non-init fields on the frame + frame.pts = 12345 + frame.metadata = {"key": "value", "nested": {"a": 1}} + original_frame.append(frame) + await self.broadcast_frame_instance(frame) + else: + await self.push_frame(frame, direction) + + class CaptureProcessor(FrameProcessor): + def __init__(self, capture_list: List[Frame], direction: FrameDirection): + super().__init__() + self._capture_list = capture_list + self._capture_direction = direction + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + if direction == self._capture_direction and isinstance(frame, BroadcastTestFrame): + self._capture_list.append(frame) + await self.push_frame(frame, direction) + + up_capture = CaptureProcessor(upstream_frames, FrameDirection.UPSTREAM) + broadcaster = BroadcastInstanceTestProcessor() + down_capture = CaptureProcessor(downstream_frames, FrameDirection.DOWNSTREAM) + + pipeline = Pipeline([up_capture, broadcaster, down_capture]) + + # Create a frame with mutable fields to test shallow copying + test_frame = BroadcastTestFrame(text="test", value=99, items=["x", "y", "z"]) + + frames_to_send = [test_frame] + expected_down_frames = [BroadcastTestFrame] + expected_up_frames = [BroadcastTestFrame] + + await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + expected_up_frames=expected_up_frames, + ) + + # Verify we got one frame in each direction + self.assertEqual(len(downstream_frames), 1) + self.assertEqual(len(upstream_frames), 1) + self.assertEqual(len(original_frame), 1) + + orig = original_frame[0] + down_frame = downstream_frames[0] + up_frame = upstream_frames[0] + + # Verify the frames have different IDs and names (fresh values) + self.assertNotEqual(down_frame.id, orig.id) + self.assertNotEqual(up_frame.id, orig.id) + self.assertNotEqual(down_frame.id, up_frame.id) + self.assertNotEqual(down_frame.name, orig.name) + self.assertNotEqual(up_frame.name, orig.name) + + # Verify init fields are copied correctly + self.assertEqual(down_frame.text, "test") + self.assertEqual(down_frame.value, 99) + self.assertEqual(down_frame.items, ["x", "y", "z"]) + self.assertEqual(up_frame.text, "test") + self.assertEqual(up_frame.value, 99) + self.assertEqual(up_frame.items, ["x", "y", "z"]) + + # Verify non-init fields (except id/name) are copied + self.assertEqual(down_frame.pts, 12345) + self.assertEqual(down_frame.metadata, {"key": "value", "nested": {"a": 1}}) + self.assertEqual(up_frame.pts, 12345) + self.assertEqual(up_frame.metadata, {"key": "value", "nested": {"a": 1}}) + + # Verify mutable fields are shallow-copied (shared references) + self.assertIs(down_frame.items, orig.items) + self.assertIs(up_frame.items, orig.items) + self.assertIs(down_frame.metadata, orig.metadata) + self.assertIs(up_frame.metadata, orig.metadata) + + async def test_terminal_frames_survive_interruption(self): + """Test that EndFrame survives interruption (it is uninterruptible). + + This test simulates issue #3524 where an InterruptionFrame during slow + processing would cause terminal frames to be lost, freezing the pipeline. + """ + received_frames: List[Frame] = [] + + class DelayAndInterruptProcessor(FrameProcessor): + """This processor delays processing and then generates an interruption. + + When processing a TextFrame, it sleeps and then pushes an + InterruptionFrame to simulate what happens when interruption occurs + while a terminal frame is in the queue. + """ + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + if isinstance(frame, TextFrame): + # Delay to allow EndFrame to be queued + await asyncio.sleep(0.1) + # Push interruption - this should NOT discard the EndFrame + await self.push_frame(InterruptionFrame(), direction) + await self.push_frame(frame, direction) + + class CaptureFrameProcessor(FrameProcessor): + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + received_frames.append(frame) + await self.push_frame(frame, direction) + + pipeline = Pipeline([DelayAndInterruptProcessor(), CaptureFrameProcessor()]) + + frames_to_send = [ + TextFrame(text="trigger"), + ] + expected_down_frames = [ + InterruptionFrame, + TextFrame, + ] + await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + ) + + # Verify EndFrame was received by our capture processor (survived interruption) + # Note: run_test filters EndFrame from expected_down_frames when send_end_frame=True, + # but our capture processor sees it before that filtering. + end_frames = [f for f in received_frames if isinstance(f, EndFrame)] + self.assertEqual(len(end_frames), 1, "EndFrame should survive interruption") + + async def test_stop_frame_survives_interruption(self): + """Test that StopFrame survives interruption (it is uninterruptible). + + Similar to test_terminal_frames_survive_interruption but specifically + for StopFrame. + """ + received_frames: List[Frame] = [] + + class DelayAndInterruptProcessor(FrameProcessor): + """This processor delays processing and then generates an interruption.""" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + if isinstance(frame, TextFrame): + # Delay to allow StopFrame to be queued + await asyncio.sleep(0.1) + # Push interruption - this should NOT discard the StopFrame + await self.push_frame(InterruptionFrame(), direction) + await self.push_frame(frame, direction) + + class CaptureFrameProcessor(FrameProcessor): + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + received_frames.append(frame) + await self.push_frame(frame, direction) + + pipeline = Pipeline([DelayAndInterruptProcessor(), CaptureFrameProcessor()]) + + frames_to_send = [ + TextFrame(text="trigger"), + StopFrame(), + ] + expected_down_frames = [ + InterruptionFrame, + TextFrame, + StopFrame, + ] + await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + send_end_frame=False, + ) + + # Verify StopFrame was received (survived interruption) + stop_frames = [f for f in received_frames if isinstance(f, StopFrame)] + self.assertEqual(len(stop_frames), 1, "StopFrame should survive interruption") + + async def test_broadcast_interruption_allows_subsequent_code(self): + """Test that broadcast_interruption() returns immediately, allowing the + caller to run code afterwards (e.g. push an urgent frame).""" + code_after_ran = False + + class InterruptOnTextProcessor(FrameProcessor): + async def process_frame(self, frame: Frame, direction: FrameDirection): + nonlocal code_after_ran + + await super().process_frame(frame, direction) + if isinstance(frame, TextFrame): + await self.broadcast_interruption() + + code_after_ran = True + await self.push_frame(OutputTransportMessageUrgentFrame(message="done")) + else: + await self.push_frame(frame, direction) + + pipeline = Pipeline([InterruptOnTextProcessor()]) + + frames_to_send = [ + TextFrame(text="trigger"), + ] + expected_down_frames = [ + InterruptionFrame, + OutputTransportMessageUrgentFrame, + ] + await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + ) + self.assertTrue(code_after_ran, "Code after broadcast_interruption() should execute") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_function_calling_adapters.py b/tests/test_function_calling_adapters.py index 5df59ed79..348754f47 100644 --- a/tests/test_function_calling_adapters.py +++ b/tests/test_function_calling_adapters.py @@ -204,3 +204,7 @@ class TestFunctionAdapters(unittest.TestCase): } ] assert AWSBedrockLLMAdapter().to_provider_tools_format(self.tools_def) == expected + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_get_llm_invocation_params.py b/tests/test_get_llm_invocation_params.py index ad1437227..9cfeb8933 100644 --- a/tests/test_get_llm_invocation_params.py +++ b/tests/test_get_llm_invocation_params.py @@ -43,15 +43,14 @@ For AWS Bedrock adapter: import unittest from google.genai.types import Content, Part -from openai.types.chat import ChatCompletionMessage from pipecat.adapters.services.anthropic_adapter import AnthropicLLMAdapter from pipecat.adapters.services.bedrock_adapter import AWSBedrockLLMAdapter from pipecat.adapters.services.gemini_adapter import GeminiLLMAdapter from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter +from pipecat.adapters.services.perplexity_adapter import PerplexityLLMAdapter from pipecat.processors.aggregators.llm_context import ( LLMContext, - LLMSpecificMessage, LLMStandardMessage, ) @@ -994,5 +993,222 @@ class TestAWSBedrockGetLLMInvocationParams(unittest.TestCase): self.assertEqual(len(params["messages"]), 0) +class TestPerplexityGetLLMInvocationParams(unittest.TestCase): + def setUp(self) -> None: + """Sets up a common adapter instance for all tests.""" + self.adapter = PerplexityLLMAdapter() + + def test_standard_messages_pass_through(self): + """Test that a valid [user, assistant, user] sequence passes through unchanged.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "How are you?"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 3) + self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][0]["content"], "Hello") + self.assertEqual(params["messages"][1]["role"], "assistant") + self.assertEqual(params["messages"][1]["content"], "Hi there!") + self.assertEqual(params["messages"][2]["role"], "user") + self.assertEqual(params["messages"][2]["content"], "How are you?") + + def test_initial_system_message_preserved(self): + """Test that a valid [system, user, assistant, user] sequence passes through unchanged.""" + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "Bye"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 4) + self.assertEqual(params["messages"][0]["role"], "system") + self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.") + self.assertEqual(params["messages"][1]["role"], "user") + self.assertEqual(params["messages"][2]["role"], "assistant") + self.assertEqual(params["messages"][3]["role"], "user") + + def test_consecutive_same_role_messages_merged(self): + """Test that consecutive user messages are merged into list-of-dicts content.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "First message"}, + {"role": "user", "content": "Second message"}, + {"role": "assistant", "content": "Response"}, + {"role": "user", "content": "Third message"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 3) + + # First message should be merged users + merged = params["messages"][0] + self.assertEqual(merged["role"], "user") + self.assertIsInstance(merged["content"], list) + self.assertEqual(len(merged["content"]), 2) + self.assertEqual(merged["content"][0]["type"], "text") + self.assertEqual(merged["content"][0]["text"], "First message") + self.assertEqual(merged["content"][1]["type"], "text") + self.assertEqual(merged["content"][1]["text"], "Second message") + + self.assertEqual(params["messages"][1]["role"], "assistant") + self.assertEqual(params["messages"][2]["role"], "user") + + def test_non_initial_system_converted_to_user(self): + """Test that non-initial system messages are converted to user and merged with adjacent user.""" + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "system", "content": "Be concise."}, + {"role": "user", "content": "Tell me about Python."}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + # system(initial), user, assistant, merged(system→user + user) + self.assertEqual(len(params["messages"]), 4) + self.assertEqual(params["messages"][0]["role"], "system") + self.assertEqual(params["messages"][1]["role"], "user") + self.assertEqual(params["messages"][2]["role"], "assistant") + + # The converted system→user and the following user should be merged + merged = params["messages"][3] + self.assertEqual(merged["role"], "user") + self.assertIsInstance(merged["content"], list) + self.assertEqual(len(merged["content"]), 2) + self.assertEqual(merged["content"][0]["text"], "Be concise.") + self.assertEqual(merged["content"][1]["text"], "Tell me about Python.") + + def test_multiple_system_messages_at_start_preserved(self): + """Test that multiple consecutive system messages at start pass through unchanged.""" + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "Always be polite."}, + {"role": "user", "content": "Hello"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 3) + self.assertEqual(params["messages"][0]["role"], "system") + self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.") + self.assertEqual(params["messages"][1]["role"], "system") + self.assertEqual(params["messages"][1]["content"], "Always be polite.") + self.assertEqual(params["messages"][2]["role"], "user") + self.assertEqual(params["messages"][2]["content"], "Hello") + + def test_trailing_assistant_removed(self): + """Test that a trailing assistant message is removed.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 1) + self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][0]["content"], "Hello") + + def test_only_system_messages_preserved(self): + """Test that system-only contexts are left unchanged (no system→user conversion). + + We intentionally do not convert trailing system messages to "user" + because that would make the transformation unstable across calls — + Perplexity has statefulness within a conversation, so a message that + was "user" in one call but becomes "system" in the next causes errors. + """ + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are a helpful assistant."}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 1) + self.assertEqual(params["messages"][0]["role"], "system") + + def test_system_exposed_after_trailing_assistant_removed(self): + """Test that a system message exposed by trailing assistant removal stays system. + + It's important that initial system messages are never converted to + "user", because Perplexity has statefulness within a conversation — if + a message was sent as "system" in one call and then becomes "user" in a + later call (after more messages are appended), the API rejects it. + """ + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are helpful."}, + {"role": "assistant", "content": "Sure thing."}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + # Trailing assistant removed → [system], system stays as-is + self.assertEqual(len(params["messages"]), 1) + self.assertEqual(params["messages"][0]["role"], "system") + self.assertEqual(params["messages"][0]["content"], "You are helpful.") + + def test_consecutive_assistants_merged_then_trailing_removed(self): + """Test that consecutive assistant messages are merged, then trailing assistant is removed.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "First response"}, + {"role": "assistant", "content": "Second response"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + # After merging assistants we get [user, assistant(merged)], then trailing + # assistant is removed, leaving just [user] + self.assertEqual(len(params["messages"]), 1) + self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][0]["content"], "Hello") + + def test_tool_messages_preserved(self): + """Test that tool messages pass through without modification.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": "Let me check.", + "tool_calls": [{"id": "1", "function": {"name": "get_weather", "arguments": "{}"}}], + }, + {"role": "tool", "content": "Sunny, 72F", "tool_call_id": "1"}, + {"role": "user", "content": "Thanks!"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 4) + self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][1]["role"], "assistant") + self.assertEqual(params["messages"][2]["role"], "tool") + self.assertEqual(params["messages"][2]["content"], "Sunny, 72F") + self.assertEqual(params["messages"][3]["role"], "user") + + def test_empty_messages(self): + """Test that empty messages list returns empty.""" + context = LLMContext(messages=[]) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(params["messages"], []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_google_llm_openai.py b/tests/test_google_llm_openai.py new file mode 100644 index 000000000..5e6cee6f8 --- /dev/null +++ b/tests/test_google_llm_openai.py @@ -0,0 +1,81 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Unit tests for Google LLM OpenAI Beta service.""" + +import asyncio +import warnings +from unittest.mock import AsyncMock, patch + +import pytest + +from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext + +try: + from pipecat.services.google.openai.llm import GoogleLLMOpenAIBetaService + + google_available = True +except Exception: + google_available = False + + +@pytest.mark.asyncio +@pytest.mark.skipif(not google_available, reason="Google dependencies not installed") +async def test_google_llm_openai_stream_closed_on_cancellation(): + """Test that the stream is closed when CancelledError occurs during iteration. + + This prevents socket leaks when the pipeline is interrupted (e.g., user interruption). + See issue #3639. + """ + with patch.object(GoogleLLMOpenAIBetaService, "create_client"): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + service = GoogleLLMOpenAIBetaService(api_key="test-key", model="test-model") + service._client = AsyncMock() + + stream_closed = False + + class MockAsyncStream: + """Mock AsyncStream that tracks close() calls and raises CancelledError.""" + + def __init__(self): + self.iteration_count = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + nonlocal stream_closed + stream_closed = True + return False + + def __aiter__(self): + return self + + async def __anext__(self): + self.iteration_count += 1 + if self.iteration_count > 1: + raise asyncio.CancelledError() + mock_chunk = AsyncMock() + mock_chunk.usage = None + mock_chunk.choices = [] + return mock_chunk + + mock_stream = MockAsyncStream() + + service._stream_chat_completions_specific_context = AsyncMock(return_value=mock_stream) + service.start_ttfb_metrics = AsyncMock() + service.stop_ttfb_metrics = AsyncMock() + service.start_llm_usage_metrics = AsyncMock() + + context = OpenAILLMContext( + messages=[{"role": "user", "content": "Hello"}], + ) + + with pytest.raises(asyncio.CancelledError): + await service._process_context(context) + + assert stream_closed, "Stream should be closed even when CancelledError occurs" diff --git a/tests/test_interruption_strategies.py b/tests/test_interruption_strategies.py index 220649a53..d4aff2a7a 100644 --- a/tests/test_interruption_strategies.py +++ b/tests/test_interruption_strategies.py @@ -22,3 +22,7 @@ class TestMinWordsInterruptionStrategy(unittest.IsolatedAsyncioTestCase): self.assertEqual(await strategy.should_interrupt(), False) await strategy.append_text(" How are you?") self.assertEqual(await strategy.should_interrupt(), True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ivr_navigation.py b/tests/test_ivr_navigation.py index 420ea2c2f..e1737437a 100644 --- a/tests/test_ivr_navigation.py +++ b/tests/test_ivr_navigation.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.extensions.ivr.ivr_navigator import IVRProcessor from pipecat.frames.frames import ( + AggregatedTextFrame, LLMFullResponseEndFrame, LLMMessagesUpdateFrame, LLMTextFrame, @@ -339,7 +340,7 @@ class TestIVRNavigation(unittest.IsolatedAsyncioTestCase): ] expected_down_frames = [ - LLMTextFrame, # Should pass through unchanged + AggregatedTextFrame, # LLMTextFrames aggregrated and converted to AggregatedTextFrame LLMFullResponseEndFrame, ] @@ -353,3 +354,7 @@ class TestIVRNavigation(unittest.IsolatedAsyncioTestCase): expected_down_frames=expected_down_frames, expected_up_frames=expected_up_frames, ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_krisp_sdk_manager.py b/tests/test_krisp_sdk_manager.py index a98c97f09..78a4d955f 100644 --- a/tests/test_krisp_sdk_manager.py +++ b/tests/test_krisp_sdk_manager.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2024-2025 Daily +# Copyright (c) 2024-2026, Daily # # SPDX-License-Identifier: BSD 2-Clause License # @@ -194,3 +194,7 @@ class TestSampleRateConversion: expected_rates = [8000, 16000, 24000, 32000, 44100, 48000] for rate in expected_rates: assert rate in KRISP_SAMPLE_RATES + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_krisp_viva_filter.py b/tests/test_krisp_viva_filter.py index c8f1a23c0..a788ab131 100644 --- a/tests/test_krisp_viva_filter.py +++ b/tests/test_krisp_viva_filter.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2024-2025 Daily +# Copyright (c) 2024-2026, Daily # # SPDX-License-Identifier: BSD 2-Clause License # @@ -9,7 +9,7 @@ import os import sys import tempfile import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import MagicMock, patch import numpy as np diff --git a/tests/test_langchain.py b/tests/test_langchain.py index 1a3e1fe7d..cc5c8f030 100644 --- a/tests/test_langchain.py +++ b/tests/test_langchain.py @@ -24,10 +24,15 @@ from pipecat.frames.frames import ( ) from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) from pipecat.processors.frame_processor import FrameProcessor from pipecat.processors.frameworks.langchain import LangchainProcessor from pipecat.tests.utils import SleepFrame, run_test +from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy +from pipecat.turns.user_turn_strategies import UserTurnStrategies class TestLangchain(unittest.IsolatedAsyncioTestCase): @@ -65,7 +70,12 @@ class TestLangchain(unittest.IsolatedAsyncioTestCase): self.mock_proc = self.MockProcessor("token_collector") context = LLMContext() - context_aggregator = LLMContextAggregatorPair(context) + context_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=UserTurnStrategies(stop=[SpeechTimeoutUserTurnStopStrategy()]) + ), + ) pipeline = Pipeline( [context_aggregator.user(), proc, self.mock_proc, context_aggregator.assistant()] @@ -97,3 +107,7 @@ class TestLangchain(unittest.IsolatedAsyncioTestCase): self.assertEqual( context_aggregator.assistant().messages[-1]["content"], self.expected_response ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_livekit_transport.py b/tests/test_livekit_transport.py new file mode 100644 index 000000000..472d60cba --- /dev/null +++ b/tests/test_livekit_transport.py @@ -0,0 +1,124 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Tests for LiveKit transport video stream handling. + +Regression tests for issue #3116: Memory leak when video_in_enabled=False +but video tracks are subscribed. The fix ensures video stream processing +only starts when there is a consumer for the frames. +""" + +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +try: + from livekit import rtc + + from pipecat.transports.livekit.transport import ( + LiveKitCallbacks, + LiveKitParams, + LiveKitTransportClient, + ) + + LIVEKIT_AVAILABLE = True +except ImportError: + LIVEKIT_AVAILABLE = False + + +@unittest.skipUnless(LIVEKIT_AVAILABLE, "livekit package not installed") +class TestLiveKitVideoStreamMemoryLeak(unittest.IsolatedAsyncioTestCase): + """Regression tests for video queue memory leak (#3116). + + The bug: When video_in_enabled=False, subscribing to a video track would + start a producer that fills _video_queue, but no consumer would drain it, + causing unbounded memory growth (~3GB/min). + + The fix: Only start video stream processing when video_in_enabled=True. + """ + + def _create_client(self, video_in_enabled: bool) -> LiveKitTransportClient: + """Create a client with the specified video input setting.""" + params = LiveKitParams(video_in_enabled=video_in_enabled) + callbacks = LiveKitCallbacks( + on_connected=AsyncMock(), + on_disconnected=AsyncMock(), + on_before_disconnect=AsyncMock(), + on_participant_connected=AsyncMock(), + on_participant_disconnected=AsyncMock(), + on_audio_track_subscribed=AsyncMock(), + on_audio_track_unsubscribed=AsyncMock(), + on_video_track_subscribed=AsyncMock(), + on_video_track_unsubscribed=AsyncMock(), + on_data_received=AsyncMock(), + on_first_participant_joined=AsyncMock(), + ) + client = LiveKitTransportClient( + url="wss://test.livekit.cloud", + token="test-token", + room_name="test-room", + params=params, + callbacks=callbacks, + transport_name="test-transport", + ) + client._task_manager = MagicMock() + return client + + def _create_mock_video_track(self): + """Create a mock video track subscription event.""" + track = MagicMock() + track.kind = rtc.TrackKind.KIND_VIDEO + track.sid = "video-track-123" + publication = MagicMock() + participant = MagicMock() + participant.sid = "participant-456" + return track, publication, participant + + async def test_disabled_video_input_does_not_start_queue_producer(self): + """When video input is disabled, no producer should fill the queue. + + This prevents the memory leak where frames accumulate with no consumer. + """ + client = self._create_client(video_in_enabled=False) + track, publication, participant = self._create_mock_video_track() + + await client._async_on_track_subscribed(track, publication, participant) + + # Verify no video processing task was started + task_names = [call[0][1] for call in client._task_manager.create_task.call_args_list] + video_tasks = [name for name in task_names if "video" in name.lower()] + self.assertEqual(video_tasks, [], "No video processing task should be started") + + # Queue should remain empty + self.assertEqual(client._video_queue.qsize(), 0) + + # Track metadata should still be recorded + self.assertIn(participant.sid, client._video_tracks) + + # Callback should still fire for user code + client._callbacks.on_video_track_subscribed.assert_called_once() + + async def test_enabled_video_input_starts_queue_producer(self): + """When video input is enabled, the producer should start.""" + client = self._create_client(video_in_enabled=True) + track, publication, participant = self._create_mock_video_track() + + with patch.object(rtc, "VideoStream"): + await client._async_on_track_subscribed(track, publication, participant) + + # Verify video processing task was started + task_names = [call[0][1] for call in client._task_manager.create_task.call_args_list] + video_tasks = [name for name in task_names if "video" in name.lower()] + self.assertEqual(len(video_tasks), 1, "Video processing task should be started") + + # Track metadata should be recorded + self.assertIn(participant.sid, client._video_tracks) + + # Callback should fire + client._callbacks.on_video_track_subscribed.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_llm_context_summarizer.py b/tests/test_llm_context_summarizer.py new file mode 100644 index 000000000..bbe8648ef --- /dev/null +++ b/tests/test_llm_context_summarizer.py @@ -0,0 +1,765 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import unittest + +from pipecat.frames.frames import ( + InterruptionFrame, + LLMContextSummaryRequestFrame, + LLMContextSummaryResultFrame, + LLMFullResponseStartFrame, + LLMSummarizeContextFrame, +) +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_context_summarizer import ( + LLMContextSummarizer, + SummaryAppliedEvent, +) +from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams +from pipecat.utils.context.llm_context_summarization import ( + LLMAutoContextSummarizationConfig, + LLMContextSummaryConfig, +) + + +class TestLLMContextSummarizer(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.task_manager = TaskManager() + self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop())) + + self.context = LLMContext( + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + ] + ) + + async def test_summarization_triggered_by_token_limit(self): + """Test that summarization is triggered when token limit is reached.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=100, # Very low to trigger easily + max_unsummarized_messages=100, # High so it doesn't trigger by message count + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + # Add messages to exceed token limit + for i in range(10): + self.context.add_message( + { + "role": "user", + "content": "This is a test message that adds tokens to the context.", + } + ) + + # Trigger check by processing LLMFullResponseStartFrame + await summarizer.process_frame(LLMFullResponseStartFrame()) + + # Should have triggered summarization + self.assertIsNotNone(request_frame) + self.assertIsInstance(request_frame, LLMContextSummaryRequestFrame) + self.assertEqual(request_frame.context, self.context) + + await summarizer.cleanup() + + async def test_summarization_triggered_by_message_count(self): + """Test that summarization is triggered when message count threshold is reached.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=100000, # Very high so it doesn't trigger by tokens + max_unsummarized_messages=5, # Low to trigger easily + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + # Add messages to exceed message count + for i in range(6): + self.context.add_message({"role": "user", "content": f"Message {i}"}) + + # Trigger check + await summarizer.process_frame(LLMFullResponseStartFrame()) + + # Should have triggered summarization + self.assertIsNotNone(request_frame) + self.assertIsInstance(request_frame, LLMContextSummaryRequestFrame) + + await summarizer.cleanup() + + async def test_summarization_not_triggered_below_thresholds(self): + """Test that summarization is not triggered when below thresholds.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=10000, + max_unsummarized_messages=20, + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + # Add a few messages (below threshold) + for i in range(3): + self.context.add_message({"role": "user", "content": "Short message"}) + + # Trigger check + await summarizer.process_frame(LLMFullResponseStartFrame()) + + # Should NOT have triggered summarization + self.assertIsNone(request_frame) + + await summarizer.cleanup() + + async def test_summarization_in_progress_prevents_duplicate(self): + """Test that a summarization in progress prevents triggering another.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, # Very low + max_unsummarized_messages=100, + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_count = 0 + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_count + request_count += 1 + + # Add enough messages to trigger + for i in range(10): + self.context.add_message({"role": "user", "content": "Test message to add tokens."}) + + # First trigger - should request summarization + await summarizer.process_frame(LLMFullResponseStartFrame()) + self.assertEqual(request_count, 1) + + # Second trigger while first is in progress - should NOT request again + await summarizer.process_frame(LLMFullResponseStartFrame()) + self.assertEqual(request_count, 1) + + await summarizer.cleanup() + + async def test_summary_result_handling(self): + """Test that summary results are processed and applied correctly.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + # Add messages and trigger summarization + for i in range(10): + self.context.add_message({"role": "user", "content": "Test message."}) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + original_message_count = len(self.context.messages) + await summarizer.process_frame(LLMFullResponseStartFrame()) + self.assertIsNotNone(request_frame) + + # Simulate receiving a summary result + summary_result = LLMContextSummaryResultFrame( + request_id=request_frame.request_id, + summary="This is a test summary.", + last_summarized_index=5, + error=None, + ) + + await summarizer.process_frame(summary_result) + + # Should have applied the summary and reduced message count + # Expected: system message + summary message + 2 recent messages = 4 messages + # (since last_summarized_index=5, we keep messages after index 5) + self.assertLess(len(self.context.messages), original_message_count) + + # Check that summary was added + summary_messages = [ + msg + for msg in self.context.messages + if "Conversation summary:" in msg.get("content", "") + ] + self.assertEqual(len(summary_messages), 1) + + await summarizer.cleanup() + + async def test_interruption_cancels_summarization(self): + """Test that an interruption cancels pending summarization.""" + config = LLMAutoContextSummarizationConfig(max_context_tokens=50) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + # Add messages and trigger summarization + for i in range(10): + self.context.add_message({"role": "user", "content": "Test message."}) + + request_count = 0 + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_count + request_count += 1 + + await summarizer.process_frame(LLMFullResponseStartFrame()) + self.assertEqual(request_count, 1) + + # Process interruption + await summarizer.process_frame(InterruptionFrame()) + + # Try to trigger again - should work since the previous one was canceled + await summarizer.process_frame(LLMFullResponseStartFrame()) + self.assertEqual(request_count, 2) + + await summarizer.cleanup() + + async def test_stale_summary_result_ignored(self): + """Test that stale summary results are ignored.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + # Add messages and trigger summarization + for i in range(10): + self.context.add_message({"role": "user", "content": "Test message."}) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + original_message_count = len(self.context.messages) + await summarizer.process_frame(LLMFullResponseStartFrame()) + valid_request_id = request_frame.request_id + + # Send a stale summary result (wrong request_id) + stale_result = LLMContextSummaryResultFrame( + request_id="stale-id-123", + summary="Stale summary", + last_summarized_index=3, + error=None, + ) + + await summarizer.process_frame(stale_result) + + # Should be ignored - message count should not change + self.assertEqual(len(self.context.messages), original_message_count) + + # Send the correct summary result + valid_result = LLMContextSummaryResultFrame( + request_id=valid_request_id, + summary="Valid summary", + last_summarized_index=5, + error=None, + ) + + await summarizer.process_frame(valid_result) + + # Should be processed - message count should decrease + self.assertLess(len(self.context.messages), original_message_count) + + # Check that summary was added + summary_messages = [ + msg + for msg in self.context.messages + if "Conversation summary:" in msg.get("content", "") + ] + self.assertEqual(len(summary_messages), 1) + + await summarizer.cleanup() + + async def test_manual_summarization_via_frame(self): + """Test that LLMSummarizeContextFrame triggers summarization on demand.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=100000, # High — auto trigger would never fire + max_unsummarized_messages=100, + ) + + summarizer = LLMContextSummarizer( + context=self.context, + config=config, + auto_trigger=False, # Disable auto; only manual requests should work + ) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + # Add messages + for i in range(5): + self.context.add_message({"role": "user", "content": f"Message {i}"}) + + # Auto-trigger should NOT fire even on LLMFullResponseStartFrame + await summarizer.process_frame(LLMFullResponseStartFrame()) + self.assertIsNone(request_frame) + + # Manual trigger via LLMSummarizeContextFrame should fire + await summarizer.process_frame(LLMSummarizeContextFrame()) + self.assertIsNotNone(request_frame) + self.assertIsInstance(request_frame, LLMContextSummaryRequestFrame) + + # The request must have a valid request_id and carry the current context + self.assertTrue(request_frame.request_id) + self.assertEqual(request_frame.context, self.context) + + await summarizer.cleanup() + + async def test_manual_summarization_with_config_override(self): + """Test that LLMSummarizeContextFrame can override default summary config.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=100000, + summary_config=LLMContextSummaryConfig( + target_context_tokens=6000, + min_messages_after_summary=4, + ), + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + for i in range(5): + self.context.add_message({"role": "user", "content": f"Message {i}"}) + + # Push a manual frame with custom config overrides + custom_config = LLMContextSummaryConfig( + target_context_tokens=500, + min_messages_after_summary=1, + ) + await summarizer.process_frame(LLMSummarizeContextFrame(config=custom_config)) + + self.assertIsNotNone(request_frame) + # The request should use the overridden values + self.assertEqual(request_frame.target_context_tokens, 500) + self.assertEqual(request_frame.min_messages_to_keep, 1) + + await summarizer.cleanup() + + async def test_manual_summarization_blocked_when_in_progress(self): + """Test that a second LLMSummarizeContextFrame is ignored while one is in progress.""" + config = LLMAutoContextSummarizationConfig(max_context_tokens=100000) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_count = 0 + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_count + request_count += 1 + + for i in range(5): + self.context.add_message({"role": "user", "content": f"Message {i}"}) + + # First manual request + await summarizer.process_frame(LLMSummarizeContextFrame()) + self.assertEqual(request_count, 1) + + # Second manual request while first is in progress — should be ignored + await summarizer.process_frame(LLMSummarizeContextFrame()) + self.assertEqual(request_count, 1) + + await summarizer.cleanup() + + async def test_summary_message_role_is_user(self): + """Test that the summary message uses the user role.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + # Add messages and trigger summarization + for i in range(10): + self.context.add_message({"role": "user", "content": "Test message."}) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + await summarizer.process_frame(LLMFullResponseStartFrame()) + self.assertIsNotNone(request_frame) + + # Simulate receiving a summary result + summary_result = LLMContextSummaryResultFrame( + request_id=request_frame.request_id, + summary="This is a test summary.", + last_summarized_index=5, + ) + await summarizer.process_frame(summary_result) + + # Find the summary message and verify its role is "user" + summary_msg = next( + (msg for msg in self.context.messages if "summary" in msg.get("content", "").lower()), + None, + ) + self.assertIsNotNone(summary_msg) + self.assertEqual(summary_msg["role"], "user") + + await summarizer.cleanup() + + async def test_summary_message_default_template(self): + """Test that the default summary_message_template is used.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + for i in range(10): + self.context.add_message({"role": "user", "content": "Test message."}) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + await summarizer.process_frame(LLMFullResponseStartFrame()) + + summary_result = LLMContextSummaryResultFrame( + request_id=request_frame.request_id, + summary="Key facts from conversation.", + last_summarized_index=5, + ) + await summarizer.process_frame(summary_result) + + # Default template wraps with "Conversation summary: {summary}" + summary_msg = next( + ( + msg + for msg in self.context.messages + if "Conversation summary:" in msg.get("content", "") + ), + None, + ) + self.assertIsNotNone(summary_msg) + self.assertEqual( + summary_msg["content"], "Conversation summary: Key facts from conversation." + ) + + await summarizer.cleanup() + + async def test_summary_message_custom_template(self): + """Test that a custom summary_message_template is applied.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig( + min_messages_after_summary=2, + summary_message_template="\n{summary}\n", + ), + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + for i in range(10): + self.context.add_message({"role": "user", "content": "Test message."}) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + await summarizer.process_frame(LLMFullResponseStartFrame()) + + summary_result = LLMContextSummaryResultFrame( + request_id=request_frame.request_id, + summary="Key facts from conversation.", + last_summarized_index=5, + ) + await summarizer.process_frame(summary_result) + + # Custom template wraps with XML tags + summary_msg = next( + (msg for msg in self.context.messages if "" in msg.get("content", "")), + None, + ) + self.assertIsNotNone(summary_msg) + self.assertEqual( + summary_msg["content"], + "\nKey facts from conversation.\n", + ) + + await summarizer.cleanup() + + async def test_on_summary_applied_event(self): + """Test that on_summary_applied event fires with correct data.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + # Add messages (1 system + 10 user = 11 total) + for i in range(10): + self.context.add_message({"role": "user", "content": "Test message."}) + + request_frame = None + applied_event = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + @summarizer.event_handler("on_summary_applied") + async def on_summary_applied(summarizer, event): + nonlocal applied_event + applied_event = event + + original_count = len(self.context.messages) # 11 + await summarizer.process_frame(LLMFullResponseStartFrame()) + + # Summarize up to index 7 (system=0, user1..user7), keep last 3 (user8, user9, user10) + summary_result = LLMContextSummaryResultFrame( + request_id=request_frame.request_id, + summary="Test summary.", + last_summarized_index=7, + ) + await summarizer.process_frame(summary_result) + + # Allow async event handler to complete + await asyncio.sleep(0.05) + + # Verify event was fired + self.assertIsNotNone(applied_event) + self.assertIsInstance(applied_event, SummaryAppliedEvent) + self.assertEqual(applied_event.original_message_count, original_count) + + # After summarization: system + summary + 3 recent = 5 + self.assertEqual(applied_event.new_message_count, 5) + + # Summarized messages: indices 1-7 = 7 messages (excluding system at index 0) + self.assertEqual(applied_event.summarized_message_count, 7) + + # Preserved: system (1) + recent messages after index 7 (3) = 4 + self.assertEqual(applied_event.preserved_message_count, 4) + + await summarizer.cleanup() + + async def test_on_summary_applied_not_fired_on_error(self): + """Test that on_summary_applied event is NOT fired when summarization fails.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(min_messages_after_summary=2), + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + for i in range(10): + self.context.add_message({"role": "user", "content": "Test message."}) + + request_frame = None + applied_event = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + @summarizer.event_handler("on_summary_applied") + async def on_summary_applied(summarizer, event): + nonlocal applied_event + applied_event = event + + await summarizer.process_frame(LLMFullResponseStartFrame()) + + # Send a result with an error + error_result = LLMContextSummaryResultFrame( + request_id=request_frame.request_id, + summary="", + last_summarized_index=-1, + error="Summarization timed out", + ) + await summarizer.process_frame(error_result) + + await asyncio.sleep(0.05) + + # Event should NOT have fired + self.assertIsNone(applied_event) + + await summarizer.cleanup() + + async def test_request_frame_includes_timeout(self): + """Test that the request frame includes the configured summarization_timeout.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=50, + summary_config=LLMContextSummaryConfig(summarization_timeout=60.0), + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + for i in range(10): + self.context.add_message({"role": "user", "content": "Test message to add tokens."}) + + await summarizer.process_frame(LLMFullResponseStartFrame()) + + self.assertIsNotNone(request_frame) + self.assertEqual(request_frame.summarization_timeout, 60.0) + + await summarizer.cleanup() + + async def test_token_limit_none_only_message_threshold(self): + """Test that only message threshold triggers when token limit is None.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=None, + max_unsummarized_messages=5, + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + # Add many tokens but fewer than 5 messages — should NOT trigger + for i in range(3): + self.context.add_message( + {"role": "user", "content": "x" * 10000} # Lots of tokens + ) + + await summarizer.process_frame(LLMFullResponseStartFrame()) + self.assertIsNone(request_frame) + + # Cross the message threshold (5 messages since summary = 6 total including system) + for i in range(3): + self.context.add_message({"role": "user", "content": f"Message {i}"}) + + await summarizer.process_frame(LLMFullResponseStartFrame()) + self.assertIsNotNone(request_frame) + + await summarizer.cleanup() + + async def test_message_limit_none_only_token_threshold(self): + """Test that only token threshold triggers when message limit is None.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=100, # Very low + max_unsummarized_messages=None, + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + # Add many messages that exceed the token limit + for i in range(10): + self.context.add_message( + {"role": "user", "content": "This is a test message with enough tokens."} + ) + + await summarizer.process_frame(LLMFullResponseStartFrame()) + self.assertIsNotNone(request_frame) + + await summarizer.cleanup() + + async def test_message_limit_none_no_trigger_below_tokens(self): + """Test that many messages don't trigger when message limit is None and tokens are low.""" + config = LLMAutoContextSummarizationConfig( + max_context_tokens=100000, # Very high + max_unsummarized_messages=None, + ) + + summarizer = LLMContextSummarizer(context=self.context, config=config) + await summarizer.setup(self.task_manager) + + request_frame = None + + @summarizer.event_handler("on_request_summarization") + async def on_request_summarization(summarizer, frame): + nonlocal request_frame + request_frame = frame + + # Add many short messages — would exceed any reasonable message count + # but tokens stay well below the limit + for i in range(50): + self.context.add_message({"role": "user", "content": f"Msg {i}"}) + + await summarizer.process_frame(LLMFullResponseStartFrame()) + + # Should NOT trigger because token limit is not exceeded + self.assertIsNone(request_frame) + + await summarizer.cleanup() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_llm_response.py b/tests/test_llm_response.py index 51296f40b..8de7dda2d 100644 --- a/tests/test_llm_response.py +++ b/tests/test_llm_response.py @@ -134,3 +134,7 @@ class TestLLMFullResponseAggregator(unittest.IsolatedAsyncioTestCase): expected_down_frames=expected_down_frames, ) assert completion_ok + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_markdown_text_filter.py b/tests/test_markdown_text_filter.py index 7f6268953..250bc685a 100644 --- a/tests/test_markdown_text_filter.py +++ b/tests/test_markdown_text_filter.py @@ -244,3 +244,7 @@ class TestMarkdownTextFilter(unittest.IsolatedAsyncioTestCase): "bold and italic", "Text filtering should be re-enabled", ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_openai_llm_timeout.py b/tests/test_openai_llm_timeout.py new file mode 100644 index 000000000..8ee776a9b --- /dev/null +++ b/tests/test_openai_llm_timeout.py @@ -0,0 +1,299 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Unit tests for OpenAI LLM error handling.""" + +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from pipecat.frames.frames import ( + LLMContextFrame, + LLMFullResponseEndFrame, + LLMFullResponseStartFrame, +) +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.openai.llm import OpenAILLMService + + +@pytest.mark.asyncio +async def test_openai_llm_emits_error_frame_on_timeout(): + """Test that OpenAI LLM service emits ErrorFrame when a timeout occurs. + + This enables LLMSwitcher to trigger failover to backup LLMs when the + primary LLM times out. + """ + with patch.object(OpenAILLMService, "create_client"): + service = OpenAILLMService(model="gpt-4") + service._client = AsyncMock() + + # Track pushed frames and errors + pushed_frames = [] + pushed_errors = [] + timeout_handler_called = False + + original_push_frame = service.push_frame + + async def mock_push_frame(frame, direction=FrameDirection.DOWNSTREAM): + pushed_frames.append(frame) + await original_push_frame(frame, direction) + + async def mock_push_error(error_msg, exception=None): + pushed_errors.append({"error_msg": error_msg, "exception": exception}) + + async def mock_timeout_handler(event_name): + nonlocal timeout_handler_called + if event_name == "on_completion_timeout": + timeout_handler_called = True + + service.push_frame = mock_push_frame + service.push_error = mock_push_error + service._call_event_handler = AsyncMock(side_effect=mock_timeout_handler) + + # Mock _process_context to raise TimeoutException + service._process_context = AsyncMock( + side_effect=httpx.TimeoutException("Connection timed out") + ) + + # Mock metrics methods + service.start_processing_metrics = AsyncMock() + service.stop_processing_metrics = AsyncMock() + service.start_ttfb_metrics = AsyncMock() + + # Create a context frame to process + context = LLMContext( + messages=[{"role": "user", "content": "Hello"}], + ) + frame = LLMContextFrame(context=context) + + # Process the frame + await service.process_frame(frame, FrameDirection.DOWNSTREAM) + + # Verify timeout handler was called + service._call_event_handler.assert_called_once_with("on_completion_timeout") + assert timeout_handler_called + + # Verify push_error was called with correct message + assert len(pushed_errors) == 1 + assert pushed_errors[0]["error_msg"] == "LLM completion timeout" + assert isinstance(pushed_errors[0]["exception"], httpx.TimeoutException) + + # Verify LLMFullResponseStartFrame and LLMFullResponseEndFrame were pushed + frame_types = [type(f) for f in pushed_frames] + assert LLMFullResponseStartFrame in frame_types + assert LLMFullResponseEndFrame in frame_types + + +@pytest.mark.asyncio +async def test_openai_llm_timeout_still_pushes_end_frame(): + """Test that LLMFullResponseEndFrame is pushed even when timeout occurs. + + The finally block should ensure proper cleanup regardless of timeout. + """ + with patch.object(OpenAILLMService, "create_client"): + service = OpenAILLMService(model="gpt-4") + service._client = AsyncMock() + + pushed_frames = [] + + async def mock_push_frame(frame, direction=FrameDirection.DOWNSTREAM): + pushed_frames.append(frame) + + service.push_frame = mock_push_frame + service.push_error = AsyncMock() + service._call_event_handler = AsyncMock() + service._process_context = AsyncMock(side_effect=httpx.TimeoutException("Timeout")) + service.start_processing_metrics = AsyncMock() + service.stop_processing_metrics = AsyncMock() + + context = LLMContext( + messages=[{"role": "user", "content": "Hello"}], + ) + frame = LLMContextFrame(context=context) + + await service.process_frame(frame, FrameDirection.DOWNSTREAM) + + # Verify both start and end frames are pushed + frame_types = [type(f) for f in pushed_frames] + assert LLMFullResponseStartFrame in frame_types + assert LLMFullResponseEndFrame in frame_types + + # Verify metrics were stopped + service.stop_processing_metrics.assert_called_once() + + +@pytest.mark.asyncio +async def test_openai_llm_stream_closed_on_cancellation(): + """Test that the stream is closed when CancelledError occurs during iteration. + + This prevents socket leaks when the pipeline is interrupted (e.g., user interruption). + See issue #3589. + """ + import asyncio + + with patch.object(OpenAILLMService, "create_client"): + service = OpenAILLMService(model="gpt-4") + service._client = AsyncMock() + + # Track if close was called + stream_closed = False + + class MockAsyncStream: + """Mock AsyncStream that tracks close() calls and raises CancelledError.""" + + def __init__(self): + self.iteration_count = 0 + + async def close(self): + nonlocal stream_closed + stream_closed = True + + def __aiter__(self): + return self + + async def __anext__(self): + self.iteration_count += 1 + if self.iteration_count > 1: + # Simulate cancellation during iteration + raise asyncio.CancelledError() + # Return a minimal chunk for first iteration + mock_chunk = AsyncMock() + mock_chunk.usage = None + mock_chunk.model = None + mock_chunk.choices = [] + return mock_chunk + + mock_stream = MockAsyncStream() + + # Mock the stream creation methods + 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"}], + ) + + # Process context should raise CancelledError but stream should still be closed + with pytest.raises(asyncio.CancelledError): + await service._process_context(context) + + # Verify stream was closed despite the cancellation + assert stream_closed, "Stream should be closed even when CancelledError occurs" + + +@pytest.mark.asyncio +async def test_openai_llm_emits_error_frame_on_exception(): + """Test that OpenAI LLM service emits ErrorFrame when a general exception occurs. + + This enables proper error handling for API errors, rate limits, and other failures. + """ + with patch.object(OpenAILLMService, "create_client"): + service = OpenAILLMService(model="gpt-4") + service._client = AsyncMock() + + pushed_errors = [] + + async def mock_push_error(error_msg, exception=None): + pushed_errors.append({"error_msg": error_msg, "exception": exception}) + + service.push_frame = AsyncMock() + service.push_error = mock_push_error + service._call_event_handler = AsyncMock() + service._process_context = AsyncMock(side_effect=RuntimeError("API Error")) + service.start_processing_metrics = AsyncMock() + service.stop_processing_metrics = AsyncMock() + + context = LLMContext( + messages=[{"role": "user", "content": "Hello"}], + ) + frame = LLMContextFrame(context=context) + + await service.process_frame(frame, FrameDirection.DOWNSTREAM) + + # Verify push_error was called with correct message + assert len(pushed_errors) == 1 + 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_openai_responses_adapter.py b/tests/test_openai_responses_adapter.py new file mode 100644 index 000000000..973c05c8c --- /dev/null +++ b/tests/test_openai_responses_adapter.py @@ -0,0 +1,349 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Unit tests for the OpenAI Responses API adapter. + +Tests the conversion from LLMContext messages to Responses API input items, including: + +1. Simple user/assistant text messages pass through (with correct role) +2. System role converted to developer role +3. First-message system role triggers a warning +4. Assistant messages with tool_calls produce function_call input items +5. Tool messages produce function_call_output input items +6. Mixed conversations with text + function calls convert correctly +7. Multimodal content conversion (text -> input_text, image_url -> input_image) +8. Tools schema flattening (nested function dict -> flat format) +9. Empty messages list +10. LLMSpecificMessage with llm="openai_responses" passes through +""" + +import unittest +from unittest.mock import patch + +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.adapters.services.open_ai_responses_adapter import OpenAIResponsesLLMAdapter +from pipecat.processors.aggregators.llm_context import LLMContext, LLMStandardMessage + + +class TestOpenAIResponsesAdapter(unittest.TestCase): + def setUp(self): + self.adapter = OpenAIResponsesLLMAdapter() + + def test_simple_user_assistant_messages(self): + """Simple user/assistant text messages are converted correctly.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + ] + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["input"]), 2) + self.assertEqual(params["input"][0], {"role": "user", "content": "Hello"}) + self.assertEqual(params["input"][1], {"role": "assistant", "content": "Hi there!"}) + + def test_system_role_converted_to_developer(self): + """System role messages are converted to developer role.""" + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + ] + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(params["input"][0]["role"], "developer") + self.assertEqual(params["input"][0]["content"], "You are helpful.") + + def test_first_system_message_triggers_warning(self): + """First system message triggers a warning about using system_instruction.""" + # Use a fresh adapter so the warning hasn't been emitted yet + adapter = OpenAIResponsesLLMAdapter() + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + ] + context = LLMContext(messages=messages) + + with patch("pipecat.adapters.services.open_ai_responses_adapter.logger") as mock_logger: + adapter.get_llm_invocation_params(context) + mock_logger.warning.assert_called_once() + warning_msg = mock_logger.warning.call_args[0][0] + self.assertIn("system_instruction", warning_msg) + + def test_non_initial_system_message_no_warning(self): + """Non-initial system messages are converted without a warning.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "Hello"}, + {"role": "system", "content": "New instruction"}, + ] + context = LLMContext(messages=messages) + + adapter = OpenAIResponsesLLMAdapter() + with patch("pipecat.adapters.services.open_ai_responses_adapter.logger") as mock_logger: + params = adapter.get_llm_invocation_params(context) + mock_logger.warning.assert_not_called() + + self.assertEqual(params["input"][1]["role"], "developer") + self.assertEqual(params["input"][1]["content"], "New instruction") + + def test_first_system_message_warning_fires_only_once(self): + """The first-system-message warning fires only once per adapter instance.""" + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + ] + context = LLMContext(messages=messages) + + adapter = OpenAIResponsesLLMAdapter() + with patch("pipecat.adapters.services.open_ai_responses_adapter.logger") as mock_logger: + adapter.get_llm_invocation_params(context) + adapter.get_llm_invocation_params(context) + # Warning should have been emitted exactly once, not twice + mock_logger.warning.assert_called_once() + + def test_assistant_tool_calls_to_function_call(self): + """Assistant messages with tool_calls produce function_call input items.""" + messages = [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_123", + "function": { + "name": "get_weather", + "arguments": '{"location": "SF"}', + }, + "type": "function", + } + ], + } + ] + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["input"]), 1) + fc = params["input"][0] + self.assertEqual(fc["type"], "function_call") + self.assertEqual(fc["call_id"], "call_123") + self.assertEqual(fc["name"], "get_weather") + self.assertEqual(fc["arguments"], '{"location": "SF"}') + + def test_multiple_tool_calls(self): + """Multiple tool calls in one assistant message produce multiple function_call items.""" + messages = [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_1", + "function": {"name": "get_weather", "arguments": '{"location": "SF"}'}, + "type": "function", + }, + { + "id": "call_2", + "function": {"name": "get_restaurant", "arguments": '{"location": "SF"}'}, + "type": "function", + }, + ], + } + ] + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["input"]), 2) + self.assertEqual(params["input"][0]["name"], "get_weather") + self.assertEqual(params["input"][1]["name"], "get_restaurant") + + def test_tool_message_to_function_call_output(self): + """Tool role messages produce function_call_output input items.""" + messages = [ + { + "role": "tool", + "content": '{"temperature": "72"}', + "tool_call_id": "call_123", + } + ] + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["input"]), 1) + fco = params["input"][0] + self.assertEqual(fco["type"], "function_call_output") + self.assertEqual(fco["call_id"], "call_123") + self.assertEqual(fco["output"], '{"temperature": "72"}') + + def test_mixed_conversation(self): + """Mixed conversation with text + function calls converts correctly.""" + messages = [ + {"role": "user", "content": "What's the weather in SF?"}, + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_abc", + "function": {"name": "get_weather", "arguments": '{"location": "SF"}'}, + "type": "function", + } + ], + }, + { + "role": "tool", + "content": '{"temp": "72"}', + "tool_call_id": "call_abc", + }, + {"role": "assistant", "content": "It's 72 degrees in SF."}, + ] + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["input"]), 4) + self.assertEqual(params["input"][0]["role"], "user") + self.assertEqual(params["input"][1]["type"], "function_call") + self.assertEqual(params["input"][2]["type"], "function_call_output") + self.assertEqual(params["input"][3]["role"], "assistant") + + def test_multimodal_text_conversion(self): + """Multimodal text content parts are converted to input_text.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + ], + } + ] + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + content = params["input"][0]["content"] + self.assertEqual(len(content), 1) + self.assertEqual(content[0]["type"], "input_text") + self.assertEqual(content[0]["text"], "What's in this image?") + + def test_multimodal_image_conversion(self): + """Multimodal image_url content parts are converted to input_image.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this:"}, + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,abc123"}, + }, + ], + } + ] + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + content = params["input"][0]["content"] + self.assertEqual(len(content), 2) + self.assertEqual(content[0]["type"], "input_text") + self.assertEqual(content[1]["type"], "input_image") + self.assertEqual(content[1]["image_url"], "data:image/jpeg;base64,abc123") + self.assertEqual(content[1]["detail"], "auto") + + def test_multimodal_image_with_detail(self): + """Image content parts preserve the detail setting when provided.""" + messages = [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.png", "detail": "high"}, + }, + ], + } + ] + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + content = params["input"][0]["content"] + self.assertEqual(content[0]["detail"], "high") + + def test_tools_schema_flattening(self): + """Tools schema with nested function dict is flattened to Responses API format.""" + weather_fn = FunctionSchema( + name="get_weather", + description="Get the current weather", + properties={ + "location": {"type": "string", "description": "The city"}, + }, + required=["location"], + ) + tools = ToolsSchema(standard_tools=[weather_fn]) + context = LLMContext(tools=tools) + params = self.adapter.get_llm_invocation_params(context) + + tool_list = params["tools"] + self.assertEqual(len(tool_list), 1) + tool = tool_list[0] + self.assertEqual(tool["type"], "function") + self.assertEqual(tool["name"], "get_weather") + self.assertEqual(tool["description"], "Get the current weather") + self.assertIn("properties", tool["parameters"]) + + def test_empty_messages(self): + """Empty messages list produces empty input list.""" + context = LLMContext(messages=[]) + params = self.adapter.get_llm_invocation_params(context) + self.assertEqual(params["input"], []) + + def test_llm_specific_message_passthrough(self): + """LLMSpecificMessage with llm='openai_responses' passes through.""" + specific_msg = self.adapter.create_llm_specific_message( + {"type": "function_call", "call_id": "x", "name": "foo", "arguments": "{}"} + ) + messages = [ + {"role": "user", "content": "Hello"}, + specific_msg, + ] + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["input"]), 2) + self.assertEqual(params["input"][0]["role"], "user") + self.assertEqual(params["input"][1]["type"], "function_call") + + def test_id_for_llm_specific_messages(self): + """Adapter identifier is 'openai_responses'.""" + self.assertEqual(self.adapter.id_for_llm_specific_messages, "openai_responses") + + def test_system_instruction_with_messages_sets_instructions(self): + """When system_instruction is provided and input is non-empty, sets instructions.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "Hello"}, + ] + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context, system_instruction="Be helpful.") + + self.assertEqual(params["instructions"], "Be helpful.") + self.assertEqual(len(params["input"]), 1) + self.assertEqual(params["input"][0]["role"], "user") + + def test_system_instruction_with_empty_input_becomes_developer_message(self): + """When system_instruction is provided but input is empty, it becomes a developer message.""" + context = LLMContext(messages=[]) + params = self.adapter.get_llm_invocation_params(context, system_instruction="Be helpful.") + + self.assertNotIn("instructions", params) + self.assertEqual(len(params["input"]), 1) + self.assertEqual(params["input"][0]["role"], "developer") + self.assertEqual(params["input"][0]["content"], "Be helpful.") + + def test_no_system_instruction_omits_instructions(self): + """When no system_instruction is provided, instructions key is absent.""" + context = LLMContext(messages=[{"role": "user", "content": "Hi"}]) + params = self.adapter.get_llm_invocation_params(context) + + self.assertNotIn("instructions", params) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pattern_pair_aggregator.py b/tests/test_pattern_pair_aggregator.py index f4b660feb..6c9e23552 100644 --- a/tests/test_pattern_pair_aggregator.py +++ b/tests/test_pattern_pair_aggregator.py @@ -192,3 +192,68 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase): # Buffer should be empty self.assertEqual(self.aggregator.text.text, "") + + +class TestPatternPairAggregatorTokenMode(unittest.IsolatedAsyncioTestCase): + def setUp(self): + from pipecat.utils.text.base_text_aggregator import AggregationType + + self.aggregator = PatternPairAggregator(aggregation_type=AggregationType.TOKEN) + self.handler = AsyncMock() + self.aggregator.add_pattern( + type="think", + start_pattern="", + end_pattern="", + action=MatchAction.REMOVE, + ) + self.aggregator.on_pattern_match("think", self.handler) + + async def test_token_no_patterns(self): + """Non-pattern text passes through as TOKEN, one per aggregate call.""" + results = [] + for token in ["Hello", " world", "."]: + async for r in self.aggregator.aggregate(token): + results.append(r) + + self.assertEqual(len(results), 3) + self.assertEqual(results[0].text, "Hello") + self.assertEqual(results[1].text, " world") + self.assertEqual(results[2].text, ".") + for r in results: + self.assertEqual(r.type, "token") + + async def test_token_pattern_detection(self): + """Pattern detection still works with word-by-word token delivery.""" + results = [] + for token in ["Hi ", "", "secret", "", " bye"]: + async for r in self.aggregator.aggregate(token): + results.append(r) + + # Handler called once when the pattern completes + self.handler.assert_called_once() + call_args = self.handler.call_args[0][0] + self.assertEqual(call_args.text, "secret") + + # "Hi " yields before pattern starts, pattern is removed, " bye" yields after + self.assertEqual(len(results), 2) + self.assertEqual(results[0].text, "Hi ") + self.assertEqual(results[0].type, "token") + self.assertEqual(results[1].text, " bye") + self.assertEqual(results[1].type, "token") + + async def test_token_incomplete_pattern_buffers(self): + """Incomplete pattern is buffered across calls, not leaked to output.""" + results = [] + for token in ["Hi ", "", "partial"]: + async for r in self.aggregator.aggregate(token): + results.append(r) + + # Only "Hi " should be yielded; "partial" stays buffered + self.assertEqual(len(results), 1) + self.assertEqual(results[0].text, "Hi ") + self.assertEqual(results[0].type, "token") + self.handler.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 4b1b57e29..04601bf14 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -96,6 +96,34 @@ class TestParallelPipeline(unittest.IsolatedAsyncioTestCase): expected_down_frames=expected_down_frames, ) + async def test_parallel_internal_frames_buffered_during_start(self): + """Frames pushed by internal processors during StartFrame processing + should be buffered and only released after StartFrame synchronization + completes.""" + + class EmitOnStartProcessor(FrameProcessor): + """Pushes a TextFrame when it receives a StartFrame.""" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + await self.push_frame(frame, direction) + if isinstance(frame, StartFrame): + await self.push_frame(TextFrame(text="from start")) + + pipeline = ParallelPipeline([EmitOnStartProcessor()], [IdentityFilter()]) + + frames_to_send = [TextFrame(text="Hello!")] + + # StartFrame should come first, then the TextFrame emitted during + # StartFrame processing, then the regular TextFrame. + expected_down_frames = [StartFrame, TextFrame, TextFrame] + await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + ignore_start=False, + ) + class TestPipelineTask(unittest.IsolatedAsyncioTestCase): async def test_task_single(self): @@ -264,6 +292,63 @@ class TestPipelineTask(unittest.IsolatedAsyncioTestCase): assert upstream_received assert downstream_received + async def test_task_queue_frame_upstream(self): + upstream_received = False + + pipeline = Pipeline([IdentityFilter()]) + task = PipelineTask(pipeline, cancel_on_idle_timeout=False) + task.set_reached_upstream_filter((TextFrame,)) + + @task.event_handler("on_frame_reached_upstream") + async def on_frame_reached_upstream(task, frame): + nonlocal upstream_received + if isinstance(frame, TextFrame) and frame.text == "Hello Upstream!": + upstream_received = True + + @task.event_handler("on_pipeline_started") + async def on_pipeline_started(task, frame): + await task.queue_frame(TextFrame(text="Hello Upstream!"), FrameDirection.UPSTREAM) + + try: + await asyncio.wait_for( + task.run(PipelineTaskParams(loop=asyncio.get_event_loop())), + timeout=1.0, + ) + except asyncio.TimeoutError: + pass + + assert upstream_received + + async def test_task_queue_frames_upstream(self): + upstream_texts = [] + + pipeline = Pipeline([IdentityFilter()]) + task = PipelineTask(pipeline, cancel_on_idle_timeout=False) + task.set_reached_upstream_filter((TextFrame,)) + + @task.event_handler("on_frame_reached_upstream") + async def on_frame_reached_upstream(task, frame): + if isinstance(frame, TextFrame): + upstream_texts.append(frame.text) + + @task.event_handler("on_pipeline_started") + async def on_pipeline_started(task, frame): + await task.queue_frames( + [TextFrame(text="First"), TextFrame(text="Second")], + FrameDirection.UPSTREAM, + ) + + try: + await asyncio.wait_for( + task.run(PipelineTaskParams(loop=asyncio.get_event_loop())), + timeout=1.0, + ) + except asyncio.TimeoutError: + pass + + assert "First" in upstream_texts + assert "Second" in upstream_texts + async def test_task_heartbeats(self): heartbeats_counter = 0 @@ -528,3 +613,7 @@ class TestPipelineTask(unittest.IsolatedAsyncioTestCase): await task.run(PipelineTaskParams(loop=asyncio.get_event_loop())) except asyncio.CancelledError: assert error_received + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_piper_tts.py b/tests/test_piper_tts.py index e52be2d91..3b822f07c 100644 --- a/tests/test_piper_tts.py +++ b/tests/test_piper_tts.py @@ -7,6 +7,7 @@ """Tests for PiperTTSService.""" import asyncio +import unittest import aiohttp import pytest @@ -21,7 +22,7 @@ from pipecat.frames.frames import ( TTSStoppedFrame, TTSTextFrame, ) -from pipecat.services.piper.tts import PiperTTSService +from pipecat.services.piper.tts import PiperHttpTTSService from pipecat.tests.utils import run_test @@ -67,35 +68,45 @@ async def test_run_piper_tts_success(aiohttp_client): base_url = str(client.make_url("")).rstrip("/") async with aiohttp.ClientSession() as session: - # Instantiate PiperTTSService with our mock server - tts_service = PiperTTSService(base_url=base_url, aiohttp_session=session, sample_rate=24000) + # Instantiate PiperHttpTTSService with our mock server + tts_service = PiperHttpTTSService( + base_url=base_url, aiohttp_session=session, sample_rate=24000 + ) frames_to_send = [ TTSSpeakFrame(text="Hello world."), ] - expected_returned_frames = [ - AggregatedTextFrame, - TTSStartedFrame, - TTSAudioRawFrame, - TTSAudioRawFrame, - TTSAudioRawFrame, - TTSAudioRawFrame, - TTSAudioRawFrame, - TTSAudioRawFrame, - TTSAudioRawFrame, - TTSAudioRawFrame, - TTSStoppedFrame, - TTSTextFrame, - ] - frames_received = await run_test( tts_service, frames_to_send=frames_to_send, - expected_down_frames=expected_returned_frames, ) down_frames = frames_received[0] + frame_types = [type(f) for f in down_frames] + + # Verify key frames are present + assert AggregatedTextFrame in frame_types + assert TTSStartedFrame in frame_types + assert TTSStoppedFrame in frame_types + assert TTSTextFrame in frame_types + + # Verify ordering: Started → audio → Stopped → Text + started_idx = frame_types.index(TTSStartedFrame) + stopped_idx = frame_types.index(TTSStoppedFrame) + text_idx = frame_types.index(TTSTextFrame) + assert started_idx < text_idx < stopped_idx, ( + "Expected: TTSStartedFrame < TTSTextFrame < TTSStoppedFrame" + ) + + # Frames between Started and Stopped must all be audio or text + for i in range(started_idx + 1, stopped_idx): + assert frame_types[i] in (TTSAudioRawFrame, TTSTextFrame), ( + f"Unexpected frame type between Started and Stopped: {frame_types[i]}" + ) + + # All audio frames have correct sample rate audio_frames = [f for f in down_frames if isinstance(f, TTSAudioRawFrame)] + assert len(audio_frames) >= 1, "Expected at least one audio frame" for a_frame in audio_frames: assert a_frame.sample_rate == 24000, "Sample rate should match the default (24000)" @@ -117,13 +128,15 @@ async def test_run_piper_tts_error(aiohttp_client): base_url = str(client.make_url("")).rstrip("/") async with aiohttp.ClientSession() as session: - tts_service = PiperTTSService(base_url=base_url, aiohttp_session=session, sample_rate=24000) + tts_service = PiperHttpTTSService( + base_url=base_url, aiohttp_session=session, sample_rate=24000 + ) frames_to_send = [ - TTSSpeakFrame(text="Error case."), + TTSSpeakFrame(text="Error case.", append_to_context=False), ] - expected_down_frames = [AggregatedTextFrame, TTSStoppedFrame, TTSTextFrame] + expected_down_frames = [AggregatedTextFrame, TTSStartedFrame, TTSStoppedFrame, TTSTextFrame] expected_up_frames = [ErrorFrame] @@ -139,3 +152,7 @@ async def test_run_piper_tts_error(aiohttp_client): assert "status: 404" in up_frames[0].error, ( "ErrorFrame should contain details about the 404" ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_producer_consumer.py b/tests/test_producer_consumer.py index 4e9804c1b..0fd3f862c 100644 --- a/tests/test_producer_consumer.py +++ b/tests/test_producer_consumer.py @@ -118,3 +118,7 @@ class TestProducerConsumerProcessor(unittest.IsolatedAsyncioTestCase): frames_to_send=frames_to_send, expected_down_frames=expected_down_frames, ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_protobuf_serializer.py b/tests/test_protobuf_serializer.py index 99df3f96b..14d7d1aa7 100644 --- a/tests/test_protobuf_serializer.py +++ b/tests/test_protobuf_serializer.py @@ -38,3 +38,7 @@ class TestProtobufFrameSerializer(unittest.IsolatedAsyncioTestCase): self.assertEqual(frame.audio, audio_frame.audio) self.assertEqual(frame.sample_rate, audio_frame.sample_rate) self.assertEqual(frame.num_channels, audio_frame.num_channels) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rnnoise_cancellation.py b/tests/test_rnnoise_cancellation.py index 386dcd424..a90b4f1e9 100644 --- a/tests/test_rnnoise_cancellation.py +++ b/tests/test_rnnoise_cancellation.py @@ -171,3 +171,7 @@ class TestRNNoiseCancellation(unittest.IsolatedAsyncioTestCase): self.assertLess(mse_output, mse_input, "MSE did not improve") print("Test Passed: Noise cancellation verified.") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rnnoise_filter.py b/tests/test_rnnoise_filter.py index 9e53b5b47..853ea0c77 100644 --- a/tests/test_rnnoise_filter.py +++ b/tests/test_rnnoise_filter.py @@ -5,7 +5,7 @@ # import unittest -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock import numpy as np @@ -143,3 +143,7 @@ class TestRNNoiseFilter(unittest.IsolatedAsyncioTestCase): ) await filter.stop() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rnnoise_resampling.py b/tests/test_rnnoise_resampling.py index c69419d2e..83bf65edc 100644 --- a/tests/test_rnnoise_resampling.py +++ b/tests/test_rnnoise_resampling.py @@ -4,7 +4,6 @@ # SPDX-License-Identifier: BSD 2-Clause License # -import asyncio import sys import unittest from unittest.mock import MagicMock, patch @@ -133,3 +132,7 @@ class TestRNNoiseResampling(unittest.IsolatedAsyncioTestCase): ) print("Test Passed: Resampling logic verified (with mocked RNNoise).") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_run_inference.py b/tests/test_run_inference.py index ea08445a9..cef13fb27 100644 --- a/tests/test_run_inference.py +++ b/tests/test_run_inference.py @@ -20,6 +20,7 @@ from pipecat.services.anthropic.llm import AnthropicLLMService from pipecat.services.aws.llm import AWSBedrockLLMService from pipecat.services.google.llm import GoogleLLMService from pipecat.services.openai.llm import OpenAILLMService +from pipecat.services.openai.responses.llm import OpenAIResponsesLLMService @pytest.mark.asyncio @@ -509,3 +510,428 @@ async def test_aws_bedrock_run_inference_client_exception(): with patch.object(service._aws_session, "client", return_value=mock_context_manager): with pytest.raises(Exception, match="Bedrock API Error"): await service.run_inference(mock_context) + + +# --- system_instruction parameter tests --- + + +@pytest.mark.asyncio +async def test_openai_run_inference_system_instruction_overrides_context(): + """Test that system_instruction overrides the system message from context.""" + with patch.object(OpenAILLMService, "create_client"): + service = OpenAILLMService(model="gpt-4") + service._client = AsyncMock() + + mock_context = MagicMock(spec=LLMContext) + mock_adapter = MagicMock() + test_messages = [ + {"role": "system", "content": "Original system message"}, + {"role": "user", "content": "Hello"}, + ] + mock_adapter.get_llm_invocation_params.return_value = OpenAILLMInvocationParams( + messages=test_messages, tools=OPENAI_NOT_GIVEN, tool_choice=OPENAI_NOT_GIVEN + ) + service.get_llm_adapter = MagicMock(return_value=mock_adapter) + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Response" + service._client.chat.completions.create.return_value = mock_response + + result = await service.run_inference( + mock_context, system_instruction="New system instruction" + ) + + assert result == "Response" + call_kwargs = service._client.chat.completions.create.call_args.kwargs + messages = call_kwargs["messages"] + # system_instruction should be prepended as the first message + assert messages[0] == {"role": "system", "content": "New system instruction"} + # Original system message should still be present + assert messages[1] == {"role": "system", "content": "Original system message"} + # User message should still be present + assert messages[2] == {"role": "user", "content": "Hello"} + assert len(messages) == 3 + + +@pytest.mark.asyncio +async def test_openai_run_inference_system_instruction_none_unchanged(): + """Test that when system_instruction is None, behavior is unchanged.""" + with patch.object(OpenAILLMService, "create_client"): + service = OpenAILLMService(model="gpt-4") + service._client = AsyncMock() + + mock_context = MagicMock(spec=LLMContext) + mock_adapter = MagicMock() + test_messages = [ + {"role": "system", "content": "Original system message"}, + {"role": "user", "content": "Hello"}, + ] + mock_adapter.get_llm_invocation_params.return_value = OpenAILLMInvocationParams( + messages=test_messages, tools=OPENAI_NOT_GIVEN, tool_choice=OPENAI_NOT_GIVEN + ) + service.get_llm_adapter = MagicMock(return_value=mock_adapter) + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Response" + service._client.chat.completions.create.return_value = mock_response + + result = await service.run_inference(mock_context) + + assert result == "Response" + call_kwargs = service._client.chat.completions.create.call_args.kwargs + messages = call_kwargs["messages"] + assert messages[0] == {"role": "system", "content": "Original system message"} + assert messages[1] == {"role": "user", "content": "Hello"} + + +@pytest.mark.asyncio +async def test_anthropic_run_inference_system_instruction_overrides_context(): + """Test that system_instruction overrides the system message for Anthropic.""" + service = AnthropicLLMService(api_key="test-key", model="claude-3-sonnet-20240229") + service._client = AsyncMock() + + mock_context = MagicMock(spec=LLMContext) + mock_adapter = MagicMock() + test_messages = [{"role": "user", "content": "Hello"}] + mock_adapter.get_llm_invocation_params.return_value = AnthropicLLMInvocationParams( + messages=test_messages, system="Original system", tools=[] + ) + service.get_llm_adapter = MagicMock(return_value=mock_adapter) + + mock_response = MagicMock() + mock_response.content = [MagicMock()] + mock_response.content[0].text = "Response" + service._client.beta.messages.create.return_value = mock_response + + result = await service.run_inference(mock_context, system_instruction="New system instruction") + + assert result == "Response" + call_kwargs = service._client.beta.messages.create.call_args.kwargs + assert call_kwargs["system"] == "New system instruction" + assert call_kwargs["messages"] == test_messages + + +@pytest.mark.asyncio +async def test_anthropic_run_inference_system_instruction_none_unchanged(): + """Test that when system_instruction is None, Anthropic behavior is unchanged.""" + service = AnthropicLLMService(api_key="test-key", model="claude-3-sonnet-20240229") + service._client = AsyncMock() + + mock_context = MagicMock(spec=LLMContext) + mock_adapter = MagicMock() + test_messages = [{"role": "user", "content": "Hello"}] + mock_adapter.get_llm_invocation_params.return_value = AnthropicLLMInvocationParams( + messages=test_messages, system="Original system", tools=[] + ) + service.get_llm_adapter = MagicMock(return_value=mock_adapter) + + mock_response = MagicMock() + mock_response.content = [MagicMock()] + mock_response.content[0].text = "Response" + service._client.beta.messages.create.return_value = mock_response + + result = await service.run_inference(mock_context) + + assert result == "Response" + call_kwargs = service._client.beta.messages.create.call_args.kwargs + assert call_kwargs["system"] == "Original system" + + +@pytest.mark.asyncio +async def test_google_run_inference_system_instruction_overrides_context(): + """Test that system_instruction overrides the system message for Google.""" + service = GoogleLLMService(api_key="test-key", model="gemini-2.0-flash") + service._client = AsyncMock() + + mock_context = MagicMock(spec=LLMContext) + mock_adapter = MagicMock() + test_messages = [{"role": "user", "content": "Hello"}] + mock_adapter.get_llm_invocation_params.return_value = GeminiLLMInvocationParams( + messages=test_messages, system_instruction="Original system", tools=NotGiven() + ) + service.get_llm_adapter = MagicMock(return_value=mock_adapter) + + mock_response = MagicMock() + mock_response.candidates = [MagicMock()] + mock_response.candidates[0].content = MagicMock() + mock_response.candidates[0].content.parts = [MagicMock()] + mock_response.candidates[0].content.parts[0].text = "Response" + service._client.aio = AsyncMock() + service._client.aio.models = AsyncMock() + service._client.aio.models.generate_content = AsyncMock(return_value=mock_response) + + result = await service.run_inference(mock_context, system_instruction="New system instruction") + + assert result == "Response" + call_kwargs = service._client.aio.models.generate_content.call_args.kwargs + config = call_kwargs["config"] + assert config.system_instruction == "New system instruction" + + +@pytest.mark.asyncio +async def test_google_run_inference_system_instruction_none_unchanged(): + """Test that when system_instruction is None, Google behavior is unchanged.""" + service = GoogleLLMService(api_key="test-key", model="gemini-2.0-flash") + service._client = AsyncMock() + + mock_context = MagicMock(spec=LLMContext) + mock_adapter = MagicMock() + test_messages = [{"role": "user", "content": "Hello"}] + mock_adapter.get_llm_invocation_params.return_value = GeminiLLMInvocationParams( + messages=test_messages, system_instruction="Original system", tools=NotGiven() + ) + service.get_llm_adapter = MagicMock(return_value=mock_adapter) + + mock_response = MagicMock() + mock_response.candidates = [MagicMock()] + mock_response.candidates[0].content = MagicMock() + mock_response.candidates[0].content.parts = [MagicMock()] + mock_response.candidates[0].content.parts[0].text = "Response" + service._client.aio = AsyncMock() + service._client.aio.models = AsyncMock() + service._client.aio.models.generate_content = AsyncMock(return_value=mock_response) + + result = await service.run_inference(mock_context) + + assert result == "Response" + call_kwargs = service._client.aio.models.generate_content.call_args.kwargs + config = call_kwargs["config"] + assert config.system_instruction == "Original system" + + +@pytest.mark.asyncio +async def test_aws_bedrock_run_inference_system_instruction_overrides_context(): + """Test that system_instruction overrides the system message for AWS Bedrock.""" + service = AWSBedrockLLMService(model="anthropic.claude-3-sonnet-20240229-v1:0") + + mock_context = MagicMock(spec=LLMContext) + mock_adapter = MagicMock() + test_messages = [{"role": "user", "content": [{"text": "Hello"}]}] + mock_adapter.get_llm_invocation_params.return_value = AWSBedrockLLMInvocationParams( + messages=test_messages, + system=[{"text": "Original system"}], + tools=[], + tool_choice=None, + ) + service.get_llm_adapter = MagicMock(return_value=mock_adapter) + + mock_client = AsyncMock() + mock_response = {"output": {"message": {"content": [{"text": "Response"}]}}} + mock_client.converse.return_value = mock_response + + mock_context_manager = AsyncMock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_client) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + + with patch.object(service._aws_session, "client", return_value=mock_context_manager): + result = await service.run_inference( + mock_context, system_instruction="New system instruction" + ) + + assert result == "Response" + call_kwargs = mock_client.converse.call_args.kwargs + assert call_kwargs["system"] == [{"text": "New system instruction"}] + assert call_kwargs["messages"] == test_messages + + +@pytest.mark.asyncio +async def test_aws_bedrock_run_inference_system_instruction_none_unchanged(): + """Test that when system_instruction is None, AWS Bedrock behavior is unchanged.""" + service = AWSBedrockLLMService(model="anthropic.claude-3-sonnet-20240229-v1:0") + + mock_context = MagicMock(spec=LLMContext) + mock_adapter = MagicMock() + test_messages = [{"role": "user", "content": [{"text": "Hello"}]}] + mock_adapter.get_llm_invocation_params.return_value = AWSBedrockLLMInvocationParams( + messages=test_messages, + system=[{"text": "Original system"}], + tools=[], + tool_choice=None, + ) + service.get_llm_adapter = MagicMock(return_value=mock_adapter) + + mock_client = AsyncMock() + mock_response = {"output": {"message": {"content": [{"text": "Response"}]}}} + mock_client.converse.return_value = mock_response + + mock_context_manager = AsyncMock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_client) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + + with patch.object(service._aws_session, "client", return_value=mock_context_manager): + result = await service.run_inference(mock_context) + + assert result == "Response" + call_kwargs = mock_client.converse.call_args.kwargs + assert call_kwargs["system"] == [{"text": "Original system"}] + + +# --- OpenAI Responses API tests --- + + +@pytest.mark.asyncio +async def test_openai_responses_run_inference_with_llm_context(): + """Test run_inference with LLMContext returns expected response.""" + with patch.object(OpenAIResponsesLLMService, "_create_client"): + service = OpenAIResponsesLLMService( + settings=OpenAIResponsesLLMService.Settings( + model="gpt-4.1", + system_instruction="You are a helpful assistant", + temperature=0.7, + max_completion_tokens=100, + ), + ) + service._client = AsyncMock() + + context = LLMContext( + messages=[ + {"role": "user", "content": "Hello, world!"}, + ] + ) + + mock_response = MagicMock() + mock_response.output_text = "Hello! How can I help you today?" + service._client.responses.create = AsyncMock(return_value=mock_response) + + result = await service.run_inference(context) + + assert result == "Hello! How can I help you today?" + call_kwargs = service._client.responses.create.call_args.kwargs + assert call_kwargs["model"] == "gpt-4.1" + assert call_kwargs["stream"] is False + assert call_kwargs["store"] is False + assert call_kwargs["input"] == [{"role": "user", "content": "Hello, world!"}] + assert call_kwargs["instructions"] == "You are a helpful assistant" + assert call_kwargs["temperature"] == 0.7 + assert call_kwargs["max_output_tokens"] == 100 + + +@pytest.mark.asyncio +async def test_openai_responses_run_inference_client_exception(): + """Test that exceptions from the client are propagated.""" + with patch.object(OpenAIResponsesLLMService, "_create_client"): + service = OpenAIResponsesLLMService() + service._client = AsyncMock() + + context = LLMContext(messages=[{"role": "user", "content": "Hello"}]) + service._client.responses.create = AsyncMock(side_effect=Exception("API Error")) + + with pytest.raises(Exception, match="API Error"): + await service.run_inference(context) + + +@pytest.mark.asyncio +async def test_openai_responses_run_inference_system_instruction_overrides(): + """Test that system_instruction parameter overrides the settings instruction.""" + with patch.object(OpenAIResponsesLLMService, "_create_client"): + service = OpenAIResponsesLLMService( + settings=OpenAIResponsesLLMService.Settings( + model="gpt-4.1", + system_instruction="Original instruction", + ), + ) + service._client = AsyncMock() + + context = LLMContext( + messages=[{"role": "user", "content": "Hello"}], + ) + + mock_response = MagicMock() + mock_response.output_text = "Response" + service._client.responses.create = AsyncMock(return_value=mock_response) + + result = await service.run_inference(context, system_instruction="New system instruction") + + assert result == "Response" + call_kwargs = service._client.responses.create.call_args.kwargs + assert call_kwargs["instructions"] == "New system instruction" + assert call_kwargs["input"] == [{"role": "user", "content": "Hello"}] + + +@pytest.mark.asyncio +async def test_openai_responses_run_inference_empty_context_with_instruction(): + """Test that system_instruction becomes a developer message when context is empty.""" + with patch.object(OpenAIResponsesLLMService, "_create_client"): + service = OpenAIResponsesLLMService( + settings=OpenAIResponsesLLMService.Settings( + model="gpt-4.1", + system_instruction="You are helpful", + ), + ) + service._client = AsyncMock() + + context = LLMContext(messages=[]) + + mock_response = MagicMock() + mock_response.output_text = "Response" + service._client.responses.create = AsyncMock(return_value=mock_response) + + result = await service.run_inference(context) + + assert result == "Response" + call_kwargs = service._client.responses.create.call_args.kwargs + # With empty context, instruction should become a developer message + assert call_kwargs["input"] == [{"role": "developer", "content": "You are helpful"}] + assert "instructions" not in call_kwargs + + +@pytest.mark.asyncio +async def test_openai_responses_run_inference_max_tokens_override(): + """Test that max_tokens parameter overrides max_output_tokens.""" + with patch.object(OpenAIResponsesLLMService, "_create_client"): + service = OpenAIResponsesLLMService( + settings=OpenAIResponsesLLMService.Settings( + model="gpt-4.1", + max_completion_tokens=500, + ), + ) + service._client = AsyncMock() + + context = LLMContext( + messages=[{"role": "user", "content": "Summarize this"}], + ) + + mock_response = MagicMock() + mock_response.output_text = "Summary" + service._client.responses.create = AsyncMock(return_value=mock_response) + + result = await service.run_inference(context, max_tokens=200) + + assert result == "Summary" + call_kwargs = service._client.responses.create.call_args.kwargs + assert call_kwargs["max_output_tokens"] == 200 + + +@pytest.mark.asyncio +async def test_openai_responses_run_inference_system_instruction_param_with_empty_context(): + """Test that system_instruction param becomes a developer message when context is empty. + + The Responses API rejects requests with instructions but no input items. + When run_inference is called with an explicit system_instruction and an + empty context, the instruction must become a developer message — not be + sent as the instructions parameter. + """ + with patch.object(OpenAIResponsesLLMService, "_create_client"): + service = OpenAIResponsesLLMService( + settings=OpenAIResponsesLLMService.Settings(model="gpt-4.1"), + ) + service._client = AsyncMock() + + context = LLMContext(messages=[]) + + mock_response = MagicMock() + mock_response.output_text = "Response" + service._client.responses.create = AsyncMock(return_value=mock_response) + + result = await service.run_inference( + context, system_instruction="Summarize the conversation" + ) + + assert result == "Response" + call_kwargs = service._client.responses.create.call_args.kwargs + assert call_kwargs["input"] == [ + {"role": "developer", "content": "Summarize the conversation"} + ] + assert "instructions" not in call_kwargs diff --git a/tests/test_runner_utils.py b/tests/test_runner_utils.py new file mode 100644 index 000000000..18f156cbb --- /dev/null +++ b/tests/test_runner_utils.py @@ -0,0 +1,153 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import json +import unittest +from unittest.mock import MagicMock + +from pipecat.runner.utils import parse_telephony_websocket + + +class MockAsyncIterator: + """Mock async iterator for WebSocket messages.""" + + def __init__(self, messages): + self.messages = messages + self.index = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + if self.index >= len(self.messages): + raise StopAsyncIteration + message = self.messages[self.index] + self.index += 1 + return message + + +class TestParseTelephonyWebSocket(unittest.IsolatedAsyncioTestCase): + async def test_no_messages_raises_value_error(self): + """Test that no messages raises ValueError.""" + mock_websocket = MagicMock() + mock_websocket.iter_text.return_value = MockAsyncIterator([]) + + with self.assertRaises(ValueError) as context: + await parse_telephony_websocket(mock_websocket) + + self.assertIn("WebSocket closed before receiving", str(context.exception)) + + async def test_one_message_logs_warning_and_continues(self): + """Test that one message logs warning but continues processing.""" + twilio_message = json.dumps( + { + "event": "start", + "start": { + "streamSid": "MZ123", + "callSid": "CA123", + "customParameters": {"user_id": "test_user"}, + }, + } + ) + + mock_websocket = MagicMock() + mock_websocket.iter_text.return_value = MockAsyncIterator([twilio_message]) + + transport_type, call_data = await parse_telephony_websocket(mock_websocket) + + self.assertEqual(transport_type, "twilio") + self.assertEqual(call_data["stream_id"], "MZ123") + self.assertEqual(call_data["call_id"], "CA123") + + async def test_two_messages_normal_operation(self): + """Test normal operation with two messages.""" + first_message = json.dumps({"event": "connected"}) + twilio_message = json.dumps( + { + "event": "start", + "start": { + "streamSid": "MZ456", + "callSid": "CA456", + "customParameters": {}, + }, + } + ) + + mock_websocket = MagicMock() + mock_websocket.iter_text.return_value = MockAsyncIterator([first_message, twilio_message]) + + transport_type, call_data = await parse_telephony_websocket(mock_websocket) + + self.assertEqual(transport_type, "twilio") + self.assertEqual(call_data["stream_id"], "MZ456") + self.assertEqual(call_data["call_id"], "CA456") + + async def test_telnyx_detection(self): + """Test Telnyx provider detection.""" + telnyx_message = json.dumps( + { + "stream_id": "stream_123", + "start": { + "call_control_id": "cc_123", + "media_format": {"encoding": "PCMU"}, + "from": "+15551234567", + "to": "+15559876543", + }, + } + ) + + mock_websocket = MagicMock() + mock_websocket.iter_text.return_value = MockAsyncIterator([telnyx_message]) + + transport_type, call_data = await parse_telephony_websocket(mock_websocket) + + self.assertEqual(transport_type, "telnyx") + self.assertEqual(call_data["stream_id"], "stream_123") + self.assertEqual(call_data["call_control_id"], "cc_123") + + async def test_plivo_detection(self): + """Test Plivo provider detection.""" + plivo_message = json.dumps( + {"start": {"streamId": "stream_plivo_123", "callId": "call_plivo_123"}} + ) + + mock_websocket = MagicMock() + mock_websocket.iter_text.return_value = MockAsyncIterator([plivo_message]) + + transport_type, call_data = await parse_telephony_websocket(mock_websocket) + + self.assertEqual(transport_type, "plivo") + self.assertEqual(call_data["stream_id"], "stream_plivo_123") + self.assertEqual(call_data["call_id"], "call_plivo_123") + + async def test_exotel_detection(self): + """Test Exotel provider detection.""" + exotel_message = json.dumps( + { + "event": "start", + "start": { + "stream_sid": "stream_exo_123", + "call_sid": "call_exo_123", + "account_sid": "acc_123", + "from": "+15551111111", + "to": "+15552222222", + }, + } + ) + + mock_websocket = MagicMock() + mock_websocket.iter_text.return_value = MockAsyncIterator([exotel_message]) + + transport_type, call_data = await parse_telephony_websocket(mock_websocket) + + self.assertEqual(transport_type, "exotel") + self.assertEqual(call_data["stream_id"], "stream_exo_123") + self.assertEqual(call_data["call_id"], "call_exo_123") + self.assertEqual(call_data["account_sid"], "acc_123") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sambanova_llm.py b/tests/test_sambanova_llm.py new file mode 100644 index 000000000..6632951fc --- /dev/null +++ b/tests/test_sambanova_llm.py @@ -0,0 +1,72 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Unit tests for SambaNova LLM service.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.services.sambanova.llm import SambaNovaLLMService + + +@pytest.mark.asyncio +async def test_sambanova_llm_stream_closed_on_cancellation(): + """Test that the stream is closed when CancelledError occurs during iteration. + + This prevents socket leaks when the pipeline is interrupted (e.g., user interruption). + See issue #3639. + """ + with patch.object(SambaNovaLLMService, "create_client"): + service = SambaNovaLLMService(api_key="test-key", model="test-model") + service._client = AsyncMock() + + stream_closed = False + + class MockAsyncStream: + """Mock AsyncStream that tracks close() calls and raises CancelledError.""" + + def __init__(self): + self.iteration_count = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + nonlocal stream_closed + stream_closed = True + return False + + def __aiter__(self): + return self + + async def __anext__(self): + self.iteration_count += 1 + if self.iteration_count > 1: + raise asyncio.CancelledError() + mock_chunk = AsyncMock() + mock_chunk.usage = None + mock_chunk.choices = [] + return mock_chunk + + mock_stream = MockAsyncStream() + + 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"}], + ) + + with pytest.raises(asyncio.CancelledError): + await service._process_context(context) + + assert stream_closed, "Stream should be closed even when CancelledError occurs" diff --git a/tests/test_service_init.py b/tests/test_service_init.py new file mode 100644 index 000000000..377300f64 --- /dev/null +++ b/tests/test_service_init.py @@ -0,0 +1,180 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Tests for service settings and initialization patterns. + +Settings objects operate in two modes: + +- **Store mode** (``self._settings``): the live state inside a service. + Every field must hold a real value (``None`` is fine, ``NOT_GIVEN`` is not). +- **Delta mode** (``FooSettings()`` with no args): a sparse update. + Every field must default to ``NOT_GIVEN`` so ``apply_update()`` skips + untouched fields and doesn't accidentally overwrite the store. + +These tests verify both sides of that contract automatically: + +1. **Delta defaults** — Instantiate every ``ServiceSettings`` subclass with + no arguments and assert that every field is ``NOT_GIVEN``. Catches the + bug where a field defaults to ``None`` instead of ``NOT_GIVEN``, which + would cause partial deltas to silently overwrite unrelated store values. + +2. **Store completeness** — Instantiate every concrete service with dummy + args and assert that ``_settings`` contains no ``NOT_GIVEN`` values. + This is the same check that ``validate_complete()`` runs in ``start()``, + but caught here at unit-test time without needing a running pipeline. + Catches services that forget to initialize a field in ``default_settings``. + +All Settings and Service classes are auto-discovered via ``pkgutil``; +new services are covered automatically with no per-service maintenance. +""" + +import importlib +import inspect +import pkgutil +from dataclasses import fields + +import pytest + +import pipecat.services +from pipecat.services.ai_service import AIService +from pipecat.services.settings import ServiceSettings, is_given + +# Modules that define abstract base service classes (not concrete services). +_BASE_MODULES = frozenset( + { + "pipecat.services.ai_service", + "pipecat.services.llm_service", + "pipecat.services.stt_service", + "pipecat.services.tts_service", + "pipecat.services.image_gen_service", + "pipecat.services.vision_service", + } +) + + +# --------------------------------------------------------------------------- +# Auto-discovery +# --------------------------------------------------------------------------- + + +def _all_subclasses(cls): + result = set() + for sub in cls.__subclasses__(): + result.add(sub) + result.update(_all_subclasses(sub)) + return result + + +def _import_all_service_modules(): + """Import every module under pipecat.services (skipping missing deps).""" + package = pipecat.services + for _importer, modname, _ispkg in pkgutil.walk_packages( + package.__path__, prefix=package.__name__ + ".", onerror=lambda _name: None + ): + try: + importlib.import_module(modname) + except Exception: + continue + + +_import_all_service_modules() + +ALL_SETTINGS_CLASSES = sorted(_all_subclasses(ServiceSettings), key=lambda c: c.__qualname__) +assert ALL_SETTINGS_CLASSES, "No settings classes discovered" + + +# --------------------------------------------------------------------------- +# Service instantiation helpers +# --------------------------------------------------------------------------- + + +def _try_instantiate(cls): + """Try to instantiate a service with dummy values for required args. + + Inspects the __init__ signature and passes "test" for every required + keyword-only parameter. Services that need non-string required args + or fail for other reasons will raise and be skipped by the test. + """ + sig = inspect.signature(cls.__init__) + kwargs = {} + for name, param in sig.parameters.items(): + if name == "self": + continue + if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): + continue + if param.default is not param.empty: + continue + # Required parameter — pass a dummy string + kwargs[name] = "test" + return cls(**kwargs) + + +def _discover_service_classes(): + """Return concrete service classes that can be instantiated with dummy args.""" + result = [] + for cls in sorted(_all_subclasses(AIService), key=lambda c: c.__qualname__): + # Skip abstract base classes defined in framework modules. + if cls.__module__ in _BASE_MODULES: + continue + try: + svc = _try_instantiate(cls) + except Exception: + continue + if hasattr(svc, "_settings"): + result.append(cls) + return result + + +ALL_SERVICE_CLASSES = _discover_service_classes() +assert ALL_SERVICE_CLASSES, "No service classes could be instantiated" + + +# --------------------------------------------------------------------------- +# 1. Settings defaults: delta-mode safety +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("settings_cls", ALL_SETTINGS_CLASSES, ids=lambda c: c.__qualname__) +def test_delta_defaults_are_not_given(settings_cls): + """Every field must default to NOT_GIVEN so empty deltas are no-ops. + + A field that defaults to None instead of NOT_GIVEN will cause + apply_update() to overwrite the corresponding store value whenever + a partial delta is applied. + """ + instance = settings_cls() + for f in fields(instance): + if f.name == "extra": + continue + val = getattr(instance, f.name) + assert not is_given(val), ( + f"{settings_cls.__qualname__}.{f.name} defaults to {val!r}, expected NOT_GIVEN" + ) + + +# --------------------------------------------------------------------------- +# 2. Service construction: store-mode completeness +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("service_cls", ALL_SERVICE_CLASSES, ids=lambda c: c.__qualname__) +def test_service_settings_complete(service_cls): + """After construction, _settings must have no NOT_GIVEN values. + + This is what validate_complete() checks in start(). Catching it + here means we don't need a running pipeline to find missing defaults. + """ + try: + svc = _try_instantiate(service_cls) + except Exception: + pytest.skip("Cannot re-instantiate (environment issue)") + for f in fields(svc._settings): + if f.name == "extra": + continue + val = getattr(svc._settings, f.name) + assert is_given(val), ( + f"{service_cls.__qualname__}._settings.{f.name} is NOT_GIVEN after construction" + ) diff --git a/tests/test_service_language.py b/tests/test_service_language.py new file mode 100644 index 000000000..6ac23c823 --- /dev/null +++ b/tests/test_service_language.py @@ -0,0 +1,251 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Tests for language parameter handling in TTS and STT services. + +Verifies that Language enums, raw strings (e.g. "de-DE"), and unrecognized +strings are all resolved correctly at both init time and runtime update time. +""" + +from typing import AsyncGenerator, Optional +from unittest.mock import patch + +import pytest + +from pipecat.frames.frames import Frame +from pipecat.services.settings import STTSettings, TTSSettings +from pipecat.services.stt_service import STTService +from pipecat.services.tts_service import TTSService +from pipecat.transcriptions.language import Language, resolve_language + +# --------------------------------------------------------------------------- +# Minimal concrete subclasses for testing +# --------------------------------------------------------------------------- + +# A simple language map using only base codes (like ElevenLabs does). +_LANGUAGE_MAP = { + Language.DE: "de", + Language.EN: "en", + Language.FR: "fr", +} + + +class _TestTTSService(TTSService): + """Minimal concrete TTS service for testing language resolution.""" + + class Settings(TTSSettings): + pass + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: + yield # pragma: no cover + + def language_to_service_language(self, language: Language) -> Optional[str]: + return resolve_language(language, _LANGUAGE_MAP, use_base_code=True) + + +class _TestSTTService(STTService): + """Minimal concrete STT service for testing language resolution.""" + + class Settings(STTSettings): + pass + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: + yield # pragma: no cover + + async def process_audio_frame(self, frame, direction): + pass # pragma: no cover + + def language_to_service_language(self, language: Language) -> Optional[str]: + return resolve_language(language, _LANGUAGE_MAP, use_base_code=True) + + +# --------------------------------------------------------------------------- +# TTS init tests +# --------------------------------------------------------------------------- + + +class TestTTSLanguageInit: + """Test language resolution at TTS service init time.""" + + def test_language_enum_base_code(self): + """Language.DE (base code in map) resolves to 'de'.""" + svc = _TestTTSService(settings=_TestTTSService.Settings(language=Language.DE)) + assert svc._settings.language == "de" + + def test_language_enum_regional_code(self): + """Language.DE_DE (regional, not in map) falls back to base code 'de'.""" + svc = _TestTTSService(settings=_TestTTSService.Settings(language=Language.DE_DE)) + assert svc._settings.language == "de" + + def test_raw_string_base_code(self): + """Raw string 'de' is converted to Language.DE then resolved to 'de'.""" + svc = _TestTTSService(settings=_TestTTSService.Settings(language="de")) + assert svc._settings.language == "de" + + def test_raw_string_regional_code(self): + """Raw string 'de-DE' is converted to Language.DE_DE then resolved to 'de'.""" + svc = _TestTTSService(settings=_TestTTSService.Settings(language="de-DE")) + assert svc._settings.language == "de" + + def test_raw_string_other_regional(self): + """Raw string 'en-US' is converted to Language.EN_US then resolved to 'en'.""" + svc = _TestTTSService(settings=_TestTTSService.Settings(language="en-US")) + assert svc._settings.language == "en" + + def test_raw_string_unrecognized(self): + """Unrecognized raw string logs a warning and is passed through as-is.""" + with patch("pipecat.services.tts_service.logger") as mock_logger: + svc = _TestTTSService(settings=_TestTTSService.Settings(language="klingon")) + assert svc._settings.language == "klingon" + mock_logger.warning.assert_called_once() + assert "klingon" in mock_logger.warning.call_args[0][0] + + def test_language_none(self): + """None language is left as None.""" + svc = _TestTTSService(settings=_TestTTSService.Settings(language=None)) + assert svc._settings.language is None + + +# --------------------------------------------------------------------------- +# STT init tests +# --------------------------------------------------------------------------- + + +class TestSTTLanguageInit: + """Test language resolution at STT service init time.""" + + def test_language_enum_base_code(self): + """Language.FR (base code in map) resolves to 'fr'.""" + svc = _TestSTTService(settings=_TestSTTService.Settings(language=Language.FR)) + assert svc._settings.language == "fr" + + def test_language_enum_regional_code(self): + """Language.FR_FR (regional, not in map) falls back to base code 'fr'.""" + svc = _TestSTTService(settings=_TestSTTService.Settings(language=Language.FR_FR)) + assert svc._settings.language == "fr" + + def test_raw_string_base_code(self): + """Raw string 'fr' is converted to Language.FR then resolved to 'fr'.""" + svc = _TestSTTService(settings=_TestSTTService.Settings(language="fr")) + assert svc._settings.language == "fr" + + def test_raw_string_regional_code(self): + """Raw string 'de-DE' is converted to Language.DE_DE then resolved to 'de'.""" + svc = _TestSTTService(settings=_TestSTTService.Settings(language="de-DE")) + assert svc._settings.language == "de" + + def test_raw_string_unrecognized(self): + """Unrecognized raw string logs a warning and is passed through as-is.""" + with patch("pipecat.services.stt_service.logger") as mock_logger: + svc = _TestSTTService(settings=_TestSTTService.Settings(language="klingon")) + assert svc._settings.language == "klingon" + mock_logger.warning.assert_called_once() + assert "klingon" in mock_logger.warning.call_args[0][0] + + def test_language_none(self): + """None language is left as None.""" + svc = _TestSTTService(settings=_TestSTTService.Settings(language=None)) + assert svc._settings.language is None + + +# --------------------------------------------------------------------------- +# TTS runtime update tests +# --------------------------------------------------------------------------- + + +class TestTTSLanguageUpdate: + """Test language resolution during runtime settings updates.""" + + @pytest.mark.asyncio + async def test_update_language_enum_base_code(self): + """Updating with Language.EN resolves to 'en'.""" + svc = _TestTTSService(settings=_TestTTSService.Settings(language=None)) + await svc._update_settings(_TestTTSService.Settings(language=Language.EN)) + assert svc._settings.language == "en" + + @pytest.mark.asyncio + async def test_update_language_enum_regional_code(self): + """Updating with Language.DE_DE falls back to base code 'de'.""" + svc = _TestTTSService(settings=_TestTTSService.Settings(language=None)) + await svc._update_settings(_TestTTSService.Settings(language=Language.DE_DE)) + assert svc._settings.language == "de" + + @pytest.mark.asyncio + async def test_update_raw_string_base_code(self): + """Updating with raw string 'de' resolves to 'de'.""" + svc = _TestTTSService(settings=_TestTTSService.Settings(language=None)) + await svc._update_settings(_TestTTSService.Settings(language="de")) + assert svc._settings.language == "de" + + @pytest.mark.asyncio + async def test_update_raw_string_regional_code(self): + """Updating with raw string 'de-DE' resolves to 'de'.""" + svc = _TestTTSService(settings=_TestTTSService.Settings(language=None)) + await svc._update_settings(_TestTTSService.Settings(language="de-DE")) + assert svc._settings.language == "de" + + @pytest.mark.asyncio + async def test_update_raw_string_unrecognized(self): + """Updating with unrecognized string logs warning and passes through.""" + svc = _TestTTSService(settings=_TestTTSService.Settings(language=None)) + with patch("pipecat.services.tts_service.logger") as mock_logger: + await svc._update_settings(_TestTTSService.Settings(language="klingon")) + assert svc._settings.language == "klingon" + mock_logger.warning.assert_called_once() + assert "klingon" in mock_logger.warning.call_args[0][0] + + +# --------------------------------------------------------------------------- +# STT runtime update tests +# --------------------------------------------------------------------------- + + +class TestSTTLanguageUpdate: + """Test language resolution during runtime settings updates.""" + + @pytest.mark.asyncio + async def test_update_language_enum_base_code(self): + """Updating with Language.EN resolves to 'en'.""" + svc = _TestSTTService(settings=_TestSTTService.Settings(language=None)) + await svc._update_settings(_TestSTTService.Settings(language=Language.EN)) + assert svc._settings.language == "en" + + @pytest.mark.asyncio + async def test_update_language_enum_regional_code(self): + """Updating with Language.FR_FR falls back to base code 'fr'.""" + svc = _TestSTTService(settings=_TestSTTService.Settings(language=None)) + await svc._update_settings(_TestSTTService.Settings(language=Language.FR_FR)) + assert svc._settings.language == "fr" + + @pytest.mark.asyncio + async def test_update_raw_string_base_code(self): + """Updating with raw string 'fr' resolves to 'fr'.""" + svc = _TestSTTService(settings=_TestSTTService.Settings(language=None)) + await svc._update_settings(_TestSTTService.Settings(language="fr")) + assert svc._settings.language == "fr" + + @pytest.mark.asyncio + async def test_update_raw_string_regional_code(self): + """Updating with raw string 'fr-FR' resolves to 'fr'.""" + svc = _TestSTTService(settings=_TestSTTService.Settings(language=None)) + await svc._update_settings(_TestSTTService.Settings(language="fr-FR")) + assert svc._settings.language == "fr" + + @pytest.mark.asyncio + async def test_update_raw_string_unrecognized(self): + """Updating with unrecognized string logs warning and passes through.""" + svc = _TestSTTService(settings=_TestSTTService.Settings(language=None)) + with patch("pipecat.services.stt_service.logger") as mock_logger: + await svc._update_settings(_TestSTTService.Settings(language="klingon")) + assert svc._settings.language == "klingon" + mock_logger.warning.assert_called_once() + assert "klingon" in mock_logger.warning.call_args[0][0] diff --git a/tests/test_service_switcher.py b/tests/test_service_switcher.py index eef85e761..4cdf38f1a 100644 --- a/tests/test_service_switcher.py +++ b/tests/test_service_switcher.py @@ -6,17 +6,27 @@ """Unit tests for ServiceSwitcher and related components.""" +import asyncio import unittest from dataclasses import dataclass from pipecat.frames.frames import ( + ErrorFrame, Frame, ManuallySwitchServiceFrame, + ServiceMetadataFrame, + ServiceSwitcherRequestMetadataFrame, + StartFrame, SystemFrame, TextFrame, ) from pipecat.pipeline.pipeline import Pipeline -from pipecat.pipeline.service_switcher import ServiceSwitcher, ServiceSwitcherStrategyManual +from pipecat.pipeline.service_switcher import ( + ServiceSwitcher, + ServiceSwitcherStrategy, + ServiceSwitcherStrategyFailover, + ServiceSwitcherStrategyManual, +) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.tests.utils import run_test @@ -54,6 +64,47 @@ class MockFrameProcessor(FrameProcessor): self.frame_count = 0 +@dataclass +class MockMetadataFrame(ServiceMetadataFrame): + """A mock metadata frame for testing ServiceMetadataFrame handling.""" + + pass + + +class MockMetadataService(FrameProcessor): + """A mock service that emits ServiceMetadataFrame like STT services. + + Pushes MockMetadataFrame on StartFrame and ServiceSwitcherRequestMetadataFrame. + """ + + def __init__(self, test_name: str, **kwargs): + super().__init__(name=test_name, **kwargs) + self.test_name = test_name + self.processed_frames = [] + self.metadata_push_count = 0 + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + self.processed_frames.append(frame) + + if isinstance(frame, StartFrame): + await self.push_frame(frame, direction) + await self._push_metadata() + elif isinstance(frame, ServiceSwitcherRequestMetadataFrame): + await self._push_metadata() + await self.push_frame(frame, direction) + else: + await self.push_frame(frame, direction) + + async def _push_metadata(self): + self.metadata_push_count += 1 + await self.push_frame(MockMetadataFrame(service_name=self.test_name)) + + def reset_counters(self): + self.processed_frames = [] + self.metadata_push_count = 0 + + @dataclass class DummySystemFrame(SystemFrame): """A dummy system frame for testing purposes.""" @@ -61,6 +112,52 @@ class DummySystemFrame(SystemFrame): text: str = "" +class TestServiceSwitcherStrategy(unittest.IsolatedAsyncioTestCase): + """Test cases for the base ServiceSwitcherStrategy.""" + + def setUp(self): + """Set up test fixtures.""" + self.service1 = MockFrameProcessor("service1") + self.service2 = MockFrameProcessor("service2") + self.service3 = MockFrameProcessor("service3") + self.services = [self.service1, self.service2, self.service3] + + def test_init_with_services(self): + """Test initialization with a list of services.""" + strategy = ServiceSwitcherStrategy(self.services) + + self.assertEqual(strategy.services, self.services) + self.assertEqual(strategy.active_service, self.service1) + + async def test_handle_frame_returns_none_for_manual_switch(self): + """Test that base strategy does not handle ManuallySwitchServiceFrame.""" + strategy = ServiceSwitcherStrategy(self.services) + + switch_frame = ManuallySwitchServiceFrame(service=self.service2) + result = await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM) + + self.assertIsNone(result) + self.assertEqual(strategy.active_service, self.service1) + + async def test_handle_frame_returns_none_for_unsupported_frame(self): + """Test that unsupported frame types return None.""" + strategy = ServiceSwitcherStrategy(self.services) + unsupported_frame = TextFrame(text="test") + + result = await strategy.handle_frame(unsupported_frame, FrameDirection.DOWNSTREAM) + + self.assertIsNone(result) + + async def test_handle_error_returns_none(self): + """Test that handle_error returns None by default.""" + strategy = ServiceSwitcherStrategy(self.services) + + result = await strategy.handle_error(ErrorFrame(error="error")) + + self.assertIsNone(result) + self.assertEqual(strategy.active_service, self.service1) + + class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase): """Test cases for ServiceSwitcherStrategyManual.""" @@ -71,53 +168,64 @@ class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase): self.service3 = MockFrameProcessor("service3") self.services = [self.service1, self.service2, self.service3] - def test_init_with_services(self): - """Test initialization with a list of services.""" + def test_is_subclass_of_base_strategy(self): + """Test that ServiceSwitcherStrategyManual is a subclass of ServiceSwitcherStrategy.""" strategy = ServiceSwitcherStrategyManual(self.services) + self.assertIsInstance(strategy, ServiceSwitcherStrategy) - self.assertEqual(strategy.services, self.services) - self.assertEqual(strategy.active_service, self.service1) # First service should be active - - def test_init_with_empty_services(self): - """Test initialization with an empty list of services.""" - strategy = ServiceSwitcherStrategyManual([]) - - self.assertEqual(strategy.services, []) - self.assertIsNone(strategy.active_service) - - def test_handle_manually_switch_service_frame(self): + async def test_handle_manually_switch_service_frame(self): """Test manual service switching with ManuallySwitchServiceFrame.""" strategy = ServiceSwitcherStrategyManual(self.services) # Initially service1 should be active self.assertEqual(strategy.active_service, self.service1) - self.assertNotEqual(strategy.active_service, self.service2) # Switch to service2 switch_frame = ManuallySwitchServiceFrame(service=self.service2) - strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM) - - self.assertNotEqual(strategy.active_service, self.service1) + await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM) self.assertEqual(strategy.active_service, self.service2) - self.assertNotEqual(strategy.active_service, self.service3) # Switch to service3 switch_frame = ManuallySwitchServiceFrame(service=self.service3) - strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM) - - self.assertNotEqual(strategy.active_service, self.service1) - self.assertNotEqual(strategy.active_service, self.service2) + await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM) self.assertEqual(strategy.active_service, self.service3) - def test_handle_frame_unsupported_frame_type(self): - """Test that unsupported frame types raise an error.""" + async def test_on_service_switched_event(self): + """Test that on_service_switched event fires with correct arguments.""" strategy = ServiceSwitcherStrategyManual(self.services) - unsupported_frame = TextFrame(text="test") # Not a ServiceSwitcherFrame - with self.assertRaises(ValueError) as context: - strategy.handle_frame(unsupported_frame, FrameDirection.DOWNSTREAM) + switched_events = [] - self.assertIn("Unsupported frame type", str(context.exception)) + @strategy.event_handler("on_service_switched") + async def on_service_switched(strategy, service): + switched_events.append((strategy, service)) + + switch_frame = ManuallySwitchServiceFrame(service=self.service2) + await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM) + await asyncio.sleep(0) + + self.assertEqual(len(switched_events), 1) + self.assertIsInstance(switched_events[0][0], ServiceSwitcherStrategyManual) + self.assertEqual(switched_events[0][1], self.service2) + + async def test_unknown_service_ignored(self): + """Test that switching to an unknown service is ignored.""" + strategy = ServiceSwitcherStrategyManual(self.services) + + switched_events = [] + + @strategy.event_handler("on_service_switched") + async def on_service_switched(strategy, service): + switched_events.append(service) + + unknown_service = MockFrameProcessor("unknown") + switch_frame = ManuallySwitchServiceFrame(service=unknown_service) + result = await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM) + await asyncio.sleep(0) + + self.assertIsNone(result) + self.assertEqual(len(switched_events), 0) + self.assertEqual(strategy.active_service, self.service1) class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase): @@ -130,9 +238,9 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase): self.service3 = MockFrameProcessor("service3") self.services = [self.service1, self.service2, self.service3] - def test_init_with_manual_strategy(self): - """Test initialization with manual strategy.""" - switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual) + def test_init_with_default_strategy(self): + """Test initialization with default strategy.""" + switcher = ServiceSwitcher(self.services) self.assertEqual(switcher.services, self.services) self.assertIsInstance(switcher.strategy, ServiceSwitcherStrategyManual) @@ -140,7 +248,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase): async def test_default_active_service(self): """Test that the initially-active service receives frames while others don't.""" - switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual) + switcher = ServiceSwitcher(self.services) # Reset counters for service in self.services: @@ -209,7 +317,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase): async def test_service_switching(self): """Test that after service switching using ManuallySwitchServiceFrame, the new active service receives frames while others don't.""" - switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual) + switcher = ServiceSwitcher(self.services) # Reset counters for service in self.services: @@ -223,7 +331,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase): ManuallySwitchServiceFrame(service=self.service2), TextFrame("Hello 2"), ], - expected_down_frames=[TextFrame, ManuallySwitchServiceFrame, TextFrame], + expected_down_frames=[TextFrame, TextFrame], expected_up_frames=[], # Expect no error frames ) @@ -258,8 +366,8 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase): switcher2_services = [switcher2_service1, switcher2_service2] # Create two service switchers - switcher1 = ServiceSwitcher(switcher1_services, ServiceSwitcherStrategyManual) - switcher2 = ServiceSwitcher(switcher2_services, ServiceSwitcherStrategyManual) + switcher1 = ServiceSwitcher(switcher1_services) + switcher2 = ServiceSwitcher(switcher2_services) # Create a pipeline with both switchers: switcher1 -> switcher2 pipeline = Pipeline([switcher1, switcher2]) @@ -289,9 +397,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase): ], expected_down_frames=[ TextFrame, - ManuallySwitchServiceFrame, TextFrame, - ManuallySwitchServiceFrame, TextFrame, ], expected_up_frames=[], # Expect no error frames @@ -336,5 +442,160 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase): self.assertEqual(switcher2_service2_texts[0].text, "After switching second switcher") +class TestServiceSwitcherMetadata(unittest.IsolatedAsyncioTestCase): + """Test cases for ServiceMetadataFrame handling in ServiceSwitcher.""" + + def setUp(self): + """Set up test fixtures with mock metadata services.""" + self.service1 = MockMetadataService("service1") + self.service2 = MockMetadataService("service2") + self.services = [self.service1, self.service2] + + async def test_only_active_service_metadata_at_startup(self): + """Test that only the active service's metadata leaves the ServiceSwitcher at startup.""" + switcher = ServiceSwitcher(self.services) + + # Run the pipeline (StartFrame triggers metadata emission) + output_frames = [] + + async def capture_frame(frame: Frame): + output_frames.append(frame) + + await run_test( + switcher, + frames_to_send=[TextFrame(text="test")], + expected_down_frames=[MockMetadataFrame, TextFrame], + expected_up_frames=[], + ) + + # Both services push metadata internally on StartFrame, but only the + # active service's metadata passes through the filter + self.assertEqual(self.service1.metadata_push_count, 1) # StartFrame (passes filter) + self.assertEqual(self.service2.metadata_push_count, 1) # StartFrame (blocked by filter) + + async def test_metadata_emitted_on_service_switch(self): + """Test that switching services triggers metadata emission from the new active service.""" + switcher = ServiceSwitcher(self.services) + + # Reset counters after startup + self.service1.reset_counters() + self.service2.reset_counters() + + await run_test( + switcher, + frames_to_send=[ + TextFrame(text="before switch"), + ManuallySwitchServiceFrame(service=self.service2), + TextFrame(text="after switch"), + ], + expected_down_frames=[ + MockMetadataFrame, # From startup (service1) + TextFrame, + MockMetadataFrame, # From service2 after switch + TextFrame, + ], + expected_up_frames=[], + ) + + # service2 should have received ServiceSwitcherRequestMetadataFrame after becoming active + request_frames = [ + f + for f in self.service2.processed_frames + if isinstance(f, ServiceSwitcherRequestMetadataFrame) + ] + self.assertEqual(len(request_frames), 1) + + async def test_inactive_service_metadata_blocked(self): + """Test that metadata from inactive services is blocked.""" + switcher = ServiceSwitcher(self.services) + + # Run and collect output frames + await run_test( + switcher, + frames_to_send=[TextFrame(text="test")], + expected_down_frames=[MockMetadataFrame, TextFrame], + expected_up_frames=[], + ) + + # service2 pushed metadata on StartFrame, but it should have been blocked + self.assertGreaterEqual(self.service2.metadata_push_count, 1) + # Only one MockMetadataFrame should have left (from service1) + + +class TestServiceSwitcherStrategyFailover(unittest.IsolatedAsyncioTestCase): + """Test cases for ServiceSwitcherStrategyFailover.""" + + def setUp(self): + """Set up test fixtures.""" + self.service1 = MockFrameProcessor("service1") + self.service2 = MockFrameProcessor("service2") + self.service3 = MockFrameProcessor("service3") + self.services = [self.service1, self.service2, self.service3] + + def test_init_defaults(self): + """Test that default values are set correctly.""" + strategy = ServiceSwitcherStrategyFailover(self.services) + self.assertEqual(strategy.active_service, self.service1) + + async def test_error_switches_to_next_service(self): + """Test that an error on the active service switches to the next one.""" + strategy = ServiceSwitcherStrategyFailover(self.services) + + error = ErrorFrame(error="connection lost") + result = await strategy.handle_error(error) + + self.assertEqual(result, self.service2) + self.assertEqual(strategy.active_service, self.service2) + + async def test_consecutive_errors_cycle_through_services(self): + """Test that repeated errors cycle through all services.""" + strategy = ServiceSwitcherStrategyFailover(self.services) + + # First error: service1 -> service2 + await strategy.handle_error(ErrorFrame(error="error 1")) + self.assertEqual(strategy.active_service, self.service2) + + # Second error: service2 -> service3 + await strategy.handle_error(ErrorFrame(error="error 2")) + self.assertEqual(strategy.active_service, self.service3) + + # Third error: service3 -> service1 (wraps around) + await strategy.handle_error(ErrorFrame(error="error 3")) + self.assertEqual(strategy.active_service, self.service1) + + async def test_single_service_returns_none(self): + """Test that handle_error returns None with only one service.""" + strategy = ServiceSwitcherStrategyFailover([self.service1]) + + result = await strategy.handle_error(ErrorFrame(error="error")) + self.assertIsNone(result) + + async def test_manual_switch_still_works(self): + """Test that ManuallySwitchServiceFrame is still handled.""" + strategy = ServiceSwitcherStrategyFailover(self.services) + + frame = ManuallySwitchServiceFrame(service=self.service3) + result = await strategy.handle_frame(frame, FrameDirection.DOWNSTREAM) + + self.assertEqual(result, self.service3) + self.assertEqual(strategy.active_service, self.service3) + + async def test_on_service_switched_event_fires_on_error(self): + """Test that on_service_switched event fires when an error triggers a switch.""" + strategy = ServiceSwitcherStrategyFailover(self.services) + + switched_events = [] + + @strategy.event_handler("on_service_switched") + async def on_service_switched(strategy, service): + switched_events.append(service) + + await strategy.handle_error(ErrorFrame(error="error")) + await asyncio.sleep(0) + + self.assertEqual(len(switched_events), 1) + self.assertEqual(switched_events[0], self.service2) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 000000000..8ffe355ad --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,983 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Tests for the typed settings infrastructure in pipecat.services.settings.""" + +from unittest.mock import patch + +from pipecat.services.deepgram.stt import DeepgramSTTService, DeepgramSTTSettings +from pipecat.services.deepgram.stt_sagemaker import DeepgramSageMakerSTTSettings +from pipecat.services.grok.realtime import events as grok_events +from pipecat.services.grok.realtime.llm import GrokRealtimeLLMSettings +from pipecat.services.openai.realtime import events +from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMSettings +from pipecat.services.settings import ( + NOT_GIVEN, + LLMSettings, + ServiceSettings, + STTSettings, + TTSSettings, + _NotGiven, + is_given, +) + +# --------------------------------------------------------------------------- +# NOT_GIVEN sentinel +# --------------------------------------------------------------------------- + + +class TestNotGiven: + def test_singleton(self): + """NOT_GIVEN is a singleton — every reference is the same object.""" + assert _NotGiven() is _NotGiven() + assert NOT_GIVEN is _NotGiven() + + def test_repr(self): + assert repr(NOT_GIVEN) == "NOT_GIVEN" + + def test_bool_is_false(self): + assert not NOT_GIVEN + assert bool(NOT_GIVEN) is False + + def test_is_given_with_not_given(self): + assert is_given(NOT_GIVEN) is False + + def test_is_given_with_none(self): + assert is_given(None) is True + + def test_is_given_with_values(self): + assert is_given(0) is True + assert is_given("") is True + assert is_given(False) is True + assert is_given(42) is True + assert is_given("hello") is True + + +# --------------------------------------------------------------------------- +# ServiceSettings base +# --------------------------------------------------------------------------- + + +class TestServiceSettings: + def test_default_fields_are_not_given(self): + s = ServiceSettings() + assert not is_given(s.model) + assert s.extra == {} + + def test_given_fields_empty_by_default(self): + s = ServiceSettings() + assert s.given_fields() == {} + + def test_given_fields_includes_set_values(self): + s = ServiceSettings(model="gpt-4o") + assert s.given_fields() == {"model": "gpt-4o"} + + def test_given_fields_includes_extra(self): + s = ServiceSettings(model="gpt-4o") + s.extra = {"custom_key": 42} + result = s.given_fields() + assert result == {"model": "gpt-4o", "custom_key": 42} + + def test_copy_is_deep(self): + s = ServiceSettings(model="gpt-4o") + s.extra = {"nested": {"a": 1}} + c = s.copy() + assert c.model == "gpt-4o" + assert c.extra == {"nested": {"a": 1}} + # Mutating the copy shouldn't affect the original + c.extra["nested"]["a"] = 999 + assert s.extra["nested"]["a"] == 1 + + +# --------------------------------------------------------------------------- +# apply_update +# --------------------------------------------------------------------------- + + +class TestApplyUpdate: + def test_apply_update_basic(self): + current = TTSSettings(voice="alice", language="en") + delta = TTSSettings(voice="bob") + changed = current.apply_update(delta) + assert changed.keys() == {"voice"} + assert changed["voice"] == "alice" # old value + assert current.voice == "bob" + assert current.language == "en" + + def test_apply_update_no_change(self): + current = TTSSettings(voice="alice", language="en") + delta = TTSSettings(voice="alice") + changed = current.apply_update(delta) + assert changed == {} + assert current.voice == "alice" + + def test_apply_update_not_given_skipped(self): + current = TTSSettings(voice="alice", language="en") + delta = TTSSettings() # all NOT_GIVEN + changed = current.apply_update(delta) + assert changed == {} + assert current.voice == "alice" + assert current.language == "en" + + def test_apply_update_multiple_fields(self): + current = LLMSettings(temperature=0.7, max_tokens=100) + delta = LLMSettings(temperature=0.9, max_tokens=200, top_p=0.95) + changed = current.apply_update(delta) + assert changed.keys() == {"temperature", "max_tokens", "top_p"} + assert changed["temperature"] == 0.7 + assert changed["max_tokens"] == 100 + assert current.temperature == 0.9 + assert current.max_tokens == 200 + assert current.top_p == 0.95 + + def test_apply_update_extra_merged(self): + current = TTSSettings(voice="alice") + current.extra = {"speed": 1.0, "stability": 0.5} + delta = TTSSettings() + delta.extra = {"speed": 1.2} + changed = current.apply_update(delta) + assert "speed" in changed + assert changed["speed"] == 1.0 # old value + assert current.extra == {"speed": 1.2, "stability": 0.5} + + def test_apply_update_extra_no_change(self): + current = TTSSettings(voice="alice") + current.extra = {"speed": 1.0} + delta = TTSSettings() + delta.extra = {"speed": 1.0} + changed = current.apply_update(delta) + assert changed == {} + + def test_apply_update_model_field(self): + current = ServiceSettings(model="old-model") + delta = ServiceSettings(model="new-model") + changed = current.apply_update(delta) + assert changed.keys() == {"model"} + assert changed["model"] == "old-model" + assert current.model == "new-model" + + def test_apply_update_none_is_a_valid_value(self): + """Setting a field to None should be treated as a change from NOT_GIVEN.""" + current = TTSSettings() + delta = TTSSettings(language=None) + changed = current.apply_update(delta) + assert "language" in changed + assert current.language is None + + def test_apply_update_none_to_value(self): + current = TTSSettings(language=None) + delta = TTSSettings(language="en") + changed = current.apply_update(delta) + assert "language" in changed + assert changed["language"] is None # old value was None + assert current.language == "en" + + +# --------------------------------------------------------------------------- +# from_mapping +# --------------------------------------------------------------------------- + + +class TestFromMapping: + def test_basic_mapping(self): + s = TTSSettings.from_mapping({"voice": "alice", "language": "en"}) + assert s.voice == "alice" + assert s.language == "en" + assert not is_given(s.model) + + def test_alias_resolution(self): + """'voice_id' is an alias for 'voice' in TTSSettings.""" + s = TTSSettings.from_mapping({"voice_id": "alice"}) + assert s.voice == "alice" + + def test_unknown_keys_go_to_extra(self): + s = TTSSettings.from_mapping({"voice": "alice", "speed": 1.2, "stability": 0.5}) + assert s.voice == "alice" + assert s.extra == {"speed": 1.2, "stability": 0.5} + + def test_model_field(self): + s = LLMSettings.from_mapping({"model": "gpt-4o", "temperature": 0.7}) + assert s.model == "gpt-4o" + assert s.temperature == 0.7 + + def test_empty_mapping(self): + s = ServiceSettings.from_mapping({}) + assert s.given_fields() == {} + + def test_all_unknown_keys(self): + s = ServiceSettings.from_mapping({"foo": 1, "bar": 2}) + assert not is_given(s.model) + assert s.extra == {"foo": 1, "bar": 2} + + def test_llm_settings_from_mapping(self): + s = LLMSettings.from_mapping({"temperature": 0.5, "max_tokens": 1000, "custom_param": True}) + assert s.temperature == 0.5 + assert s.max_tokens == 1000 + assert s.extra == {"custom_param": True} + + def test_stt_settings_from_mapping(self): + s = STTSettings.from_mapping({"language": "fr", "model": "whisper-large"}) + assert s.language == "fr" + assert s.model == "whisper-large" + + +# --------------------------------------------------------------------------- +# LLMSettings specifics +# --------------------------------------------------------------------------- + + +class TestLLMSettings: + def test_all_fields_not_given_by_default(self): + s = LLMSettings() + for name in ( + "model", + "temperature", + "max_tokens", + "top_p", + "top_k", + "frequency_penalty", + "presence_penalty", + "seed", + ): + assert not is_given(getattr(s, name)), f"{name} should be NOT_GIVEN" + + def test_given_fields(self): + s = LLMSettings(temperature=0.7, seed=42) + assert s.given_fields() == {"temperature": 0.7, "seed": 42} + + +# --------------------------------------------------------------------------- +# TTSSettings specifics +# --------------------------------------------------------------------------- + + +class TestTTSSettings: + def test_all_fields_not_given_by_default(self): + s = TTSSettings() + for name in ("model", "voice", "language"): + assert not is_given(getattr(s, name)), f"{name} should be NOT_GIVEN" + + def test_aliases_class_var(self): + assert TTSSettings._aliases == {"voice_id": "voice"} + + def test_given_fields(self): + s = TTSSettings(voice="alice") + assert s.given_fields() == {"voice": "alice"} + + +# --------------------------------------------------------------------------- +# STTSettings specifics +# --------------------------------------------------------------------------- + + +class TestSTTSettings: + def test_all_fields_not_given_by_default(self): + s = STTSettings() + for name in ("model", "language"): + assert not is_given(getattr(s, name)), f"{name} should be NOT_GIVEN" + + def test_given_fields(self): + s = STTSettings(language="en", model="whisper-large") + assert s.given_fields() == {"language": "en", "model": "whisper-large"} + + +# --------------------------------------------------------------------------- +# Integration: roundtrip from_mapping → apply_update +# --------------------------------------------------------------------------- + + +class TestRoundtrip: + def test_from_mapping_then_apply_update(self): + """Simulate the real flow: dict arrives via frame, gets converted, applied.""" + # Simulating current service state + current = TTSSettings(model="eleven_turbo_v2_5", voice="alice", language="en") + current.extra = {"stability": 0.5, "speed": 1.0} + + # Incoming dict-based update + raw = {"voice_id": "bob", "speed": 1.2} + delta = TTSSettings.from_mapping(raw) + + changed = current.apply_update(delta) + assert changed.keys() == {"voice", "speed"} + assert changed["voice"] == "alice" + assert changed["speed"] == 1.0 + assert current.voice == "bob" + assert current.language == "en" + assert current.extra["speed"] == 1.2 + assert current.extra["stability"] == 0.5 + + def test_from_mapping_preserves_model(self): + current = LLMSettings(model="gpt-4o", temperature=0.7) + delta = LLMSettings.from_mapping({"model": "gpt-4o-mini", "temperature": 0.9}) + changed = current.apply_update(delta) + assert changed.keys() == {"model", "temperature"} + assert changed["model"] == "gpt-4o" + assert current.model == "gpt-4o-mini" + assert current.temperature == 0.9 + + +# --------------------------------------------------------------------------- +# DeepgramSTTSettings: flat field apply_update +# --------------------------------------------------------------------------- + + +class TestDeepgramSTTSettingsApplyUpdate: + def _make_store(self, **kwargs) -> DeepgramSTTSettings: + """Helper to build a store-mode DeepgramSTTSettings.""" + defaults = dict( + model="nova-3-general", + language="en", + interim_results=True, + smart_format=False, + punctuate=True, + profanity_filter=True, + vad_events=False, + ) + defaults.update(kwargs) + return DeepgramSTTSettings(**defaults) + + def test_apply_update_merges_flat_fields_as_delta(self): + """Only the given fields in the delta are merged.""" + current = self._make_store() + assert current.punctuate is True + + delta = DeepgramSTTSettings(punctuate=False) + changed = current.apply_update(delta) + + assert current.punctuate is False + assert "punctuate" in changed + # Other fields are untouched + assert current.model == "nova-3-general" + assert current.language == "en" + + def test_apply_update_model(self): + """model field is updated directly.""" + current = self._make_store() + assert current.model == "nova-3-general" + + delta = DeepgramSTTSettings(model="nova-2") + changed = current.apply_update(delta) + + assert current.model == "nova-2" + assert "model" in changed + + def test_apply_update_language(self): + """language field is updated directly.""" + current = self._make_store() + assert current.language == "en" + + delta = DeepgramSTTSettings(language="es") + changed = current.apply_update(delta) + + assert current.language == "es" + assert "language" in changed + + def test_apply_update_no_change(self): + """Delta with same values should report no changes.""" + current = self._make_store() + delta = DeepgramSTTSettings(punctuate=True) + changed = current.apply_update(delta) + assert changed == {} + + def test_apply_update_multiple_fields(self): + """Multiple flat fields updated at once.""" + current = self._make_store() + + delta = DeepgramSTTSettings(model="nova-2", language="fr", punctuate=False) + changed = current.apply_update(delta) + + assert current.model == "nova-2" + assert current.language == "fr" + assert current.punctuate is False + assert changed.keys() == {"model", "language", "punctuate"} + + +class TestDeepgramSTTSettingsFromMapping: + def test_known_flat_fields_mapped_directly(self): + """Deepgram field names map directly to flat settings fields.""" + delta = DeepgramSTTSettings.from_mapping({"punctuate": False, "diarize": True}) + assert delta.punctuate is False + assert delta.diarize is True + + def test_model_and_language_top_level(self): + """model and language are top-level fields.""" + delta = DeepgramSTTSettings.from_mapping({"model": "nova-2", "language": "es"}) + assert delta.model == "nova-2" + assert delta.language == "es" + + def test_unknown_keys_go_to_extra(self): + """Keys that aren't declared fields go to extra.""" + delta = DeepgramSTTSettings.from_mapping({"unknown_param": 42}) + assert delta.extra == {"unknown_param": 42} + + def test_mixed_keys(self): + """model + known Deepgram fields + unknown keys are routed correctly.""" + delta = DeepgramSTTSettings.from_mapping( + {"model": "nova-2", "punctuate": False, "unknown": "val"} + ) + assert delta.model == "nova-2" + assert delta.punctuate is False + assert delta.extra == {"unknown": "val"} + + def test_roundtrip_from_mapping_apply_update(self): + """Simulate dict-style update: from_mapping -> apply_update.""" + current = DeepgramSTTSettings( + model="nova-3-general", + language="en", + interim_results=True, + punctuate=True, + profanity_filter=True, + vad_events=False, + ) + + raw = {"punctuate": False, "diarize": True} + delta = DeepgramSTTSettings.from_mapping(raw) + changed = current.apply_update(delta) + + assert current.punctuate is False + assert current.diarize is True + # Unchanged fields stay put + assert current.model == "nova-3-general" + assert "punctuate" in changed + + def test_roundtrip_model_via_dict(self): + """Dict update with model should change top-level model field.""" + current = DeepgramSTTSettings( + model="nova-3-general", + language="en", + ) + + raw = {"model": "nova-2"} + delta = DeepgramSTTSettings.from_mapping(raw) + changed = current.apply_update(delta) + + assert current.model == "nova-2" + assert "model" in changed + + +# --------------------------------------------------------------------------- +# DeepgramSageMakerSTTSettings: smoke test that flat base is inherited +# --------------------------------------------------------------------------- + + +class TestDeepgramSageMakerSTTSettings: + def test_inherits_flat_settings_behavior(self): + """Smoke test: SageMaker settings inherit the flat base correctly.""" + store = DeepgramSageMakerSTTSettings( + model="nova-3", + language="en", + ) + delta = DeepgramSageMakerSTTSettings(model="nova-2") + changed = store.apply_update(delta) + + assert store.model == "nova-2" + assert store.language == "en" + assert "model" in changed + + +# --------------------------------------------------------------------------- +# DeepgramSTTService: settings initialization with extra syncing +# --------------------------------------------------------------------------- + + +class TestDeepgramSTTSettingsExtraSync: + """Test that settings.extra values are synced to declared fields at init time.""" + + def _make_service(self, **kwargs): + with patch("pipecat.services.deepgram.stt.AsyncDeepgramClient"): + return DeepgramSTTService(api_key="test-key", sample_rate=16000, **kwargs) + + def test_extra_synced_to_declared_field_at_init(self): + """LiveOptions params that match declared fields are synced at init.""" + from pipecat.services.deepgram.stt import LiveOptions + + live_options = LiveOptions(numerals=True) + + svc = self._make_service(live_options=live_options) + + # 'numerals' is a declared DeepgramSTTSettings field, + # so it should be promoted from extra to the declared field + assert svc._settings.numerals is True + assert "numerals" not in svc._settings.extra + + def test_declared_field_from_live_options(self): + """LiveOptions fields that match DeepgramSTTSettings fields are applied.""" + from pipecat.services.deepgram.stt import LiveOptions + + live_options = LiveOptions( + punctuate=False, + diarize=True, + ) + + svc = self._make_service(live_options=live_options) + + # These should be in the declared fields + assert svc._settings.punctuate is False + assert svc._settings.diarize is True + + def test_sync_after_from_mapping_with_extra(self): + """If we use from_mapping with keys matching declared fields, they sync.""" + # Simulate a dict-style update with both declared and undeclared keys + raw_dict = { + "diarize": True, # matches declared field + "punctuate": False, # matches declared field + "custom_param": "value", # doesn't match - stays in extra + } + + delta = DeepgramSTTSettings.from_mapping(raw_dict) + + # After from_mapping, declared fields should be set + assert delta.diarize is True + assert delta.punctuate is False + # Unknown stays in extra + assert delta.extra["custom_param"] == "value" + + # Now simulate syncing (though from_mapping already routes correctly) + delta._sync_extra_to_fields() + + # Still the same - from_mapping already put them in the right place + assert delta.diarize is True + assert delta.punctuate is False + assert delta.extra["custom_param"] == "value" + + def test_sync_promotes_extra_to_field_when_not_given(self): + """_sync_extra_to_fields promotes extra dict entries to declared fields.""" + settings = DeepgramSTTSettings() + # Manually populate extra with a key matching a declared field + settings.extra = {"diarize": True, "punctuate": False, "unknown": "value"} + + # Before sync, fields are NOT_GIVEN + assert not is_given(settings.diarize) + assert not is_given(settings.punctuate) + + # Sync it + settings._sync_extra_to_fields() + + # Now the matching fields should be promoted + assert settings.diarize is True + assert settings.punctuate is False + # And removed from extra + assert "diarize" not in settings.extra + assert "punctuate" not in settings.extra + # Unknown stays + assert settings.extra["unknown"] == "value" + + def test_sync_doesnt_overwrite_already_set_field(self): + """If a field is already set, extra shouldn't overwrite it.""" + settings = DeepgramSTTSettings(punctuate=True) + # Try to put a different value in extra + settings.extra = {"punctuate": False} + + # Sync + settings._sync_extra_to_fields() + + # The already-set field should win + assert settings.punctuate is True + # extra entry should still be removed to avoid confusion + assert "punctuate" not in settings.extra + + def test_build_connect_kwargs_after_sync(self): + """After syncing, _build_connect_kwargs should use the right values.""" + from pipecat.services.deepgram.stt import LiveOptions + + live_options = LiveOptions( + model="nova-2", + language="es", + punctuate=True, + diarize=False, + ) + + svc = self._make_service(live_options=live_options) + kwargs = svc._build_connect_kwargs() + + # All should appear in connect kwargs + assert kwargs["model"] == "nova-2" + assert kwargs["language"] == "es" + assert kwargs["punctuate"] == "true" + assert kwargs["diarize"] == "false" + + def test_unknown_params_stay_in_extra_and_appear_in_kwargs(self): + """Unknown params (not matching fields) stay in extra and get forwarded.""" + from pipecat.services.deepgram.stt import LiveOptions + + # 'numerals' is now a declared field; 'custom_param' is not + live_options = LiveOptions(numerals=True, custom_param="test") + + svc = self._make_service(live_options=live_options) + + # 'numerals' is a declared field, so it should be promoted + assert svc._settings.numerals is True + # 'custom_param' is unknown, so it stays in extra + assert svc._settings.extra["custom_param"] == "test" + + # Both forwarded to kwargs + kwargs = svc._build_connect_kwargs() + assert kwargs["numerals"] == "true" + assert kwargs["custom_param"] == "test" + + +# --------------------------------------------------------------------------- +# OpenAIRealtimeLLMSettings: apply_update with bidirectional sync +# --------------------------------------------------------------------------- + + +class TestOpenAIRealtimeSettingsApplyUpdate: + def _make_store(self, **kwargs) -> OpenAIRealtimeLLMSettings: + """Helper to build a store-mode OpenAIRealtimeLLMSettings.""" + defaults = dict( + model="gpt-realtime-1.5", + system_instruction=None, + temperature=None, + max_tokens=None, + top_p=None, + top_k=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + session_properties=events.SessionProperties(), + ) + defaults.update(kwargs) + return OpenAIRealtimeLLMSettings(**defaults) + + def test_top_level_model_syncs_to_sp(self): + """Updating top-level model should propagate to session_properties.model.""" + store = self._make_store() + delta = OpenAIRealtimeLLMSettings(model="gpt-realtime-2.0") + changed = store.apply_update(delta) + + assert "model" in changed + assert store.model == "gpt-realtime-2.0" + assert store.session_properties.model == "gpt-realtime-2.0" + + def test_top_level_system_instruction_syncs_to_sp(self): + """Updating top-level system_instruction should propagate to session_properties.instructions.""" + store = self._make_store() + delta = OpenAIRealtimeLLMSettings(system_instruction="Be helpful.") + changed = store.apply_update(delta) + + assert "system_instruction" in changed + assert store.system_instruction == "Be helpful." + assert store.session_properties.instructions == "Be helpful." + + def test_sp_replaces_wholesale(self): + """session_properties in delta replaces the entire stored SP.""" + store = self._make_store( + session_properties=events.SessionProperties( + output_modalities=["audio", "text"], + instructions="Old instructions.", + ), + system_instruction="Old instructions.", + ) + + new_sp = events.SessionProperties(output_modalities=["text"]) + delta = OpenAIRealtimeLLMSettings(session_properties=new_sp) + changed = store.apply_update(delta) + + assert "session_properties" in changed + assert store.session_properties.output_modalities == ["text"] + # Fields not in the new SP become None (wholesale replacement) + # But model is synced from top-level + assert store.session_properties.model == "gpt-realtime-1.5" + + def test_sp_model_syncs_to_top_level(self): + """session_properties.model should sync to top-level model.""" + store = self._make_store() + new_sp = events.SessionProperties(model="gpt-realtime-2.0") + delta = OpenAIRealtimeLLMSettings(session_properties=new_sp) + changed = store.apply_update(delta) + + assert "model" in changed + assert store.model == "gpt-realtime-2.0" + assert store.session_properties.model == "gpt-realtime-2.0" + + def test_sp_instructions_syncs_to_top_level(self): + """session_properties.instructions should sync to top-level system_instruction.""" + store = self._make_store() + new_sp = events.SessionProperties(instructions="New instructions.") + delta = OpenAIRealtimeLLMSettings(session_properties=new_sp) + changed = store.apply_update(delta) + + assert "system_instruction" in changed + assert store.system_instruction == "New instructions." + assert store.session_properties.instructions == "New instructions." + + def test_top_level_model_takes_precedence_over_sp_model(self): + """When both model and session_properties.model are in the delta, top-level wins.""" + store = self._make_store() + new_sp = events.SessionProperties(model="sp-model") + delta = OpenAIRealtimeLLMSettings(model="top-model", session_properties=new_sp) + store.apply_update(delta) + + assert store.model == "top-model" + assert store.session_properties.model == "top-model" + + def test_top_level_si_takes_precedence_over_sp_instructions(self): + """When both system_instruction and SP.instructions are in delta, top-level wins.""" + store = self._make_store() + new_sp = events.SessionProperties(instructions="sp instructions") + delta = OpenAIRealtimeLLMSettings( + system_instruction="top instructions", + session_properties=new_sp, + ) + store.apply_update(delta) + + assert store.system_instruction == "top instructions" + assert store.session_properties.instructions == "top instructions" + + def test_non_synced_field_update_does_not_affect_sp(self): + """Updating a non-synced field like temperature shouldn't touch session_properties.""" + store = self._make_store( + session_properties=events.SessionProperties(instructions="Keep me."), + system_instruction="Keep me.", + ) + original_sp = store.session_properties + + delta = OpenAIRealtimeLLMSettings(temperature=0.5) + changed = store.apply_update(delta) + + assert "temperature" in changed + assert store.temperature == 0.5 + # SP should be untouched (same object) + assert store.session_properties is original_sp + assert store.session_properties.instructions == "Keep me." + + +# --------------------------------------------------------------------------- +# OpenAIRealtimeLLMSettings: from_mapping +# --------------------------------------------------------------------------- + + +class TestOpenAIRealtimeSettingsFromMapping: + def test_sp_keys_route_to_session_properties(self): + """SessionProperties fields (instructions, audio, etc.) route into nested SP.""" + delta = OpenAIRealtimeLLMSettings.from_mapping( + {"instructions": "Be concise.", "output_modalities": ["text"]} + ) + assert is_given(delta.session_properties) + assert delta.session_properties.instructions == "Be concise." + assert delta.session_properties.output_modalities == ["text"] + + def test_model_routes_to_top_level(self): + """model should go to the top-level field, not session_properties.""" + delta = OpenAIRealtimeLLMSettings.from_mapping({"model": "gpt-realtime-2.0"}) + assert delta.model == "gpt-realtime-2.0" + # No session_properties should be created since no SP keys were present + assert not is_given(delta.session_properties) + + def test_unknown_keys_go_to_extra(self): + """Unrecognized keys should land in extra.""" + delta = OpenAIRealtimeLLMSettings.from_mapping({"unknown_param": 42}) + assert not is_given(delta.model) + assert not is_given(delta.session_properties) + assert delta.extra == {"unknown_param": 42} + + def test_mixed_keys(self): + """model + SP keys + unknown keys are routed correctly.""" + delta = OpenAIRealtimeLLMSettings.from_mapping( + { + "model": "gpt-realtime-2.0", + "instructions": "Be helpful.", + "unknown": "val", + } + ) + assert delta.model == "gpt-realtime-2.0" + assert is_given(delta.session_properties) + assert delta.session_properties.instructions == "Be helpful." + assert delta.extra == {"unknown": "val"} + + def test_roundtrip_from_mapping_apply_update(self): + """Simulate dict-style update: from_mapping -> apply_update.""" + store = OpenAIRealtimeLLMSettings( + model="gpt-realtime-1.5", + system_instruction=None, + temperature=None, + max_tokens=None, + top_p=None, + top_k=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + session_properties=events.SessionProperties(), + ) + + raw = {"instructions": "Be concise.", "output_modalities": ["text"]} + delta = OpenAIRealtimeLLMSettings.from_mapping(raw) + changed = store.apply_update(delta) + + assert "session_properties" in changed + assert store.session_properties.instructions == "Be concise." + assert store.session_properties.output_modalities == ["text"] + assert store.system_instruction == "Be concise." + + +# --------------------------------------------------------------------------- +# GrokRealtimeLLMSettings: apply_update +# --------------------------------------------------------------------------- + + +class TestGrokRealtimeSettingsApplyUpdate: + def _make_store(self, **kwargs) -> GrokRealtimeLLMSettings: + """Helper to build a store-mode GrokRealtimeLLMSettings.""" + defaults = dict( + model=None, + system_instruction=None, + temperature=None, + max_tokens=None, + top_p=None, + top_k=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + session_properties=grok_events.SessionProperties(), + ) + defaults.update(kwargs) + return GrokRealtimeLLMSettings(**defaults) + + def test_top_level_system_instruction_syncs_to_sp(self): + """Updating top-level system_instruction should propagate to session_properties.instructions.""" + store = self._make_store() + delta = GrokRealtimeLLMSettings(system_instruction="Be helpful.") + changed = store.apply_update(delta) + + assert "system_instruction" in changed + assert store.system_instruction == "Be helpful." + assert store.session_properties.instructions == "Be helpful." + + def test_sp_replaces_wholesale(self): + """session_properties in delta replaces the entire stored SP.""" + store = self._make_store( + session_properties=grok_events.SessionProperties( + voice="Rex", + instructions="Old instructions.", + ), + system_instruction="Old instructions.", + ) + + new_sp = grok_events.SessionProperties(voice="Sal") + delta = GrokRealtimeLLMSettings(session_properties=new_sp) + changed = store.apply_update(delta) + + assert "session_properties" in changed + assert store.session_properties.voice == "Sal" + # instructions is synced from top-level system_instruction + assert store.session_properties.instructions == "Old instructions." + + def test_sp_instructions_syncs_to_top_level(self): + """session_properties.instructions should sync to top-level system_instruction.""" + store = self._make_store() + new_sp = grok_events.SessionProperties(instructions="New instructions.") + delta = GrokRealtimeLLMSettings(session_properties=new_sp) + changed = store.apply_update(delta) + + assert "system_instruction" in changed + assert store.system_instruction == "New instructions." + assert store.session_properties.instructions == "New instructions." + + def test_top_level_si_takes_precedence_over_sp_instructions(self): + """When both system_instruction and SP.instructions are in delta, top-level wins.""" + store = self._make_store() + new_sp = grok_events.SessionProperties(instructions="sp instructions") + delta = GrokRealtimeLLMSettings( + system_instruction="top instructions", + session_properties=new_sp, + ) + store.apply_update(delta) + + assert store.system_instruction == "top instructions" + assert store.session_properties.instructions == "top instructions" + + def test_non_synced_field_update_does_not_affect_sp(self): + """Updating a non-synced field like temperature shouldn't touch session_properties.""" + store = self._make_store( + session_properties=grok_events.SessionProperties(instructions="Keep me."), + system_instruction="Keep me.", + ) + original_sp = store.session_properties + + delta = GrokRealtimeLLMSettings(temperature=0.5) + changed = store.apply_update(delta) + + assert "temperature" in changed + assert store.temperature == 0.5 + # SP should be untouched (same object) + assert store.session_properties is original_sp + assert store.session_properties.instructions == "Keep me." + + +# --------------------------------------------------------------------------- +# GrokRealtimeLLMSettings: from_mapping +# --------------------------------------------------------------------------- + + +class TestGrokRealtimeSettingsFromMapping: + def test_sp_keys_route_to_session_properties(self): + """SessionProperties fields (instructions, voice, etc.) route into nested SP.""" + delta = GrokRealtimeLLMSettings.from_mapping( + {"instructions": "Be concise.", "voice": "Rex"} + ) + assert is_given(delta.session_properties) + assert delta.session_properties.instructions == "Be concise." + assert delta.session_properties.voice == "Rex" + + def test_model_routes_to_top_level(self): + """model should go to the top-level field, not session_properties.""" + delta = GrokRealtimeLLMSettings.from_mapping({"model": "some-model"}) + assert delta.model == "some-model" + # No session_properties should be created since no SP keys were present + assert not is_given(delta.session_properties) + + def test_unknown_keys_go_to_extra(self): + """Unrecognized keys should land in extra.""" + delta = GrokRealtimeLLMSettings.from_mapping({"unknown_param": 42}) + assert not is_given(delta.model) + assert not is_given(delta.session_properties) + assert delta.extra == {"unknown_param": 42} + + def test_mixed_keys(self): + """model + SP keys + unknown keys are routed correctly.""" + delta = GrokRealtimeLLMSettings.from_mapping( + { + "model": "some-model", + "instructions": "Be helpful.", + "unknown": "val", + } + ) + assert delta.model == "some-model" + assert is_given(delta.session_properties) + assert delta.session_properties.instructions == "Be helpful." + assert delta.extra == {"unknown": "val"} + + def test_roundtrip_from_mapping_apply_update(self): + """Simulate dict-style update: from_mapping -> apply_update.""" + store = GrokRealtimeLLMSettings( + model=None, + system_instruction=None, + temperature=None, + max_tokens=None, + top_p=None, + top_k=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=False, + user_turn_completion_config=None, + session_properties=grok_events.SessionProperties(), + ) + + raw = {"instructions": "Be concise.", "voice": "Eve"} + delta = GrokRealtimeLLMSettings.from_mapping(raw) + changed = store.apply_update(delta) + + assert "session_properties" in changed + assert store.session_properties.instructions == "Be concise." + assert store.session_properties.voice == "Eve" + assert store.system_instruction == "Be concise." diff --git a/tests/test_simple_text_aggregator.py b/tests/test_simple_text_aggregator.py index 6baab4f26..46c77df42 100644 --- a/tests/test_simple_text_aggregator.py +++ b/tests/test_simple_text_aggregator.py @@ -123,3 +123,97 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase): # flush() returns any remaining text (the "W" in this case) result = await self.aggregator.flush() assert result.text == "W" + + async def test_japanese_multiple_sentences(self): + """Test that Japanese sentences are properly split during streaming.""" + text = "こんにちは。元気ですか?" + results = [agg async for agg in self.aggregator.aggregate(text)] + + # First sentence detected when 元 arrives as lookahead after 。 + assert len(results) == 1 + assert results[0].text == "こんにちは。" + + # Flush returns the second sentence + result = await self.aggregator.flush() + assert result.text == "元気ですか?" + + async def test_japanese_sentence_with_lookahead(self): + """Test that a Japanese sentence is detected with a lookahead character.""" + text = "こんにちは。元" + results = [agg async for agg in self.aggregator.aggregate(text)] + + # 。 triggers lookahead, then 元 confirms it + assert len(results) == 1 + assert results[0].text == "こんにちは。" + + # Flush returns remainder + result = await self.aggregator.flush() + assert result.text == "元" + + async def test_chinese_streaming_tokens(self): + """Test Chinese text split across multiple streaming tokens.""" + aggregator = SimpleTextAggregator() + + tokens = ["你好", "世界", "。", "下一", "句话", "。"] + all_results = [] + for token in tokens: + results = [agg async for agg in aggregator.aggregate(token)] + all_results.extend(results) + + # First sentence detected when 下 arrives after 。 + assert len(all_results) == 1 + assert all_results[0].text == "你好世界。" + + # Flush returns the second sentence + result = await aggregator.flush() + assert result.text == "下一句话。" + + async def test_japanese_single_sentence_flush(self): + """Test that a single Japanese sentence with no lookahead flushes correctly.""" + text = "こんにちは。" + results = [agg async for agg in self.aggregator.aggregate(text)] + + # No lookahead yet - waiting + assert len(results) == 0 + + # Flush returns the complete sentence + result = await self.aggregator.flush() + assert result.text == "こんにちは。" + + +class TestSimpleTextAggregatorTokenMode(unittest.IsolatedAsyncioTestCase): + def setUp(self): + from pipecat.utils.text.base_text_aggregator import AggregationType + + self.aggregator = SimpleTextAggregator(aggregation_type=AggregationType.TOKEN) + + async def test_token_passthrough(self): + """TOKEN mode yields text immediately without buffering.""" + results = [agg async for agg in self.aggregator.aggregate("Hello")] + assert len(results) == 1 + assert results[0].text == "Hello" + assert results[0].type == "token" + + async def test_token_multiple_calls(self): + """Each aggregate call yields its text independently.""" + r1 = [agg async for agg in self.aggregator.aggregate("Hello ")] + r2 = [agg async for agg in self.aggregator.aggregate("world.")] + assert len(r1) == 1 + assert r1[0].text == "Hello " + assert len(r2) == 1 + assert r2[0].text == "world." + + async def test_token_empty_text(self): + """Empty text yields nothing.""" + results = [agg async for agg in self.aggregator.aggregate("")] + assert len(results) == 0 + + async def test_token_flush_returns_none(self): + """Flush returns None in TOKEN mode since nothing is buffered.""" + await self.aggregator.aggregate("Hello").__anext__() + result = await self.aggregator.flush() + assert result is None + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_skip_tags_aggregator.py b/tests/test_skip_tags_aggregator.py index 1f550198d..882b26e82 100644 --- a/tests/test_skip_tags_aggregator.py +++ b/tests/test_skip_tags_aggregator.py @@ -62,3 +62,62 @@ class TestSkipTagsAggregator(unittest.IsolatedAsyncioTestCase): self.assertEqual(result.text, text) self.assertEqual(self.aggregator.text.text, "") self.assertEqual(self.aggregator.text.type, "sentence") + + +class TestSkipTagsAggregatorTokenMode(unittest.IsolatedAsyncioTestCase): + def setUp(self): + from pipecat.utils.text.base_text_aggregator import AggregationType + + self.aggregator = SkipTagsAggregator( + [("", "")], aggregation_type=AggregationType.TOKEN + ) + + async def test_token_no_tags(self): + """No tags: text passes through immediately as TOKEN.""" + results = [agg async for agg in self.aggregator.aggregate("Hello!")] + self.assertEqual(len(results), 1) + self.assertEqual(results[0].text, "Hello!") + self.assertEqual(results[0].type, "token") + + async def test_token_inside_tag_buffers(self): + """Inside a tag, text is buffered until the closing tag is found.""" + results = [agg async for agg in self.aggregator.aggregate("foo@bar")] + # Still inside tag, nothing yielded + self.assertEqual(len(results), 0) + + # Close the tag + results = [agg async for agg in self.aggregator.aggregate("")] + self.assertEqual(len(results), 1) + self.assertEqual(results[0].text, "foo@bar") + self.assertEqual(results[0].type, "token") + + async def test_token_flush_unclosed_tag(self): + """Flush with unclosed tag returns remaining text.""" + async for _ in self.aggregator.aggregate("unclosed"): + pass + result = await self.aggregator.flush() + # TOKEN mode flush returns None (parent behavior) + self.assertIsNone(result) + + async def test_token_text_around_tags(self): + """Simulate word-by-word token delivery with tags.""" + results = [] + # Simulate LLM streaming tokens one at a time + for token in ["Hi ", "", "X", "", " bye"]: + async for agg in self.aggregator.aggregate(token): + results.append(agg) + + self.assertEqual(len(results), 3) + # Text before tag passes through immediately + self.assertEqual(results[0].text, "Hi ") + self.assertEqual(results[0].type, "token") + # Tagged content is buffered until the closing tag, then yielded whole + self.assertEqual(results[1].text, "X") + self.assertEqual(results[1].type, "token") + # Text after tag passes through immediately + self.assertEqual(results[2].text, " bye") + self.assertEqual(results[2].type, "token") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_startup_timing_observer.py b/tests/test_startup_timing_observer.py new file mode 100644 index 000000000..6355c6081 --- /dev/null +++ b/tests/test_startup_timing_observer.py @@ -0,0 +1,337 @@ +import asyncio +import unittest + +from pipecat.frames.frames import ( + BotConnectedFrame, + ClientConnectedFrame, + Frame, + StartFrame, + TextFrame, +) +from pipecat.observers.startup_timing_observer import ( + StartupTimingObserver, + StartupTimingReport, + TransportTimingReport, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.tests.utils import run_test + + +class SlowStartProcessor(FrameProcessor): + """A processor that sleeps during start to simulate slow initialization.""" + + def __init__(self, delay: float = 0.1, **kwargs): + super().__init__(**kwargs) + self._delay = delay + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + if isinstance(frame, StartFrame): + await asyncio.sleep(self._delay) + await self.push_frame(frame, direction) + + +class FastProcessor(FrameProcessor): + """A processor with no start delay.""" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + await self.push_frame(frame, direction) + + +class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase): + """Tests for StartupTimingObserver.""" + + async def test_timing_reported(self): + """Test that startup timing is measured and reported.""" + observer = StartupTimingObserver() + processor = SlowStartProcessor(delay=0.1) + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + self.assertGreater(report.total_duration_secs, 0) + self.assertGreater(len(report.processor_timings), 0) + + # Find our slow processor in the timings. + slow_timings = [ + t for t in report.processor_timings if "SlowStartProcessor" in t.processor_name + ] + self.assertEqual(len(slow_timings), 1) + self.assertGreaterEqual(slow_timings[0].duration_secs, 0.05) + + async def test_processor_types_filter(self): + """Test that processor_types filter limits which processors appear.""" + observer = StartupTimingObserver(processor_types=(SlowStartProcessor,)) + processor = SlowStartProcessor(delay=0.05) + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + + # Only SlowStartProcessor should be in the timings. + for t in report.processor_timings: + self.assertIn("SlowStartProcessor", t.processor_name) + + async def test_report_emits_once(self): + """Test that the report is emitted only once even with multiple frames.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [ + TextFrame(text="first"), + TextFrame(text="second"), + TextFrame(text="third"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame, TextFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + + async def test_event_handler_receives_report(self): + """Test that the event handler receives a proper StartupTimingReport.""" + observer = StartupTimingObserver() + processor = SlowStartProcessor(delay=0.05) + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + self.assertIsInstance(report, StartupTimingReport) + self.assertIsInstance(report.total_duration_secs, float) + self.assertGreater(report.start_time, 0) + for timing in report.processor_timings: + self.assertIsInstance(timing.processor_name, str) + self.assertIsInstance(timing.duration_secs, float) + self.assertGreaterEqual(timing.start_offset_secs, 0) + + async def test_excludes_internal_processors(self): + """Test that internal pipeline processors are excluded by default.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + + # No internal processors (PipelineSource, PipelineSink, Pipeline) in the report. + internal_names = ("Pipeline#", "PipelineTask#") + for t in report.processor_timings: + for prefix in internal_names: + self.assertNotIn( + prefix, + t.processor_name, + f"Internal processor {t.processor_name} should be excluded by default", + ) + + async def test_transport_timing_client_only(self): + """Test that ClientConnectedFrame emits on_transport_timing_report.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + transport_reports = [] + + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) + + frames_to_send = [ClientConnectedFrame(), TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[ClientConnectedFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(transport_reports), 1) + report = transport_reports[0] + self.assertIsInstance(report, TransportTimingReport) + self.assertGreater(report.start_time, 0) + self.assertGreater(report.client_connected_secs, 0) + self.assertIsNone(report.bot_connected_secs) + + async def test_transport_timing_only_first_client(self): + """Test that only the first ClientConnectedFrame triggers the event.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + transport_reports = [] + + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) + + frames_to_send = [ + ClientConnectedFrame(), + ClientConnectedFrame(), + TextFrame(text="hello"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[ClientConnectedFrame, ClientConnectedFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(transport_reports), 1) + + async def test_transport_timing_without_start_frame(self): + """Test that ClientConnectedFrame before StartFrame does not crash.""" + observer = StartupTimingObserver() + + # Directly call on_push_frame with a ClientConnectedFrame before any + # StartFrame has been seen. This should be a no-op (no crash). + from pipecat.observers.base_observer import FramePushed + + processor = FastProcessor() + destination = FastProcessor() + data = FramePushed( + source=processor, + destination=destination, + frame=ClientConnectedFrame(), + direction=FrameDirection.DOWNSTREAM, + timestamp=1000, + ) + await observer.on_push_frame(data) + + # No event should have been emitted. + self.assertFalse(observer._transport_timing_reported) + + async def test_bot_and_client_connected(self): + """Test that BotConnectedFrame timing is included in the transport report.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + transport_reports = [] + + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) + + frames_to_send = [ + BotConnectedFrame(), + ClientConnectedFrame(), + TextFrame(text="hello"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[BotConnectedFrame, ClientConnectedFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(transport_reports), 1) + report = transport_reports[0] + self.assertGreater(report.client_connected_secs, 0) + self.assertIsNotNone(report.bot_connected_secs) + self.assertGreater(report.bot_connected_secs, 0) + + # Client connected should be >= bot connected. + self.assertGreaterEqual(report.client_connected_secs, report.bot_connected_secs) + + async def test_bot_connected_only_first(self): + """Test that only the first BotConnectedFrame is recorded.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + transport_reports = [] + + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) + + frames_to_send = [ + BotConnectedFrame(), + BotConnectedFrame(), + ClientConnectedFrame(), + TextFrame(text="hello"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[ + BotConnectedFrame, + BotConnectedFrame, + ClientConnectedFrame, + TextFrame, + ], + observers=[observer], + ) + + # Only one transport report, with bot timing from first frame. + self.assertEqual(len(transport_reports), 1) + self.assertIsNotNone(transport_reports[0].bot_connected_secs) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_stt_mute_filter.py b/tests/test_stt_mute_filter.py index 459060b86..8f55bdecb 100644 --- a/tests/test_stt_mute_filter.py +++ b/tests/test_stt_mute_filter.py @@ -10,11 +10,11 @@ from pipecat.frames.frames import ( BotStartedSpeakingFrame, BotStoppedSpeakingFrame, FunctionCallFromLLM, - FunctionCallInProgressFrame, FunctionCallResultFrame, FunctionCallsStartedFrame, InputAudioRawFrame, InterimTranscriptionFrame, + InterruptionFrame, TranscriptionFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame, @@ -327,3 +327,28 @@ class TestSTTMuteFilter(unittest.IsolatedAsyncioTestCase): frames_to_send=frames_to_send, expected_down_frames=expected_returned_frames, ) + + async def test_interruption_frame_suppressed_when_muted(self): + """Test that InterruptionFrame is suppressed when the filter is muted.""" + filter = STTMuteFilter(config=STTMuteConfig(strategies={STTMuteStrategy.ALWAYS})) + + frames_to_send = [ + BotStartedSpeakingFrame(), + InterruptionFrame(), + BotStoppedSpeakingFrame(), + ] + + expected_returned_frames = [ + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + ] + + await run_test( + filter, + frames_to_send=frames_to_send, + expected_down_frames=expected_returned_frames, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_parallel_pipeline.py b/tests/test_sync_parallel_pipeline.py new file mode 100644 index 000000000..6c6faf7c7 --- /dev/null +++ b/tests/test_sync_parallel_pipeline.py @@ -0,0 +1,117 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import unittest +from dataclasses import dataclass + +from pipecat.frames.frames import Frame, TextFrame +from pipecat.pipeline.sync_parallel_pipeline import FrameOrder, SyncParallelPipeline +from pipecat.processors.filters.identity_filter import IdentityFilter +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.tests.utils import run_test + + +@dataclass +class TaggedFrame(Frame): + """A simple tagged frame for testing pipeline ordering.""" + + tag: str = "" + + def __str__(self): + return f"{self.name}(tag: {self.tag})" + + +class EmitTaggedFrameProcessor(FrameProcessor): + """Emits a TaggedFrame for every TextFrame it receives. + + Used to produce distinguishable output from different pipelines so tests + can verify ordering. + """ + + def __init__(self, tag: str, *, delay: float = 0, **kwargs): + super().__init__(**kwargs) + self._tag = tag + self._delay = delay + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + + if isinstance(frame, TextFrame): + if self._delay > 0: + await asyncio.sleep(self._delay) + await self.push_frame(TaggedFrame(tag=self._tag)) + else: + await self.push_frame(frame, direction) + + +class TestSyncParallelPipeline(unittest.IsolatedAsyncioTestCase): + async def test_dedup_multiple_frames(self): + """Identical frames from multiple paths should be deduplicated.""" + pipeline = SyncParallelPipeline([IdentityFilter()], [IdentityFilter()]) + + frames_to_send = [TextFrame(text="one"), TextFrame(text="two")] + expected_down_frames = [TextFrame, TextFrame] + await run_test( + pipeline, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + ) + + async def test_arrival_order(self): + """With FrameOrder.ARRIVAL, a slow first pipeline's frames should + arrive after a fast second pipeline's frames.""" + pipeline = SyncParallelPipeline( + [EmitTaggedFrameProcessor("slow", delay=0.05)], + [EmitTaggedFrameProcessor("fast")], + frame_order=FrameOrder.ARRIVAL, + ) + + frames_to_send = [TextFrame(text="one"), TextFrame(text="two")] + (down_frames, _) = await run_test( + pipeline, + frames_to_send=frames_to_send, + ) + + tags = [f.tag for f in down_frames if isinstance(f, TaggedFrame)] + assert tags == [ + "fast", + "slow", + "fast", + "slow", + ], f"Expected fast before slow in each batch, got {tags}" + + async def test_pipeline_order(self): + """With FrameOrder.PIPELINE and multiple input frames, each batch + should follow pipeline definition order regardless of processing speed.""" + pipeline = SyncParallelPipeline( + [EmitTaggedFrameProcessor("slow", delay=0.05)], + [EmitTaggedFrameProcessor("fast")], + frame_order=FrameOrder.PIPELINE, + ) + + frames_to_send = [TextFrame(text="one"), TextFrame(text="two")] + (down_frames, _) = await run_test( + pipeline, + frames_to_send=frames_to_send, + ) + + tags = [f.tag for f in down_frames if isinstance(f, TaggedFrame)] + assert tags == [ + "slow", + "fast", + "slow", + "fast", + ], f"Expected pipeline definition order (slow, fast) in each batch, got {tags}" + + async def test_default_is_arrival(self): + """The default frame_order should be ARRIVAL.""" + pipeline = SyncParallelPipeline([IdentityFilter()]) + assert pipeline._frame_order == FrameOrder.ARRIVAL + + +if __name__ == "__main__": + unittest.main() 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_transcript_processor.py b/tests/test_transcript_processor.py index 99e231c90..1dfdd58a3 100644 --- a/tests/test_transcript_processor.py +++ b/tests/test_transcript_processor.py @@ -792,3 +792,7 @@ class TestThoughtTranscription(unittest.IsolatedAsyncioTestCase): # Verify no updates since thought wasn't properly started self.assertEqual(len(received_updates), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tts_frame_ordering.py b/tests/test_tts_frame_ordering.py new file mode 100644 index 000000000..52d3df4e7 --- /dev/null +++ b/tests/test_tts_frame_ordering.py @@ -0,0 +1,315 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Tests for frame ordering across TTS service types. + +Covers three patterns: +- HTTP TTS services (e.g. CartesiaHttpTTSService): yield audio frames synchronously. +- WebSocket TTS services without pause (e.g. CartesiaTTSService): deliver audio via + append_to_audio_context from a background receive loop, no frame-processing pause. +- WebSocket TTS services with pause (e.g. ElevenLabsTTSService): same delivery + mechanism, but pause downstream frame processing while audio is in flight. + +For all three patterns we verify: + AggregatedTextFrame → TTSStartedFrame → TTSAudioRawFrame (1+) → TTSStoppedFrame → FooFrame + +repeated for each TTSSpeakFrame, with no cross-group contamination. +""" + +import asyncio +import unittest +from dataclasses import dataclass +from typing import AsyncGenerator, List, Sequence, Tuple + +import pytest + +from pipecat.frames.frames import ( + AggregatedTextFrame, + DataFrame, + Frame, + TTSAudioRawFrame, + TTSSpeakFrame, + TTSStartedFrame, + TTSStoppedFrame, +) +from pipecat.services.tts_service import TTSService +from pipecat.tests.utils import run_test + +# --------------------------------------------------------------------------- +# Test-only frame +# --------------------------------------------------------------------------- + +_FAKE_AUDIO = b"\x00\x01" * 320 # 320 bytes of silence +_SAMPLE_RATE = 16000 + + +@dataclass +class FooFrame(DataFrame): + """Marker frame used to verify relative ordering against TTS audio frames.""" + + label: str = "" + + +# --------------------------------------------------------------------------- +# Mock TTS services +# --------------------------------------------------------------------------- + + +class MockHttpTTSService(TTSService): + """Simulates an HTTP TTS service (e.g. CartesiaHttpTTSService). + + Audio frames are yielded synchronously from run_tts(), so the audio context + is fully populated before the next downstream frame is processed. + TTSStoppedFrame is appended by the base class in on_turn_context_completed() + once it detects _is_yielding_frames_synchronously is True. + """ + + def __init__(self, **kwargs): + super().__init__( + push_start_frame=True, + push_stop_frames=True, + push_text_frames=False, + sample_rate=_SAMPLE_RATE, + **kwargs, + ) + + def can_generate_metrics(self) -> bool: + return False + + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: + yield TTSAudioRawFrame( + audio=_FAKE_AUDIO, + sample_rate=_SAMPLE_RATE, + num_channels=1, + context_id=context_id, + ) + + +class MockWebSocketTTSService(TTSService): + """Simulates a WebSocket TTS service without frame-processing pause (e.g. CartesiaTTSService). + + run_tts() is an empty async generator (signals async delivery). A background + task appends audio frames and the TTSStoppedFrame to the audio context after a + short delay, mimicking real WebSocket receive-loop behaviour. + pause_frame_processing=False means downstream frames (FooFrame) are NOT held. + """ + + def __init__(self, **kwargs): + super().__init__( + push_start_frame=True, + push_text_frames=False, + pause_frame_processing=False, + sample_rate=_SAMPLE_RATE, + **kwargs, + ) + + def can_generate_metrics(self) -> bool: + return False + + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: + async def _deliver_audio(): + await asyncio.sleep(0.01) + await self.append_to_audio_context( + context_id, + TTSAudioRawFrame( + audio=_FAKE_AUDIO, + sample_rate=_SAMPLE_RATE, + num_channels=1, + context_id=context_id, + ), + ) + await self.append_to_audio_context(context_id, TTSStoppedFrame(context_id=context_id)) + await self.remove_audio_context(context_id) + + self.create_task(_deliver_audio(), name=f"mock_ws_deliver_{context_id}") + if False: + yield # make this an async generator without yielding anything + + +class MockWebSocketPauseTTSService(TTSService): + """Simulates a WebSocket TTS service WITH frame-processing pause (e.g. ElevenLabsTTSService). + + Identical to MockWebSocketTTSService except pause_frame_processing=True. + on_audio_context_completed() resumes downstream processing once the full + audio context has been pushed, guaranteeing FooFrame arrives after TTSStoppedFrame. + """ + + def __init__(self, **kwargs): + super().__init__( + push_start_frame=True, + push_text_frames=False, + pause_frame_processing=True, + sample_rate=_SAMPLE_RATE, + **kwargs, + ) + + def can_generate_metrics(self) -> bool: + return False + + async def on_audio_context_completed(self, context_id: str): + # Resume frame processing after the audio context is fully played out. + await self._maybe_resume_frame_processing() + + async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: + async def _deliver_audio(): + await asyncio.sleep(0.01) + await self.append_to_audio_context( + context_id, + TTSAudioRawFrame( + audio=_FAKE_AUDIO, + sample_rate=_SAMPLE_RATE, + num_channels=1, + context_id=context_id, + ), + ) + await self.append_to_audio_context(context_id, TTSStoppedFrame(context_id=context_id)) + await self.remove_audio_context(context_id) + + self.create_task(_deliver_audio(), name=f"mock_ws_pause_deliver_{context_id}") + if False: + yield + + +# --------------------------------------------------------------------------- +# Assertion helper +# --------------------------------------------------------------------------- + + +def _assert_group_ordering( + down_frames: Sequence[Frame], + expected_groups: List[Tuple[str, str]], +) -> None: + """Assert two (or more) TTS+FooFrame groups are in strict order. + + Args: + down_frames: All downstream frames received by the test sink. + expected_groups: List of (tts_text, foo_label) pairs, one per TTSSpeakFrame. + tts_text is unused in assertions today but included for readability. + """ + relevant = [ + f + for f in down_frames + if isinstance( + f, (AggregatedTextFrame, TTSStartedFrame, TTSAudioRawFrame, TTSStoppedFrame, FooFrame) + ) + ] + + # Locate the FooFrames that delimit groups. + foo_indices = [i for i, f in enumerate(relevant) if isinstance(f, FooFrame)] + assert len(foo_indices) == len(expected_groups), ( + f"Expected {len(expected_groups)} FooFrames, got {len(foo_indices)}.\n" + f"Relevant frames: {[type(f).__name__ for f in relevant]}" + ) + + # Build groups: everything up to and including each FooFrame. + groups: List[List[Frame]] = [] + prev = 0 + for idx in foo_indices: + groups.append(relevant[prev : idx + 1]) + prev = idx + 1 + + for group, (_, foo_label) in zip(groups, expected_groups): + types = [type(f) for f in group] + type_names = [t.__name__ for t in types] + + assert AggregatedTextFrame in types, ( + f"Group {foo_label!r}: missing AggregatedTextFrame. Got: {type_names}" + ) + assert TTSStartedFrame in types, ( + f"Group {foo_label!r}: missing TTSStartedFrame. Got: {type_names}" + ) + assert TTSAudioRawFrame in types, ( + f"Group {foo_label!r}: missing TTSAudioRawFrame. Got: {type_names}" + ) + assert TTSStoppedFrame in types, ( + f"Group {foo_label!r}: missing TTSStoppedFrame. Got: {type_names}" + ) + + started_idx = types.index(TTSStartedFrame) + stopped_idx = types.index(TTSStoppedFrame) + foo_idx = types.index(FooFrame) + + assert started_idx < stopped_idx, ( + f"Group {foo_label!r}: TTSStartedFrame (pos {started_idx}) must precede " + f"TTSStoppedFrame (pos {stopped_idx}). Got: {type_names}" + ) + assert stopped_idx < foo_idx, ( + f"Group {foo_label!r}: TTSStoppedFrame (pos {stopped_idx}) must precede " + f"FooFrame (pos {foo_idx}). Got: {type_names}" + ) + + # All frames between TTSStartedFrame and TTSStoppedFrame must be audio. + mid_types = types[started_idx + 1 : stopped_idx] + for t in mid_types: + assert t is TTSAudioRawFrame, ( + f"Group {foo_label!r}: unexpected frame {t.__name__!r} between " + f"TTSStartedFrame and TTSStoppedFrame. Got: {type_names}" + ) + + # Check the FooFrame label. + actual_label = group[foo_idx].label + assert actual_label == foo_label, ( + f"Expected FooFrame(label={foo_label!r}), got label={actual_label!r}" + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +_GROUPS = [("test 1", "1"), ("test 2", "2")] + + +def _make_frames_no_sleep() -> List[Frame]: + """Return two TTSSpeakFrame+FooFrame pairs sent back-to-back. + + Only correct for services that pause downstream processing until the audio + context is fully consumed (pause_frame_processing=True + on_audio_context_completed). + """ + return [ + TTSSpeakFrame(text="test 1", append_to_context=False), + FooFrame(label="1"), + TTSSpeakFrame(text="test 2", append_to_context=False), + FooFrame(label="2"), + ] + + +def _print_frames_received(frames_received) -> None: + print("FRAMES RECEIVED:") + for frame in frames_received[0]: + print(frame.name) + + +@pytest.mark.asyncio +async def test_http_tts_frame_ordering(): + """HTTP TTS services yield audio synchronously.""" + tts = MockHttpTTSService() + frames_received = await run_test(tts, frames_to_send=_make_frames_no_sleep()) + + # only for debugging + _print_frames_received(frames_received) + + _assert_group_ordering(frames_received[0], _GROUPS) + + +@pytest.mark.asyncio +async def test_websocket_tts_no_pause_frame_ordering(): + """WebSocket TTS services without pause_frame_processing.""" + tts = MockWebSocketTTSService() + frames_received = await run_test(tts, frames_to_send=_make_frames_no_sleep()) + _assert_group_ordering(frames_received[0], _GROUPS) + + +@pytest.mark.asyncio +async def test_websocket_tts_with_pause_frame_ordering(): + """WebSocket TTS services with pause_frame_processing=True.""" + tts = MockWebSocketPauseTTSService() + frames_received = await run_test(tts, frames_to_send=_make_frames_no_sleep()) + _assert_group_ordering(frames_received[0], _GROUPS) + + +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_bot_latency_observer.py b/tests/test_user_bot_latency_observer.py new file mode 100644 index 000000000..96c24724b --- /dev/null +++ b/tests/test_user_bot_latency_observer.py @@ -0,0 +1,631 @@ +import unittest + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + ClientConnectedFrame, + FunctionCallInProgressFrame, + FunctionCallResultFrame, + InterruptionFrame, + MetricsFrame, + UserStoppedSpeakingFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, +) +from pipecat.metrics.metrics import ( + TextAggregationMetricsData, + TTFBMetricsData, +) +from pipecat.observers.user_bot_latency_observer import ( + FunctionCallMetrics, + LatencyBreakdown, + TextAggregationBreakdownMetrics, + TTFBBreakdownMetrics, + UserBotLatencyObserver, +) +from pipecat.processors.filters.identity_filter import IdentityFilter +from pipecat.tests.utils import SleepFrame, run_test + + +class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): + """Tests for UserBotLatencyObserver.""" + + async def test_normal_latency_measurement(self): + """Test basic latency measurement from user stop to bot start.""" + # Create observer + observer = UserBotLatencyObserver() + + # Create identity filter (passes all frames through) + processor = IdentityFilter() + + # Capture latency events + latencies = [] + + @observer.event_handler("on_latency_measured") + async def on_latency(obs, latency_seconds): + latencies.append(latency_seconds) + + # Define frame sequence + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + # Run test + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + # Verify latency was measured + self.assertEqual(len(latencies), 1) + self.assertGreater(latencies[0], 0) + self.assertLess(latencies[0], 1.0) # Should be very quick + + async def test_multiple_latency_measurements(self): + """Test that multiple user-bot exchanges produce separate latency events.""" + # Create observer + observer = UserBotLatencyObserver() + + # Create identity filter + processor = IdentityFilter() + + # Capture latency events + latencies = [] + + @observer.event_handler("on_latency_measured") + async def on_latency(obs, latency_seconds): + latencies.append(latency_seconds) + + # Define frame sequence with two complete cycles + frames_to_send = [ + # First cycle + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + # Second cycle + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + # Run test + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + # Verify two separate latencies were measured + self.assertEqual(len(latencies), 2) + self.assertGreater(latencies[0], 0) + self.assertGreater(latencies[1], 0) + + async def test_breakdown_with_metrics(self): + """Test that metrics collected between VADUserStopped and BotStarted appear in breakdown.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + stt_ttfb = TTFBMetricsData(processor="DeepgramSTTService#0", value=0.080) + llm_ttfb = TTFBMetricsData(processor="OpenAILLMService#0", model="gpt-4o", value=0.250) + tts_ttfb = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.070) + text_agg = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.030) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + MetricsFrame(data=[stt_ttfb]), + MetricsFrame(data=[llm_ttfb, text_agg]), + MetricsFrame(data=[tts_ttfb]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + MetricsFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + bd = breakdowns[0] + self.assertEqual(len(bd.ttfb), 3) + self.assertEqual(bd.ttfb[0].processor, "DeepgramSTTService#0") + self.assertEqual(bd.ttfb[1].processor, "OpenAILLMService#0") + self.assertEqual(bd.ttfb[2].processor, "CartesiaTTSService#0") + self.assertIsNotNone(bd.text_aggregation) + self.assertEqual(bd.text_aggregation.duration_secs, 0.030) + + async def test_interruption_resets_accumulators(self): + """Test that InterruptionFrame clears stale metrics from earlier cycles.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + # First cycle metrics (will be interrupted) + stale_llm = TTFBMetricsData(processor="OpenAILLMService#0", value=0.245) + # Second cycle metrics (the ones that matter) + final_llm = TTFBMetricsData(processor="OpenAILLMService#0", value=0.224) + final_tts = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.142) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + MetricsFrame(data=[stale_llm]), + InterruptionFrame(), + MetricsFrame(data=[final_llm]), + MetricsFrame(data=[final_tts]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + MetricsFrame, + InterruptionFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + bd = breakdowns[0] + # Only the post-interruption metrics should be present + self.assertEqual(len(bd.ttfb), 2) + self.assertEqual(bd.ttfb[0].processor, "OpenAILLMService#0") + self.assertEqual(bd.ttfb[0].duration_secs, 0.224) + self.assertEqual(bd.ttfb[1].processor, "CartesiaTTSService#0") + self.assertEqual(bd.ttfb[1].duration_secs, 0.142) + + async def test_only_first_text_aggregation_kept(self): + """Test that only the first text aggregation metric is kept per cycle.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + text_agg_1 = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.030) + text_agg_2 = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.080) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + MetricsFrame(data=[text_agg_1]), + MetricsFrame(data=[text_agg_2]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertIsNotNone(breakdowns[0].text_aggregation) + self.assertEqual(breakdowns[0].text_aggregation.duration_secs, 0.030) + + async def test_user_turn_measured(self): + """Test that pre-LLM wait from user silence to UserStopped is captured.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + SleepFrame(sleep=0.1), # Simulate turn analyzer wait + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertIsNotNone(breakdowns[0].user_turn_secs) + self.assertGreaterEqual(breakdowns[0].user_turn_secs, 0.1) + + async def test_user_turn_none_without_user_stopped(self): + """Test that user_turn is None when no UserStoppedSpeakingFrame arrives.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertIsNone(breakdowns[0].user_turn_secs) + + async def test_no_measurement_without_user_stop(self): + """Test that BotStartedSpeaking without prior user stop emits nothing.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + latencies = [] + breakdowns = [] + + @observer.event_handler("on_latency_measured") + async def on_latency(obs, latency_seconds): + latencies.append(latency_seconds) + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + frames_to_send = [ + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(latencies), 0) + self.assertEqual(len(breakdowns), 0) + + async def test_first_bot_speech_latency(self): + """Test first bot speech latency and breakdown from ClientConnected to BotStartedSpeaking.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + first_speech_latencies = [] + breakdowns = [] + + @observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech(obs, latency_seconds): + first_speech_latencies.append(latency_seconds) + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + llm_ttfb = TTFBMetricsData(processor="OpenAILLMService#0", value=0.250) + tts_ttfb = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.070) + + frames_to_send = [ + ClientConnectedFrame(), + MetricsFrame(data=[llm_ttfb]), + MetricsFrame(data=[tts_ttfb]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + ClientConnectedFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(first_speech_latencies), 1) + self.assertGreater(first_speech_latencies[0], 0) + self.assertLess(first_speech_latencies[0], 1.0) + + # Breakdown should also be emitted with the accumulated metrics + self.assertEqual(len(breakdowns), 1) + self.assertEqual(len(breakdowns[0].ttfb), 2) + self.assertEqual(breakdowns[0].ttfb[0].processor, "OpenAILLMService#0") + self.assertEqual(breakdowns[0].ttfb[1].processor, "CartesiaTTSService#0") + + async def test_first_bot_speech_only_once(self): + """Test that first bot speech latency is only emitted once.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + first_speech_latencies = [] + + @observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech(obs, latency_seconds): + first_speech_latencies.append(latency_seconds) + + frames_to_send = [ + ClientConnectedFrame(), + BotStartedSpeakingFrame(), + # Second bot speech should not trigger the event again + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + ClientConnectedFrame, + BotStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(first_speech_latencies), 1) + + async def test_first_bot_speech_skipped_when_user_speaks_first(self): + """Test that first bot speech event is not emitted when user speaks before the bot.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + first_speech_latencies = [] + + @observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech(obs, latency_seconds): + first_speech_latencies.append(latency_seconds) + + frames_to_send = [ + ClientConnectedFrame(), + # User speaks before bot has a chance to greet + VADUserStartedSpeakingFrame(), + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + ClientConnectedFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(first_speech_latencies), 0) + + async def test_function_call_latency_in_breakdown(self): + """Test that function call duration appears in the latency breakdown.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + tool_call_id = "call_abc123" + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + FunctionCallInProgressFrame( + function_name="get_weather", + tool_call_id=tool_call_id, + arguments={"location": "Atlanta"}, + ), + SleepFrame(sleep=0.1), + FunctionCallResultFrame( + function_name="get_weather", + tool_call_id=tool_call_id, + arguments={"location": "Atlanta"}, + result={"temperature": "75"}, + ), + BotStartedSpeakingFrame(), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertEqual(len(breakdowns[0].function_calls), 1) + fc = breakdowns[0].function_calls[0] + self.assertEqual(fc.function_name, "get_weather") + self.assertGreaterEqual(fc.duration_secs, 0.1) + + async def test_function_call_reset_on_interruption(self): + """Test that function call metrics are cleared on interruption.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + FunctionCallInProgressFrame( + function_name="get_weather", + tool_call_id="call_1", + arguments={}, + ), + FunctionCallResultFrame( + function_name="get_weather", + tool_call_id="call_1", + arguments={}, + result={}, + ), + InterruptionFrame(), + BotStartedSpeakingFrame(), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertEqual(len(breakdowns[0].function_calls), 0) + + +class TestLatencyBreakdownChronologicalEvents(unittest.TestCase): + """Tests for LatencyBreakdown.chronological_events().""" + + def test_events_sorted_by_start_time(self): + """Test that events are returned in chronological order.""" + breakdown = LatencyBreakdown( + user_turn_start_time=100.0, + user_turn_secs=0.150, + ttfb=[ + TTFBBreakdownMetrics( + processor="OpenAILLMService#0", + model="gpt-4o", + start_time=100.200, + duration_secs=0.250, + ), + TTFBBreakdownMetrics( + processor="DeepgramSTTService#0", + start_time=100.050, + duration_secs=0.080, + ), + TTFBBreakdownMetrics( + processor="CartesiaTTSService#0", + start_time=100.500, + duration_secs=0.070, + ), + ], + function_calls=[ + FunctionCallMetrics( + function_name="get_weather", + start_time=100.450, + duration_secs=0.120, + ), + ], + text_aggregation=TextAggregationBreakdownMetrics( + processor="CartesiaTTSService#0", + start_time=100.480, + duration_secs=0.030, + ), + ) + + events = breakdown.chronological_events() + + self.assertEqual(len(events), 6) + self.assertEqual(events[0], "User turn: 0.150s") + self.assertEqual(events[1], "DeepgramSTTService#0: TTFB 0.080s") + self.assertEqual(events[2], "OpenAILLMService#0: TTFB 0.250s") + self.assertEqual(events[3], "get_weather: 0.120s") + self.assertEqual(events[4], "CartesiaTTSService#0: text aggregation 0.030s") + self.assertEqual(events[5], "CartesiaTTSService#0: TTFB 0.070s") + + def test_empty_breakdown(self): + """Test that an empty breakdown returns no events.""" + breakdown = LatencyBreakdown() + self.assertEqual(breakdown.chronological_events(), []) + + def test_user_turn_requires_both_fields(self): + """Test that user turn is only included when both start_time and secs are set.""" + # Only start_time, no duration + breakdown = LatencyBreakdown(user_turn_start_time=100.0) + self.assertEqual(breakdown.chronological_events(), []) + + # Only duration, no start_time + breakdown = LatencyBreakdown(user_turn_secs=0.150) + self.assertEqual(breakdown.chronological_events(), []) + + def test_ttfb_only(self): + """Test breakdown with only TTFB metrics.""" + breakdown = LatencyBreakdown( + ttfb=[ + TTFBBreakdownMetrics(processor="LLM#0", start_time=100.0, duration_secs=0.200), + ], + ) + events = breakdown.chronological_events() + self.assertEqual(events, ["LLM#0: TTFB 0.200s"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user_idle_controller.py b/tests/test_user_idle_controller.py new file mode 100644 index 000000000..646223d37 --- /dev/null +++ b/tests/test_user_idle_controller.py @@ -0,0 +1,323 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import unittest +import unittest.mock + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + FunctionCallResultFrame, + FunctionCallsStartedFrame, + UserIdleTimeoutUpdateFrame, + UserStartedSpeakingFrame, +) +from pipecat.turns.user_idle_controller import UserIdleController +from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams + +USER_IDLE_TIMEOUT = 0.2 + + +class TestUserIdleController(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.task_manager = TaskManager() + self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop())) + + 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) + + 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.assertTrue(idle_triggered) + + await controller.cleanup() + + 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) + + 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.process_frame(UserStartedSpeakingFrame()) + + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + self.assertFalse(idle_triggered) + + await controller.cleanup() + + 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) + + 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.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()) + + # 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_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) + + idle_count = 0 + + @controller.event_handler("on_user_turn_idle") + async def on_user_turn_idle(controller): + nonlocal idle_count + idle_count += 1 + + # First cycle: bot stops → idle fires + await controller.process_frame(BotStoppedSpeakingFrame()) + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + self.assertEqual(idle_count, 1) + + # 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) + self.assertEqual(idle_count, 2) + + await controller.cleanup() + + 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() + + await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1) + + 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() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user_idle_processor.py b/tests/test_user_idle_processor.py index eec3abe5a..3a0169ec7 100644 --- a/tests/test_user_idle_processor.py +++ b/tests/test_user_idle_processor.py @@ -218,3 +218,7 @@ class TestUserIdleProcessor(unittest.IsolatedAsyncioTestCase): ) assert callback_called.is_set(), "Idle callback not called after bot speech" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user_mute_strategy.py b/tests/test_user_mute_strategy.py index 26a7af1cc..07724dbfa 100644 --- a/tests/test_user_mute_strategy.py +++ b/tests/test_user_mute_strategy.py @@ -15,7 +15,7 @@ from pipecat.frames.frames import ( FunctionCallsStartedFrame, InterruptionFrame, ) -from pipecat.turns.mute import ( +from pipecat.turns.user_mute import ( AlwaysUserMuteStrategy, FirstSpeechUserMuteStrategy, FunctionCallUserMuteStrategy, @@ -137,3 +137,7 @@ class TestFunctionCallUserMuteStrategy(unittest.IsolatedAsyncioTestCase): ) ) self.assertFalse(await strategy.process_frame(InterruptionFrame())) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user_turn_completion_mixin.py b/tests/test_user_turn_completion_mixin.py new file mode 100644 index 000000000..e503ecbf2 --- /dev/null +++ b/tests/test_user_turn_completion_mixin.py @@ -0,0 +1,261 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import unittest +import unittest.mock +from unittest.mock import AsyncMock + +from pipecat.frames.frames import LLMFullResponseEndFrame, LLMTextFrame +from pipecat.processors.frame_processor import FrameProcessor +from pipecat.services.llm_service import LLMService +from pipecat.services.settings import LLMSettings +from pipecat.turns.user_turn_completion_mixin import ( + USER_TURN_COMPLETE_MARKER, + USER_TURN_COMPLETION_INSTRUCTIONS, + USER_TURN_INCOMPLETE_LONG_MARKER, + USER_TURN_INCOMPLETE_SHORT_MARKER, + UserTurnCompletionConfig, + UserTurnCompletionLLMServiceMixin, +) + + +class MockProcessor(UserTurnCompletionLLMServiceMixin, FrameProcessor): + """Simple mock processor using the turn completion mixin.""" + + pass + + +class TestUserUserTurnCompletionLLMServiceMixin(unittest.IsolatedAsyncioTestCase): + """Tests for UserUserTurnCompletionLLMServiceMixin functionality.""" + + async def test_complete_marker_pushes_text(self): + """Test that ✓ marker is detected and text after it is pushed normally.""" + processor = MockProcessor() + + # Capture frames that get pushed + pushed_frames = [] + processor.push_frame = AsyncMock( + side_effect=lambda f, *args, **kwargs: pushed_frames.append(f) + ) + + # Simulate LLM generating: "✓ Hello there!" + await processor._push_turn_text(f"{USER_TURN_COMPLETE_MARKER} Hello there!") + + # Should have 2 text frames: marker (skip_tts) and content (normal) + self.assertEqual(len(pushed_frames), 2) + + # First frame should be the marker with skip_tts=True + self.assertIsInstance(pushed_frames[0], LLMTextFrame) + self.assertEqual(pushed_frames[0].text, USER_TURN_COMPLETE_MARKER) + self.assertTrue(pushed_frames[0].skip_tts) + + # Second frame should be the actual text without skip_tts + self.assertIsInstance(pushed_frames[1], LLMTextFrame) + self.assertEqual(pushed_frames[1].text, "Hello there!") + self.assertFalse(pushed_frames[1].skip_tts) + + async def test_incomplete_short_marker_suppresses_text(self): + """Test that ○ marker suppresses text with skip_tts.""" + processor = MockProcessor() + + pushed_frames = [] + processor.push_frame = AsyncMock( + side_effect=lambda f, *args, **kwargs: pushed_frames.append(f) + ) + # Mock timeout to avoid needing task manager + processor._start_incomplete_timeout = AsyncMock() + + await processor._push_turn_text(USER_TURN_INCOMPLETE_SHORT_MARKER) + + # Should have 1 text frame with skip_tts=True + self.assertEqual(len(pushed_frames), 1) + self.assertIsInstance(pushed_frames[0], LLMTextFrame) + self.assertEqual(pushed_frames[0].text, USER_TURN_INCOMPLETE_SHORT_MARKER) + self.assertTrue(pushed_frames[0].skip_tts) + + async def test_incomplete_long_marker_suppresses_text(self): + """Test that ◐ marker suppresses text with skip_tts.""" + processor = MockProcessor() + + pushed_frames = [] + processor.push_frame = AsyncMock( + side_effect=lambda f, *args, **kwargs: pushed_frames.append(f) + ) + # Mock timeout to avoid needing task manager + processor._start_incomplete_timeout = AsyncMock() + + await processor._push_turn_text(USER_TURN_INCOMPLETE_LONG_MARKER) + + # Should have 1 text frame with skip_tts=True + self.assertEqual(len(pushed_frames), 1) + self.assertIsInstance(pushed_frames[0], LLMTextFrame) + self.assertEqual(pushed_frames[0].text, USER_TURN_INCOMPLETE_LONG_MARKER) + self.assertTrue(pushed_frames[0].skip_tts) + + async def test_text_buffered_until_marker_found(self): + """Test that text is buffered until a marker is detected.""" + processor = MockProcessor() + + pushed_frames = [] + processor.push_frame = AsyncMock( + side_effect=lambda f, *args, **kwargs: pushed_frames.append(f) + ) + + # Simulate token-by-token streaming without marker + await processor._push_turn_text("Hello") + await processor._push_turn_text(" there") + + # No frames should be pushed yet (buffering) + self.assertEqual(len(pushed_frames), 0) + + # Now send the complete marker + await processor._push_turn_text(f" {USER_TURN_COMPLETE_MARKER} How are you?") + + # Now frames should be pushed + self.assertEqual(len(pushed_frames), 2) + + async def test_turn_state_reset_after_llm_full_response_end_frame(self): + """Test that _turn_complete_found is reset when LLMFullResponseEndFrame is pushed.""" + processor = MockProcessor() + + # Mock push_frame on the instance so _push_turn_text can call it without + # a live pipeline, but keep _turn_reset as the real implementation. + processor.push_frame = AsyncMock() + + # Simulate first LLM response: complete marker sets _turn_complete_found = True + await processor._push_turn_text(f"{USER_TURN_COMPLETE_MARKER} Hello!") + self.assertTrue(processor._turn_complete_found) + + # Restore the real push_frame so the mixin override runs, then call it + # with LLMFullResponseEndFrame as the LLM service would. + del processor.push_frame # removes instance mock, restores class method + + # Patch only the FrameProcessor-level send so no live pipeline is needed. + with unittest.mock.patch.object(FrameProcessor, "push_frame", AsyncMock()): + end_frame = LLMFullResponseEndFrame() + await processor.push_frame(end_frame) + + # _turn_complete_found must now be False — ready for the next response + self.assertFalse(processor._turn_complete_found) + self.assertEqual(processor._turn_text_buffer, "") + self.assertFalse(processor._turn_suppressed) + + +class MockLLMService(LLMService): + """Minimal LLM service for testing system_instruction composition.""" + + def __init__(self, **kwargs): + settings = LLMSettings( + model="test-model", + system_instruction=kwargs.pop("system_instruction", None), + temperature=None, + max_tokens=None, + top_p=None, + top_k=None, + frequency_penalty=None, + presence_penalty=None, + seed=None, + filter_incomplete_user_turns=None, + user_turn_completion_config=None, + ) + super().__init__(settings=settings, **kwargs) + + +class TestSystemInstructionComposition(unittest.IsolatedAsyncioTestCase): + """Tests for turn completion system_instruction composition in LLMService.""" + + async def test_enable_turn_completion_sets_system_instruction(self): + """Enabling turn completion should set system_instruction to completion instructions.""" + service = MockLLMService() + self.assertIsNone(service._settings.system_instruction) + + delta = LLMSettings(filter_incomplete_user_turns=True) + await service._update_settings(delta) + + self.assertEqual(service._settings.system_instruction, USER_TURN_COMPLETION_INSTRUCTIONS) + self.assertIsNone(service._base_system_instruction) + + async def test_enable_turn_completion_appends_to_existing_system_instruction(self): + """Enabling turn completion should append instructions to existing system_instruction.""" + service = MockLLMService(system_instruction="You are a helpful assistant.") + + delta = LLMSettings(filter_incomplete_user_turns=True) + await service._update_settings(delta) + + expected = f"You are a helpful assistant.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}" + self.assertEqual(service._settings.system_instruction, expected) + self.assertEqual(service._base_system_instruction, "You are a helpful assistant.") + + async def test_disable_turn_completion_restores_system_instruction(self): + """Disabling turn completion should restore the original system_instruction.""" + service = MockLLMService(system_instruction="You are a helpful assistant.") + + # Enable + await service._update_settings(LLMSettings(filter_incomplete_user_turns=True)) + self.assertIn(USER_TURN_COMPLETION_INSTRUCTIONS, service._settings.system_instruction) + + # Disable + await service._update_settings(LLMSettings(filter_incomplete_user_turns=False)) + self.assertEqual(service._settings.system_instruction, "You are a helpful assistant.") + self.assertIsNone(service._base_system_instruction) + + async def test_disable_turn_completion_restores_none(self): + """Disabling turn completion when original was None should restore None.""" + service = MockLLMService() + + await service._update_settings(LLMSettings(filter_incomplete_user_turns=True)) + self.assertEqual(service._settings.system_instruction, USER_TURN_COMPLETION_INSTRUCTIONS) + + await service._update_settings(LLMSettings(filter_incomplete_user_turns=False)) + self.assertIsNone(service._settings.system_instruction) + + async def test_update_system_instruction_while_turn_completion_active(self): + """Changing system_instruction while turn completion is active should recompose.""" + service = MockLLMService(system_instruction="Original prompt.") + + await service._update_settings(LLMSettings(filter_incomplete_user_turns=True)) + expected = f"Original prompt.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}" + self.assertEqual(service._settings.system_instruction, expected) + + # Now update system_instruction + await service._update_settings(LLMSettings(system_instruction="New prompt.")) + expected = f"New prompt.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}" + self.assertEqual(service._settings.system_instruction, expected) + self.assertEqual(service._base_system_instruction, "New prompt.") + + async def test_update_config_recomposes_with_custom_instructions(self): + """Updating turn completion config should recompose with new instructions.""" + service = MockLLMService(system_instruction="Base prompt.") + + await service._update_settings(LLMSettings(filter_incomplete_user_turns=True)) + + custom_config = UserTurnCompletionConfig(instructions="Custom turn instructions.") + await service._update_settings(LLMSettings(user_turn_completion_config=custom_config)) + + expected = "Base prompt.\n\nCustom turn instructions." + self.assertEqual(service._settings.system_instruction, expected) + + async def test_simultaneous_enable_and_system_instruction_change(self): + """Enabling turn completion and changing system_instruction in the same delta + should use the new system_instruction as the base.""" + service = MockLLMService(system_instruction="Original prompt.") + + await service._update_settings( + LLMSettings( + filter_incomplete_user_turns=True, + system_instruction="New prompt.", + ) + ) + + # apply_update sets system_instruction to "New prompt." before _update_settings + # runs, so the base should be the new value the user explicitly set. + self.assertEqual(service._base_system_instruction, "New prompt.") + expected = f"New prompt.\n\n{USER_TURN_COMPLETION_INSTRUCTIONS}" + self.assertEqual(service._settings.system_instruction, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user_turn_controller.py b/tests/test_user_turn_controller.py index 6762bd219..2883d39bd 100644 --- a/tests/test_user_turn_controller.py +++ b/tests/test_user_turn_controller.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2024-2026 Daily +# Copyright (c) 2024-2026, Daily # # SPDX-License-Identifier: BSD 2-Clause License # @@ -8,15 +8,24 @@ import asyncio import unittest from pipecat.frames.frames import ( + BotStartedSpeakingFrame, TranscriptionFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) +from pipecat.turns.user_start import VADUserTurnStartStrategy +from pipecat.turns.user_start.min_words_user_turn_start_strategy import ( + MinWordsUserTurnStartStrategy, +) +from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy from pipecat.turns.user_turn_controller import UserTurnController -from pipecat.turns.user_turn_strategies import UserTurnStrategies +from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies, UserTurnStrategies from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams USER_TURN_STOP_TIMEOUT = 0.2 +TRANSCRIPTION_TIMEOUT = 0.1 class TestUserTurnController(unittest.IsolatedAsyncioTestCase): @@ -25,7 +34,11 @@ class TestUserTurnController(unittest.IsolatedAsyncioTestCase): self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop())) async def test_default_user_turn_strategies(self): - controller = UserTurnController(user_turn_strategies=UserTurnStrategies()) + controller = UserTurnController( + user_turn_strategies=UserTurnStrategies( + stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)], + ) + ) await controller.setup(self.task_manager) @@ -54,8 +67,48 @@ class TestUserTurnController(unittest.IsolatedAsyncioTestCase): await controller.process_frame(VADUserStoppedSpeakingFrame()) self.assertTrue(should_start) + # Wait for user_speech_timeout to elapse + await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1) self.assertTrue(should_stop) + async def test_user_turn_start_reset(self): + controller = UserTurnController( + user_turn_strategies=UserTurnStrategies( + start=[MinWordsUserTurnStartStrategy(min_words=3)] + ), + user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT, + ) + + await controller.setup(self.task_manager) + + should_start = 0 + + @controller.event_handler("on_user_turn_started") + async def on_user_turn_started(controller, strategy, params): + nonlocal should_start + should_start += 1 + + await controller.process_frame(BotStartedSpeakingFrame()) + await controller.process_frame(TranscriptionFrame(text="One", user_id="cat", timestamp="")) + self.assertEqual(should_start, 0) + + await controller.process_frame( + TranscriptionFrame(text="One two three!", user_id="cat", timestamp="") + ) + self.assertEqual(should_start, 1) + + # Trigger user stop turn so we can trigger user start turn again. + await asyncio.sleep(USER_TURN_STOP_TIMEOUT + 0.1) + + await controller.process_frame(BotStartedSpeakingFrame()) + await controller.process_frame(TranscriptionFrame(text="Hi!", user_id="cat", timestamp="")) + self.assertEqual(should_start, 1) + + await controller.process_frame( + TranscriptionFrame(text="How are you?", user_id="cat", timestamp="") + ) + self.assertEqual(should_start, 2) + async def test_user_turn_stop_timeout_no_transcription(self): controller = UserTurnController( user_turn_strategies=UserTurnStrategies(), @@ -96,3 +149,124 @@ class TestUserTurnController(unittest.IsolatedAsyncioTestCase): self.assertTrue(should_start) self.assertTrue(should_stop) self.assertTrue(timeout) + + async def test_external_user_turn_strategies_no_timeout_while_speaking(self): + """Test that timeout does not trigger when user is still speaking with external strategies.""" + controller = UserTurnController( + user_turn_strategies=ExternalUserTurnStrategies(), + user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT, + ) + + await controller.setup(self.task_manager) + + should_start = None + should_stop = None + timeout = None + + @controller.event_handler("on_user_turn_started") + async def on_user_turn_started(controller, strategy, params): + nonlocal should_start + should_start = True + + @controller.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(controller, strategy, params): + nonlocal should_stop + should_stop = True + + @controller.event_handler("on_user_turn_stop_timeout") + async def on_user_turn_stop_timeout(controller): + nonlocal timeout + timeout = True + + # Simulate external service (like Deepgram Flux) broadcasting UserStartedSpeakingFrame + await controller.process_frame(UserStartedSpeakingFrame()) + self.assertTrue(should_start) + self.assertFalse(should_stop) + self.assertFalse(timeout) + + # User is still speaking, timeout should not trigger + await asyncio.sleep(USER_TURN_STOP_TIMEOUT + 0.1) + self.assertTrue(should_start) + self.assertFalse(should_stop) + self.assertFalse(timeout) + + # Now external service broadcasts UserStoppedSpeakingFrame + await controller.process_frame(UserStoppedSpeakingFrame()) + + # But no transcription, so timeout should trigger + await asyncio.sleep(USER_TURN_STOP_TIMEOUT + 0.1) + + self.assertTrue(should_start) + self.assertTrue(should_stop) + self.assertTrue(timeout) + + async def test_late_transcription_between_turns_no_premature_stop(self): + """Test that a late transcription arriving between turns does not cause a premature stop. + + Reproduces the bug from issue #4053: after turn 1 completes and reset() + clears state, a late TranscriptionFrame sets _text to stale content. On + the next turn, that stale _text gates a premature turn stop via timeout(0) + before the current turn's transcript arrives. + + Uses only VADUserTurnStartStrategy (no TranscriptionUserTurnStartStrategy) + so the late transcription doesn't trigger a spurious turn start. + """ + controller = UserTurnController( + user_turn_strategies=UserTurnStrategies( + start=[VADUserTurnStartStrategy()], + stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)], + ), + user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT, + ) + + await controller.setup(self.task_manager) + + start_count = 0 + stop_count = 0 + + @controller.event_handler("on_user_turn_started") + async def on_user_turn_started(controller, strategy, params): + nonlocal start_count + start_count += 1 + + @controller.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(controller, strategy, params): + nonlocal stop_count + stop_count += 1 + + # === Turn 1: S-T-E === + await controller.process_frame(VADUserStartedSpeakingFrame()) + self.assertEqual(start_count, 1) + + await controller.process_frame( + TranscriptionFrame(text="Hello!", user_id="", timestamp="now") + ) + + await controller.process_frame(VADUserStoppedSpeakingFrame()) + await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1) + self.assertEqual(stop_count, 1) + + # === Between turns: late transcription arrives === + # This sets _text on the stop strategy while _user_turn is False. + await controller.process_frame( + TranscriptionFrame(text="Hello!", user_id="", timestamp="now") + ) + + # === Turn 2: S-T-E (transcription arrives during turn) === + # The fix resets stop strategies at turn start, clearing stale _text. + await controller.process_frame(VADUserStartedSpeakingFrame()) + self.assertEqual(start_count, 2) + + await controller.process_frame( + TranscriptionFrame(text="How are you?", user_id="", timestamp="now") + ) + + await controller.process_frame(VADUserStoppedSpeakingFrame()) + + # Wait for user_speech_timeout to elapse — should get turn 2 stop + await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1) + self.assertEqual(stop_count, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user_turn_processor.py b/tests/test_user_turn_processor.py index 6a3dd3ef3..02a4b29f6 100644 --- a/tests/test_user_turn_processor.py +++ b/tests/test_user_turn_processor.py @@ -16,7 +16,7 @@ from pipecat.frames.frames import ( ) from pipecat.pipeline.pipeline import Pipeline from pipecat.tests.utils import SleepFrame, run_test -from pipecat.turns.user_stop import TranscriptionUserTurnStopStrategy +from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy from pipecat.turns.user_turn_processor import UserTurnProcessor from pipecat.turns.user_turn_strategies import UserTurnStrategies @@ -26,7 +26,11 @@ TRANSCRIPTION_TIMEOUT = 0.1 class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase): async def test_default_user_turn_strategies(self): - user_turn_processor = UserTurnProcessor(user_turn_strategies=UserTurnStrategies()) + user_turn_processor = UserTurnProcessor( + user_turn_strategies=UserTurnStrategies( + stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)], + ) + ) should_start = None should_stop = None @@ -48,6 +52,8 @@ class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase): TranscriptionFrame(text="Hello!", user_id="", timestamp="now"), SleepFrame(), VADUserStoppedSpeakingFrame(), + # Wait for user_speech_timeout to elapse + SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1), ] expected_down_frames = [ VADUserStartedSpeakingFrame, @@ -109,7 +115,7 @@ class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase): async def test_user_turn_stop_timeout_transcription(self): user_turn_processor = UserTurnProcessor( user_turn_strategies=UserTurnStrategies( - stop=[TranscriptionUserTurnStopStrategy(timeout=TRANSCRIPTION_TIMEOUT)], + stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)], ), user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT, ) @@ -135,13 +141,13 @@ class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase): pipeline = Pipeline([user_turn_processor]) + # Transcript arrives before VAD stop, then we wait for user_speech_timeout frames_to_send = [ VADUserStartedSpeakingFrame(), - VADUserStoppedSpeakingFrame(), - SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1), TranscriptionFrame(text="Hello!", user_id="", timestamp="now"), - SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1), - SleepFrame(sleep=TRANSCRIPTION_TIMEOUT), + VADUserStoppedSpeakingFrame(), + # Wait for user_speech_timeout (TRANSCRIPTION_TIMEOUT=0.1s) to elapse + SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05), ] await run_test( pipeline, @@ -152,3 +158,7 @@ class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase): self.assertTrue(should_start) self.assertTrue(should_stop) self.assertFalse(timeout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user_turn_start_strategy.py b/tests/test_user_turn_start_strategy.py index 51856ff9c..2b4f7eb43 100644 --- a/tests/test_user_turn_start_strategy.py +++ b/tests/test_user_turn_start_strategy.py @@ -38,7 +38,7 @@ class TestMinWordsInterruptionStrategy(unittest.IsolatedAsyncioTestCase): self.assertFalse(should_start) await strategy.process_frame( - TranscriptionFrame(text=" there!", user_id="cat", timestamp="") + TranscriptionFrame(text="Hello there!", user_id="cat", timestamp="") ) self.assertTrue(should_start) @@ -55,6 +55,26 @@ class TestMinWordsInterruptionStrategy(unittest.IsolatedAsyncioTestCase): ) self.assertTrue(should_start) + async def test_bot_speaking_singlw_words(self): + strategy = MinWordsUserTurnStartStrategy(min_words=3) + + should_start = None + + @strategy.event_handler("on_user_turn_started") + async def on_user_turn_started(strategy, params): + nonlocal should_start + should_start = True + + await strategy.process_frame(BotStartedSpeakingFrame()) + await strategy.process_frame(TranscriptionFrame(text="One", user_id="cat", timestamp="")) + self.assertFalse(should_start) + + await strategy.process_frame(TranscriptionFrame(text="Two", user_id="cat", timestamp="")) + self.assertFalse(should_start) + + await strategy.process_frame(TranscriptionFrame(text="Three", user_id="cat", timestamp="")) + self.assertFalse(should_start) + async def test_bot_speaking_interim_transcriptions(self): strategy = MinWordsUserTurnStartStrategy(min_words=2) @@ -179,3 +199,7 @@ class TestExternalUserTurnStartStrategy(unittest.IsolatedAsyncioTestCase): await strategy.process_frame(UserStartedSpeakingFrame()) self.assertTrue(should_start) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user_turn_stop_strategy.py b/tests/test_user_turn_stop_strategy.py index f0d336bbf..85f9f2752 100644 --- a/tests/test_user_turn_stop_strategy.py +++ b/tests/test_user_turn_stop_strategy.py @@ -9,25 +9,38 @@ import unittest from pipecat.frames.frames import ( InterimTranscriptionFrame, + STTMetadataFrame, TranscriptionFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) -from pipecat.turns.user_stop import ExternalUserTurnStopStrategy, TranscriptionUserTurnStopStrategy +from pipecat.turns.user_stop import ExternalUserTurnStopStrategy, SpeechTimeoutUserTurnStopStrategy from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams AGGREGATION_TIMEOUT = 0.1 +# Use 0 STT timeout for deterministic test timing +STT_TIMEOUT = 0.0 -class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): +class TestSpeechTimeoutUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: self.task_manager = TaskManager() self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop())) + async def _create_strategy(self, user_speech_timeout=AGGREGATION_TIMEOUT): + """Create strategy and configure STT timeout via metadata frame.""" + strategy = SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=user_speech_timeout) + await strategy.setup(self.task_manager) + # Set STT timeout via metadata frame (as would happen in real pipeline) + await strategy.process_frame( + STTMetadataFrame(service_name="test", ttfs_p99_latency=STT_TIMEOUT) + ) + return strategy + async def test_ste(self): - strategy = TranscriptionUserTurnStopStrategy() + strategy = await self._create_strategy() should_start = None @@ -46,13 +59,15 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): # E await strategy.process_frame(VADUserStoppedSpeakingFrame()) + self.assertIsNone(should_start) - # Transcription comes in between user started/stopped and there are not - # interim, we just trigger bot speech. + # Transcription came in between user started/stopped. Now we wait for + # timeout before triggering. + await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1) self.assertTrue(should_start) async def test_site(self): - strategy = TranscriptionUserTurnStopStrategy() + strategy = await self._create_strategy() should_start = None @@ -77,13 +92,15 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): # E await strategy.process_frame(VADUserStoppedSpeakingFrame()) + self.assertIsNone(should_start) - # Transcription comes in between user started/stopped, so we trigger - # speech right away. + # Transcription came in between user started/stopped. Now we wait for + # timeout before triggering. + await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1) self.assertTrue(should_start) async def test_st1iest2e(self): - strategy = TranscriptionUserTurnStopStrategy() + strategy = await self._create_strategy() should_start = None @@ -122,15 +139,14 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): # E await strategy.process_frame(VADUserStoppedSpeakingFrame()) + self.assertIsNone(should_start) - # There was an interim before the first user stopped speaking, then we - # got a transcription comes in between user started/stopped, so we - # trigger speech right away. + # Now we wait for timeout before triggering. + await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1) self.assertTrue(should_start) async def test_siet(self): - strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT) - await strategy.setup(self.task_manager) + strategy = await self._create_strategy() should_start = None @@ -163,8 +179,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): self.assertTrue(should_start) async def test_sieit(self): - strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT) - await strategy.setup(self.task_manager) + strategy = await self._create_strategy() should_start = None @@ -205,8 +220,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): self.assertTrue(should_start) async def test_set(self): - strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT) - await strategy.setup(self.task_manager) + strategy = await self._create_strategy() should_start = None @@ -235,8 +249,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): self.assertTrue(should_start) async def test_seit(self): - strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT) - await strategy.setup(self.task_manager) + strategy = await self._create_strategy() should_start = None @@ -271,8 +284,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): self.assertTrue(should_start) async def test_st1et2(self): - strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT) - await strategy.setup(self.task_manager) + strategy = await self._create_strategy() should_start = None @@ -291,26 +303,37 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): # E await strategy.process_frame(VADUserStoppedSpeakingFrame()) + self.assertIsNone(should_start) - # Transcription comes between user start/stopped speaking, we need to - # trigger speech right away. + # Transcription came between user start/stopped speaking, wait for timeout. + await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1) self.assertTrue(should_start) should_start = None + # Reset for next turn (in real usage, UserTurnController would do this) + await strategy.reset() + + # S - new turn starts + await strategy.process_frame(VADUserStartedSpeakingFrame()) + self.assertIsNone(should_start) + # T2 await strategy.process_frame( TranscriptionFrame(text="How are you?", user_id="cat", timestamp="") ) self.assertIsNone(should_start) + # E + await strategy.process_frame(VADUserStoppedSpeakingFrame()) + self.assertIsNone(should_start) + # Transcription comes after user stopped speaking, we need to wait for # at least the aggregation timeout. await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1) self.assertTrue(should_start) async def test_set1t2(self): - strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT) - await strategy.setup(self.task_manager) + strategy = await self._create_strategy() should_start = None @@ -343,8 +366,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): self.assertTrue(should_start) async def test_siet1it2(self): - strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT) - await strategy.setup(self.task_manager) + strategy = await self._create_strategy() should_start = None @@ -388,8 +410,8 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): self.assertTrue(should_start) async def test_t(self): - strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT) - await strategy.setup(self.task_manager) + """Transcription without VAD - uses fallback timeout.""" + strategy = await self._create_strategy() should_start = None @@ -402,14 +424,13 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp="")) self.assertIsNone(should_start) - # Transcription comes after user stopped speaking, we need to wait for - # at least the aggregation timeout. + # Transcription without VAD triggers fallback timeout. await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1) self.assertTrue(should_start) async def test_it(self): - strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT) - await strategy.setup(self.task_manager) + """Interim + Transcription without VAD - uses fallback timeout.""" + strategy = await self._create_strategy() should_start = None @@ -427,14 +448,12 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp="")) self.assertIsNone(should_start) - # Transcription comes after user stopped speaking, we need to wait for - # at least the aggregation timeout. + # Transcription without VAD triggers fallback timeout. await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1) self.assertTrue(should_start) async def test_sie_delay_it(self): - strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT) - await strategy.setup(self.task_manager) + strategy = await self._create_strategy() should_start = None @@ -456,24 +475,67 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): await strategy.process_frame(VADUserStoppedSpeakingFrame()) self.assertIsNone(should_start) - # Delay + # Delay - timeout expires but no transcript yet await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1) + # Still no trigger because no transcript received + self.assertIsNone(should_start) # I await strategy.process_frame( InterimTranscriptionFrame(text="How", user_id="cat", timestamp="") ) - # T + # T (finalized) - triggers immediately since timeout already elapsed + await strategy.process_frame( + TranscriptionFrame(text="How are you?", user_id="cat", timestamp="", finalized=True) + ) + + # Finalized transcript received after timeout, triggers immediately + self.assertTrue(should_start) + + async def test_reset_clears_stale_text_no_premature_stop(self): + """Test that reset() clears stale text and cancels timeout, preventing premature stop. + + Reproduces the bug from issue #4053: after turn 1 completes and + reset() is called, a late transcription sets _text. If reset() is + called again at turn 2 start, the stale _text should be cleared + so no premature stop occurs on VAD stop. + """ + strategy = await self._create_strategy() + + stop_count = 0 + + @strategy.event_handler("on_user_turn_stopped") + async def on_user_turn_stopped(strategy, params): + nonlocal stop_count + stop_count += 1 + + # === Turn 1: S-T-E === + await strategy.process_frame(VADUserStartedSpeakingFrame()) + await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp="")) + await strategy.process_frame(VADUserStoppedSpeakingFrame()) + await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1) + self.assertEqual(stop_count, 1) + + # Reset after turn 1 (as controller would do at turn stop) + await strategy.reset() + + # === Late transcription arrives between turns === + await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp="")) + + # Reset at turn 2 start (the fix: controller now resets stop strategies at turn start) + await strategy.reset() + + # === Turn 2: S-T-E (transcription arrives during turn) === + await strategy.process_frame(VADUserStartedSpeakingFrame()) await strategy.process_frame( TranscriptionFrame(text="How are you?", user_id="cat", timestamp="") ) - self.assertIsNone(should_start) + await strategy.process_frame(VADUserStoppedSpeakingFrame()) - # Transcription comes after user stopped speaking, we need to wait for - # at least the aggregation timeout. + # Wait for timeout — should get turn 2 stop with the real transcription await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1) - self.assertTrue(should_start) + self.assertEqual(stop_count, 2) class TestExternalUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): @@ -506,3 +568,7 @@ class TestExternalUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase): await strategy.process_frame(UserStoppedSpeakingFrame()) self.assertTrue(should_start) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utils_network.py b/tests/test_utils_network.py index 616ed896a..1f0048cc5 100644 --- a/tests/test_utils_network.py +++ b/tests/test_utils_network.py @@ -32,3 +32,7 @@ class TestUtilsNetwork(unittest.IsolatedAsyncioTestCase): assert exponential_backoff_time(attempt=4, min_wait=1, max_wait=20, multiplier=2) == 16 assert exponential_backoff_time(attempt=5, min_wait=1, max_wait=20, multiplier=2) == 20 assert exponential_backoff_time(attempt=6, min_wait=1, max_wait=20, multiplier=2) == 20 + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utils_string.py b/tests/test_utils_string.py index 0f4844748..5130c1daa 100644 --- a/tests/test_utils_string.py +++ b/tests/test_utils_string.py @@ -153,6 +153,46 @@ class TestUtilsString(unittest.IsolatedAsyncioTestCase): for sentence in latin_script_sentences: assert match_endofsentence(sentence), f"Failed for Latin script: {sentence}" + async def test_endofsentence_cjk_with_lookahead(self): + """Test sentence detection for CJK text with lookahead characters. + + This tests the NLTK fallback path: NLTK returns entire text as one + sentence because it doesn't support CJK languages, but unambiguous + punctuation is detected via the fallback scan. + """ + # Japanese: sentence + lookahead character + assert match_endofsentence("こんにちは。元") == 6 + assert match_endofsentence("元気ですか?は") == 6 + assert match_endofsentence("ありがとう!次") == 6 + + # Chinese: sentence + lookahead character + assert match_endofsentence("你好世界。下") == 5 + assert match_endofsentence("你好吗?我") == 4 + + # Korean: sentence + lookahead character + assert match_endofsentence("안녕하세요。다") == 6 + + # Multiple CJK sentences with lookahead - should return first sentence + assert match_endofsentence("こんにちは。元気ですか?は") == 6 + + # Indic script with lookahead + assert match_endofsentence("हैलो।अ") == 5 + + # Arabic with lookahead + assert match_endofsentence("مرحبا؟ك") == 6 + + async def test_endofsentence_latin_not_affected_by_fallback(self): + """Verify that the CJK fallback does not change behavior for Latin text.""" + # These should still return 0 - Latin "." is NOT in the unambiguous set + assert not match_endofsentence("Mr. S") + assert not match_endofsentence("Ok, Mr. Smith let's ") + assert not match_endofsentence("The number pi is 3.14159") + assert not match_endofsentence("America, or the U.S") + + # These should still return correct values via NLTK path + assert match_endofsentence("This is a sentence. This is another one") == 19 + assert match_endofsentence("For information, call 411.") == 26 + async def test_endofsentence_streaming_tokens(self): """Test the specific use case of streaming LLM tokens.""" @@ -232,3 +272,7 @@ class TestStartEndTags(unittest.IsolatedAsyncioTestCase): ("", ""), 41, ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vad_controller.py b/tests/test_vad_controller.py new file mode 100644 index 000000000..c208b5122 --- /dev/null +++ b/tests/test_vad_controller.py @@ -0,0 +1,210 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import unittest +from typing import List + +from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams, VADState +from pipecat.audio.vad.vad_controller import VADController +from pipecat.frames.frames import Frame, InputAudioRawFrame, SpeechControlParamsFrame, StartFrame +from pipecat.processors.frame_processor import FrameDirection + + +class MockVADAnalyzer(VADAnalyzer): + """A mock VAD analyzer that returns a configurable state.""" + + def __init__(self): + """Initialize with default QUIET state.""" + super().__init__(sample_rate=16000) + self._next_state = VADState.QUIET + + def set_next_state(self, state: VADState): + """Set the state to return on the next analyze_audio call. + + Args: + state: The VADState to return. + """ + self._next_state = state + + def num_frames_required(self) -> int: + return 512 + + def voice_confidence(self, buffer: bytes) -> float: + return 0.9 + + async def analyze_audio(self, buffer: bytes) -> VADState: + """Return the configured state.""" + return self._next_state + + +class TestVADController(unittest.IsolatedAsyncioTestCase): + async def test_speech_started_event(self): + """Test that on_speech_started event is triggered when speech begins.""" + analyzer = MockVADAnalyzer() + controller = VADController(analyzer) + + speech_started = False + + @controller.event_handler("on_speech_started") + async def on_speech_started(_controller): + nonlocal speech_started + speech_started = True + + start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000) + await controller.process_frame(start_frame) + + audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1) + + # Process with QUIET state - no event should fire + analyzer.set_next_state(VADState.QUIET) + await controller.process_frame(audio_frame) + self.assertFalse(speech_started) + + # Process with SPEAKING state - event should fire + analyzer.set_next_state(VADState.SPEAKING) + await controller.process_frame(audio_frame) + self.assertTrue(speech_started) + + async def test_speech_stopped_event(self): + """Test that on_speech_stopped event is triggered when speech ends.""" + analyzer = MockVADAnalyzer() + controller = VADController(analyzer) + + speech_stopped = False + + @controller.event_handler("on_speech_stopped") + async def on_speech_stopped(_controller): + nonlocal speech_stopped + speech_stopped = True + + start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000) + await controller.process_frame(start_frame) + + audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1) + + # Start speaking + analyzer.set_next_state(VADState.SPEAKING) + await controller.process_frame(audio_frame) + self.assertFalse(speech_stopped) + + # Stop speaking - event should fire + analyzer.set_next_state(VADState.QUIET) + await controller.process_frame(audio_frame) + self.assertTrue(speech_stopped) + + async def test_speech_activity_event(self): + """Test that on_speech_activity event is triggered while speaking.""" + analyzer = MockVADAnalyzer() + controller = VADController(analyzer) + + activity_count = 0 + + @controller.event_handler("on_speech_activity") + async def on_speech_activity(_controller): + nonlocal activity_count + activity_count += 1 + + start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000) + await controller.process_frame(start_frame) + + audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1) + + # Activity events fire while in SPEAKING state + analyzer.set_next_state(VADState.SPEAKING) + await controller.process_frame(audio_frame) + await controller.process_frame(audio_frame) + self.assertEqual(activity_count, 2) + + async def test_push_frame_event(self): + """Test that push_frame emits on_push_frame event.""" + analyzer = MockVADAnalyzer() + controller = VADController(analyzer) + + pushed_frames: List[tuple] = [] + + @controller.event_handler("on_push_frame") + async def on_push_frame(_controller, frame: Frame, direction: FrameDirection): + pushed_frames.append((frame, direction)) + + test_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1) + await controller.push_frame(test_frame, FrameDirection.DOWNSTREAM) + + self.assertEqual(len(pushed_frames), 1) + self.assertEqual(pushed_frames[0][0], test_frame) + self.assertEqual(pushed_frames[0][1], FrameDirection.DOWNSTREAM) + + async def test_broadcast_frame_event(self): + """Test that broadcast_frame emits on_broadcast_frame event.""" + analyzer = MockVADAnalyzer() + controller = VADController(analyzer) + + broadcast_calls: List[tuple] = [] + + @controller.event_handler("on_broadcast_frame") + async def on_broadcast_frame(_controller, frame_cls, **kwargs): + broadcast_calls.append((frame_cls, kwargs)) + + await controller.broadcast_frame( + InputAudioRawFrame, audio=b"\x00", sample_rate=16000, num_channels=1 + ) + + self.assertEqual(len(broadcast_calls), 1) + self.assertEqual(broadcast_calls[0][0], InputAudioRawFrame) + self.assertEqual(broadcast_calls[0][1]["sample_rate"], 16000) + + async def test_no_event_on_transitional_states(self): + """Test that STARTING and STOPPING states don't trigger events.""" + analyzer = MockVADAnalyzer() + controller = VADController(analyzer) + + events_triggered = [] + + @controller.event_handler("on_speech_started") + async def on_speech_started(_controller): + events_triggered.append("started") + + @controller.event_handler("on_speech_stopped") + async def on_speech_stopped(_controller): + events_triggered.append("stopped") + + start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000) + await controller.process_frame(start_frame) + + audio_frame = InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1) + + # STARTING is a transitional state - no event + analyzer.set_next_state(VADState.STARTING) + await controller.process_frame(audio_frame) + self.assertEqual(events_triggered, []) + + # STOPPING is a transitional state - no event + analyzer.set_next_state(VADState.STOPPING) + await controller.process_frame(audio_frame) + self.assertEqual(events_triggered, []) + + async def test_start_frame_broadcasts_vad_params(self): + """Test that StartFrame triggers broadcast of SpeechControlParamsFrame with VAD params.""" + analyzer = MockVADAnalyzer() + controller = VADController(analyzer) + + broadcast_calls: List[tuple] = [] + + @controller.event_handler("on_broadcast_frame") + async def on_broadcast_frame(_controller, frame_cls, **kwargs): + broadcast_calls.append((frame_cls, kwargs)) + + start_frame = StartFrame(audio_in_sample_rate=16000, audio_out_sample_rate=16000) + await controller.process_frame(start_frame) + + # Should have broadcast SpeechControlParamsFrame with VAD params + self.assertEqual(len(broadcast_calls), 1) + self.assertEqual(broadcast_calls[0][0], SpeechControlParamsFrame) + self.assertIn("vad_params", broadcast_calls[0][1]) + self.assertIsInstance(broadcast_calls[0][1]["vad_params"], VADParams) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vad_processor.py b/tests/test_vad_processor.py new file mode 100644 index 000000000..afb6e1482 --- /dev/null +++ b/tests/test_vad_processor.py @@ -0,0 +1,150 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import unittest +from typing import List + +from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADState +from pipecat.frames.frames import ( + InputAudioRawFrame, + SpeechControlParamsFrame, + UserSpeakingFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, +) +from pipecat.processors.audio.vad_processor import VADProcessor +from pipecat.tests.utils import run_test + + +class MockVADAnalyzer(VADAnalyzer): + """A mock VAD analyzer that returns states from a predefined sequence.""" + + def __init__(self, states: List[VADState]): + super().__init__(sample_rate=16000) + self._states = list(states) + self._call_index = 0 + + def num_frames_required(self) -> int: + return 512 + + def voice_confidence(self, buffer: bytes) -> float: + return 0.9 + + async def analyze_audio(self, buffer: bytes) -> VADState: + if self._call_index < len(self._states): + state = self._states[self._call_index] + self._call_index += 1 + return state + return VADState.QUIET + + +class TestVADProcessor(unittest.IsolatedAsyncioTestCase): + def _make_audio_frame(self): + return InputAudioRawFrame(audio=b"\x00" * 1024, sample_rate=16000, num_channels=1) + + async def test_forwards_audio_frames(self): + """Test that audio frames are forwarded downstream.""" + analyzer = MockVADAnalyzer([VADState.QUIET]) + processor = VADProcessor(vad_analyzer=analyzer) + + await run_test( + processor, + frames_to_send=[self._make_audio_frame()], + expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame], + ) + + async def test_pushes_started_speaking_frame(self): + """Test that VADUserStartedSpeakingFrame is pushed when speech starts.""" + analyzer = MockVADAnalyzer([VADState.QUIET, VADState.SPEAKING]) + processor = VADProcessor(vad_analyzer=analyzer) + + # Audio frames are forwarded first, then VAD processes and broadcasts VAD frames + await run_test( + processor, + frames_to_send=[self._make_audio_frame(), self._make_audio_frame()], + expected_down_frames=[ + SpeechControlParamsFrame, + InputAudioRawFrame, + InputAudioRawFrame, + VADUserStartedSpeakingFrame, + UserSpeakingFrame, + ], + ) + + async def test_pushes_stopped_speaking_frame(self): + """Test that VADUserStoppedSpeakingFrame is pushed when speech stops.""" + analyzer = MockVADAnalyzer([VADState.SPEAKING, VADState.QUIET]) + processor = VADProcessor(vad_analyzer=analyzer) + + # Audio frames are forwarded first, then VAD processes and broadcasts VAD frames + await run_test( + processor, + frames_to_send=[self._make_audio_frame(), self._make_audio_frame()], + expected_down_frames=[ + SpeechControlParamsFrame, + InputAudioRawFrame, + VADUserStartedSpeakingFrame, + UserSpeakingFrame, + InputAudioRawFrame, + VADUserStoppedSpeakingFrame, + ], + ) + + async def test_pushes_user_speaking_frame(self): + """Test that UserSpeakingFrame is pushed while speaking.""" + analyzer = MockVADAnalyzer([VADState.SPEAKING, VADState.SPEAKING]) + processor = VADProcessor(vad_analyzer=analyzer) + + # Audio frames are forwarded first, then VAD processes and broadcasts VAD frames + await run_test( + processor, + frames_to_send=[self._make_audio_frame(), self._make_audio_frame()], + expected_down_frames=[ + SpeechControlParamsFrame, + InputAudioRawFrame, + VADUserStartedSpeakingFrame, + UserSpeakingFrame, + InputAudioRawFrame, + UserSpeakingFrame, + ], + ) + + async def test_no_vad_frames_on_starting_state(self): + """Test that STARTING state doesn't push VAD frames.""" + analyzer = MockVADAnalyzer([VADState.STARTING]) + processor = VADProcessor(vad_analyzer=analyzer) + + await run_test( + processor, + frames_to_send=[self._make_audio_frame()], + expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame], + ) + + async def test_no_vad_frames_on_stopping_state(self): + """Test that STOPPING state doesn't push VAD frames.""" + analyzer = MockVADAnalyzer([VADState.STOPPING]) + processor = VADProcessor(vad_analyzer=analyzer) + + await run_test( + processor, + frames_to_send=[self._make_audio_frame()], + expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame], + ) + + async def test_no_vad_frames_when_quiet(self): + """Test that no VAD frames are pushed when staying quiet.""" + analyzer = MockVADAnalyzer([VADState.QUIET, VADState.QUIET]) + processor = VADProcessor(vad_analyzer=analyzer) + + await run_test( + processor, + frames_to_send=[self._make_audio_frame(), self._make_audio_frame()], + expected_down_frames=[SpeechControlParamsFrame, InputAudioRawFrame, InputAudioRawFrame], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wake_phrase_user_turn_start_strategy.py b/tests/test_wake_phrase_user_turn_start_strategy.py new file mode 100644 index 000000000..e79b9ab99 --- /dev/null +++ b/tests/test_wake_phrase_user_turn_start_strategy.py @@ -0,0 +1,346 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import unittest + +from pipecat.frames.frames import ( + BotSpeakingFrame, + InterimTranscriptionFrame, + TranscriptionFrame, + UserSpeakingFrame, + VADUserStartedSpeakingFrame, +) +from pipecat.turns.types import ProcessFrameResult +from pipecat.turns.user_start.wake_phrase_user_turn_start_strategy import ( + WakePhraseUserTurnStartStrategy, + _WakeState, +) +from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams + + +class TestWakePhraseUserTurnStartStrategy(unittest.IsolatedAsyncioTestCase): + def _create_strategy(self, **kwargs) -> WakePhraseUserTurnStartStrategy: + kwargs.setdefault("phrases", ["hey pipecat"]) + kwargs.setdefault("timeout", 10.0) + return WakePhraseUserTurnStartStrategy(**kwargs) + + async def _setup_strategy(self, strategy: WakePhraseUserTurnStartStrategy): + task_manager = TaskManager() + loop = asyncio.get_running_loop() + task_manager.setup(TaskManagerParams(loop=loop)) + await strategy.setup(task_manager) + return task_manager + + async def test_wake_phrase_in_final_transcription(self): + strategy = self._create_strategy() + await self._setup_strategy(strategy) + + result = await strategy.process_frame( + TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.STOP) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + await strategy.cleanup() + + async def test_interim_transcription_ignored(self): + """Interim transcriptions are never used for wake phrase matching.""" + strategy = self._create_strategy() + await self._setup_strategy(strategy) + + result = await strategy.process_frame( + InterimTranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.STOP) + self.assertEqual(strategy.state, _WakeState.IDLE) + + await strategy.cleanup() + + async def test_no_wake_phrase_returns_stop(self): + strategy = self._create_strategy() + await self._setup_strategy(strategy) + + result = await strategy.process_frame( + TranscriptionFrame(text="hello world", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.STOP) + self.assertEqual(strategy.state, _WakeState.IDLE) + + await strategy.cleanup() + + async def test_non_matching_text_resets_aggregation(self): + """Non-matching transcription triggers aggregation reset to prevent LLM context pollution.""" + strategy = self._create_strategy() + await self._setup_strategy(strategy) + + reset_called = False + + @strategy.event_handler("on_reset_aggregation") + async def on_reset_aggregation(strategy): + nonlocal reset_called + reset_called = True + + await strategy.process_frame( + TranscriptionFrame(text="hello world", user_id="user1", timestamp="") + ) + self.assertTrue(reset_called) + + await strategy.cleanup() + + async def test_vad_frame_returns_stop_in_listening(self): + strategy = self._create_strategy() + await self._setup_strategy(strategy) + + result = await strategy.process_frame(VADUserStartedSpeakingFrame()) + self.assertEqual(result, ProcessFrameResult.STOP) + self.assertEqual(strategy.state, _WakeState.IDLE) + + await strategy.cleanup() + + async def test_inactive_returns_continue(self): + strategy = self._create_strategy() + await self._setup_strategy(strategy) + + # Trigger wake phrase first. + await strategy.process_frame( + TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + # Subsequent frames should return CONTINUE. + result = await strategy.process_frame(VADUserStartedSpeakingFrame()) + self.assertEqual(result, ProcessFrameResult.CONTINUE) + + result = await strategy.process_frame( + TranscriptionFrame(text="what is the weather", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.CONTINUE) + + await strategy.cleanup() + + async def test_accumulation_across_frames(self): + strategy = self._create_strategy() + await self._setup_strategy(strategy) + + result = await strategy.process_frame( + TranscriptionFrame(text="hey", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.STOP) + self.assertEqual(strategy.state, _WakeState.IDLE) + + result = await strategy.process_frame( + TranscriptionFrame(text="pipecat", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.STOP) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + await strategy.cleanup() + + async def test_multiple_phrases(self): + strategy = self._create_strategy(phrases=["hey pipecat", "ok computer"]) + await self._setup_strategy(strategy) + + result = await strategy.process_frame( + TranscriptionFrame(text="ok computer", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.STOP) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + await strategy.cleanup() + + async def test_punctuation_stripped(self): + """STT punctuation like 'Hey, Pipecat!' should still match.""" + strategy = self._create_strategy() + await self._setup_strategy(strategy) + + result = await strategy.process_frame( + TranscriptionFrame(text="Hey, Pipecat!", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.STOP) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + await strategy.cleanup() + + async def test_reset_preserves_inactive_state(self): + strategy = self._create_strategy() + await self._setup_strategy(strategy) + + await strategy.process_frame( + TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + await strategy.reset() + self.assertEqual(strategy.state, _WakeState.AWAKE) + + await strategy.cleanup() + + async def test_timeout_returns_to_listening(self): + strategy = self._create_strategy(timeout=0.1) + await self._setup_strategy(strategy) + + # Trigger wake phrase. + await strategy.process_frame( + TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + # Wait for timeout to expire. + await asyncio.sleep(0.3) + + self.assertEqual(strategy.state, _WakeState.IDLE) + + await strategy.cleanup() + + async def test_activity_refreshes_timeout(self): + strategy = self._create_strategy(timeout=0.2) + await self._setup_strategy(strategy) + + # Trigger wake phrase. + await strategy.process_frame( + TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + # Send activity before timeout. + await asyncio.sleep(0.1) + await strategy.process_frame(UserSpeakingFrame()) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + # Send more activity. + await asyncio.sleep(0.1) + await strategy.process_frame(BotSpeakingFrame()) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + # Wait for timeout to expire after last activity. + await asyncio.sleep(0.3) + self.assertEqual(strategy.state, _WakeState.IDLE) + + await strategy.cleanup() + + async def test_wake_phrase_detected_event(self): + strategy = self._create_strategy() + await self._setup_strategy(strategy) + + detected_phrase = None + + @strategy.event_handler("on_wake_phrase_detected") + async def on_wake_phrase_detected(strategy, phrase): + nonlocal detected_phrase + detected_phrase = phrase + + await strategy.process_frame( + TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + + # Event fires in a background task, give it a moment. + await asyncio.sleep(0.05) + self.assertEqual(detected_phrase, "hey pipecat") + + await strategy.cleanup() + + async def test_wake_phrase_timeout_event(self): + strategy = self._create_strategy(timeout=0.1) + await self._setup_strategy(strategy) + + timeout_fired = False + + @strategy.event_handler("on_wake_phrase_timeout") + async def on_wake_phrase_timeout(strategy): + nonlocal timeout_fired + timeout_fired = True + + await strategy.process_frame( + TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + + # Wait for timeout. + await asyncio.sleep(0.3) + self.assertTrue(timeout_fired) + + await strategy.cleanup() + + async def test_single_activation_stays_inactive_after_reset(self): + """In single activation mode, reset() keeps INACTIVE so the current turn can finish.""" + strategy = self._create_strategy(single_activation=True, timeout=0.5) + await self._setup_strategy(strategy) + + # Trigger wake phrase. + result = await strategy.process_frame( + TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.STOP) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + # Simulate turn start (controller calls reset on all start strategies). + await strategy.reset() + # State remains INACTIVE so frames continue to flow. + self.assertEqual(strategy.state, _WakeState.AWAKE) + + # Subsequent frames should pass through (CONTINUE). + result = await strategy.process_frame(VADUserStartedSpeakingFrame()) + self.assertEqual(result, ProcessFrameResult.CONTINUE) + + result = await strategy.process_frame( + TranscriptionFrame(text="what is the weather", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.CONTINUE) + + await strategy.cleanup() + + async def test_single_activation_timeout_returns_to_listening(self): + """In single activation mode, the keepalive timeout returns to LISTENING.""" + strategy = self._create_strategy(single_activation=True, timeout=0.1) + await self._setup_strategy(strategy) + + # Trigger wake phrase. + await strategy.process_frame( + TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + # Wait for keepalive timeout to expire. + await asyncio.sleep(0.3) + self.assertEqual(strategy.state, _WakeState.IDLE) + + # Frames should now be blocked again. + result = await strategy.process_frame(VADUserStartedSpeakingFrame()) + self.assertEqual(result, ProcessFrameResult.STOP) + + await strategy.cleanup() + + async def test_single_activation_requires_wake_phrase_after_timeout(self): + """Single activation mode requires wake phrase again after keepalive timeout.""" + strategy = self._create_strategy(single_activation=True, timeout=0.1) + await self._setup_strategy(strategy) + + # First turn: wake phrase -> INACTIVE -> timeout -> LISTENING. + await strategy.process_frame( + TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + self.assertEqual(strategy.state, _WakeState.AWAKE) + await asyncio.sleep(0.3) + self.assertEqual(strategy.state, _WakeState.IDLE) + + # Without wake phrase, frames are blocked. + result = await strategy.process_frame( + TranscriptionFrame(text="what is the weather", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.STOP) + + # Second turn: wake phrase again. + result = await strategy.process_frame( + TranscriptionFrame(text="hey pipecat", user_id="user1", timestamp="") + ) + self.assertEqual(result, ProcessFrameResult.STOP) + self.assertEqual(strategy.state, _WakeState.AWAKE) + + await strategy.cleanup() + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 51a0a2ec1..1263097a8 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", "python_full_version < '3.11'", @@ -14,7 +15,8 @@ version = "1.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, { name = "psutil" }, { name = "pyyaml" }, @@ -26,22 +28,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/a0/d9ef19f780f319c21ee90ecfef4431cbeeca95bec7f14071785c17b6029b/accelerate-1.10.1-py3-none-any.whl", hash = "sha256:3621cff60b9a27ce798857ece05e2b9f56fcc71631cfb31ccf71f0359c311f11", size = 374909, upload-time = "2025-08-25T13:57:04.55Z" }, ] -[[package]] -name = "aenum" -version = "3.1.16" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/52/6ad8f63ec8da1bf40f96996d25d5b650fdd38f5975f8c813732c47388f18/aenum-3.1.16-py3-none-any.whl", hash = "sha256:9035092855a98e41b66e3d0998bd7b96280e85ceb3a04cc035636138a1943eaf", size = 165627, upload-time = "2025-04-25T03:17:58.89Z" }, -] - [[package]] name = "aic-sdk" -version = "1.2.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/d1/faca6596c0d598063b4b9e879f4110fde9dee1496273d6410505bc81fdcc/aic_sdk-2.1.0.tar.gz", hash = "sha256:b661743dce36413ddd264b909d818dfc997c3a189e4c52fed263f2177ee3bb17", size = 5315216, upload-time = "2026-02-27T23:04:43.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/69/c1585d8b1e98cc1614cb3825714c5dac962db4ca7febf0ebcd5fbdc125d7/aic_sdk-2.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ecbc90101cfa86f8428c699ffbbacd60b11f3d683f9cb82976a5e6dca7ab1bf1", size = 3592958, upload-time = "2026-02-27T23:03:56.657Z" }, + { url = "https://files.pythonhosted.org/packages/08/34/f1d09f74ff6e8c830777109008761a0452144ff14790b498bf423a99ff93/aic_sdk-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:54b21f2fd8388e5077ae597e11e0222d4bdf2c2f15ed49296a8a231a3b19be82", size = 3204056, upload-time = "2026-02-27T23:03:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/03/04/aa22b45ef00909ec1964fae1871b08e1c2282ebe29c71e51fc9e8702baef/aic_sdk-2.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57cd0938370b37ebab31d63912dbf93ac0dd727d9d95840f1bdcba71e1a885e9", size = 3120257, upload-time = "2026-02-27T23:03:59.505Z" }, + { url = "https://files.pythonhosted.org/packages/f8/17/44fc4a4da7e792fd6e00e720237ac3183b10f02fa77d68fa151632753ab0/aic_sdk-2.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b41c51553e37cf811cb8ba5e957581e3cd0c87f833789dcdb6ab62ba803bfdbf", size = 3476457, upload-time = "2026-02-27T23:04:00.84Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7c/666d348e2502f0b8a58b858f45a528f02980ffff661512a73d6eb8b17c54/aic_sdk-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:7359a5c1db01a916ebeea24906bdafa5f2bb320aada50bcca75e28a09be1f54e", size = 2961331, upload-time = "2026-02-27T23:04:02.254Z" }, + { url = "https://files.pythonhosted.org/packages/4d/18/5e02f96c52ee92cf91d4044ef426a45a65863617b388cd45f487d334bf62/aic_sdk-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:3a78f68b6073113a41615614de760c734d776bcdd5a0340c7d77e8dbb45bde55", size = 2647972, upload-time = "2026-02-27T23:04:03.916Z" }, + { url = "https://files.pythonhosted.org/packages/b1/33/db0009e8c0337a14c4501d0bcf081ed7949e95dcc48fd49734b4f7a32715/aic_sdk-2.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b574c041e1624dd0a4c6a34517dfe40bef26749716d398e169c6729a44eb166c", size = 3592590, upload-time = "2026-02-27T23:04:05.343Z" }, + { url = "https://files.pythonhosted.org/packages/dc/79/345a3c1cacd14d12bcfa0a45563ae0470cc227f3bc8ab2c64753b886036e/aic_sdk-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9027253eb9d438e1e859578c381b57f0f71cfd96186e3999c25cbcfc6ac9a6e6", size = 3204174, upload-time = "2026-02-27T23:04:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/9e/28/109e9d69a95980d0805a95ffe934a7653fcef64fdb8f2ed0dffe2c6ba4d9/aic_sdk-2.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6003104cc44e588a022d0bbdcc09db38de20a058e07e8ae160006f0abdbe9874", size = 3120225, upload-time = "2026-02-27T23:04:08.679Z" }, + { url = "https://files.pythonhosted.org/packages/bd/74/ba9be31a9e47c6297cae39008b6893801626f0a45b61f7543d2bc42449e3/aic_sdk-2.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:134d847967549c45f3bc952c47c7f1c6cf76be4e61e56c9ae1df60fc1657ccb4", size = 3476431, upload-time = "2026-02-27T23:04:10.276Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/f94aabf70fcf443d1b6c256675e7a67ce25bec47daeefc93ec561b0fb0da/aic_sdk-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d85c3419825d154889bfebaa5d8a50855ddfb4d9b24722f174fe7e4666d1deb", size = 2961012, upload-time = "2026-02-27T23:04:11.996Z" }, + { url = "https://files.pythonhosted.org/packages/52/ec/ca4dae8adb9b799c1a4455a1e5f39242c73e87c10b71e57eaf650d6c455a/aic_sdk-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:c612f246ccf2f10e57151972926b7aefdf0d006633a2496f0e5f31dae5ce6b4a", size = 2648071, upload-time = "2026-02-27T23:04:13.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/6628d40bee36683fc0326cb04cda86110117cac1d8f0be01853fe5947901/aic_sdk-2.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:aa48d55ae3fa8768616f79e1d6794558edb35aa622b3741380b266ff62d7e109", size = 3592839, upload-time = "2026-02-27T23:04:15.968Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/b4dd728d224159b282c23a1da2f3469b4c73c786067214e11e4612948e99/aic_sdk-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d67c00d462342381f44de49340482e1f29625af71f5266bd2a96bbfe5beb684", size = 3204605, upload-time = "2026-02-27T23:04:18.658Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1b/0826e0fe91efd84899afe645ac184514a1f326beccc741c7a2dda65a44a5/aic_sdk-2.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e36280c0d6adfff644c8c29c6274e12fb0d805b7c97cf6395f35c10f45e1150", size = 3119984, upload-time = "2026-02-27T23:04:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/6b/34/aa722c8fb6770713caf85c3ead104ddbe64d099f0627da67acc95f9dad40/aic_sdk-2.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b110813c3784ba37c4df992a157ac53f4a65fd17aec604f233eda031857607d2", size = 3477716, upload-time = "2026-02-27T23:04:21.643Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a7/e2173e19153e91520b0926f53649fdda37bda40082b66e42039bfacfbb27/aic_sdk-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:98ce4ba6d3afe8c04425a0b3fd630369f1300ed1bfee82d9f197d521827aa257", size = 2958707, upload-time = "2026-02-27T23:04:22.992Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/4bb29c673e739453309c6e5acac1fd451bf9ecc12ba8f0bfeb6497e9658a/aic_sdk-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:aedeedc12009ca0e0dd662aa5df55fd3cfcbe4d67eb84e08b7df92288c6f2908", size = 2644622, upload-time = "2026-02-27T23:04:24.328Z" }, + { url = "https://files.pythonhosted.org/packages/1f/07/84856156fb2fbe17f502b6e37468010eeb5d699ab818047524e1ea98fe28/aic_sdk-2.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7b837806b43959ee7405ffd5a129bd5414651664ae503b6cee742331fbb86c72", size = 3592075, upload-time = "2026-02-27T23:04:25.699Z" }, + { url = "https://files.pythonhosted.org/packages/44/8d/29beab45bd22f95adf5f1db51d6c56386b9e83b4518c1db37f867523b419/aic_sdk-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6e0b5d2117f203de43e1e4edb85ca751cb62ccc74a1a216a9f76f0f171c36c25", size = 3204125, upload-time = "2026-02-27T23:04:27.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/cc87240ad4907d23c96cc9b4645e6fd9b73f48e3a2e337dd2c7bf7f72d6e/aic_sdk-2.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322b56a6c636acd5b869c09a55deb280f8eb56e7b9d13cb9ac833616491b0c46", size = 3119501, upload-time = "2026-02-27T23:04:28.28Z" }, + { url = "https://files.pythonhosted.org/packages/87/b3/a15bb0721c54b440291c4f0f58cacb3f8f7a6848394956aca7e5dcad59a4/aic_sdk-2.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6ce9b1f54150644825e2ffda7f2a8eb6a60d207c4e2265124afb90277351065", size = 3476855, upload-time = "2026-02-27T23:04:29.642Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/76ac25c997569248d3bfa0c812468f3917a15bcd957c3ed7e066ab928d56/aic_sdk-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4290a6d0220fe8e9edd1c328d5c2b6bc8c4f4c36b50c24ab0eb073a71d8c58be", size = 2958258, upload-time = "2026-02-27T23:04:31.493Z" }, + { url = "https://files.pythonhosted.org/packages/ee/12/c463bbbc71c19fb1b74015f72301c3d762acc83a8311c1f2f9d9150c9926/aic_sdk-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:bee85004a44bcc50146c07796d3f71ab58df465a1110a98a04d715c54748910e", size = 2644105, upload-time = "2026-02-27T23:04:32.728Z" }, + { url = "https://files.pythonhosted.org/packages/f5/05/f78557c1c8636d3f2a25a74b38fb805ef98ca90aaaa772f83b77f2123072/aic_sdk-2.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4c1f4cf18e7f44bc554dc90d94d6ff5250f9ffcc0edf91fab0e95f11ae859ac9", size = 3593602, upload-time = "2026-02-27T23:04:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/17/87/67c333feb57df6a288f8b7c5245feab5b2248870499a958121f103f60b1c/aic_sdk-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:679260aaa6ecb8ebf531c10d2eaa148edfc91c1c31a84bf63d8c3aa691d09afc", size = 3204838, upload-time = "2026-02-27T23:04:36.326Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a6/9964ab0e93139f214a23717101b6834ca3422f5a6787ca61ea852f6772ec/aic_sdk-2.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcf8e9d84a3cefff9aca3a84224c210439e9590d8461d8c718e0cee5a7054744", size = 3120147, upload-time = "2026-02-27T23:04:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/c6823e1f02559884eed7aeb4b580d5155568628748af844952fdf833858e/aic_sdk-2.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e845faeaab18ce76d8ad81d9dbbeab8bca12c7f8503a1e5a6a638e2fac5ee8d", size = 3478437, upload-time = "2026-02-27T23:04:39.092Z" }, + { url = "https://files.pythonhosted.org/packages/96/9c/21e7a85d4ad27cce9e8e385f5ab2f1b25b547cc74597a806945025d75efc/aic_sdk-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:31434001b7963bbc8a92602b6e57149c95f94e7430edbb956230f29235b9098a", size = 2960332, upload-time = "2026-02-27T23:04:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e4/0a537fb4653deedfbd0b8f55eceef618f0555c641751533cbeaf9f302717/aic_sdk-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:2efff1e70704f0cc44bab078a2d227eaf94e0c63c32f6bfbe63915dc5335161e", size = 2645446, upload-time = "2026-02-27T23:04:42.107Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/ba/3ebe31b91e03d42437ec864e9d2af3a52b7ccc73a1a0c1026275956270b0/aic_sdk-1.2.0.tar.gz", hash = "sha256:eeda9a181c679f175dbe6f0efc0c67ec98ff3d84cfe01541fef7fa12ecd505ca", size = 35606, upload-time = "2025-11-20T14:42:14.333Z" } [[package]] name = "aioboto3" @@ -99,7 +126,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.12.15" +version = "3.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -111,117 +138,150 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/dc/ef9394bde9080128ad401ac7ede185267ed637df03b51f05d14d1c99ad67/aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc", size = 703921, upload-time = "2025-07-29T05:49:43.584Z" }, - { url = "https://files.pythonhosted.org/packages/8f/42/63fccfc3a7ed97eb6e1a71722396f409c46b60a0552d8a56d7aad74e0df5/aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af", size = 480288, upload-time = "2025-07-29T05:49:47.851Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a2/7b8a020549f66ea2a68129db6960a762d2393248f1994499f8ba9728bbed/aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421", size = 468063, upload-time = "2025-07-29T05:49:49.789Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f5/d11e088da9176e2ad8220338ae0000ed5429a15f3c9dfd983f39105399cd/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79", size = 1650122, upload-time = "2025-07-29T05:49:51.874Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6b/b60ce2757e2faed3d70ed45dafee48cee7bfb878785a9423f7e883f0639c/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77", size = 1624176, upload-time = "2025-07-29T05:49:53.805Z" }, - { url = "https://files.pythonhosted.org/packages/dd/de/8c9fde2072a1b72c4fadecf4f7d4be7a85b1d9a4ab333d8245694057b4c6/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c", size = 1696583, upload-time = "2025-07-29T05:49:55.338Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ad/07f863ca3d895a1ad958a54006c6dafb4f9310f8c2fdb5f961b8529029d3/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4", size = 1738896, upload-time = "2025-07-29T05:49:57.045Z" }, - { url = "https://files.pythonhosted.org/packages/20/43/2bd482ebe2b126533e8755a49b128ec4e58f1a3af56879a3abdb7b42c54f/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6", size = 1643561, upload-time = "2025-07-29T05:49:58.762Z" }, - { url = "https://files.pythonhosted.org/packages/23/40/2fa9f514c4cf4cbae8d7911927f81a1901838baf5e09a8b2c299de1acfe5/aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2", size = 1583685, upload-time = "2025-07-29T05:50:00.375Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c3/94dc7357bc421f4fb978ca72a201a6c604ee90148f1181790c129396ceeb/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d", size = 1627533, upload-time = "2025-07-29T05:50:02.306Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3f/1f8911fe1844a07001e26593b5c255a685318943864b27b4e0267e840f95/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb", size = 1638319, upload-time = "2025-07-29T05:50:04.282Z" }, - { url = "https://files.pythonhosted.org/packages/4e/46/27bf57a99168c4e145ffee6b63d0458b9c66e58bb70687c23ad3d2f0bd17/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5", size = 1613776, upload-time = "2025-07-29T05:50:05.863Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7e/1d2d9061a574584bb4ad3dbdba0da90a27fdc795bc227def3a46186a8bc1/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b", size = 1693359, upload-time = "2025-07-29T05:50:07.563Z" }, - { url = "https://files.pythonhosted.org/packages/08/98/bee429b52233c4a391980a5b3b196b060872a13eadd41c3a34be9b1469ed/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065", size = 1716598, upload-time = "2025-07-29T05:50:09.33Z" }, - { url = "https://files.pythonhosted.org/packages/57/39/b0314c1ea774df3392751b686104a3938c63ece2b7ce0ba1ed7c0b4a934f/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1", size = 1644940, upload-time = "2025-07-29T05:50:11.334Z" }, - { url = "https://files.pythonhosted.org/packages/1b/83/3dacb8d3f8f512c8ca43e3fa8a68b20583bd25636ffa4e56ee841ffd79ae/aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a", size = 429239, upload-time = "2025-07-29T05:50:12.803Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f9/470b5daba04d558c9673ca2034f28d067f3202a40e17804425f0c331c89f/aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830", size = 452297, upload-time = "2025-07-29T05:50:14.266Z" }, - { url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246, upload-time = "2025-07-29T05:50:15.937Z" }, - { url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515, upload-time = "2025-07-29T05:50:17.442Z" }, - { url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776, upload-time = "2025-07-29T05:50:19.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6c/f766d0aaafcee0447fad0328da780d344489c042e25cd58fde566bf40aed/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5", size = 1741977, upload-time = "2025-07-29T05:50:21.665Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/fb779a05ba6ff44d7bc1e9d24c644e876bfff5abe5454f7b854cace1b9cc/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728", size = 1690645, upload-time = "2025-07-29T05:50:23.333Z" }, - { url = "https://files.pythonhosted.org/packages/37/4e/a22e799c2035f5d6a4ad2cf8e7c1d1bd0923192871dd6e367dafb158b14c/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16", size = 1789437, upload-time = "2025-07-29T05:50:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/28/e5/55a33b991f6433569babb56018b2fb8fb9146424f8b3a0c8ecca80556762/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0", size = 1828482, upload-time = "2025-07-29T05:50:26.693Z" }, - { url = "https://files.pythonhosted.org/packages/c6/82/1ddf0ea4f2f3afe79dffed5e8a246737cff6cbe781887a6a170299e33204/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b", size = 1730944, upload-time = "2025-07-29T05:50:28.382Z" }, - { url = "https://files.pythonhosted.org/packages/1b/96/784c785674117b4cb3877522a177ba1b5e4db9ce0fd519430b5de76eec90/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd", size = 1668020, upload-time = "2025-07-29T05:50:30.032Z" }, - { url = "https://files.pythonhosted.org/packages/12/8a/8b75f203ea7e5c21c0920d84dd24a5c0e971fe1e9b9ebbf29ae7e8e39790/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8", size = 1716292, upload-time = "2025-07-29T05:50:31.983Z" }, - { url = "https://files.pythonhosted.org/packages/47/0b/a1451543475bb6b86a5cfc27861e52b14085ae232896a2654ff1231c0992/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50", size = 1711451, upload-time = "2025-07-29T05:50:33.989Z" }, - { url = "https://files.pythonhosted.org/packages/55/fd/793a23a197cc2f0d29188805cfc93aa613407f07e5f9da5cd1366afd9d7c/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676", size = 1691634, upload-time = "2025-07-29T05:50:35.846Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bf/23a335a6670b5f5dfc6d268328e55a22651b440fca341a64fccf1eada0c6/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7", size = 1785238, upload-time = "2025-07-29T05:50:37.597Z" }, - { url = "https://files.pythonhosted.org/packages/57/4f/ed60a591839a9d85d40694aba5cef86dde9ee51ce6cca0bb30d6eb1581e7/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7", size = 1805701, upload-time = "2025-07-29T05:50:39.591Z" }, - { url = "https://files.pythonhosted.org/packages/85/e0/444747a9455c5de188c0f4a0173ee701e2e325d4b2550e9af84abb20cdba/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685", size = 1718758, upload-time = "2025-07-29T05:50:41.292Z" }, - { url = "https://files.pythonhosted.org/packages/36/ab/1006278d1ffd13a698e5dd4bfa01e5878f6bddefc296c8b62649753ff249/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b", size = 428868, upload-time = "2025-07-29T05:50:43.063Z" }, - { url = "https://files.pythonhosted.org/packages/10/97/ad2b18700708452400278039272032170246a1bf8ec5d832772372c71f1a/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d", size = 453273, upload-time = "2025-07-29T05:50:44.613Z" }, - { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, - { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, - { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, - { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, - { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, - { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, - { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, - { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, - { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, - { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, - { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, - { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, - { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, - { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, - { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, - { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, - { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, - { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, + { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, + { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, + { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, + { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, ] [[package]] name = "aioice" -version = "0.10.1" +version = "0.10.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dnspython" }, { name = "ifaddr" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/a2/45dfab1d5a7f96c48595a5770379acf406cdf02a2cd1ac1729b599322b08/aioice-0.10.1.tar.gz", hash = "sha256:5c8e1422103448d171925c678fb39795e5fe13d79108bebb00aa75a899c2094a", size = 44304, upload-time = "2025-04-13T08:15:25.629Z" } +sdist = { url = "https://files.pythonhosted.org/packages/67/04/df7286233f468e19e9bedff023b6b246182f0b2ccb04ceeb69b2994021c6/aioice-0.10.2.tar.gz", hash = "sha256:bf236c6829ee33c8e540535d31cd5a066b531cb56de2be94c46be76d68b1a806", size = 44307, upload-time = "2025-11-28T15:56:48.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/58/af07dda649c22a1ae954ffb7aaaf4d4a57f1bf00ebdf62307affc0b8552f/aioice-0.10.1-py3-none-any.whl", hash = "sha256:f31ae2abc8608b1283ed5f21aebd7b6bd472b152ff9551e9b559b2d8efed79e9", size = 24872, upload-time = "2025-04-13T08:15:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e3/0d23b1f930c17d371ce1ec36ee529f22fd19ebc2a07fe3418e3d1d884ce2/aioice-0.10.2-py3-none-any.whl", hash = "sha256:14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf", size = 24875, upload-time = "2025-11-28T15:56:47.847Z" }, ] [[package]] name = "aioitertools" -version = "0.12.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/de/38491a84ab323b47c7f86e94d2830e748780525f7a10c8600b67ead7e9ea/aioitertools-0.12.0.tar.gz", hash = "sha256:c2a9055b4fbb7705f561b9d86053e8af5d10cc845d22c32008c43490b2d8dd6b", size = 19369, upload-time = "2024-09-02T03:33:40.349Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/13/58b70a580de00893223d61de8fea167877a3aed97d4a5e1405c9159ef925/aioitertools-0.12.0-py3-none-any.whl", hash = "sha256:fc1f5fac3d737354de8831cbba3eb04f79dd649d8f3afb4c5b114925e662a796", size = 24345, upload-time = "2024-09-02T03:34:59.454Z" }, + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, ] [[package]] name = "aiortc" -version = "1.13.0" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aioice" }, { name = "av" }, - { name = "cffi" }, { name = "cryptography" }, { name = "google-crc32c" }, { name = "pyee" }, { name = "pylibsrtp" }, { name = "pyopenssl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/03/bc947d74c548e0c17cf94e5d5bdacaed0ee9e5b2bb7b8b8cf1ac7a7c01ec/aiortc-1.13.0.tar.gz", hash = "sha256:5d209975c22d0910fb5a0f0e2caa828f2da966c53580f7c7170ac3a16a871620", size = 1179894, upload-time = "2025-05-27T03:23:59.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/9c/4e027bfe0195de0442da301e2389329496745d40ae44d2d7c4571c4290ce/aiortc-1.14.0.tar.gz", hash = "sha256:adc8a67ace10a085721e588e06a00358ed8eaf5f6b62f0a95358ff45628dd762", size = 1180864, upload-time = "2025-10-13T21:40:37.905Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/29/765633cab5f1888890f5f172d1d53009b9b14e079cdfa01a62d9896a9ea9/aiortc-1.13.0-py3-none-any.whl", hash = "sha256:9ccccec98796f6a96bd1c3dd437a06da7e0f57521c96bd56e4b965a91b03a0a0", size = 92910, upload-time = "2025-05-27T03:23:57.344Z" }, + { url = "https://files.pythonhosted.org/packages/57/ab/31646a49209568cde3b97eeade0d28bb78b400e6645c56422c101df68932/aiortc-1.14.0-py3-none-any.whl", hash = "sha256:4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e", size = 93183, upload-time = "2025-10-13T21:40:36.59Z" }, ] [[package]] @@ -248,11 +308,11 @@ wheels = [ [[package]] name = "annotated-doc" -version = "0.0.3" +version = "0.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload-time = "2025-10-24T14:57:10.718Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload-time = "2025-10-24T14:57:09.462Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] @@ -284,17 +344,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.11.0" +version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] [[package]] @@ -317,131 +376,161 @@ wheels = [ [[package]] name = "audiolab" -version = "0.4.7" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "av" }, { name = "click" }, { name = "humanize" }, { name = "jinja2" }, + { name = "requests" }, { name = "smart-open" }, { name = "soundfile" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/62/343e39ff6560517ffb02c21796d155df4a019eea235ab7f86e46f8b10a73/audiolab-0.4.7.tar.gz", hash = "sha256:9a4618fd39601d5dd366f5dca3a0d23e6eacf5ee0d824ece2bc74ab15c8342b3", size = 31885, upload-time = "2025-12-19T07:40:13.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/ba/c0fab2cff574cfef0fb81071c1a0bc9f021b477a341adcf79e3220447b3f/audiolab-0.5.0.tar.gz", hash = "sha256:12f33c3cbbd09a9b6089f78fa8dd3f6472af345e8cdd6524009e5b2409b46c6a", size = 32970, upload-time = "2026-03-16T11:43:44.544Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/ac/7dc51c1a0b15ba9162d9d506a7a9f2589f0258b2810dad0a193cc781b7d6/audiolab-0.4.7-py3-none-any.whl", hash = "sha256:52ea93f0c0950727f6ab79c90d95910ee6b3a608bbe1bcd33ce51530b3de064e", size = 50938, upload-time = "2025-12-19T07:40:12.098Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/c96512dedf6dcb06bf3647460d4bbd0f119853f9bb60115703cee1cc1711/audiolab-0.5.0-py3-none-any.whl", hash = "sha256:9670e6253cec87cca8e6f37c32abc93aadfe5e185b6743e05c83bdee2acd310e", size = 51682, upload-time = "2026-03-16T11:43:43.351Z" }, ] [[package]] name = "audioop-lts" -version = "0.2.1" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/3b/69ff8a885e4c1c42014c2765275c4bd91fe7bc9847e9d8543dbcbb09f820/audioop_lts-0.2.1.tar.gz", hash = "sha256:e81268da0baa880431b68b1308ab7257eb33f356e57a5f9b1f915dfb13dd1387", size = 30204, upload-time = "2024-08-04T21:14:43.957Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/91/a219253cc6e92db2ebeaf5cf8197f71d995df6f6b16091d1f3ce62cb169d/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a", size = 46252, upload-time = "2024-08-04T21:13:56.209Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f6/3cb21e0accd9e112d27cee3b1477cd04dafe88675c54ad8b0d56226c1e0b/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e", size = 27183, upload-time = "2024-08-04T21:13:59.966Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7e/f94c8a6a8b2571694375b4cf94d3e5e0f529e8e6ba280fad4d8c70621f27/audioop_lts-0.2.1-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:4a8dd6a81770f6ecf019c4b6d659e000dc26571b273953cef7cd1d5ce2ff3ae6", size = 26726, upload-time = "2024-08-04T21:14:00.846Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f8/a0e8e7a033b03fae2b16bc5aa48100b461c4f3a8a38af56d5ad579924a3a/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cd3c0b6f2ca25c7d2b1c3adeecbe23e65689839ba73331ebc7d893fcda7ffe", size = 80718, upload-time = "2024-08-04T21:14:01.989Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ea/a98ebd4ed631c93b8b8f2368862cd8084d75c77a697248c24437c36a6f7e/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff3f97b3372c97782e9c6d3d7fdbe83bce8f70de719605bd7ee1839cd1ab360a", size = 88326, upload-time = "2024-08-04T21:14:03.509Z" }, - { url = "https://files.pythonhosted.org/packages/33/79/e97a9f9daac0982aa92db1199339bd393594d9a4196ad95ae088635a105f/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a351af79edefc2a1bd2234bfd8b339935f389209943043913a919df4b0f13300", size = 80539, upload-time = "2024-08-04T21:14:04.679Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d3/1051d80e6f2d6f4773f90c07e73743a1e19fcd31af58ff4e8ef0375d3a80/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aeb6f96f7f6da80354330470b9134d81b4cf544cdd1c549f2f45fe964d28059", size = 78577, upload-time = "2024-08-04T21:14:09.038Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/54f4c58bae8dc8c64a75071c7e98e105ddaca35449376fcb0180f6e3c9df/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c589f06407e8340e81962575fcffbba1e92671879a221186c3d4662de9fe804e", size = 82074, upload-time = "2024-08-04T21:14:09.99Z" }, - { url = "https://files.pythonhosted.org/packages/36/89/2e78daa7cebbea57e72c0e1927413be4db675548a537cfba6a19040d52fa/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fbae5d6925d7c26e712f0beda5ed69ebb40e14212c185d129b8dfbfcc335eb48", size = 84210, upload-time = "2024-08-04T21:14:11.468Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/3ff8a74df2ec2fa6d2ae06ac86e4a27d6412dbb7d0e0d41024222744c7e0/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_i686.whl", hash = "sha256:d2d5434717f33117f29b5691fbdf142d36573d751716249a288fbb96ba26a281", size = 85664, upload-time = "2024-08-04T21:14:12.394Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/21cc4e5878f6edbc8e54be4c108d7cb9cb6202313cfe98e4ece6064580dd/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f626a01c0a186b08f7ff61431c01c055961ee28769591efa8800beadd27a2959", size = 93255, upload-time = "2024-08-04T21:14:13.707Z" }, - { url = "https://files.pythonhosted.org/packages/3e/28/7f7418c362a899ac3b0bf13b1fde2d4ffccfdeb6a859abd26f2d142a1d58/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:05da64e73837f88ee5c6217d732d2584cf638003ac72df124740460531e95e47", size = 87760, upload-time = "2024-08-04T21:14:14.74Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d8/577a8be87dc7dd2ba568895045cee7d32e81d85a7e44a29000fe02c4d9d4/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:56b7a0a4dba8e353436f31a932f3045d108a67b5943b30f85a5563f4d8488d77", size = 84992, upload-time = "2024-08-04T21:14:19.155Z" }, - { url = "https://files.pythonhosted.org/packages/ef/9a/4699b0c4fcf89936d2bfb5425f55f1a8b86dff4237cfcc104946c9cd9858/audioop_lts-0.2.1-cp313-abi3-win32.whl", hash = "sha256:6e899eb8874dc2413b11926b5fb3857ec0ab55222840e38016a6ba2ea9b7d5e3", size = 26059, upload-time = "2024-08-04T21:14:20.438Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1c/1f88e9c5dd4785a547ce5fd1eb83fff832c00cc0e15c04c1119b02582d06/audioop_lts-0.2.1-cp313-abi3-win_amd64.whl", hash = "sha256:64562c5c771fb0a8b6262829b9b4f37a7b886c01b4d3ecdbae1d629717db08b4", size = 30412, upload-time = "2024-08-04T21:14:21.342Z" }, - { url = "https://files.pythonhosted.org/packages/c4/e9/c123fd29d89a6402ad261516f848437472ccc602abb59bba522af45e281b/audioop_lts-0.2.1-cp313-abi3-win_arm64.whl", hash = "sha256:c45317debeb64002e980077642afbd977773a25fa3dfd7ed0c84dccfc1fafcb0", size = 23578, upload-time = "2024-08-04T21:14:22.193Z" }, - { url = "https://files.pythonhosted.org/packages/7a/99/bb664a99561fd4266687e5cb8965e6ec31ba4ff7002c3fce3dc5ef2709db/audioop_lts-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3827e3fce6fee4d69d96a3d00cd2ab07f3c0d844cb1e44e26f719b34a5b15455", size = 46827, upload-time = "2024-08-04T21:14:23.034Z" }, - { url = "https://files.pythonhosted.org/packages/c4/e3/f664171e867e0768ab982715e744430cf323f1282eb2e11ebfb6ee4c4551/audioop_lts-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:161249db9343b3c9780ca92c0be0d1ccbfecdbccac6844f3d0d44b9c4a00a17f", size = 27479, upload-time = "2024-08-04T21:14:23.922Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0d/2a79231ff54eb20e83b47e7610462ad6a2bea4e113fae5aa91c6547e7764/audioop_lts-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5b7b4ff9de7a44e0ad2618afdc2ac920b91f4a6d3509520ee65339d4acde5abf", size = 27056, upload-time = "2024-08-04T21:14:28.061Z" }, - { url = "https://files.pythonhosted.org/packages/86/46/342471398283bb0634f5a6df947806a423ba74b2e29e250c7ec0e3720e4f/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72e37f416adb43b0ced93419de0122b42753ee74e87070777b53c5d2241e7fab", size = 87802, upload-time = "2024-08-04T21:14:29.586Z" }, - { url = "https://files.pythonhosted.org/packages/56/44/7a85b08d4ed55517634ff19ddfbd0af05bf8bfd39a204e4445cd0e6f0cc9/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534ce808e6bab6adb65548723c8cbe189a3379245db89b9d555c4210b4aaa9b6", size = 95016, upload-time = "2024-08-04T21:14:30.481Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2a/45edbca97ea9ee9e6bbbdb8d25613a36e16a4d1e14ae01557392f15cc8d3/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2de9b6fb8b1cf9f03990b299a9112bfdf8b86b6987003ca9e8a6c4f56d39543", size = 87394, upload-time = "2024-08-04T21:14:31.883Z" }, - { url = "https://files.pythonhosted.org/packages/14/ae/832bcbbef2c510629593bf46739374174606e25ac7d106b08d396b74c964/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f24865991b5ed4b038add5edbf424639d1358144f4e2a3e7a84bc6ba23e35074", size = 84874, upload-time = "2024-08-04T21:14:32.751Z" }, - { url = "https://files.pythonhosted.org/packages/26/1c/8023c3490798ed2f90dfe58ec3b26d7520a243ae9c0fc751ed3c9d8dbb69/audioop_lts-0.2.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bdb3b7912ccd57ea53197943f1bbc67262dcf29802c4a6df79ec1c715d45a78", size = 88698, upload-time = "2024-08-04T21:14:34.147Z" }, - { url = "https://files.pythonhosted.org/packages/2c/db/5379d953d4918278b1f04a5a64b2c112bd7aae8f81021009da0dcb77173c/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:120678b208cca1158f0a12d667af592e067f7a50df9adc4dc8f6ad8d065a93fb", size = 90401, upload-time = "2024-08-04T21:14:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/99/6e/3c45d316705ab1aec2e69543a5b5e458d0d112a93d08994347fafef03d50/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:54cd4520fc830b23c7d223693ed3e1b4d464997dd3abc7c15dce9a1f9bd76ab2", size = 91864, upload-time = "2024-08-04T21:14:36.158Z" }, - { url = "https://files.pythonhosted.org/packages/08/58/6a371d8fed4f34debdb532c0b00942a84ebf3e7ad368e5edc26931d0e251/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:d6bd20c7a10abcb0fb3d8aaa7508c0bf3d40dfad7515c572014da4b979d3310a", size = 98796, upload-time = "2024-08-04T21:14:37.185Z" }, - { url = "https://files.pythonhosted.org/packages/ee/77/d637aa35497e0034ff846fd3330d1db26bc6fd9dd79c406e1341188b06a2/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:f0ed1ad9bd862539ea875fb339ecb18fcc4148f8d9908f4502df28f94d23491a", size = 94116, upload-time = "2024-08-04T21:14:38.145Z" }, - { url = "https://files.pythonhosted.org/packages/1a/60/7afc2abf46bbcf525a6ebc0305d85ab08dc2d1e2da72c48dbb35eee5b62c/audioop_lts-0.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e1af3ff32b8c38a7d900382646e91f2fc515fd19dea37e9392275a5cbfdbff63", size = 91520, upload-time = "2024-08-04T21:14:39.128Z" }, - { url = "https://files.pythonhosted.org/packages/65/6d/42d40da100be1afb661fd77c2b1c0dfab08af1540df57533621aea3db52a/audioop_lts-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:f51bb55122a89f7a0817d7ac2319744b4640b5b446c4c3efcea5764ea99ae509", size = 26482, upload-time = "2024-08-04T21:14:40.269Z" }, - { url = "https://files.pythonhosted.org/packages/01/09/f08494dca79f65212f5b273aecc5a2f96691bf3307cac29acfcf84300c01/audioop_lts-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f0f2f336aa2aee2bce0b0dcc32bbba9178995454c7b979cf6ce086a8801e14c7", size = 30780, upload-time = "2024-08-04T21:14:41.128Z" }, - { url = "https://files.pythonhosted.org/packages/5d/35/be73b6015511aa0173ec595fc579133b797ad532996f2998fd6b8d1bbe6b/audioop_lts-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:78bfb3703388c780edf900be66e07de5a3d4105ca8e8720c5c4d67927e0b15d0", size = 23918, upload-time = "2024-08-04T21:14:42.803Z" }, + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, + { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, + { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, ] [[package]] name = "av" -version = "14.4.0" +version = "16.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/f6/0b473dab52dfdea05f28f3578b1c56b6c796ce85e76951bab7c4e38d5a74/av-14.4.0.tar.gz", hash = "sha256:3ecbf803a7fdf67229c0edada0830d6bfaea4d10bfb24f0c3f4e607cd1064b42", size = 3892203, upload-time = "2025-05-16T19:13:35.737Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/cd/3a83ffbc3cc25b39721d174487fb0d51a76582f4a1703f98e46170ce83d4/av-16.1.0.tar.gz", hash = "sha256:a094b4fd87a3721dacf02794d3d2c82b8d712c85b9534437e82a8a978c175ffd", size = 4285203, upload-time = "2026-01-11T07:31:33.772Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/0f/cf6b888747cd1e10eafc4a28942e5b666417c03c39853818900bdaa86116/av-14.4.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:10219620699a65b9829cfa08784da2ed38371f1a223ab8f3523f440a24c8381c", size = 19979523, upload-time = "2025-05-16T19:08:59.751Z" }, - { url = "https://files.pythonhosted.org/packages/45/30/8f09ac71ad23344ff247f16a9229b36b1e2a36214fd56ba55df885e9bf85/av-14.4.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:8bac981fde1c05e231df9f73a06ed9febce1f03fb0f1320707ac2861bba2567f", size = 23765838, upload-time = "2025-05-16T19:09:02.362Z" }, - { url = "https://files.pythonhosted.org/packages/a2/57/e0c30ceb1e59e7b2b88c9cd6bf79a0a979128de19a94b300a700d3a7ca52/av-14.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc634ed5bdeb362f0523b73693b079b540418d35d7f3003654f788ae6c317eef", size = 33122039, upload-time = "2025-05-16T19:09:04.729Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a7/9b3064c49f2d2219ee1b895cc77fca18c84d6121b51c8ce6b7f618a2661b/av-14.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23973ed5c5bec9565094d2b3643f10a6996707ddffa5252e112d578ad34aa9ae", size = 31758563, upload-time = "2025-05-16T19:09:07.679Z" }, - { url = "https://files.pythonhosted.org/packages/23/42/0eafe0de75de6a0db71add8e4ea51ebf090482bad3068f4a874c90fbd110/av-14.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0655f7207db6a211d7cedb8ac6a2f7ccc9c4b62290130e393a3fd99425247311", size = 34750358, upload-time = "2025-05-16T19:09:10.932Z" }, - { url = "https://files.pythonhosted.org/packages/75/33/5430ba9ad73036f2d69395d36f3d57b261c51db6f6542bcfc60087640bb7/av-14.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1edaab73319bfefe53ee09c4b1cf7b141ea7e6678a0a1c62f7bac1e2c68ec4e7", size = 35793636, upload-time = "2025-05-16T19:09:13.726Z" }, - { url = "https://files.pythonhosted.org/packages/00/a9/d8c07f0ab69be05a4939719d7a31dc3e9fb112ee8ec6c9411a6c9c085f0a/av-14.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b54838fa17c031ffd780df07b9962fac1be05220f3c28468f7fe49474f1bf8d2", size = 34123666, upload-time = "2025-05-16T19:09:16.968Z" }, - { url = "https://files.pythonhosted.org/packages/48/e1/2f2f607553f2ac6369e5fc814e77b41f9ceb285ce9d8c02c9ee034b8b6db/av-14.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f4b59ac6c563b9b6197299944145958a8ec34710799fd851f1a889b0cbcd1059", size = 36756157, upload-time = "2025-05-16T19:09:21.447Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f0/d653d4eaa7e68732f8c0013aee40f31ff0cd49e90fdec89cca6c193db207/av-14.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a0192a584fae9f6cedfac03c06d5bf246517cdf00c8779bc33414404796a526e", size = 27931039, upload-time = "2025-05-16T19:09:24.739Z" }, - { url = "https://files.pythonhosted.org/packages/18/8a/d57418b686ffd05fabd5a0a9cfa97e63b38c35d7101af00e87c51c8cc43c/av-14.4.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5b21d5586a88b9fce0ab78e26bd1c38f8642f8e2aad5b35e619f4d202217c701", size = 19965048, upload-time = "2025-05-16T19:09:27.419Z" }, - { url = "https://files.pythonhosted.org/packages/f5/aa/3f878b0301efe587e9b07bb773dd6b47ef44ca09a3cffb4af50c08a170f3/av-14.4.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:cf8762d90b0f94a20c9f6e25a94f1757db5a256707964dfd0b1d4403e7a16835", size = 23750064, upload-time = "2025-05-16T19:09:30.012Z" }, - { url = "https://files.pythonhosted.org/packages/9a/b4/6fe94a31f9ed3a927daa72df67c7151968587106f30f9f8fcd792b186633/av-14.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0ac9f08920c7bbe0795319689d901e27cb3d7870b9a0acae3f26fc9daa801a6", size = 33648775, upload-time = "2025-05-16T19:09:33.811Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f3/7f3130753521d779450c935aec3f4beefc8d4645471159f27b54e896470c/av-14.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a56d9ad2afdb638ec0404e962dc570960aae7e08ae331ad7ff70fbe99a6cf40e", size = 32216915, upload-time = "2025-05-16T19:09:36.99Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9a/8ffabfcafb42154b4b3a67d63f9b69e68fa8c34cb39ddd5cb813dd049ed4/av-14.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bed513cbcb3437d0ae47743edc1f5b4a113c0b66cdd4e1aafc533abf5b2fbf2", size = 35287279, upload-time = "2025-05-16T19:09:39.711Z" }, - { url = "https://files.pythonhosted.org/packages/ad/11/7023ba0a2ca94a57aedf3114ab8cfcecb0819b50c30982a4c5be4d31df41/av-14.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d030c2d3647931e53d51f2f6e0fcf465263e7acf9ec6e4faa8dbfc77975318c3", size = 36294683, upload-time = "2025-05-16T19:09:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/3d/fa/b8ac9636bd5034e2b899354468bef9f4dadb067420a16d8a493a514b7817/av-14.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1cc21582a4f606271d8c2036ec7a6247df0831050306c55cf8a905701d0f0474", size = 34552391, upload-time = "2025-05-16T19:09:46.852Z" }, - { url = "https://files.pythonhosted.org/packages/fb/29/0db48079c207d1cba7a2783896db5aec3816e17de55942262c244dffbc0f/av-14.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce7c9cd452153d36f1b1478f904ed5f9ab191d76db873bdd3a597193290805d4", size = 37265250, upload-time = "2025-05-16T19:09:50.013Z" }, - { url = "https://files.pythonhosted.org/packages/1c/55/715858c3feb7efa4d667ce83a829c8e6ee3862e297fb2b568da3f968639d/av-14.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd261e31cc6b43ca722f80656c39934199d8f2eb391e0147e704b6226acebc29", size = 27925845, upload-time = "2025-05-16T19:09:52.663Z" }, - { url = "https://files.pythonhosted.org/packages/a6/75/b8641653780336c90ba89e5352cac0afa6256a86a150c7703c0b38851c6d/av-14.4.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:a53e682b239dd23b4e3bc9568cfb1168fc629ab01925fdb2e7556eb426339e94", size = 19954125, upload-time = "2025-05-16T19:09:54.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/e6/37fe6fa5853a48d54d749526365780a63a4bc530be6abf2115e3a21e292a/av-14.4.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:5aa0b901751a32703fa938d2155d56ce3faf3630e4a48d238b35d2f7e49e5395", size = 23751479, upload-time = "2025-05-16T19:09:57.113Z" }, - { url = "https://files.pythonhosted.org/packages/f7/75/9a5f0e6bda5f513b62bafd1cff2b495441a8b07ab7fb7b8e62f0c0d1683f/av-14.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b316fed3597675fe2aacfed34e25fc9d5bb0196dc8c0b014ae5ed4adda48de", size = 33801401, upload-time = "2025-05-16T19:09:59.479Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/e4df32a2ad1cb7f3a112d0ed610c5e43c89da80b63c60d60e3dc23793ec0/av-14.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a587b5c5014c3c0e16143a0f8d99874e46b5d0c50db6111aa0b54206b5687c81", size = 32364330, upload-time = "2025-05-16T19:10:02.111Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f0/64e7444a41817fde49a07d0239c033f7e9280bec4a4bb4784f5c79af95e6/av-14.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d53f75e8ac1ec8877a551c0db32a83c0aaeae719d05285281eaaba211bbc30", size = 35519508, upload-time = "2025-05-16T19:10:05.008Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a8/a370099daa9033a3b6f9b9bd815304b3d8396907a14d09845f27467ba138/av-14.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c8558cfde79dd8fc92d97c70e0f0fa8c94c7a66f68ae73afdf58598f0fe5e10d", size = 36448593, upload-time = "2025-05-16T19:10:07.887Z" }, - { url = "https://files.pythonhosted.org/packages/27/bb/edb6ceff8fa7259cb6330c51dbfbc98dd1912bd6eb5f7bc05a4bb14a9d6e/av-14.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:455b6410dea0ab2d30234ffb28df7d62ca3cdf10708528e247bec3a4cdcced09", size = 34701485, upload-time = "2025-05-16T19:10:10.886Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8a/957da1f581aa1faa9a5dfa8b47ca955edb47f2b76b949950933b457bfa1d/av-14.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1661efbe9d975f927b8512d654704223d936f39016fad2ddab00aee7c40f412c", size = 37521981, upload-time = "2025-05-16T19:10:13.678Z" }, - { url = "https://files.pythonhosted.org/packages/28/76/3f1cf0568592f100fd68eb40ed8c491ce95ca3c1378cc2d4c1f6d1bd295d/av-14.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbbeef1f421a3461086853d6464ad5526b56ffe8ccb0ab3fd0a1f121dfbf26ad", size = 27925944, upload-time = "2025-05-16T19:10:16.485Z" }, - { url = "https://files.pythonhosted.org/packages/12/4c/b0205f77352312ff457ecdf31723dbf4403b7a03fc1659075d6d32f23ef7/av-14.4.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3d2aea7c602b105363903e4017103bc4b60336e7aff80e1c22e8b4ec09fd125f", size = 19917341, upload-time = "2025-05-16T19:10:18.826Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c4/9e783bd7d47828e9c67f9c773c99de45c5ae01b3e942f1abf6cbaf530267/av-14.4.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:38c18f036aeb6dc9abf5e867d998c867f9ec93a5f722b60721fdffc123bbb2ae", size = 23715363, upload-time = "2025-05-16T19:10:21.42Z" }, - { url = "https://files.pythonhosted.org/packages/b5/26/b2b406a676864d06b1c591205782d8527e7c99e5bc51a09862c3576e0087/av-14.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58c1e18c8be73b6eada2d9ec397852ec74ebe51938451bdf83644a807189d6c8", size = 33496968, upload-time = "2025-05-16T19:10:24.178Z" }, - { url = "https://files.pythonhosted.org/packages/89/09/0a032bbe30c7049fca243ec8cf01f4be49dd6e7f7b9c3c7f0cc13f83c9d3/av-14.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4c32ff03a357feb030634f093089a73cb474b04efe7fbfba31f229cb2fab115", size = 32075498, upload-time = "2025-05-16T19:10:27.384Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1f/0fee20f74c1f48086366e59dbd37fa0684cd0f3c782a65cbb719d26c7acd/av-14.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af31d16ae25964a6a02e09cc132b9decd5ee493c5dcb21bcdf0d71b2d6adbd59", size = 35224910, upload-time = "2025-05-16T19:10:30.104Z" }, - { url = "https://files.pythonhosted.org/packages/9e/19/1c4a201c75a2a431a85a43fd15d1fad55a28c22d596461d861c8d70f9b92/av-14.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9fb297009e528f4851d25f3bb2781b2db18b59b10aed10240e947b77c582fb7", size = 36172918, upload-time = "2025-05-16T19:10:32.789Z" }, - { url = "https://files.pythonhosted.org/packages/00/48/26b7e5d911c807f5f017a285362470ba16f44e8ea46f8b09ab5e348dd15b/av-14.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:573314cb9eafec2827dc98c416c965330dc7508193adbccd281700d8673b9f0a", size = 34414492, upload-time = "2025-05-16T19:10:36.023Z" }, - { url = "https://files.pythonhosted.org/packages/6d/26/2f4badfa5b5b7b8f5f83d562b143a83ed940fa458eea4cad495ce95c9741/av-14.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f82ab27ee57c3b80eb50a5293222307dfdc02f810ea41119078cfc85ea3cf9a8", size = 37245826, upload-time = "2025-05-16T19:10:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/f4/02/88dbb6f5a05998b730d2e695b05060297af127ac4250efbe0739daa446d5/av-14.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f682003bbcaac620b52f68ff0e85830fff165dea53949e217483a615993ca20", size = 27898395, upload-time = "2025-05-16T19:13:02.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/51/2217a9249409d2e88e16e3f16f7c0def9fd3e7ffc4238b2ec211f9935bdb/av-16.1.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:2395748b0c34fe3a150a1721e4f3d4487b939520991b13e7b36f8926b3b12295", size = 26942590, upload-time = "2026-01-09T20:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/a7070f4febc76a327c38808e01e2ff6b94531fe0b321af54ea3915165338/av-16.1.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:72d7ac832710a158eeb7a93242370aa024a7646516291c562ee7f14a7ea881fd", size = 21507910, upload-time = "2026-01-09T20:18:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/ae/30/ec812418cd9b297f0238fe20eb0747d8a8b68d82c5f73c56fe519a274143/av-16.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6cbac833092e66b6b0ac4d81ab077970b8ca874951e9c3974d41d922aaa653ed", size = 38738309, upload-time = "2026-01-09T20:18:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b8/6c5795bf1f05f45c5261f8bce6154e0e5e86b158a6676650ddd77c28805e/av-16.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:eb990672d97c18f99c02f31c8d5750236f770ffe354b5a52c5f4d16c5e65f619", size = 40293006, upload-time = "2026-01-09T20:18:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a7/44/5e183bcb9333fc3372ee6e683be8b0c9b515a506894b2d32ff465430c074/av-16.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05ad70933ac3b8ef896a820ea64b33b6cca91a5fac5259cb9ba7fa010435be15", size = 40123516, upload-time = "2026-01-09T20:18:09.955Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/b5346d582a3c3d958b4d26a2cc63ce607233582d956121eb20d2bbe55c2e/av-16.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d831a1062a3c47520bf99de6ec682bd1d64a40dfa958e5457bb613c5270e7ce3", size = 41463289, upload-time = "2026-01-09T20:18:12.459Z" }, + { url = "https://files.pythonhosted.org/packages/fa/31/acc946c0545f72b8d0d74584cb2a0ade9b7dfe2190af3ef9aa52a2e3c0b1/av-16.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:358ab910fef3c5a806c55176f2b27e5663b33c4d0a692dafeb049c6ed71f8aff", size = 31754959, upload-time = "2026-01-09T20:18:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/48/d0/b71b65d1b36520dcb8291a2307d98b7fc12329a45614a303ff92ada4d723/av-16.1.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:e88ad64ee9d2b9c4c5d891f16c22ae78e725188b8926eb88187538d9dd0b232f", size = 26927747, upload-time = "2026-01-09T20:18:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/2f/79/720a5a6ccdee06eafa211b945b0a450e3a0b8fc3d12922f0f3c454d870d2/av-16.1.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cb296073fa6935724de72593800ba86ae49ed48af03960a4aee34f8a611f442b", size = 21492232, upload-time = "2026-01-09T20:18:19.266Z" }, + { url = "https://files.pythonhosted.org/packages/8e/4f/a1ba8d922f2f6d1a3d52419463ef26dd6c4d43ee364164a71b424b5ae204/av-16.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:720edd4d25aa73723c1532bb0597806d7b9af5ee34fc02358782c358cfe2f879", size = 39291737, upload-time = "2026-01-09T20:18:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/1a/31/fc62b9fe8738d2693e18d99f040b219e26e8df894c10d065f27c6b4f07e3/av-16.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c7f2bc703d0df260a1fdf4de4253c7f5500ca9fc57772ea241b0cb241bcf972e", size = 40846822, upload-time = "2026-01-09T20:18:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/53/10/ab446583dbce730000e8e6beec6ec3c2753e628c7f78f334a35cad0317f4/av-16.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d69c393809babada7d54964d56099e4b30a3e1f8b5736ca5e27bd7be0e0f3c83", size = 40675604, upload-time = "2026-01-09T20:18:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/31/d7/1003be685277005f6d63fd9e64904ee222fe1f7a0ea70af313468bb597db/av-16.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:441892be28582356d53f282873c5a951592daaf71642c7f20165e3ddcb0b4c63", size = 42015955, upload-time = "2026-01-09T20:18:29.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4a/fa2a38ee9306bf4579f556f94ecbc757520652eb91294d2a99c7cf7623b9/av-16.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:273a3e32de64819e4a1cd96341824299fe06f70c46f2288b5dc4173944f0fd62", size = 31750339, upload-time = "2026-01-09T20:18:32.249Z" }, + { url = "https://files.pythonhosted.org/packages/9c/84/2535f55edcd426cebec02eb37b811b1b0c163f26b8d3f53b059e2ec32665/av-16.1.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:640f57b93f927fba8689f6966c956737ee95388a91bd0b8c8b5e0481f73513d6", size = 26945785, upload-time = "2026-01-09T20:18:34.486Z" }, + { url = "https://files.pythonhosted.org/packages/b6/17/ffb940c9e490bf42e86db4db1ff426ee1559cd355a69609ec1efe4d3a9eb/av-16.1.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ae3fb658eec00852ebd7412fdc141f17f3ddce8afee2d2e1cf366263ad2a3b35", size = 21481147, upload-time = "2026-01-09T20:18:36.716Z" }, + { url = "https://files.pythonhosted.org/packages/15/c1/e0d58003d2d83c3921887d5c8c9b8f5f7de9b58dc2194356a2656a45cfdc/av-16.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ee558d9c02a142eebcbe55578a6d817fedfde42ff5676275504e16d07a7f86", size = 39517197, upload-time = "2026-01-11T09:57:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/32/77/787797b43475d1b90626af76f80bfb0c12cfec5e11eafcfc4151b8c80218/av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", size = 41174337, upload-time = "2026-01-11T09:57:35.792Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/d90df7f1e3b97fc5554cf45076df5045f1e0a6adf13899e10121229b826c/av-16.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8cf065f9d438e1921dc31fc7aa045790b58aee71736897866420d80b5450f62a", size = 40817720, upload-time = "2026-01-11T09:57:39.039Z" }, + { url = "https://files.pythonhosted.org/packages/80/6f/13c3a35f9dbcebafd03fe0c4cbd075d71ac8968ec849a3cfce406c35a9d2/av-16.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a345877a9d3cc0f08e2bc4ec163ee83176864b92587afb9d08dff50f37a9a829", size = 42267396, upload-time = "2026-01-11T09:57:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/275df9607f7fb44317ccb1d4be74827185c0d410f52b6e2cd770fe209118/av-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f49243b1d27c91cd8c66fdba90a674e344eb8eb917264f36117bf2b6879118fd", size = 31752045, upload-time = "2026-01-11T09:57:45.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/2a/63797a4dde34283dd8054219fcb29294ba1c25d68ba8c8c8a6ae53c62c45/av-16.1.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:ce2a1b3d8bf619f6c47a9f28cfa7518ff75ddd516c234a4ee351037b05e6a587", size = 26916715, upload-time = "2026-01-11T09:57:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c4/0b49cf730d0ae8cda925402f18ae814aef351f5772d14da72dd87ff66448/av-16.1.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:408dbe6a2573ca58a855eb8cd854112b33ea598651902c36709f5f84c991ed8e", size = 21452167, upload-time = "2026-01-11T09:57:50.606Z" }, + { url = "https://files.pythonhosted.org/packages/51/23/408806503e8d5d840975aad5699b153aaa21eb6de41ade75248a79b7a37f/av-16.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:57f657f86652a160a8a01887aaab82282f9e629abf94c780bbdbb01595d6f0f7", size = 39215659, upload-time = "2026-01-11T09:57:53.757Z" }, + { url = "https://files.pythonhosted.org/packages/c4/19/a8528d5bba592b3903f44c28dab9cc653c95fcf7393f382d2751a1d1523e/av-16.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:adbad2b355c2ee4552cac59762809d791bda90586d134a33c6f13727fb86cb3a", size = 40874970, upload-time = "2026-01-11T09:57:56.802Z" }, + { url = "https://files.pythonhosted.org/packages/e8/24/2dbcdf0e929ad56b7df078e514e7bd4ca0d45cba798aff3c8caac097d2f7/av-16.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f42e1a68ec2aebd21f7eb6895be69efa6aa27eec1670536876399725bbda4b99", size = 40530345, upload-time = "2026-01-11T09:58:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/54/27/ae91b41207f34e99602d1c72ab6ffd9c51d7c67e3fbcd4e3a6c0e54f882c/av-16.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58fe47aeaef0f100c40ec8a5de9abbd37f118d3ca03829a1009cf288e9aef67c", size = 41972163, upload-time = "2026-01-11T09:58:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7a/22158fb923b2a9a00dfab0e96ef2e8a1763a94dd89e666a5858412383d46/av-16.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:565093ebc93b2f4b76782589564869dadfa83af5b852edebedd8fee746457d06", size = 31729230, upload-time = "2026-01-11T09:58:07.254Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f1/878f8687d801d6c4565d57ebec08449c46f75126ebca8e0fed6986599627/av-16.1.0-cp313-cp313t-macosx_11_0_x86_64.whl", hash = "sha256:574081a24edb98343fd9f473e21ae155bf61443d4ec9d7708987fa597d6b04b2", size = 27008769, upload-time = "2026-01-11T09:58:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/30/f1/bd4ce8c8b5cbf1d43e27048e436cbc9de628d48ede088a1d0a993768eb86/av-16.1.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:9ab00ea29c25ebf2ea1d1e928d7babb3532d562481c5d96c0829212b70756ad0", size = 21590588, upload-time = "2026-01-11T09:58:12.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/c81f6f9209201ff0b5d5bed6da6c6e641eef52d8fbc930d738c3f4f6f75d/av-16.1.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a84a91188c1071f238a9523fd42dbe567fb2e2607b22b779851b2ce0eac1b560", size = 40638029, upload-time = "2026-01-11T09:58:15.399Z" }, + { url = "https://files.pythonhosted.org/packages/15/4d/07edff82b78d0459a6e807e01cd280d3180ce832efc1543de80d77676722/av-16.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c2cd0de4dd022a7225ff224fde8e7971496d700be41c50adaaa26c07bb50bf97", size = 41970776, upload-time = "2026-01-11T09:58:19.075Z" }, + { url = "https://files.pythonhosted.org/packages/da/9d/1f48b354b82fa135d388477cd1b11b81bdd4384bd6a42a60808e2ec2d66b/av-16.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0816143530624a5a93bc5494f8c6eeaf77549b9366709c2ac8566c1e9bff6df5", size = 41764751, upload-time = "2026-01-11T09:58:22.788Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c7/a509801e98db35ec552dd79da7bdbcff7104044bfeb4c7d196c1ce121593/av-16.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e3a28053af29644696d0c007e897d19b1197585834660a54773e12a40b16974c", size = 43034355, upload-time = "2026-01-11T09:58:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/e5f530d9e8f640da5f5c5f681a424c65f9dd171c871cd255d8a861785a6e/av-16.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e3e67144a202b95ed299d165232533989390a9ea3119d37eccec697dc6dbb0c", size = 31947047, upload-time = "2026-01-11T09:58:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/df/18/8812221108c27d19f7e5f486a82c827923061edf55f906824ee0fcaadf50/av-16.1.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:39a634d8e5a87e78ea80772774bfd20c0721f0d633837ff185f36c9d14ffede4", size = 26916179, upload-time = "2026-01-11T09:58:36.506Z" }, + { url = "https://files.pythonhosted.org/packages/38/ef/49d128a9ddce42a2766fe2b6595bd9c49e067ad8937a560f7838a541464e/av-16.1.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:0ba32fb9e9300948a7fa9f8a3fc686e6f7f77599a665c71eb2118fdfd2c743f9", size = 21460168, upload-time = "2026-01-11T09:58:39.231Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a9/b310d390844656fa74eeb8c2750e98030877c75b97551a23a77d3f982741/av-16.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ca04d17815182d34ce3edc53cbda78a4f36e956c0fd73e3bab249872a831c4d7", size = 39210194, upload-time = "2026-01-11T09:58:42.138Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/e65aae179929d0f173af6e474ad1489b5b5ad4c968a62c42758d619e54cf/av-16.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ee0e8de2e124a9ef53c955fe2add6ee7c56cc8fd83318265549e44057db77142", size = 40811675, upload-time = "2026-01-11T09:58:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/54/3f/5d7edefd26b6a5187d6fac0f5065ee286109934f3dea607ef05e53f05b31/av-16.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22bf77a2f658827043a1e184b479c3bf25c4c43ab32353677df2d119f080e28f", size = 40543942, upload-time = "2026-01-11T09:58:49.759Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/f8b17897b67be0900a211142f5646a99d896168f54d57c81f3e018853796/av-16.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2dd419d262e6a71cab206d80bbf28e0a10d0f227b671cdf5e854c028faa2d043", size = 41924336, upload-time = "2026-01-11T09:58:53.344Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/d32bc6bbbcf60b65f6510c54690ed3ae1c4ca5d9fafbce835b6056858686/av-16.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:53585986fd431cd436f290fba662cfb44d9494fbc2949a183de00acc5b33fa88", size = 31735077, upload-time = "2026-01-11T09:58:56.684Z" }, + { url = "https://files.pythonhosted.org/packages/53/f4/9b63dc70af8636399bd933e9df4f3025a0294609510239782c1b746fc796/av-16.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:76f5ed8495cf41e1209a5775d3699dc63fdc1740b94a095e2485f13586593205", size = 27014423, upload-time = "2026-01-11T09:58:59.703Z" }, + { url = "https://files.pythonhosted.org/packages/d1/da/787a07a0d6ed35a0888d7e5cfb8c2ffa202f38b7ad2c657299fac08eb046/av-16.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:8d55397190f12a1a3ae7538be58c356cceb2bf50df1b33523817587748ce89e5", size = 21595536, upload-time = "2026-01-11T09:59:02.508Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f4/9a7d8651a611be6e7e3ab7b30bb43779899c8cac5f7293b9fb634c44a3f3/av-16.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:9d51d9037437218261b4bbf9df78a95e216f83d7774fbfe8d289230b5b2e28e2", size = 40642490, upload-time = "2026-01-11T09:59:05.842Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e4/eb79bc538a94b4ff93cd4237d00939cba797579f3272490dd0144c165a21/av-16.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0ce07a89c15644407f49d942111ca046e323bbab0a9078ff43ee57c9b4a50dad", size = 41976905, upload-time = "2026-01-11T09:59:09.169Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/f6db0dd86b70167a4d55ee0d9d9640983c570d25504f2bde42599f38241e/av-16.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cac0c074892ea97113b53556ff41c99562db7b9f09f098adac1f08318c2acad5", size = 41770481, upload-time = "2026-01-11T09:59:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/33651d658e45e16ab7671ea5fcf3d20980ea7983234f4d8d0c63c65581a5/av-16.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7dec3dcbc35a187ce450f65a2e0dda820d5a9e6553eea8344a1459af11c98649", size = 43036824, upload-time = "2026-01-11T09:59:16.507Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/7f13361db54d7e02f11552575c0384dadaf0918138f4eaa82ea03a9f9580/av-16.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6f90dc082ff2068ddbe77618400b44d698d25d9c4edac57459e250c16b33d700", size = 31948164, upload-time = "2026-01-11T09:59:19.501Z" }, ] [[package]] name = "aws-sdk-bedrock-runtime" -version = "0.2.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smithy-aws-core", extra = ["eventstream", "json"], marker = "python_full_version >= '3.12'" }, { name = "smithy-core", marker = "python_full_version >= '3.12'" }, { name = "smithy-http", extra = ["awscrt"], marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/94/f2451bb09c106e5690bbb88fc366637cdcec942b352ed9bb788804c877e0/aws_sdk_bedrock_runtime-0.2.0.tar.gz", hash = "sha256:8de52dd4492e74c73244d4b41a52304e1db368814a10e49dbbf8f4e8e412cd0e", size = 88156, upload-time = "2025-11-22T00:35:44.978Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/c4/149d119e07b4961580f105dd9cde6bacff143bd445d30e911deb6d21fddc/aws_sdk_bedrock_runtime-0.4.0.tar.gz", hash = "sha256:4b18e0e32cbb93ea11dfaf6a8d2ee78ce7da4445c3b1937e4f2b467bd24d1e8a", size = 159373, upload-time = "2026-02-24T18:55:24.473Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/6b/07fbddd31dd6e38c967fe088b5e91a7cc3a2bc0f645f18b4e5d45bc03f1f/aws_sdk_bedrock_runtime-0.2.0-py3-none-any.whl", hash = "sha256:19594de50a52d199d73efca153c0a2328bd781827715a6e012d50b11085236cc", size = 79875, upload-time = "2025-11-22T00:35:44.092Z" }, + { url = "https://files.pythonhosted.org/packages/73/87/aff9e04863295ba842804a71f2b2eca977a575b714819e60967f5ef9e9d3/aws_sdk_bedrock_runtime-0.4.0-py3-none-any.whl", hash = "sha256:28c60a0dca35ae40f2faa9b4677a2f83bbaa04e35eb6b075a767176308759051", size = 84261, upload-time = "2026-02-24T18:55:22.79Z" }, ] [[package]] name = "aws-sdk-sagemaker-runtime-http2" -version = "0.1.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smithy-aws-core", extra = ["eventstream", "json"], marker = "python_full_version >= '3.12'" }, { name = "smithy-core", marker = "python_full_version >= '3.12'" }, { name = "smithy-http", extra = ["awscrt"], marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/ca/00f9c55887fc0f3fa345995dd871d40ff81473ab1591e56b4b4483d99d00/aws_sdk_sagemaker_runtime_http2-0.1.0.tar.gz", hash = "sha256:5077ec0c4440495b15004bbf04e27bc0bc137f1f8950d32195c6b45d7788d837", size = 20863, upload-time = "2025-11-22T00:20:56.358Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/52/655ab732fe8c974ab7b8c6f8c695bc0ae00ccdf952f70997b4802f6e8143/aws_sdk_sagemaker_runtime_http2-0.4.0.tar.gz", hash = "sha256:fa644aa80fa881b7491b6d7a57cf75665b4ec1f0310e1dbeb0296bf95fd262ea", size = 26699, upload-time = "2026-02-24T18:55:28.67Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/24/2e2f727c51c20f4625cd19364d9421dbd7c893fe2b53a46eb0caaf6263a2/aws_sdk_sagemaker_runtime_http2-0.1.0-py3-none-any.whl", hash = "sha256:1aebb728ba6c6d14e58e29ecf89b51f7abbe8786d34144f8a7d59a419e80bd2f", size = 21911, upload-time = "2025-11-22T00:20:55.054Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cc/95cc6a61885dcc2cbc34167bed9cfd3183cf0a0a79b0e06388d8f4a78b86/aws_sdk_sagemaker_runtime_http2-0.4.0-py3-none-any.whl", hash = "sha256:be1e3415047058a655f682cb97f2ba7cadd46a30100bb8f2ef1b1c2c8d41204b", size = 21802, upload-time = "2026-02-24T18:55:27.804Z" }, ] [[package]] @@ -455,69 +544,69 @@ wheels = [ [[package]] name = "awscrt" -version = "0.28.2" +version = "0.28.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/1b/a885a699217967c3ff0e1c49ac5b1e2a050d1a8b87d1e85e958a56e3d3f5/awscrt-0.28.2.tar.gz", hash = "sha256:9715a888f2042e710dc8aeb355963a29b77e7a4cc25a14659cebd21a5fa476c1", size = 37894849, upload-time = "2025-10-14T19:06:16.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/40/99afe81abec294594302e60ee51c5ade36c5535ad5275fa50160b8a42877/awscrt-0.28.4.tar.gz", hash = "sha256:d2835094e92d0a3d1722d03afd54983115b2172d57581a664ad6a2af3d33c12c", size = 37902030, upload-time = "2025-11-04T20:08:12.208Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/b4/1a566e493bdfa6e918ba78bcd2e45dda99a25407a4fd974db2666228d154/awscrt-0.28.2-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:bec19c0dd780293a26c809aabb9f7675b28cb3a1bf05b4a5bc9f28d5ced75a81", size = 3380735, upload-time = "2025-10-14T19:05:16.58Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/6602a87aead1d413c7bd77d059b301745146635cda99ee2a61ec0d23691e/awscrt-0.28.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01f33076759ba6285f25ccc6016355607df2e715d0bab3a1ef2416b87a6c3ade", size = 3827084, upload-time = "2025-10-14T19:05:19.335Z" }, - { url = "https://files.pythonhosted.org/packages/d8/62/61fe39ae5950ad00e10dcbf6e4f4f344dc93957757160c0000390331a11b/awscrt-0.28.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b5c807b9972795ce54c05aea6918c60983c51d879ebbff7a67adb8b0d28a121", size = 4092678, upload-time = "2025-10-14T19:05:20.8Z" }, - { url = "https://files.pythonhosted.org/packages/25/7d/e38f18cfb203e8f09842c0e3f422992887ce285ecc3bf18816d559a13c80/awscrt-0.28.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bf4ff9c8c6a233246320c2d41d939b6e25cdae97728d827186e4771a9edda688", size = 3749978, upload-time = "2025-10-14T19:05:22.16Z" }, - { url = "https://files.pythonhosted.org/packages/16/6f/e8a3c0daed8f7b60c76fc2721bd4e83580ddecace24e0cb0ebb99564f699/awscrt-0.28.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0c738b83b66d1a8b43089556247fbe4adf2b73d610c7938d3bae1718a0fe8b1d", size = 3977237, upload-time = "2025-10-14T19:05:23.368Z" }, - { url = "https://files.pythonhosted.org/packages/92/3d/8400203f02dd924bcc8255703179b0c26efd03c84f838db6f026fcef9ba6/awscrt-0.28.2-cp310-cp310-win32.whl", hash = "sha256:23c30004c736a2f826a32c9720f1ccf71e8e4deb8535da5915d6073604853098", size = 3919413, upload-time = "2025-10-14T19:05:24.477Z" }, - { url = "https://files.pythonhosted.org/packages/c0/5e/b5ccf377880a70425b100f1e5f5ba516ff75e291585b3dc129239fbd1ec3/awscrt-0.28.2-cp310-cp310-win_amd64.whl", hash = "sha256:859ae8a195d51f15b631147d6792953a563bfe0a1cc7a75b6750977634de54b8", size = 4056024, upload-time = "2025-10-14T19:05:25.956Z" }, - { url = "https://files.pythonhosted.org/packages/ed/79/94e9f0ee7c60ec6233c7ad6293589c56d5145172e49eb5328eda37d3fdd1/awscrt-0.28.2-cp311-abi3-macosx_10_15_universal2.whl", hash = "sha256:025eab99b58586d8c95f8fafe1f4695ad477eda20d1207240ee4f8ee79742059", size = 3381061, upload-time = "2025-10-14T19:05:27.187Z" }, - { url = "https://files.pythonhosted.org/packages/2d/b8/0da80dd58682ddf3ec204e877d5891198654647c085e65b6b8eacd214edb/awscrt-0.28.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5c18d035d6cd92228e1db2f043517c1bcf9e0f6430c0af60cc34257dcca092c", size = 3788011, upload-time = "2025-10-14T19:05:28.768Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d2/f51cf4364364399fe90d557e2fed14c1f114720191a5825898b1242bd607/awscrt-0.28.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c75f077e90d0220a49b75a9bca914e5aa1a3c8f28af6bce4d0332be0b98dd3cb", size = 4055226, upload-time = "2025-10-14T19:05:30.054Z" }, - { url = "https://files.pythonhosted.org/packages/41/47/0fde8738a8c76de278ce431d8468ef18aeaca424329decca9ad5092df812/awscrt-0.28.2-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:1432c5c59a7e36b33eb2746cfbf30058f19ed43f2c117863897681f70bc246ba", size = 3692839, upload-time = "2025-10-14T19:05:31.471Z" }, - { url = "https://files.pythonhosted.org/packages/18/25/cb3762f6b47fe503eea7f337eca7cfd044ab28bcc2452fbf298c6492ec8b/awscrt-0.28.2-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f96703c30b22ba1e43e1bb2fe996ac7af513bea411c54dbf09a3a1af329b9a76", size = 3918023, upload-time = "2025-10-14T19:05:33.162Z" }, - { url = "https://files.pythonhosted.org/packages/95/0a/0b609acd45dbb83c04c7ecb8c7c789f5c15bbdd422129360bde093bc4a99/awscrt-0.28.2-cp311-abi3-win32.whl", hash = "sha256:3e94f63497b454d30892d7a7ce917a451c6f33590964d3a475d93f93b20083b6", size = 3917048, upload-time = "2025-10-14T19:05:34.745Z" }, - { url = "https://files.pythonhosted.org/packages/d1/38/bf33abd6d09c8572f8e09488db2b0a60124767d7f5d6d9a33cf8b051b7af/awscrt-0.28.2-cp311-abi3-win_amd64.whl", hash = "sha256:3e094772b1f6fd0f8c5f7cf37655d0984739f99493f66f534979a2a7bb7fc9f6", size = 4052877, upload-time = "2025-10-14T19:05:36.01Z" }, - { url = "https://files.pythonhosted.org/packages/10/71/4be198e472d95702434cee1f9dd889c56e22bea8554b466fad754148fd24/awscrt-0.28.2-cp313-abi3-macosx_10_15_universal2.whl", hash = "sha256:5fda9e7d0eb800491fadebe2b6c2560ac2f5742b60f4106440dca4b49da7fb03", size = 3379585, upload-time = "2025-10-14T19:05:37.225Z" }, - { url = "https://files.pythonhosted.org/packages/43/09/77084249d07dca71352341ad3fbcfa75deaccf25bd65f9fdbb36ce1f978b/awscrt-0.28.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:994a795bdc83344922a15891abb30155ec292093e856eef3929dd63dd6cadaca", size = 3779843, upload-time = "2025-10-14T19:05:38.774Z" }, - { url = "https://files.pythonhosted.org/packages/a6/bb/fcee9365e58e5860582398317571a9a5517da258cd81c3d987b9882f61d4/awscrt-0.28.2-cp313-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28537c4517168927ef74aa007a2e0c9f436921227934d82da31e9a1cec7e0c4a", size = 4049154, upload-time = "2025-10-14T19:05:40.301Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8e/ac92b2707dbe05e56d0dd5af73cb4e07a3da4aee66936071123966523759/awscrt-0.28.2-cp313-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b9fc6be63832da3ff244d56c7d9a43326d89d79e68162419c35f33e6ad033be0", size = 3683672, upload-time = "2025-10-14T19:05:41.536Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d0/15308ec37e762691f5d1871b0f1a6e462da8e421c6c38d6724e3cf0994b2/awscrt-0.28.2-cp313-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:efb57103a368de1d33148cb70a382c4f82ac376c744de9484e0f621cef8313f3", size = 3912823, upload-time = "2025-10-14T19:05:43.781Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/7693b1d72069908b7a3ee30e4ef2b5fc8f54948a96397729277cb0b0c7b4/awscrt-0.28.2-cp313-abi3-win32.whl", hash = "sha256:594dc61f4f0c1c9fb7292364d25c21810b3608cd67c0de78a032ad48f7bfd88c", size = 3911514, upload-time = "2025-10-14T19:05:45.019Z" }, - { url = "https://files.pythonhosted.org/packages/93/d6/5d8545c967690f03d55d44ed56ceff26d88363cd7d0435fd80a1c843ac2a/awscrt-0.28.2-cp313-abi3-win_amd64.whl", hash = "sha256:a17f0ab9dc5e5301da0fb00ccc4511a136d13abbd4a9564827547333fcd7ba16", size = 4047912, upload-time = "2025-10-14T19:05:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/62/96/cbd1822d38db89f7bb8f022c56d56d1428270d4d18f2a2d9acebb2b2af80/awscrt-0.28.4-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:d1e205e53b08456f0f83210c20c674ebdef96e3e80f716d1bf4ad666db2c643b", size = 3391500, upload-time = "2025-11-04T20:07:14.14Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/65560a093d8e58d1a9e11c5e0e64e2a5f40eff8f5b66e9d7376e1c6f617b/awscrt-0.28.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dc11d00600888a690c1ad875759708a4d21bdf81b6c2032e0227687d27fca910", size = 3840080, upload-time = "2025-11-04T20:07:16.636Z" }, + { url = "https://files.pythonhosted.org/packages/42/ea/0cfaaf771742a259918a0eb58377567dbd989e319ee0e8619e6c9c1774a0/awscrt-0.28.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13ed9b71a346146a89de85c173d007142416e6cc0358d7ca6b0d68dc1d159667", size = 4105891, upload-time = "2025-11-04T20:07:18.097Z" }, + { url = "https://files.pythonhosted.org/packages/96/e8/bdf550ecb10dab8b30fab8fc493af241a5dd5d8a18a1eaa16f7440595b69/awscrt-0.28.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:19adb9fa309111e20e1e850c876f093247ad084efdaa2dd654a15aef4b4bc637", size = 3762741, upload-time = "2025-11-04T20:07:19.532Z" }, + { url = "https://files.pythonhosted.org/packages/97/cd/efec2c8cab6f2e1269b1ad122ebaa9112a4c59ff5aa05d1e06b3248dc14f/awscrt-0.28.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b6d6de9172ef52ba1fb5cba12355bf6e845447a750a5214e9f57bf08aeeb6251", size = 3987691, upload-time = "2025-11-04T20:07:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e0/e0b51567d48c91d6a70f1799faaac81b2a8fb2d28b540c17a6dceedb61c3/awscrt-0.28.4-cp310-cp310-win32.whl", hash = "sha256:79d1cb861d017db8657a0fe0b4a02ddc60d596107e2e9e7816eaaca1afa30da4", size = 3932594, upload-time = "2025-11-04T20:07:22.418Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/fb31f6d26a34ff40a06eef87cee85af620f6f9488c8fd2e8370b0cfd06ad/awscrt-0.28.4-cp310-cp310-win_amd64.whl", hash = "sha256:0024b3e26a5ce9ffc9a92533f0a62bd823e025465f3b90ad3dda2878a260171a", size = 4068770, upload-time = "2025-11-04T20:07:23.854Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c2/09fd461401f7bdb5f6c1bd18ff1542a2f42ae80e0e0a6f4246857c620ff0/awscrt-0.28.4-cp311-abi3-macosx_10_15_universal2.whl", hash = "sha256:694c183bf2c3ef1d538caa5a73c007cddd841529bc43c6beeb02eb6a353094e6", size = 3391612, upload-time = "2025-11-04T20:07:26.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/32/0d63614f7aa42bc3bfad12a54bdb4375a283b6e6d3997facf5bdfeaa3b29/awscrt-0.28.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:86bb7612250925d49480a4648d30855d8f3d0e1dd8c322c586b4684847ff5d70", size = 3801912, upload-time = "2025-11-04T20:07:27.827Z" }, + { url = "https://files.pythonhosted.org/packages/79/5d/95e57e2ec10fffc977158e37a212151063ebdca1539dca28be1f2910c8f1/awscrt-0.28.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:046006703a7ed6278d5f80214c9aae02fc6b6a65a5f7ceb721becf9e1ad90604", size = 4067919, upload-time = "2025-11-04T20:07:29.359Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ea/e4cd422599ce70e486d3d5e693a4aa79903ad250eade0f657469799b0231/awscrt-0.28.4-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9389743eb4c04d1fa0ed5448b4bc6c8283239ece9a9ff4145a5d41ddecd02d42", size = 3702507, upload-time = "2025-11-04T20:07:30.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8b/953b692135db3483784436e67ee0fa6aff77c6333bdb3e1139fabe8c9382/awscrt-0.28.4-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a14b75f6c0cf79f2cb614c2459a492f8fed1836456e6488125652c9b2e7777aa", size = 3930600, upload-time = "2025-11-04T20:07:32.487Z" }, + { url = "https://files.pythonhosted.org/packages/c9/44/031b72a54d64b6c24be5d2f24d7dce9283af76cfdab6f198f808aee5e4dd/awscrt-0.28.4-cp311-abi3-win32.whl", hash = "sha256:a40aa941cf8201382986e4287c4fe51067a8bc2c78d9668937a6861cf14a54c6", size = 3930375, upload-time = "2025-11-04T20:07:34.107Z" }, + { url = "https://files.pythonhosted.org/packages/62/bc/fe2ee60ca5e121f41ed40d9a810fc86dd8a882eb53185dc664ec671fe167/awscrt-0.28.4-cp311-abi3-win_amd64.whl", hash = "sha256:7e0559ea770589958cdbed21f46d2ffdec2836ef43a00a4689d25205bb05cd22", size = 4068757, upload-time = "2025-11-04T20:07:35.43Z" }, + { url = "https://files.pythonhosted.org/packages/23/c9/3ae1c5e3be5c3d181a97cad673e9c11b56eaaf78406aa5dda2e081762799/awscrt-0.28.4-cp313-abi3-macosx_10_15_universal2.whl", hash = "sha256:dd23b9bad57812d7b1d1de785e10a44e3352cf1f3c0e5bd7b678b27d93f482a4", size = 3390633, upload-time = "2025-11-04T20:07:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/9e64a5e8259eaaf9a2ec98d2f889007dece81fe5dbbbc93ea65434342497/awscrt-0.28.4-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bfbe9dae84acb76d05ffde64a85c06e71c05819890f4c28be3204c75e0d5c76", size = 3792605, upload-time = "2025-11-04T20:07:38.107Z" }, + { url = "https://files.pythonhosted.org/packages/e1/33/afb97011c7574b7bbab68da414648d3b0935dc7d3ef2518fbf1f4858f457/awscrt-0.28.4-cp313-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f1aef999e0d48a4c3c2e6a713849392b883f918f4c1ce2b00d701c94c3252f8", size = 4063101, upload-time = "2025-11-04T20:07:39.742Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d3/fec84f55ebed6d873d6c7eb9b0349ff91645fe739dd6573f1759ac4a3804/awscrt-0.28.4-cp313-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:08b884bb6809d22f80921feb0ae9353fea1a750109a18d02057b6bba742db439", size = 3695411, upload-time = "2025-11-04T20:07:41.121Z" }, + { url = "https://files.pythonhosted.org/packages/cf/33/4c5d2c010573f872c24bdcf7e739ace882da408a5e042ae0eac275d2a13a/awscrt-0.28.4-cp313-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1c6319d297d18ba7cf3c6a8f69f76fd22b949e4ea8a280eb2098a8d6ed0d25be", size = 3925529, upload-time = "2025-11-04T20:07:42.303Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/fd0798fa2285d2d98d669620d7ef8a0bd9d3df347c074000ef99316de15d/awscrt-0.28.4-cp313-abi3-win32.whl", hash = "sha256:277af1c4e5ef666192bd04aea8c3afcbb26d7794594f6f7ba23d7285df5be65e", size = 3927616, upload-time = "2025-11-04T20:07:43.484Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c2/80ebd13c48a3398b9f36031b8eef7abd411c92f0a43c5c1aafa57bb346bd/awscrt-0.28.4-cp313-abi3-win_amd64.whl", hash = "sha256:1dd5dac3f761cb74c70c7feebf9f8dc96dc3b8db8248e5899bcbf34633d974a3", size = 4063960, upload-time = "2025-11-04T20:07:44.808Z" }, ] [[package]] name = "azure-cognitiveservices-speech" -version = "1.44.0" +version = "1.48.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/0d/0752835f079e8d2cc42bb634f3ccd761c8d6e9d0d46a2d6cf7b3ed8e714c/azure_cognitiveservices_speech-1.44.0-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:78037a147ba72abb57e8c10b693d43a1bb029986fae0918f1f9b7d6342737bfe", size = 7492396, upload-time = "2025-05-19T15:46:11.318Z" }, - { url = "https://files.pythonhosted.org/packages/76/1d/d0ed4ec0f51303a2a532dc845eeb72c7729a3c8639b08050f3c1cd96db79/azure_cognitiveservices_speech-1.44.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2c9b436326cd8dd82dfa88454b7b68359dfc7149e2ac9029f9bcff155ebd5c95", size = 7347577, upload-time = "2025-05-19T15:46:13.644Z" }, - { url = "https://files.pythonhosted.org/packages/89/c8/f0a4ea8bea014b912046f737e429378ceadad68258395454d62acf7f65bb/azure_cognitiveservices_speech-1.44.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:e5f07fc0587067850288c17aebf33d307d2c1ef9e0b2d11d9f44bff2af400568", size = 40977193, upload-time = "2025-05-19T15:46:15.878Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0d/0a0394e8102d6660afeec6b780c451401f6074b1e19f00e90785529e459e/azure_cognitiveservices_speech-1.44.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:3461e22cf04816f69a964d936218d920240f987c0656fdaaf46571529ff0f7e6", size = 40747860, upload-time = "2025-05-19T15:46:19.316Z" }, - { url = "https://files.pythonhosted.org/packages/55/ad/3b7f6eca73040821358ce01f22067446a03d876bfed41cd784291706db4c/azure_cognitiveservices_speech-1.44.0-py3-none-win32.whl", hash = "sha256:a3fe7fd67ba7db281ae490de3d71b5a22648454ec2630eb6a70797f666330586", size = 2164045, upload-time = "2025-05-19T15:46:22.373Z" }, - { url = "https://files.pythonhosted.org/packages/83/ac/f491487d7d0e25ae2929b4f07e7f9b7456feb38e65b36fb605b2c9685b10/azure_cognitiveservices_speech-1.44.0-py3-none-win_amd64.whl", hash = "sha256:77cfb5dd40733b7ccc21edc427e9fb4720997832ea8a1ba460dc94345f3588ae", size = 2422937, upload-time = "2025-05-19T15:46:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ea/22cd72035b45aa084beadb31799ae4a340b8af31433877c5ad63fcab101d/azure_cognitiveservices_speech-1.48.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:62918332eb53e26b71c1d6000bf3e255f7b2b7c8cb327ea2ec7d37181313c885", size = 3272203, upload-time = "2026-02-21T01:19:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/b41a8ee2d91259711f75a970b7b8e2ae489ae9c52f3e86495cc4d2d97cb5/azure_cognitiveservices_speech-1.48.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:47ffaead342a53ffca74c54bd0843a9c4aabdedd385d45742dac0404c551e384", size = 3111281, upload-time = "2026-02-21T01:19:21.664Z" }, + { url = "https://files.pythonhosted.org/packages/69/fd/dd4f637821b5c9a10dd7ab1bfb76082cc93dd9ebc1f551403f5b76fb1194/azure_cognitiveservices_speech-1.48.2-py3-none-manylinux1_x86_64.whl", hash = "sha256:9b03c2a0e36d926f5eb3c4c8588c5b444af257be2b7d801575faafc086a62581", size = 35424714, upload-time = "2026-02-21T01:19:25.639Z" }, + { url = "https://files.pythonhosted.org/packages/75/c1/d4f3cfbe2ada16a38ed72653211506cfa1fbfa4284692b3ed3ff59881777/azure_cognitiveservices_speech-1.48.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0f01e5c9326224845031c67812664436da9ba96d19230dfa2f44a4402fbda0e5", size = 35250857, upload-time = "2026-02-21T01:19:30.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ce/6dd6fea0662e95d76b41fe80802c1143569ff362b6f0159938a37a7af320/azure_cognitiveservices_speech-1.48.2-py3-none-win_amd64.whl", hash = "sha256:acd2549b3e716968e0cc9e95dc9cd512042129323158a4eeb74dfb740e53c065", size = 2199550, upload-time = "2026-02-21T01:19:33.121Z" }, + { url = "https://files.pythonhosted.org/packages/d9/11/c5e1daa73ce98c535dcf727639cc9400b1f7bff4727e8d97088b5b51849c/azure_cognitiveservices_speech-1.48.2-py3-none-win_arm64.whl", hash = "sha256:a8538e3732a2b157f8379063a6f9ad21c71cb3bcc24e8f2b1ee0c90023ca0d4c", size = 1990065, upload-time = "2026-02-21T01:19:34.305Z" }, ] [[package]] name = "azure-core" -version = "1.37.0" +version = "1.38.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/83/41c9371c8298999c67b007e308a0a3c4d6a59c6908fa9c62101f031f886f/azure_core-1.37.0.tar.gz", hash = "sha256:7064f2c11e4b97f340e8e8c6d923b822978be3016e46b7bc4aa4b337cfb48aee", size = 357620, upload-time = "2025-12-11T20:05:13.518Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/29/9641b73248745774a52c7ce7f965ed1febbdea787ec21caad3ae6891d18a/azure_core-1.38.3.tar.gz", hash = "sha256:a7931fd445cb4af8802c6f39c6a326bbd1e34b115846550a8245fa656ead6f8e", size = 367267, upload-time = "2026-03-12T20:28:21.122Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/34/a9914e676971a13d6cc671b1ed172f9804b50a3a80a143ff196e52f4c7ee/azure_core-1.37.0-py3-none-any.whl", hash = "sha256:b3abe2c59e7d6bb18b38c275a5029ff80f98990e7c90a5e646249a56630fcc19", size = 214006, upload-time = "2025-12-11T20:05:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3d/ac86083efa45a439d0bbfb7947615227813d368b9e1e93d23fd30de6fec0/azure_core-1.38.3-py3-none-any.whl", hash = "sha256:bf59d29765bf4748ab9edf25f98a30b7ea9797f43e367c06d846a30b29c1f845", size = 218231, upload-time = "2026-03-12T20:28:22.462Z" }, ] [[package]] name = "babel" -version = "2.17.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] @@ -568,7 +657,7 @@ wheels = [ [[package]] name = "build" -version = "1.2.2.post1" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "os_name == 'nt'" }, @@ -577,39 +666,25 @@ dependencies = [ { name = "pyproject-hooks" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/46/aeab111f8e06793e4f0e421fcad593d547fb8313b50990f31681ee2fb1ad/build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7", size = 46701, upload-time = "2024-10-06T17:22:25.251Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/18/94eaffda7b329535d91f00fe605ab1f1e5cd68b2074d03f255c7d250687d/build-1.4.0.tar.gz", hash = "sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936", size = 50054, upload-time = "2026-01-08T16:41:47.696Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/c2/80633736cd183ee4a62107413def345f7e6e3c01563dbca1417363cf957e/build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5", size = 22950, upload-time = "2024-10-06T17:22:23.299Z" }, + { url = "https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", size = 24141, upload-time = "2026-01-08T16:41:46.453Z" }, ] [[package]] -name = "cachetools" -version = "6.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/61/e4fad8155db4a04bfb4734c7c8ff0882f078f24294d42798b3568eb63bff/cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32", size = 30988, upload-time = "2025-08-25T18:57:30.924Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/56/3124f61d37a7a4e7cc96afc5492c78ba0cb551151e530b54669ddd1436ef/cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6", size = 11276, upload-time = "2025-08-25T18:57:29.684Z" }, -] - -[[package]] -name = "cartesia" -version = "2.0.9" +name = "camb-sdk" +version = "1.5.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp" }, - { name = "audioop-lts", marker = "python_full_version >= '3.13' and python_full_version < '4'" }, { name = "httpx" }, - { name = "httpx-sse" }, - { name = "iterators" }, { name = "pydantic" }, - { name = "pydantic-core" }, - { name = "pydub" }, { name = "typing-extensions" }, + { name = "websocket-client" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/d9/0ea051fb3119c22ea485f17b192526228fdaf450b8653abdf8edd60e9dcb/cartesia-2.0.9.tar.gz", hash = "sha256:e8b757b02a0ef228f610317de74aa22a7f047d178571527ecc069422d7c14639", size = 77772, upload-time = "2025-09-16T20:40:22.09Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/38/31fcc633963804a150175078c167f237bbd56dedba9903e4800a630ab944/camb_sdk-1.5.10.tar.gz", hash = "sha256:e1d23cbccea5aef5944612740b5c2e109a3c3ce778caa9664678a23b3a254419", size = 83643, upload-time = "2026-03-12T11:40:36.822Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/4b/6fa06df64db4cd4274d18ad6584ba3f624a02def134312ec98c164073319/cartesia-2.0.9-py3-none-any.whl", hash = "sha256:7eca63c79264de050258f9015d649dc2b2486d147895647fa80d9e5c2d073cea", size = 150144, upload-time = "2025-09-16T20:40:20.38Z" }, + { url = "https://files.pythonhosted.org/packages/94/12/f294bf4f343b663dced6d8ea840985d58b0afd66cd40e221fc19dc6639f4/camb_sdk-1.5.10-py3-none-any.whl", hash = "sha256:5ec6014af18cf108041c921f743fbcabc17015df2fc4b7a17793ef17d0fe593a", size = 152463, upload-time = "2026-03-12T11:38:37.934Z" }, ] [[package]] @@ -628,11 +703,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -719,87 +794,128 @@ wheels = [ [[package]] name = "cfgv" -version = "3.4.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.3" +version = "3.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, - { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, - { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, - { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, - { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, - { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, - { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, - { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, + { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] [[package]] name = "click" -version = "8.3.0" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -831,7 +947,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -898,12 +1014,13 @@ name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -982,190 +1099,296 @@ wheels = [ [[package]] name = "coremltools" -version = "8.3.0" +version = "9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "cattrs" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, { name = "protobuf" }, { name = "pyaml" }, { name = "sympy" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/f1/322d8cb29c59b8710375927a6d776887ed4c6caafd036cf4fbe14dcdb767/coremltools-8.3.0.tar.gz", hash = "sha256:c95a6051606b71273d669b107b5f32d3191f595e6821b8db04baf49d52d0704f", size = 1642701, upload-time = "2025-04-28T20:14:06.235Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/e6/8cb11a246f61736e75b20488b9c3cf9c208f500c9a3f92d717dbf592348c/coremltools-9.0.tar.gz", hash = "sha256:4ff346b29c31c4b45acd19a20e0f0a1ac65180a96776e62f15bd5c46f4926687", size = 1656978, upload-time = "2025-11-10T21:51:23.855Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/9e/b9070c8d44c7d5e3a552d5b19ebe07fd9814b72ce7be452138596bdcbc18/coremltools-8.3.0-cp310-none-macosx_10_15_x86_64.whl", hash = "sha256:730831927cde3ba39ae6f80b72c5fbff8820393f2f97ec1c98ab33ec08094c4e", size = 2767146, upload-time = "2025-04-28T20:13:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/63/ad/6293617987fc4de90030d0556ede0fcb7fc0c17e9907146e18f400efe482/coremltools-8.3.0-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:5f95af47531fb2f01a501ae5fad01e7dfb1cc0b70b035fbed6d788201cbc9a56", size = 2740012, upload-time = "2025-04-28T20:13:31.996Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/f602c2a7861a9e8a2555a083d9558d37d8811515bacd597eb3b40ba7757d/coremltools-8.3.0-cp310-none-manylinux1_x86_64.whl", hash = "sha256:e505c2b89b5bd1b1425466a8d45cc6edd75c81b0c3834403992a575a1c134c34", size = 2292024, upload-time = "2025-04-28T20:13:35.214Z" }, - { url = "https://files.pythonhosted.org/packages/72/a6/31dc762e0317d26b2d21919e12c42644ecab8401ff2aa6f00215156a45ee/coremltools-8.3.0-cp311-none-macosx_10_15_x86_64.whl", hash = "sha256:59ff68ec62bf2c0421041142117e37ef679f46e6304653aea64cbe8a39a5f9bc", size = 2770927, upload-time = "2025-04-28T20:13:37.598Z" }, - { url = "https://files.pythonhosted.org/packages/69/32/847810ade6b7105fcf810188f41dc4bb25e2278f505f8a08185bd8787cbb/coremltools-8.3.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:5f843f5a6be740d84eb7c80c49766da0b9bd67e86a9cb1dbfce838ab5366feb3", size = 2743785, upload-time = "2025-04-28T20:13:39.291Z" }, - { url = "https://files.pythonhosted.org/packages/36/9c/a6fbc66e300e176f94ab6f90530d33c703868126572fb9119cc952b8ecc6/coremltools-8.3.0-cp311-none-manylinux1_x86_64.whl", hash = "sha256:3d6d5828688347b5f6e31f1ce522b5df5733246611dbcb09fbc94687ff0fc16a", size = 2293270, upload-time = "2025-04-28T20:13:41.847Z" }, - { url = "https://files.pythonhosted.org/packages/94/74/0fea61f7644dda226fe8da364c9e68df3f1f7c6f59e6e8646215581af3d0/coremltools-8.3.0-cp312-none-macosx_10_15_x86_64.whl", hash = "sha256:2bfb57173a9fcbeb1f5bd3bfc90169c817ee04882564eafb79deafa494f96523", size = 2770175, upload-time = "2025-04-28T20:13:43.523Z" }, - { url = "https://files.pythonhosted.org/packages/56/2b/a06357e7881bacc92a4d125064202bf48acfe63368c781f49d688bca3b51/coremltools-8.3.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:e8a70ca3b34676cbcb61f70e1192593062eb85a9a64e29e03268d6f280b2c86e", size = 2740828, upload-time = "2025-04-28T20:13:45.189Z" }, - { url = "https://files.pythonhosted.org/packages/a2/52/6c15580d9049930dc6319d70cc065622e569afc1307d177bf45cb9f82076/coremltools-8.3.0-cp312-none-manylinux1_x86_64.whl", hash = "sha256:36ee21f1ab35bd45500ce006b366d45f4210a86882283d6cf2e603faeaaf791c", size = 2292484, upload-time = "2025-04-28T20:13:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/51/a5/c16527bac75d3c5f8abde9a0e65346587886e47fea18269d7157dab40333/coremltools-9.0-cp310-none-macosx_10_15_x86_64.whl", hash = "sha256:f3247ec310eb13ce3f0e98ff76747a238ff1bde31835a2a289c84e95fe93f6a9", size = 2788452, upload-time = "2025-11-10T21:46:51.937Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b5/34f15d9f43b5b70e27602752dfc6811d029e0ec61c311991be76c23022c0/coremltools-9.0-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:e9692a53b8a18891c1a54e8871de4c59ed435c5016e734c8989298b03bdb50de", size = 2763550, upload-time = "2025-11-10T21:47:08.484Z" }, + { url = "https://files.pythonhosted.org/packages/29/4e/4dfc48820739f00dc0e6d850ac8a612d8162c89de89125022adbf2b6ac00/coremltools-9.0-cp310-none-manylinux1_x86_64.whl", hash = "sha256:8e6765539e0c830ac39755e80cef9f8ff323a46c54dda051a0562a622931094b", size = 2308133, upload-time = "2025-11-10T21:47:12.799Z" }, + { url = "https://files.pythonhosted.org/packages/87/ab/d1e06207fd68aab62b1b476fc4ccf1fb52f43fa1816d6ab02f13c38d7c2c/coremltools-9.0-cp311-none-macosx_10_15_x86_64.whl", hash = "sha256:e6e58143c5270c1a37872fef41f8c18c042d22fa38f0ad33b33250007d9e1186", size = 2793255, upload-time = "2025-11-10T21:47:29.351Z" }, + { url = "https://files.pythonhosted.org/packages/27/b2/8ff944f25c0fc9e5ae9deef1707029784019b78a1df2a7d0b24d581be1a2/coremltools-9.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:0e079fea3f13f96a30587c9f7375796ff61cad53f703bde53c56fbf1374813ed", size = 2767374, upload-time = "2025-11-10T21:47:46.002Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/58fc966635ebe3b92b100fbec875d173addab62454c4438457fa3f427d1c/coremltools-9.0-cp311-none-manylinux1_x86_64.whl", hash = "sha256:e9080254a4b9d286e168f3b1bc8616edd5d48ab664c17870b85e496629a00e81", size = 2309379, upload-time = "2025-11-10T21:48:02.81Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7e/0746b42d39d903da2015d33b619319d84fc16a44e6ed68c1a4768ae27fc5/coremltools-9.0-cp312-none-macosx_10_15_x86_64.whl", hash = "sha256:35d6e972e254081e364e6c7763eae89df8cc775dbf53756ba1ca08a2bc22f018", size = 2792457, upload-time = "2025-11-10T21:48:18.418Z" }, + { url = "https://files.pythonhosted.org/packages/44/ab/6231b83d770825803284453f1ff36e5f3ba0a5740fcedbd3ba7454e5d412/coremltools-9.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:7079e8b6ff5a63f0e2c08eeeb8673e4eab8ca231d4b2eae4f7fb005e0d08a8cd", size = 2764416, upload-time = "2025-11-10T21:48:30.209Z" }, + { url = "https://files.pythonhosted.org/packages/b5/87/add15e7b4537765bef9cb47ffbd6a5d48493e65181df9864afeffa13b99b/coremltools-9.0-cp312-none-manylinux1_x86_64.whl", hash = "sha256:99a101085a7919de9f1c18e514c17d2b3e6a06ad4f7a35aae9515ad47f5a843f", size = 2308591, upload-time = "2025-11-10T21:48:47.269Z" }, + { url = "https://files.pythonhosted.org/packages/57/4c/925ad6d76ad5bb92c9dc64798dc13651050687a03b00c03abd216dfb3732/coremltools-9.0-cp313-none-macosx_10_15_x86_64.whl", hash = "sha256:c3965805df319d5f2755d0adfb8e28312db655be09be87bd00fad097b104be57", size = 2792787, upload-time = "2025-11-10T21:49:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/76d5a828d875ed8ad7392bf9294233261747de02f7415f51d4add8dc0acf/coremltools-9.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:9f2f858beec7f5d486cd1a59aefb452d59347e236670b67db325795bf692f480", size = 2764608, upload-time = "2025-11-10T21:49:21.195Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/0e8ba95ae1a1f2c9a6460c7f95a722a28a3eeaa47aef9266ef454d2e3b8b/coremltools-9.0-cp313-none-manylinux1_x86_64.whl", hash = "sha256:0af02216767232ece83bc4ec5035d7bba3c53c27de11be1a32e8461b4025d866", size = 2308338, upload-time = "2025-11-10T21:49:36.009Z" }, ] [[package]] name = "coverage" -version = "7.9.2" +version = "7.13.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/b7/c0465ca253df10a9e8dae0692a4ae6e9726d245390aaef92360e1d6d3832/coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b", size = 813556, upload-time = "2025-07-03T10:54:15.101Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/0d/5c2114fd776c207bd55068ae8dc1bef63ecd1b767b3389984a8e58f2b926/coverage-7.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:66283a192a14a3854b2e7f3418d7db05cdf411012ab7ff5db98ff3b181e1f912", size = 212039, upload-time = "2025-07-03T10:52:38.955Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ad/dc51f40492dc2d5fcd31bb44577bc0cc8920757d6bc5d3e4293146524ef9/coverage-7.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e01d138540ef34fcf35c1aa24d06c3de2a4cffa349e29a10056544f35cca15f", size = 212428, upload-time = "2025-07-03T10:52:41.36Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a3/55cb3ff1b36f00df04439c3993d8529193cdf165a2467bf1402539070f16/coverage-7.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f22627c1fe2745ee98d3ab87679ca73a97e75ca75eb5faee48660d060875465f", size = 241534, upload-time = "2025-07-03T10:52:42.956Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c9/a8410b91b6be4f6e9c2e9f0dce93749b6b40b751d7065b4410bf89cb654b/coverage-7.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b1c2d8363247b46bd51f393f86c94096e64a1cf6906803fa8d5a9d03784bdbf", size = 239408, upload-time = "2025-07-03T10:52:44.199Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c4/6f3e56d467c612b9070ae71d5d3b114c0b899b5788e1ca3c93068ccb7018/coverage-7.9.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c10c882b114faf82dbd33e876d0cbd5e1d1ebc0d2a74ceef642c6152f3f4d547", size = 240552, upload-time = "2025-07-03T10:52:45.477Z" }, - { url = "https://files.pythonhosted.org/packages/fd/20/04eda789d15af1ce79bce5cc5fd64057c3a0ac08fd0576377a3096c24663/coverage-7.9.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de3c0378bdf7066c3988d66cd5232d161e933b87103b014ab1b0b4676098fa45", size = 240464, upload-time = "2025-07-03T10:52:46.809Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5a/217b32c94cc1a0b90f253514815332d08ec0812194a1ce9cca97dda1cd20/coverage-7.9.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1e2f097eae0e5991e7623958a24ced3282676c93c013dde41399ff63e230fcf2", size = 239134, upload-time = "2025-07-03T10:52:48.149Z" }, - { url = "https://files.pythonhosted.org/packages/34/73/1d019c48f413465eb5d3b6898b6279e87141c80049f7dbf73fd020138549/coverage-7.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28dc1f67e83a14e7079b6cea4d314bc8b24d1aed42d3582ff89c0295f09b181e", size = 239405, upload-time = "2025-07-03T10:52:49.687Z" }, - { url = "https://files.pythonhosted.org/packages/49/6c/a2beca7aa2595dad0c0d3f350382c381c92400efe5261e2631f734a0e3fe/coverage-7.9.2-cp310-cp310-win32.whl", hash = "sha256:bf7d773da6af9e10dbddacbf4e5cab13d06d0ed93561d44dae0188a42c65be7e", size = 214519, upload-time = "2025-07-03T10:52:51.036Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c8/91e5e4a21f9a51e2c7cdd86e587ae01a4fcff06fc3fa8cde4d6f7cf68df4/coverage-7.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:0c0378ba787681ab1897f7c89b415bd56b0b2d9a47e5a3d8dc0ea55aac118d6c", size = 215400, upload-time = "2025-07-03T10:52:52.313Z" }, - { url = "https://files.pythonhosted.org/packages/39/40/916786453bcfafa4c788abee4ccd6f592b5b5eca0cd61a32a4e5a7ef6e02/coverage-7.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a7a56a2964a9687b6aba5b5ced6971af308ef6f79a91043c05dd4ee3ebc3e9ba", size = 212152, upload-time = "2025-07-03T10:52:53.562Z" }, - { url = "https://files.pythonhosted.org/packages/9f/66/cc13bae303284b546a030762957322bbbff1ee6b6cb8dc70a40f8a78512f/coverage-7.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123d589f32c11d9be7fe2e66d823a236fe759b0096f5db3fb1b75b2fa414a4fa", size = 212540, upload-time = "2025-07-03T10:52:55.196Z" }, - { url = "https://files.pythonhosted.org/packages/0f/3c/d56a764b2e5a3d43257c36af4a62c379df44636817bb5f89265de4bf8bd7/coverage-7.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:333b2e0ca576a7dbd66e85ab402e35c03b0b22f525eed82681c4b866e2e2653a", size = 245097, upload-time = "2025-07-03T10:52:56.509Z" }, - { url = "https://files.pythonhosted.org/packages/b1/46/bd064ea8b3c94eb4ca5d90e34d15b806cba091ffb2b8e89a0d7066c45791/coverage-7.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:326802760da234baf9f2f85a39e4a4b5861b94f6c8d95251f699e4f73b1835dc", size = 242812, upload-time = "2025-07-03T10:52:57.842Z" }, - { url = "https://files.pythonhosted.org/packages/43/02/d91992c2b29bc7afb729463bc918ebe5f361be7f1daae93375a5759d1e28/coverage-7.9.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19e7be4cfec248df38ce40968c95d3952fbffd57b400d4b9bb580f28179556d2", size = 244617, upload-time = "2025-07-03T10:52:59.239Z" }, - { url = "https://files.pythonhosted.org/packages/b7/4f/8fadff6bf56595a16d2d6e33415841b0163ac660873ed9a4e9046194f779/coverage-7.9.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b4a4cb73b9f2b891c1788711408ef9707666501ba23684387277ededab1097c", size = 244263, upload-time = "2025-07-03T10:53:00.601Z" }, - { url = "https://files.pythonhosted.org/packages/9b/d2/e0be7446a2bba11739edb9f9ba4eff30b30d8257370e237418eb44a14d11/coverage-7.9.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2c8937fa16c8c9fbbd9f118588756e7bcdc7e16a470766a9aef912dd3f117dbd", size = 242314, upload-time = "2025-07-03T10:53:01.932Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7d/dcbac9345000121b8b57a3094c2dfcf1ccc52d8a14a40c1d4bc89f936f80/coverage-7.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42da2280c4d30c57a9b578bafd1d4494fa6c056d4c419d9689e66d775539be74", size = 242904, upload-time = "2025-07-03T10:53:03.478Z" }, - { url = "https://files.pythonhosted.org/packages/41/58/11e8db0a0c0510cf31bbbdc8caf5d74a358b696302a45948d7c768dfd1cf/coverage-7.9.2-cp311-cp311-win32.whl", hash = "sha256:14fa8d3da147f5fdf9d298cacc18791818f3f1a9f542c8958b80c228320e90c6", size = 214553, upload-time = "2025-07-03T10:53:05.174Z" }, - { url = "https://files.pythonhosted.org/packages/3a/7d/751794ec8907a15e257136e48dc1021b1f671220ecccfd6c4eaf30802714/coverage-7.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:549cab4892fc82004f9739963163fd3aac7a7b0df430669b75b86d293d2df2a7", size = 215441, upload-time = "2025-07-03T10:53:06.472Z" }, - { url = "https://files.pythonhosted.org/packages/62/5b/34abcedf7b946c1c9e15b44f326cb5b0da852885312b30e916f674913428/coverage-7.9.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2667a2b913e307f06aa4e5677f01a9746cd08e4b35e14ebcde6420a9ebb4c62", size = 213873, upload-time = "2025-07-03T10:53:07.699Z" }, - { url = "https://files.pythonhosted.org/packages/53/d7/7deefc6fd4f0f1d4c58051f4004e366afc9e7ab60217ac393f247a1de70a/coverage-7.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae9eb07f1cfacd9cfe8eaee6f4ff4b8a289a668c39c165cd0c8548484920ffc0", size = 212344, upload-time = "2025-07-03T10:53:09.3Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/ee03c95d32be4d519e6a02e601267769ce2e9a91fc8faa1b540e3626c680/coverage-7.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ce85551f9a1119f02adc46d3014b5ee3f765deac166acf20dbb851ceb79b6f3", size = 212580, upload-time = "2025-07-03T10:53:11.52Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9f/826fa4b544b27620086211b87a52ca67592622e1f3af9e0a62c87aea153a/coverage-7.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8f6389ac977c5fb322e0e38885fbbf901743f79d47f50db706e7644dcdcb6e1", size = 246383, upload-time = "2025-07-03T10:53:13.134Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b3/4477aafe2a546427b58b9c540665feff874f4db651f4d3cb21b308b3a6d2/coverage-7.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d9eae8cdfcd58fe7893b88993723583a6ce4dfbfd9f29e001922544f95615", size = 243400, upload-time = "2025-07-03T10:53:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/f8/c2/efffa43778490c226d9d434827702f2dfbc8041d79101a795f11cbb2cf1e/coverage-7.9.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae939811e14e53ed8a9818dad51d434a41ee09df9305663735f2e2d2d7d959b", size = 245591, upload-time = "2025-07-03T10:53:15.872Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e7/a59888e882c9a5f0192d8627a30ae57910d5d449c80229b55e7643c078c4/coverage-7.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:31991156251ec202c798501e0a42bbdf2169dcb0f137b1f5c0f4267f3fc68ef9", size = 245402, upload-time = "2025-07-03T10:53:17.124Z" }, - { url = "https://files.pythonhosted.org/packages/92/a5/72fcd653ae3d214927edc100ce67440ed8a0a1e3576b8d5e6d066ed239db/coverage-7.9.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d0d67963f9cbfc7c7f96d4ac74ed60ecbebd2ea6eeb51887af0f8dce205e545f", size = 243583, upload-time = "2025-07-03T10:53:18.781Z" }, - { url = "https://files.pythonhosted.org/packages/5c/f5/84e70e4df28f4a131d580d7d510aa1ffd95037293da66fd20d446090a13b/coverage-7.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49b752a2858b10580969ec6af6f090a9a440a64a301ac1528d7ca5f7ed497f4d", size = 244815, upload-time = "2025-07-03T10:53:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/39/e7/d73d7cbdbd09fdcf4642655ae843ad403d9cbda55d725721965f3580a314/coverage-7.9.2-cp312-cp312-win32.whl", hash = "sha256:88d7598b8ee130f32f8a43198ee02edd16d7f77692fa056cb779616bbea1b355", size = 214719, upload-time = "2025-07-03T10:53:21.521Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d6/7486dcc3474e2e6ad26a2af2db7e7c162ccd889c4c68fa14ea8ec189c9e9/coverage-7.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:9dfb070f830739ee49d7c83e4941cc767e503e4394fdecb3b54bfdac1d7662c0", size = 215509, upload-time = "2025-07-03T10:53:22.853Z" }, - { url = "https://files.pythonhosted.org/packages/b7/34/0439f1ae2593b0346164d907cdf96a529b40b7721a45fdcf8b03c95fcd90/coverage-7.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:4e2c058aef613e79df00e86b6d42a641c877211384ce5bd07585ed7ba71ab31b", size = 213910, upload-time = "2025-07-03T10:53:24.472Z" }, - { url = "https://files.pythonhosted.org/packages/94/9d/7a8edf7acbcaa5e5c489a646226bed9591ee1c5e6a84733c0140e9ce1ae1/coverage-7.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:985abe7f242e0d7bba228ab01070fde1d6c8fa12f142e43debe9ed1dde686038", size = 212367, upload-time = "2025-07-03T10:53:25.811Z" }, - { url = "https://files.pythonhosted.org/packages/e8/9e/5cd6f130150712301f7e40fb5865c1bc27b97689ec57297e568d972eec3c/coverage-7.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c3939264a76d44fde7f213924021ed31f55ef28111a19649fec90c0f109e6d", size = 212632, upload-time = "2025-07-03T10:53:27.075Z" }, - { url = "https://files.pythonhosted.org/packages/a8/de/6287a2c2036f9fd991c61cefa8c64e57390e30c894ad3aa52fac4c1e14a8/coverage-7.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae5d563e970dbe04382f736ec214ef48103d1b875967c89d83c6e3f21706d5b3", size = 245793, upload-time = "2025-07-03T10:53:28.408Z" }, - { url = "https://files.pythonhosted.org/packages/06/cc/9b5a9961d8160e3cb0b558c71f8051fe08aa2dd4b502ee937225da564ed1/coverage-7.9.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdd612e59baed2a93c8843c9a7cb902260f181370f1d772f4842987535071d14", size = 243006, upload-time = "2025-07-03T10:53:29.754Z" }, - { url = "https://files.pythonhosted.org/packages/49/d9/4616b787d9f597d6443f5588619c1c9f659e1f5fc9eebf63699eb6d34b78/coverage-7.9.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256ea87cb2a1ed992bcdfc349d8042dcea1b80436f4ddf6e246d6bee4b5d73b6", size = 244990, upload-time = "2025-07-03T10:53:31.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/83/801cdc10f137b2d02b005a761661649ffa60eb173dcdaeb77f571e4dc192/coverage-7.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f44ae036b63c8ea432f610534a2668b0c3aee810e7037ab9d8ff6883de480f5b", size = 245157, upload-time = "2025-07-03T10:53:32.717Z" }, - { url = "https://files.pythonhosted.org/packages/c8/a4/41911ed7e9d3ceb0ffb019e7635468df7499f5cc3edca5f7dfc078e9c5ec/coverage-7.9.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82d76ad87c932935417a19b10cfe7abb15fd3f923cfe47dbdaa74ef4e503752d", size = 243128, upload-time = "2025-07-03T10:53:34.009Z" }, - { url = "https://files.pythonhosted.org/packages/10/41/344543b71d31ac9cb00a664d5d0c9ef134a0fe87cb7d8430003b20fa0b7d/coverage-7.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:619317bb86de4193debc712b9e59d5cffd91dc1d178627ab2a77b9870deb2868", size = 244511, upload-time = "2025-07-03T10:53:35.434Z" }, - { url = "https://files.pythonhosted.org/packages/d5/81/3b68c77e4812105e2a060f6946ba9e6f898ddcdc0d2bfc8b4b152a9ae522/coverage-7.9.2-cp313-cp313-win32.whl", hash = "sha256:0a07757de9feb1dfafd16ab651e0f628fd7ce551604d1bf23e47e1ddca93f08a", size = 214765, upload-time = "2025-07-03T10:53:36.787Z" }, - { url = "https://files.pythonhosted.org/packages/06/a2/7fac400f6a346bb1a4004eb2a76fbff0e242cd48926a2ce37a22a6a1d917/coverage-7.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:115db3d1f4d3f35f5bb021e270edd85011934ff97c8797216b62f461dd69374b", size = 215536, upload-time = "2025-07-03T10:53:38.188Z" }, - { url = "https://files.pythonhosted.org/packages/08/47/2c6c215452b4f90d87017e61ea0fd9e0486bb734cb515e3de56e2c32075f/coverage-7.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:48f82f889c80af8b2a7bb6e158d95a3fbec6a3453a1004d04e4f3b5945a02694", size = 213943, upload-time = "2025-07-03T10:53:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/a3/46/e211e942b22d6af5e0f323faa8a9bc7c447a1cf1923b64c47523f36ed488/coverage-7.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:55a28954545f9d2f96870b40f6c3386a59ba8ed50caf2d949676dac3ecab99f5", size = 213088, upload-time = "2025-07-03T10:53:40.874Z" }, - { url = "https://files.pythonhosted.org/packages/d2/2f/762551f97e124442eccd907bf8b0de54348635b8866a73567eb4e6417acf/coverage-7.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdef6504637731a63c133bb2e6f0f0214e2748495ec15fe42d1e219d1b133f0b", size = 213298, upload-time = "2025-07-03T10:53:42.218Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b7/76d2d132b7baf7360ed69be0bcab968f151fa31abe6d067f0384439d9edb/coverage-7.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd5ebe66c7a97273d5d2ddd4ad0ed2e706b39630ed4b53e713d360626c3dbb3", size = 256541, upload-time = "2025-07-03T10:53:43.823Z" }, - { url = "https://files.pythonhosted.org/packages/a0/17/392b219837d7ad47d8e5974ce5f8dc3deb9f99a53b3bd4d123602f960c81/coverage-7.9.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9303aed20872d7a3c9cb39c5d2b9bdbe44e3a9a1aecb52920f7e7495410dfab8", size = 252761, upload-time = "2025-07-03T10:53:45.19Z" }, - { url = "https://files.pythonhosted.org/packages/d5/77/4256d3577fe1b0daa8d3836a1ebe68eaa07dd2cbaf20cf5ab1115d6949d4/coverage-7.9.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc18ea9e417a04d1920a9a76fe9ebd2f43ca505b81994598482f938d5c315f46", size = 254917, upload-time = "2025-07-03T10:53:46.931Z" }, - { url = "https://files.pythonhosted.org/packages/53/99/fc1a008eef1805e1ddb123cf17af864743354479ea5129a8f838c433cc2c/coverage-7.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6406cff19880aaaadc932152242523e892faff224da29e241ce2fca329866584", size = 256147, upload-time = "2025-07-03T10:53:48.289Z" }, - { url = "https://files.pythonhosted.org/packages/92/c0/f63bf667e18b7f88c2bdb3160870e277c4874ced87e21426128d70aa741f/coverage-7.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d0d4f6ecdf37fcc19c88fec3e2277d5dee740fb51ffdd69b9579b8c31e4232e", size = 254261, upload-time = "2025-07-03T10:53:49.99Z" }, - { url = "https://files.pythonhosted.org/packages/8c/32/37dd1c42ce3016ff8ec9e4b607650d2e34845c0585d3518b2a93b4830c1a/coverage-7.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c33624f50cf8de418ab2b4d6ca9eda96dc45b2c4231336bac91454520e8d1fac", size = 255099, upload-time = "2025-07-03T10:53:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/da/2e/af6b86f7c95441ce82f035b3affe1cd147f727bbd92f563be35e2d585683/coverage-7.9.2-cp313-cp313t-win32.whl", hash = "sha256:1df6b76e737c6a92210eebcb2390af59a141f9e9430210595251fbaf02d46926", size = 215440, upload-time = "2025-07-03T10:53:52.808Z" }, - { url = "https://files.pythonhosted.org/packages/4d/bb/8a785d91b308867f6b2e36e41c569b367c00b70c17f54b13ac29bcd2d8c8/coverage-7.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f5fd54310b92741ebe00d9c0d1d7b2b27463952c022da6d47c175d246a98d1bd", size = 216537, upload-time = "2025-07-03T10:53:54.273Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a0/a6bffb5e0f41a47279fd45a8f3155bf193f77990ae1c30f9c224b61cacb0/coverage-7.9.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c48c2375287108c887ee87d13b4070a381c6537d30e8487b24ec721bf2a781cb", size = 214398, upload-time = "2025-07-03T10:53:56.715Z" }, - { url = "https://files.pythonhosted.org/packages/d7/85/f8bbefac27d286386961c25515431482a425967e23d3698b75a250872924/coverage-7.9.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:8a1166db2fb62473285bcb092f586e081e92656c7dfa8e9f62b4d39d7e6b5050", size = 204013, upload-time = "2025-07-03T10:54:12.084Z" }, - { url = "https://files.pythonhosted.org/packages/3c/38/bbe2e63902847cf79036ecc75550d0698af31c91c7575352eb25190d0fb3/coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4", size = 204005, upload-time = "2025-07-03T10:54:13.491Z" }, + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] [[package]] name = "cryptography" -version = "46.0.2" +version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/9b/e301418629f7bfdf72db9e80ad6ed9d1b83c487c471803eaa6464c511a01/cryptography-46.0.2.tar.gz", hash = "sha256:21b6fc8c71a3f9a604f028a329e5560009cc4a3a828bfea5fcba8eb7647d88fe", size = 749293, upload-time = "2025-10-01T00:29:11.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/98/7a8df8c19a335c8028414738490fc3955c0cecbfdd37fcc1b9c3d04bd561/cryptography-46.0.2-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3e32ab7dd1b1ef67b9232c4cf5e2ee4cd517d4316ea910acaaa9c5712a1c663", size = 7261255, upload-time = "2025-10-01T00:27:22.947Z" }, - { url = "https://files.pythonhosted.org/packages/c6/38/b2adb2aa1baa6706adc3eb746691edd6f90a656a9a65c3509e274d15a2b8/cryptography-46.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1fd1a69086926b623ef8126b4c33d5399ce9e2f3fac07c9c734c2a4ec38b6d02", size = 4297596, upload-time = "2025-10-01T00:27:25.258Z" }, - { url = "https://files.pythonhosted.org/packages/e4/27/0f190ada240003119488ae66c897b5e97149292988f556aef4a6a2a57595/cryptography-46.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb7fb9cd44c2582aa5990cf61a4183e6f54eea3172e54963787ba47287edd135", size = 4450899, upload-time = "2025-10-01T00:27:27.458Z" }, - { url = "https://files.pythonhosted.org/packages/85/d5/e4744105ab02fdf6bb58ba9a816e23b7a633255987310b4187d6745533db/cryptography-46.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9066cfd7f146f291869a9898b01df1c9b0e314bfa182cef432043f13fc462c92", size = 4300382, upload-time = "2025-10-01T00:27:29.091Z" }, - { url = "https://files.pythonhosted.org/packages/33/fb/bf9571065c18c04818cb07de90c43fc042c7977c68e5de6876049559c72f/cryptography-46.0.2-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:97e83bf4f2f2c084d8dd792d13841d0a9b241643151686010866bbd076b19659", size = 4017347, upload-time = "2025-10-01T00:27:30.767Z" }, - { url = "https://files.pythonhosted.org/packages/35/72/fc51856b9b16155ca071080e1a3ad0c3a8e86616daf7eb018d9565b99baa/cryptography-46.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:4a766d2a5d8127364fd936572c6e6757682fc5dfcbdba1632d4554943199f2fa", size = 4983500, upload-time = "2025-10-01T00:27:32.741Z" }, - { url = "https://files.pythonhosted.org/packages/c1/53/0f51e926799025e31746d454ab2e36f8c3f0d41592bc65cb9840368d3275/cryptography-46.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fab8f805e9675e61ed8538f192aad70500fa6afb33a8803932999b1049363a08", size = 4482591, upload-time = "2025-10-01T00:27:34.869Z" }, - { url = "https://files.pythonhosted.org/packages/86/96/4302af40b23ab8aa360862251fb8fc450b2a06ff24bc5e261c2007f27014/cryptography-46.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1e3b6428a3d56043bff0bb85b41c535734204e599c1c0977e1d0f261b02f3ad5", size = 4300019, upload-time = "2025-10-01T00:27:37.029Z" }, - { url = "https://files.pythonhosted.org/packages/9b/59/0be12c7fcc4c5e34fe2b665a75bc20958473047a30d095a7657c218fa9e8/cryptography-46.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:1a88634851d9b8de8bb53726f4300ab191d3b2f42595e2581a54b26aba71b7cc", size = 4950006, upload-time = "2025-10-01T00:27:40.272Z" }, - { url = "https://files.pythonhosted.org/packages/55/1d/42fda47b0111834b49e31590ae14fd020594d5e4dadd639bce89ad790fba/cryptography-46.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:be939b99d4e091eec9a2bcf41aaf8f351f312cd19ff74b5c83480f08a8a43e0b", size = 4482088, upload-time = "2025-10-01T00:27:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/60f583f69aa1602c2bdc7022dae86a0d2b837276182f8c1ec825feb9b874/cryptography-46.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f13b040649bc18e7eb37936009b24fd31ca095a5c647be8bb6aaf1761142bd1", size = 4425599, upload-time = "2025-10-01T00:27:44.616Z" }, - { url = "https://files.pythonhosted.org/packages/d1/57/d8d4134cd27e6e94cf44adb3f3489f935bde85f3a5508e1b5b43095b917d/cryptography-46.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bdc25e4e01b261a8fda4e98618f1c9515febcecebc9566ddf4a70c63967043b", size = 4697458, upload-time = "2025-10-01T00:27:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/d1/2b/531e37408573e1da33adfb4c58875013ee8ac7d548d1548967d94a0ae5c4/cryptography-46.0.2-cp311-abi3-win32.whl", hash = "sha256:8b9bf67b11ef9e28f4d78ff88b04ed0929fcd0e4f70bb0f704cfc32a5c6311ee", size = 3056077, upload-time = "2025-10-01T00:27:48.424Z" }, - { url = "https://files.pythonhosted.org/packages/a8/cd/2f83cafd47ed2dc5a3a9c783ff5d764e9e70d3a160e0df9a9dcd639414ce/cryptography-46.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:758cfc7f4c38c5c5274b55a57ef1910107436f4ae842478c4989abbd24bd5acb", size = 3512585, upload-time = "2025-10-01T00:27:50.521Z" }, - { url = "https://files.pythonhosted.org/packages/00/36/676f94e10bfaa5c5b86c469ff46d3e0663c5dc89542f7afbadac241a3ee4/cryptography-46.0.2-cp311-abi3-win_arm64.whl", hash = "sha256:218abd64a2e72f8472c2102febb596793347a3e65fafbb4ad50519969da44470", size = 2927474, upload-time = "2025-10-01T00:27:52.91Z" }, - { url = "https://files.pythonhosted.org/packages/6f/cc/47fc6223a341f26d103cb6da2216805e08a37d3b52bee7f3b2aee8066f95/cryptography-46.0.2-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:bda55e8dbe8533937956c996beaa20266a8eca3570402e52ae52ed60de1faca8", size = 7198626, upload-time = "2025-10-01T00:27:54.8Z" }, - { url = "https://files.pythonhosted.org/packages/93/22/d66a8591207c28bbe4ac7afa25c4656dc19dc0db29a219f9809205639ede/cryptography-46.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e7155c0b004e936d381b15425273aee1cebc94f879c0ce82b0d7fecbf755d53a", size = 4287584, upload-time = "2025-10-01T00:27:57.018Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3e/fac3ab6302b928e0398c269eddab5978e6c1c50b2b77bb5365ffa8633b37/cryptography-46.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a61c154cc5488272a6c4b86e8d5beff4639cdb173d75325ce464d723cda0052b", size = 4433796, upload-time = "2025-10-01T00:27:58.631Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d8/24392e5d3c58e2d83f98fe5a2322ae343360ec5b5b93fe18bc52e47298f5/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:9ec3f2e2173f36a9679d3b06d3d01121ab9b57c979de1e6a244b98d51fea1b20", size = 4292126, upload-time = "2025-10-01T00:28:00.643Z" }, - { url = "https://files.pythonhosted.org/packages/ed/38/3d9f9359b84c16c49a5a336ee8be8d322072a09fac17e737f3bb11f1ce64/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2fafb6aa24e702bbf74de4cb23bfa2c3beb7ab7683a299062b69724c92e0fa73", size = 3993056, upload-time = "2025-10-01T00:28:02.8Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a3/4c44fce0d49a4703cc94bfbe705adebf7ab36efe978053742957bc7ec324/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:0c7ffe8c9b1fcbb07a26d7c9fa5e857c2fe80d72d7b9e0353dcf1d2180ae60ee", size = 4967604, upload-time = "2025-10-01T00:28:04.783Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c2/49d73218747c8cac16bb8318a5513fde3129e06a018af3bc4dc722aa4a98/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5840f05518caa86b09d23f8b9405a7b6d5400085aa14a72a98fdf5cf1568c0d2", size = 4465367, upload-time = "2025-10-01T00:28:06.864Z" }, - { url = "https://files.pythonhosted.org/packages/1b/64/9afa7d2ee742f55ca6285a54386ed2778556a4ed8871571cb1c1bfd8db9e/cryptography-46.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:27c53b4f6a682a1b645fbf1cd5058c72cf2f5aeba7d74314c36838c7cbc06e0f", size = 4291678, upload-time = "2025-10-01T00:28:08.982Z" }, - { url = "https://files.pythonhosted.org/packages/50/48/1696d5ea9623a7b72ace87608f6899ca3c331709ac7ebf80740abb8ac673/cryptography-46.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:512c0250065e0a6b286b2db4bbcc2e67d810acd53eb81733e71314340366279e", size = 4931366, upload-time = "2025-10-01T00:28:10.74Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/9dfc778401a334db3b24435ee0733dd005aefb74afe036e2d154547cb917/cryptography-46.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:07c0eb6657c0e9cca5891f4e35081dbf985c8131825e21d99b4f440a8f496f36", size = 4464738, upload-time = "2025-10-01T00:28:12.491Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b1/abcde62072b8f3fd414e191a6238ce55a0050e9738090dc6cded24c12036/cryptography-46.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48b983089378f50cba258f7f7aa28198c3f6e13e607eaf10472c26320332ca9a", size = 4419305, upload-time = "2025-10-01T00:28:14.145Z" }, - { url = "https://files.pythonhosted.org/packages/c7/1f/3d2228492f9391395ca34c677e8f2571fb5370fe13dc48c1014f8c509864/cryptography-46.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e6f6775eaaa08c0eec73e301f7592f4367ccde5e4e4df8e58320f2ebf161ea2c", size = 4681201, upload-time = "2025-10-01T00:28:15.951Z" }, - { url = "https://files.pythonhosted.org/packages/de/77/b687745804a93a55054f391528fcfc76c3d6bfd082ce9fb62c12f0d29fc1/cryptography-46.0.2-cp314-cp314t-win32.whl", hash = "sha256:e8633996579961f9b5a3008683344c2558d38420029d3c0bc7ff77c17949a4e1", size = 3022492, upload-time = "2025-10-01T00:28:17.643Z" }, - { url = "https://files.pythonhosted.org/packages/60/a5/8d498ef2996e583de0bef1dcc5e70186376f00883ae27bf2133f490adf21/cryptography-46.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:48c01988ecbb32979bb98731f5c2b2f79042a6c58cc9a319c8c2f9987c7f68f9", size = 3496215, upload-time = "2025-10-01T00:28:19.272Z" }, - { url = "https://files.pythonhosted.org/packages/56/db/ee67aaef459a2706bc302b15889a1a8126ebe66877bab1487ae6ad00f33d/cryptography-46.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8e2ad4d1a5899b7caa3a450e33ee2734be7cc0689010964703a7c4bcc8dd4fd0", size = 2919255, upload-time = "2025-10-01T00:28:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/d5/bb/fa95abcf147a1b0bb94d95f53fbb09da77b24c776c5d87d36f3d94521d2c/cryptography-46.0.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a08e7401a94c002e79dc3bc5231b6558cd4b2280ee525c4673f650a37e2c7685", size = 7248090, upload-time = "2025-10-01T00:28:22.846Z" }, - { url = "https://files.pythonhosted.org/packages/b7/66/f42071ce0e3ffbfa80a88feadb209c779fda92a23fbc1e14f74ebf72ef6b/cryptography-46.0.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d30bc11d35743bf4ddf76674a0a369ec8a21f87aaa09b0661b04c5f6c46e8d7b", size = 4293123, upload-time = "2025-10-01T00:28:25.072Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/1fdbd2e5c1ba822828d250e5a966622ef00185e476d1cd2726b6dd135e53/cryptography-46.0.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bca3f0ce67e5a2a2cf524e86f44697c4323a86e0fd7ba857de1c30d52c11ede1", size = 4439524, upload-time = "2025-10-01T00:28:26.808Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c1/5e4989a7d102d4306053770d60f978c7b6b1ea2ff8c06e0265e305b23516/cryptography-46.0.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ff798ad7a957a5021dcbab78dfff681f0cf15744d0e6af62bd6746984d9c9e9c", size = 4297264, upload-time = "2025-10-01T00:28:29.327Z" }, - { url = "https://files.pythonhosted.org/packages/28/78/b56f847d220cb1d6d6aef5a390e116ad603ce13a0945a3386a33abc80385/cryptography-46.0.2-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cb5e8daac840e8879407acbe689a174f5ebaf344a062f8918e526824eb5d97af", size = 4011872, upload-time = "2025-10-01T00:28:31.479Z" }, - { url = "https://files.pythonhosted.org/packages/e1/80/2971f214b066b888944f7b57761bf709ee3f2cf805619a18b18cab9b263c/cryptography-46.0.2-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:3f37aa12b2d91e157827d90ce78f6180f0c02319468a0aea86ab5a9566da644b", size = 4978458, upload-time = "2025-10-01T00:28:33.267Z" }, - { url = "https://files.pythonhosted.org/packages/a5/84/0cb0a2beaa4f1cbe63ebec4e97cd7e0e9f835d0ba5ee143ed2523a1e0016/cryptography-46.0.2-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e38f203160a48b93010b07493c15f2babb4e0f2319bbd001885adb3f3696d21", size = 4472195, upload-time = "2025-10-01T00:28:36.039Z" }, - { url = "https://files.pythonhosted.org/packages/30/8b/2b542ddbf78835c7cd67b6fa79e95560023481213a060b92352a61a10efe/cryptography-46.0.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d19f5f48883752b5ab34cff9e2f7e4a7f216296f33714e77d1beb03d108632b6", size = 4296791, upload-time = "2025-10-01T00:28:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/78/12/9065b40201b4f4876e93b9b94d91feb18de9150d60bd842a16a21565007f/cryptography-46.0.2-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:04911b149eae142ccd8c9a68892a70c21613864afb47aba92d8c7ed9cc001023", size = 4939629, upload-time = "2025-10-01T00:28:39.654Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9e/6507dc048c1b1530d372c483dfd34e7709fc542765015425f0442b08547f/cryptography-46.0.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8b16c1ede6a937c291d41176934268e4ccac2c6521c69d3f5961c5a1e11e039e", size = 4471988, upload-time = "2025-10-01T00:28:41.822Z" }, - { url = "https://files.pythonhosted.org/packages/b1/86/d025584a5f7d5c5ec8d3633dbcdce83a0cd579f1141ceada7817a4c26934/cryptography-46.0.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:747b6f4a4a23d5a215aadd1d0b12233b4119c4313df83ab4137631d43672cc90", size = 4422989, upload-time = "2025-10-01T00:28:43.608Z" }, - { url = "https://files.pythonhosted.org/packages/4b/39/536370418b38a15a61bbe413006b79dfc3d2b4b0eafceb5581983f973c15/cryptography-46.0.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b275e398ab3a7905e168c036aad54b5969d63d3d9099a0a66cc147a3cc983be", size = 4685578, upload-time = "2025-10-01T00:28:45.361Z" }, - { url = "https://files.pythonhosted.org/packages/15/52/ea7e2b1910f547baed566c866fbb86de2402e501a89ecb4871ea7f169a81/cryptography-46.0.2-cp38-abi3-win32.whl", hash = "sha256:0b507c8e033307e37af61cb9f7159b416173bdf5b41d11c4df2e499a1d8e007c", size = 3036711, upload-time = "2025-10-01T00:28:47.096Z" }, - { url = "https://files.pythonhosted.org/packages/71/9e/171f40f9c70a873e73c2efcdbe91e1d4b1777a03398fa1c4af3c56a2477a/cryptography-46.0.2-cp38-abi3-win_amd64.whl", hash = "sha256:f9b2dc7668418fb6f221e4bf701f716e05e8eadb4f1988a2487b11aedf8abe62", size = 3500007, upload-time = "2025-10-01T00:28:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/3e/7c/15ad426257615f9be8caf7f97990cf3dcbb5b8dd7ed7e0db581a1c4759dd/cryptography-46.0.2-cp38-abi3-win_arm64.whl", hash = "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", size = 2918153, upload-time = "2025-10-01T00:28:51.003Z" }, - { url = "https://files.pythonhosted.org/packages/25/b2/067a7db693488f19777ecf73f925bcb6a3efa2eae42355bafaafa37a6588/cryptography-46.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f25a41f5b34b371a06dad3f01799706631331adc7d6c05253f5bca22068c7a34", size = 3701860, upload-time = "2025-10-01T00:28:53.003Z" }, - { url = "https://files.pythonhosted.org/packages/87/12/47c2aab2c285f97c71a791169529dbb89f48fc12e5f62bb6525c3927a1a2/cryptography-46.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e12b61e0b86611e3f4c1756686d9086c1d36e6fd15326f5658112ad1f1cc8807", size = 3429917, upload-time = "2025-10-01T00:28:55.03Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/1aabe338149a7d0f52c3e30f2880b20027ca2a485316756ed6f000462db3/cryptography-46.0.2-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1d3b3edd145953832e09607986f2bd86f85d1dc9c48ced41808b18009d9f30e5", size = 3714495, upload-time = "2025-10-01T00:28:57.222Z" }, - { url = "https://files.pythonhosted.org/packages/e3/0a/0d10eb970fe3e57da9e9ddcfd9464c76f42baf7b3d0db4a782d6746f788f/cryptography-46.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fe245cf4a73c20592f0f48da39748b3513db114465be78f0a36da847221bd1b4", size = 4243379, upload-time = "2025-10-01T00:28:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/7d/60/e274b4d41a9eb82538b39950a74ef06e9e4d723cb998044635d9deb1b435/cryptography-46.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2b9cad9cf71d0c45566624ff76654e9bae5f8a25970c250a26ccfc73f8553e2d", size = 4409533, upload-time = "2025-10-01T00:29:00.785Z" }, - { url = "https://files.pythonhosted.org/packages/19/9a/fb8548f762b4749aebd13b57b8f865de80258083fe814957f9b0619cfc56/cryptography-46.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9bd26f2f75a925fdf5e0a446c0de2714f17819bf560b44b7480e4dd632ad6c46", size = 4243120, upload-time = "2025-10-01T00:29:02.515Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/883f24147fd4a0c5cab74ac7e36a1ff3094a54ba5c3a6253d2ff4b19255b/cryptography-46.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:7282d8f092b5be7172d6472f29b0631f39f18512a3642aefe52c3c0e0ccfad5a", size = 4408940, upload-time = "2025-10-01T00:29:04.42Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b5/c5e179772ec38adb1c072b3aa13937d2860509ba32b2462bf1dda153833b/cryptography-46.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c4b93af7920cdf80f71650769464ccf1fb49a4b56ae0024173c24c48eb6b1612", size = 3438518, upload-time = "2025-10-01T00:29:06.139Z" }, + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "csvw" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "babel" }, + { name = "isodate" }, + { name = "jsonschema" }, + { name = "language-tags" }, + { name = "python-dateutil" }, + { name = "rdflib" }, + { name = "requests" }, + { name = "rfc3986" }, + { name = "termcolor" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/90/64efac40e52949079c2b4030167ee68ec52cf061f11e368ee1a82e410670/csvw-3.7.0.tar.gz", hash = "sha256:869b5c761481e52c01a99fb4749b278a4b8b0db4e0fa1965a33a3441c703465b", size = 74789, upload-time = "2025-10-07T10:46:28.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/cb/19e8e582fc164db200c18078bdbdcc60c012cb83c7f02ea8e876bc0b1adf/csvw-3.7.0-py2.py3-none-any.whl", hash = "sha256:21b88db50a35e940d4b5cdd8f3a8084493ad7f1bb1657ed7323aad977359940e", size = 60685, upload-time = "2025-10-07T10:46:26.708Z" }, ] [[package]] name = "ctranslate2" -version = "4.6.0" +version = "4.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pyyaml" }, { name = "setuptools" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/71/ea/4d8f098c96873196ed87cfcd0bdb65a4b1783d18030e84633bc965241ae1/ctranslate2-4.6.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:aeadeb7fd11f37ec96b40952402ce35ee7d214b09e1634fb11934f7d5e4ad1d7", size = 13300930, upload-time = "2025-04-08T19:49:23.629Z" }, - { url = "https://files.pythonhosted.org/packages/ba/9c/22417d43afc919e66f8218d6da4496bbff43636405902b4f53484ec801db/ctranslate2-4.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b5da5eee549db5137e9082fa7b479bd8bf273d9a961afdf3f8ecff2527fdf71e", size = 1289916, upload-time = "2025-04-08T19:49:27.019Z" }, - { url = "https://files.pythonhosted.org/packages/ad/38/e8121d6e29cee029ab21be01612a173dcf62a93324e43197f7b0d122645b/ctranslate2-4.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ed15383afc9d4e448d4090389f06c141a5ce1510e610c1aa7021332cfbc97f1", size = 17185979, upload-time = "2025-04-08T19:49:28.517Z" }, - { url = "https://files.pythonhosted.org/packages/8d/bc/a342f732a48258d0c9ae6d08f007e792705bc371e0ed93cf499ffc28f80c/ctranslate2-4.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ac5a714890e9f5f6876005c8a8fb2bdf9bec88437c38ff3efd71bd65333519d", size = 38445253, upload-time = "2025-04-08T19:49:30.942Z" }, - { url = "https://files.pythonhosted.org/packages/23/e3/591b46613582baea22de7308af3b10fd2188f177856282745771ff954319/ctranslate2-4.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f99502996361f7dc35f00b95a01e414c8d8ff75b8a58da97e378ceb5560689ae", size = 19466268, upload-time = "2025-04-08T19:49:33.486Z" }, - { url = "https://files.pythonhosted.org/packages/17/d9/1857a64cdbaf3c514e145d5bb06f4c659689ad086054e3c87874c29f1e5e/ctranslate2-4.6.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:2f80538ce0f619540499b505747179ee5e86a5c9b80361c1582f7c725d660509", size = 13301999, upload-time = "2025-04-08T19:49:35.962Z" }, - { url = "https://files.pythonhosted.org/packages/61/bf/42a5c004547b92cfacad221e126af182c7d98471a44cfdc41bc09c9a929a/ctranslate2-4.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:00097c52bf6be97f753e39bc7399f23bdf9803df942094b8cecdd8432f0335d5", size = 1291210, upload-time = "2025-04-08T19:49:38.044Z" }, - { url = "https://files.pythonhosted.org/packages/33/83/1cf0b771778830fc9d00d166b90aabf27d5b5df4874d92ce5e7c4ea9e090/ctranslate2-4.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4691a66cb7b9ffb04ebff4291055c20223449a6534c4a52b7432b0853946d0", size = 17419689, upload-time = "2025-04-08T19:49:39.345Z" }, - { url = "https://files.pythonhosted.org/packages/3f/89/5991e0e7333b9f4d2022ea817c0017d4cbc6891be1b3b190a0112f753430/ctranslate2-4.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79e4f2e8ea7f24797c80e0f4593d30447ef8da9036ebb4402b7f6c54687b7a46", size = 38639065, upload-time = "2025-04-08T19:49:41.957Z" }, - { url = "https://files.pythonhosted.org/packages/e1/85/284c30508fc3627c6adc855207fc970cb41c894acbbb3e6351f4874ac7c2/ctranslate2-4.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:865649cebae240fe8c5b3e868354ea6c611d2ec17f335848caf890fca6c62d71", size = 19466832, upload-time = "2025-04-08T19:49:44.645Z" }, - { url = "https://files.pythonhosted.org/packages/02/e9/3f1e35528b445b2fc928063f3ddd1ca5ac195b08c28ab10312e599c5cf28/ctranslate2-4.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff3ad05010857d450ee40fd9c28a33c10215a7180e189151e378ed2d19be8a57", size = 13310925, upload-time = "2025-04-08T19:49:47.051Z" }, - { url = "https://files.pythonhosted.org/packages/2a/72/3880c3be097596a523cb24b52dc0514f685c2ec0bab9cceaeed874aeddec/ctranslate2-4.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78a844c633b6d450b20adac296f7f60ac2a67f2c76e510a83c8916835dc13f04", size = 1297913, upload-time = "2025-04-08T19:49:48.702Z" }, - { url = "https://files.pythonhosted.org/packages/3f/b3/77af5ad0e896dd27a10db768d7a67b8807e394c8e68c2fa559c662a33547/ctranslate2-4.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44bf4b973ea985b80696093e11e9c72909aee55b35abb749428333822c70ce68", size = 17485132, upload-time = "2025-04-08T19:49:50.076Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e9/06c2bf49d6808359d71f1126ec5b8e5a5c3c9526899ed58f24666e0e1b86/ctranslate2-4.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b2ca5c2905b540dd833a0b75d912ec9acc18d33a2dc4f85f12032851659a0d", size = 38816537, upload-time = "2025-04-08T19:49:52.735Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4c/0ecd260233290bee4b2facec4d8e755e57d8781d68f276e1248433993c9f/ctranslate2-4.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:511cdf810a5bf6a2cec735799e5cd47966e63f8f7688fdee1b97fed621abda00", size = 19470040, upload-time = "2025-04-08T19:49:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/59/96/dea1633368d60eb3da7403f3773cc2ba7988e56044ae155f68ab1ebb8f81/ctranslate2-4.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6283ffe63831b980282ff64ab845c62c7ef771f2ce06cb34825fd7578818bf07", size = 13310770, upload-time = "2025-04-08T19:49:57.238Z" }, - { url = "https://files.pythonhosted.org/packages/1b/65/d6470f6cfb10e5a065bd71c8cf99d5d107a9d33caedaa622ad7bd9dca01d/ctranslate2-4.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2ebaae12ade184a235569235a875cf03d53b07732342f93b96ae76ef02c31961", size = 1297777, upload-time = "2025-04-08T19:49:59.383Z" }, - { url = "https://files.pythonhosted.org/packages/13/52/249565849281e7d6c997ffca88447b8806c119e1b0d1f799c27dda061440/ctranslate2-4.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a719cd765ec10fe20f9a866093e777a000fd926a0bf235c7921f12c84befb443", size = 17487553, upload-time = "2025-04-08T19:50:00.816Z" }, - { url = "https://files.pythonhosted.org/packages/77/6d/131193b68d3884f9ab9474d916c6244df2914fbb3234d2a4c1fada72b1d6/ctranslate2-4.6.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:039aa6cc3ed662931a60dec0be28abeaaceb3cc6f476060b8017a7a39a54a9f6", size = 38817828, upload-time = "2025-04-08T19:50:03.445Z" }, - { url = "https://files.pythonhosted.org/packages/d5/96/37470cbab08464a31877eb80c3ca3f56d097a1616adc982b53c5bf71d2c2/ctranslate2-4.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:af555c75cb9a9cc6c385f38680b92fa426761cf690e4479b1e962e2b17e02972", size = 19470232, upload-time = "2025-04-08T19:50:06.192Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e0/b69c40c3d739b213a78d327071240590792071b4f890e34088b03b95bb1e/ctranslate2-4.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9017a355dd7c6d29dc3bca6e9fc74827306c61b702c66bb1f6b939655e7de3fa", size = 1255773, upload-time = "2026-02-04T06:11:04.769Z" }, + { url = "https://files.pythonhosted.org/packages/51/29/e5c2fc1253e3fb9b2c86997f36524bba182a8ed77fb4f8fe8444a5649191/ctranslate2-4.7.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:6abcd0552285e7173475836f9d133e04dfc3e42ca8e6930f65eaa4b8b13a47fa", size = 11914945, upload-time = "2026-02-04T06:11:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/03/25/e7fe847d3f02c84d2e9c5e8312434fbeab5af3d8916b6c8e2bdbe860d052/ctranslate2-4.7.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8492cba605319e0d7f2760180957d5a2a435dfdebcef1a75d2ade740e6b9fb0b", size = 16547973, upload-time = "2026-02-04T06:11:09.021Z" }, + { url = "https://files.pythonhosted.org/packages/68/75/074ed22bc340c2e26c09af6bf85859b586516e4e2d753b20189936d0dcf7/ctranslate2-4.7.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:688bd82482b5d057eff5bc1e727f11bb9a1277b7e4fce8ab01fd3bb70e69294b", size = 38636471, upload-time = "2026-02-04T06:11:12.146Z" }, + { url = "https://files.pythonhosted.org/packages/76/b6/9baf8a565f6dcdbfbc9cfd179dd6214529838cda4e91e89b616045a670f0/ctranslate2-4.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:3b39a5f4e3c87ac91976996458a64ba08a7cbf974dc0be4e6df83a9e040d4bd2", size = 18842389, upload-time = "2026-02-04T06:11:15.154Z" }, + { url = "https://files.pythonhosted.org/packages/da/25/41920ccee68e91cb6fa0fc9e8078ab2b7839f2c668f750dc123144cb7c6e/ctranslate2-4.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f74200bab9996b14a57cf6f7cb27d0921ceedc4acc1e905598e3e85b4d75b1ec", size = 1256943, upload-time = "2026-02-04T06:11:17.781Z" }, + { url = "https://files.pythonhosted.org/packages/79/22/bc81fcc9f10ba4da3ffd1a9adec15cfb73cb700b3bbe69c6c8b55d333316/ctranslate2-4.7.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:59b427eb3ac999a746315b03a63942fddd351f511db82ba1a66880d4dea98e25", size = 11916445, upload-time = "2026-02-04T06:11:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a7/494a66bb02c7926331cadfff51d5ce81f5abfb1e8d05d7f2459082f31b48/ctranslate2-4.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95f0c1051c180669d2a83a44b44b518b2d1683de125f623bbc81ad5dd6f6141c", size = 16696997, upload-time = "2026-02-04T06:11:22.697Z" }, + { url = "https://files.pythonhosted.org/packages/ed/4e/b48f79fd36e5d3c7e12db383aa49814c340921a618ef7364bd0ced670644/ctranslate2-4.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed92d9ab0ac6bc7005942be83d68714c80adb0897ab17f98157294ee0374347", size = 38836379, upload-time = "2026-02-04T06:11:26.325Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/8c01ac52e1f26fc4dbe985a35222ae7cd365bbf7ee5db5fd5545d8926f91/ctranslate2-4.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:67d9ad9b69933fbfeee7dcec899b2cd9341d5dca4fdfb53e8ba8c109dc332ee1", size = 18843315, upload-time = "2026-02-04T06:11:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/fc/0f/581de94b64c5f2327a736270bc7e7a5f8fe5cf1ed56a2203b52de4d8986a/ctranslate2-4.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c0cbd46a23b8dc37ccdbd9b447cb5f7fadc361c90e9df17d82ca84b1f019986", size = 1257089, upload-time = "2026-02-04T06:11:32.442Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e9/d55b0e436362f9fe26bd98fefd2dd5d81926121f1d7f799c805e6035bb26/ctranslate2-4.7.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:5b141ddad1da5f84cf3c2a569a56227a37de649a555d376cbd9b80e8f0373dd8", size = 11918502, upload-time = "2026-02-04T06:11:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ce/9f29f0b0bb4280c2ebafb3ddb6cdff8ef1c2e185ee020c0ec0ecba7dc934/ctranslate2-4.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00a62544db4a3caaa58a3c50d39b25613c042b430053ae32384d94eb1d40990", size = 16859601, upload-time = "2026-02-04T06:11:36.227Z" }, + { url = "https://files.pythonhosted.org/packages/b3/86/428d270fd72117d19fb48ed3211aa8a3c8bd7577373252962cb634e0fd01/ctranslate2-4.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:722b93a89647974cbd182b4c7f87fefc7794fff7fc9cbd0303b6447905cc157e", size = 38995338, upload-time = "2026-02-04T06:11:42.789Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/d23dbfb9c62cb642c114a30f05d753ba61d6ffbfd8a3a4012fe85a073bcb/ctranslate2-4.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f734dc3757118094663bdaaf713f5090c55c1927fb330a76bb8b84173940e8", size = 18844949, upload-time = "2026-02-04T06:11:45.436Z" }, + { url = "https://files.pythonhosted.org/packages/34/6d/eb49ba05db286b4ea9d5d3fcf5f5cd0a9a5e218d46349618d5041001e303/ctranslate2-4.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b2abf2929756e3ec6246057b56df379995661560a2d776af05f9d97f63afcf5", size = 1256960, upload-time = "2026-02-04T06:11:47.487Z" }, + { url = "https://files.pythonhosted.org/packages/45/5a/b9cce7b00d89fc6fdeaf27587aa52d0597b465058563e93ff50910553bdd/ctranslate2-4.7.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:857ef3959d6b1c40dc227c715a36db33db2d097164996d6c75b6db8e30828f52", size = 11918645, upload-time = "2026-02-04T06:11:49.599Z" }, + { url = "https://files.pythonhosted.org/packages/ea/03/c0db0a5276599fb44ceafa2f2cb1afd5628808ec406fe036060a39693680/ctranslate2-4.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:393a9e7e989034660526a2c0e8bb65d1924f43d9a5c77d336494a353d16ba2a4", size = 16860452, upload-time = "2026-02-04T06:11:52.276Z" }, + { url = "https://files.pythonhosted.org/packages/0b/03/4e3728ce29d192ee75ed9a2d8589bf4f19edafe5bed3845187de51b179a3/ctranslate2-4.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a3d0682f2b9082e31c73d75b45f16cde77355ab76d7e8356a24c3cb2480a6d3", size = 38995174, upload-time = "2026-02-04T06:11:55.477Z" }, + { url = "https://files.pythonhosted.org/packages/9b/15/6e8e87c6a201d69803a79ac2e29623ce7c2cc9cd1df9db99810cca714373/ctranslate2-4.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:baa6d2b10f57933d8c11791e8522659217918722d07bbef2389a443801125fe7", size = 18844953, upload-time = "2026-02-04T06:11:58.519Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/8a6b7ba18cad0c8667ee221ddab8c361cb70926440e5b8dd0e81924c28ac/ctranslate2-4.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d5dfb076566551f4959dfd0706f94c923c1931def9b7bb249a2caa6ab23353a0", size = 1257560, upload-time = "2026-02-04T06:12:00.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/c2/8817ca5d6c1b175b23a12f7c8b91484652f8718a76353317e5919b038733/ctranslate2-4.7.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:eecdb4ed934b384f16e8c01b185b082d6b5ffc7dcbb0b6a6eb48cd465282d957", size = 11918995, upload-time = "2026-02-04T06:12:02.875Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/b8eb3acc67bbca4d9872fc9ff94db78e6167a7ba5cd932f585d1560effc7/ctranslate2-4.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1aa6796edcc3c8d163c9e39c429d50076d266d68980fed9d1b2443f617c67e9e", size = 16844162, upload-time = "2026-02-04T06:12:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/80/11/6474893b07121057035069a0a483fe1cd8c47878213f282afb4c0c6fc275/ctranslate2-4.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24c0482c51726430fb83724451921c0e539d769c8618dcfd46b1645e7f75960d", size = 38966728, upload-time = "2026-02-04T06:12:07.923Z" }, + { url = "https://files.pythonhosted.org/packages/94/88/8fc7ff435c5e783e5fad9586d839d463e023988dbbbad949d442092d01f1/ctranslate2-4.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:76db234c0446a23d20dd8eeaa7a789cc87d1d05283f48bf3152bae9fa0a69844", size = 19100788, upload-time = "2026-02-04T06:12:10.592Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b3/f100013a76a98d64e67c721bd4559ea4eeb54be3e4ac45f4d801769899af/ctranslate2-4.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:058c9db2277dc8b19ecc86c7937628f69022f341844b9081d2ab642965d88fc6", size = 1280179, upload-time = "2026-02-04T06:12:12.596Z" }, + { url = "https://files.pythonhosted.org/packages/39/22/b77f748015667a5e2ca54a5ee080d7016fce34314f0e8cf904784549305a/ctranslate2-4.7.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5abcf885062c7f28a3f9a46be8d185795e8706ac6230ad086cae0bc82917df31", size = 11940166, upload-time = "2026-02-04T06:12:14.054Z" }, + { url = "https://files.pythonhosted.org/packages/7d/78/6d7fd52f646c6ba3343f71277a9bbef33734632949d1651231948b0f0359/ctranslate2-4.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9950acb04a002d5c60ae90a1ddceead1a803af1f00cadd9b1a1dc76e1f017481", size = 16849483, upload-time = "2026-02-04T06:12:17.082Z" }, + { url = "https://files.pythonhosted.org/packages/40/27/58769ff15ac31b44205bd7a8aeca80cf7357c657ea5df1b94ce0f5c83771/ctranslate2-4.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dcc734e92e3f1ceeaa0c42bbfd009352857be179ecd4a7ed6cccc086a202f58", size = 38949393, upload-time = "2026-02-04T06:12:21.302Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5c/9fa0ad6462b62efd0fb5ac1100eee47bc96ecc198ff4e237c731e5473616/ctranslate2-4.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dfb7657bdb7b8211c8f9ecb6f3b70bc0db0e0384d01a8b1808cb66fe7199df59", size = 19123451, upload-time = "2026-02-04T06:12:24.115Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "12.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/d8/b546104b8da3f562c1ff8ab36d130c8fe1dd6a045ced80b4f6ad74f7d4e1/cuda_bindings-12.9.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d3c842c2a4303b2a580fe955018e31aea30278be19795ae05226235268032e5", size = 12148218, upload-time = "2025-10-21T14:51:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/45/e7/b47792cc2d01c7e1d37c32402182524774dadd2d26339bd224e0e913832e/cuda_bindings-12.9.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c912a3d9e6b6651853eed8eed96d6800d69c08e94052c292fec3f282c5a817c9", size = 12210593, upload-time = "2025-10-21T14:51:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8", size = 12151019, upload-time = "2025-10-21T14:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/56/e465c31dc9111be3441a9ba7df1941fe98f4aa6e71e8788a3fb4534ce24d/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32bdc5a76906be4c61eb98f546a6786c5773a881f3b166486449b5d141e4a39f", size = 11906628, upload-time = "2025-10-21T14:51:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/1e6be415e37478070aeeee5884c2022713c1ecc735e6d82d744de0252eee/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56e0043c457a99ac473ddc926fe0dc4046694d99caef633e92601ab52cbe17eb", size = 11925991, upload-time = "2025-10-21T14:51:56.535Z" }, + { url = "https://files.pythonhosted.org/packages/d1/af/6dfd8f2ed90b1d4719bc053ff8940e494640fe4212dc3dd72f383e4992da/cuda_bindings-12.9.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b72ee72a9cc1b531db31eebaaee5c69a8ec3500e32c6933f2d3b15297b53686", size = 11922703, upload-time = "2025-10-21T14:52:03.585Z" }, + { url = "https://files.pythonhosted.org/packages/6c/19/90ac264acc00f6df8a49378eedec9fd2db3061bf9263bf9f39fd3d8377c3/cuda_bindings-12.9.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80bffc357df9988dca279734bc9674c3934a654cab10cadeed27ce17d8635ee", size = 11924658, upload-time = "2025-10-21T14:52:10.411Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/59/911a1a597264f1fb7ac176995a0f0b6062e37f8c1b6e0f23071a76838507/cuda_pathfinder-1.4.3-py3-none-any.whl", hash = "sha256:4345d8ead1f701c4fb8a99be6bc1843a7348b6ba0ef3b031f5a2d66fb128ae4c", size = 47951, upload-time = "2026-03-16T21:31:25.526Z" }, ] [[package]] @@ -1179,13 +1402,13 @@ wheels = [ [[package]] name = "daily-python" -version = "0.23.0" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/3b/0f1465833787db21933caf9db0c4e45d10a31f477c2ae08ebe3edf31764d/daily_python-0.23.0-cp37-abi3-macosx_10_15_x86_64.whl", hash = "sha256:74998c9aabeccf8306def27219eb943df5a268f26ba7e25e0a105ced9425591a", size = 13450473, upload-time = "2025-12-18T03:21:50.488Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ca/7a9b581d411101454855c21559b1c9d9d58b6c527480548bba03e00a5641/daily_python-0.23.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:ce80746f5cba434371a9cb9995d8c4ec19d1490bd993b7dfd35356d81bd68425", size = 11988100, upload-time = "2025-12-18T03:21:53.265Z" }, - { url = "https://files.pythonhosted.org/packages/40/eb/15303b6ae2cdc5c591d6629d162085037697ca519d57751408c66f775991/daily_python-0.23.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:21a59b1730ae047f2335b9fdcf8ebb9fe05c3768dc4239ec955461f3f78476b0", size = 14094021, upload-time = "2025-12-18T03:21:55.606Z" }, - { url = "https://files.pythonhosted.org/packages/65/b9/f063e4854ba6e4edeea46e082720b7af82fb3cbf27e60efce63dbe34fe6d/daily_python-0.23.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:00b22041ef85556cc06262f9b8e39b686bdf37d50219d07542d7a2f9c3d413d4", size = 14616886, upload-time = "2025-12-18T03:21:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/1b/58/074c6fca866fa13006b880eab521985d39300ea0d1df75a60d6dac4b2d47/daily_python-0.25.0-cp37-abi3-macosx_10_15_x86_64.whl", hash = "sha256:bff92b598863201bdffeea17819b7418d5c03a7ffc665a02be0333237fb3e4be", size = 13312157, upload-time = "2026-03-17T00:10:03.273Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/0b623e8e6d06713e8d193335db1e5ec46760af276f08d8b378a0a8e4696b/daily_python-0.25.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:733c70e77bb1c8933a7fa779722592a109617b2d098c192399c2bfffcb210847", size = 11831685, upload-time = "2026-03-17T00:10:05.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/36/45c3a59e92a37e3a51dbe2603f9e855408ecdb48f1b4cd396de83839e6f9/daily_python-0.25.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a6da5f8c99ccbdb59042a4c5e48f9a85f68a32f65f16b3778da5f319fd6d7e17", size = 13865923, upload-time = "2026-03-17T00:10:07.391Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5e/4574dedb8faa6a578079c89825f17c07a0bd4e1b4c2d2161966e62a6c6aa/daily_python-0.25.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0258ce43b92682379ecfcee1fa276799ccf41db25410f86395ae9927c2661cba", size = 14439736, upload-time = "2026-03-17T00:10:09.51Z" }, ] [[package]] @@ -1203,33 +1426,18 @@ wheels = [ [[package]] name = "deepgram-sdk" -version = "4.7.0" +version = "6.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aenum" }, - { name = "aiofiles" }, - { name = "aiohttp" }, - { name = "dataclasses-json" }, - { name = "deprecation" }, { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-core" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/c7/3c5918c2c74e3d56cf3d738aa174bc688c73069dc9682fc1bfaeb2058cc6/deepgram_sdk-4.7.0.tar.gz", hash = "sha256:e371396d8835d449782df472c3bd501f6cad41b3c925f66771933ff3fc4b1a13", size = 100128, upload-time = "2025-07-21T15:43:56.705Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/46/6dc45de574d766a20853452d7beccf17cb0cfeb685a0f03460f1fe49b48e/deepgram_sdk-6.0.1.tar.gz", hash = "sha256:88558a43d6173a861c8b6d6491b9ee8805679fb09fb81ef51eeb6871dad77767", size = 176743, upload-time = "2026-02-24T13:52:17.163Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/63/43a6e46b35eae9739e22b5cace4a22ece76d4aff74b563563b9507411484/deepgram_sdk-4.7.0-py3-none-any.whl", hash = "sha256:1a2a0890aa43cbc510e07b0f911f6841770ca0222e6fcc069bd3e2afcde1c061", size = 157911, upload-time = "2025-07-21T15:43:55.695Z" }, -] - -[[package]] -name = "deprecation" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, + { url = "https://files.pythonhosted.org/packages/58/a4/53b9075816edc566694aed014d9864febedf232677b74f5d30bdde64b5de/deepgram_sdk-6.0.1-py3-none-any.whl", hash = "sha256:1b33d621b1c0b1d7a6a7b46fdc393aef4212e670521fada99764f5fb3f9d55fd", size = 490751, upload-time = "2026-02-24T13:52:15.998Z" }, ] [[package]] @@ -1250,6 +1458,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dlinfo" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/8e/8f2f94cd40af1b51e8e371a83b385d622170d42f98776441a6118f4dd682/dlinfo-2.0.0.tar.gz", hash = "sha256:88a2bc04f51d01bc604cdc9eb1c3cc0bde89057532ca6a3e71a41f6235433e17", size = 12727, upload-time = "2025-01-16T15:43:10.756Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/90/022c79d6e5e6f843268c10b84d4a021ee3afba0621d3c176d3ff2024bfc8/dlinfo-2.0.0-py3-none-any.whl", hash = "sha256:b32cc18e3ea67c0ca9ca409e5b41eed863bd1363dbc9dd3de90fedf11b61e7bc", size = 3654, upload-time = "2025-01-16T15:43:09.474Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -1272,18 +1489,36 @@ wheels = [ name = "docutils" version = "0.21.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] [[package]] -name = "einops" -version = "0.8.1" +name = "docutils" +version = "0.22.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/81/df4fbe24dff8ba3934af99044188e20a98ed441ad17a274539b74e82e126/einops-0.8.1.tar.gz", hash = "sha256:de5d960a7a761225532e0f1959e5315ebeafc0cd43394732f103ca44b9837e84", size = 54805, upload-time = "2025-02-09T03:17:00.434Z" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/62/9773de14fe6c45c23649e98b83231fffd7b9892b6cf863251dc2afa73643/einops-0.8.1-py3-none-any.whl", hash = "sha256:919387eb55330f5757c6bea9165c5ff5cfe63a642682ea788a6d472576d81737", size = 64359, upload-time = "2025-02-09T03:17:01.998Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "einops" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/77/850bef8d72ffb9219f0b1aac23fbc1bf7d038ee6ea666f331fa273031aa2/einops-0.8.2.tar.gz", hash = "sha256:609da665570e5e265e27283aab09e7f279ade90c4f01bcfca111f3d3e13f2827", size = 56261, upload-time = "2026-01-26T04:13:17.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638, upload-time = "2026-01-26T04:13:18.546Z" }, ] [[package]] @@ -1299,6 +1534,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] +[[package]] +name = "espeakng-loader" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/92/f44ed7f531143c3c6c97d56e2b0f9be8728dc05e18b96d46eb539230ed46/espeakng_loader-0.2.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b77477ae2ddf62a748e04e49714eabb2f3a24f344166200b00539083bd669904", size = 9938387, upload-time = "2025-01-17T01:22:42.064Z" }, + { url = "https://files.pythonhosted.org/packages/a8/26/258c0cd43b9bc1043301c5f61767d6a6c3b679df82790c9cb43a3277b865/espeakng_loader-0.2.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d27cdca31112226e7299d8562e889d3e38a1e48055c9ee381b45d669072ee59f", size = 9892565, upload-time = "2025-01-17T01:22:40.365Z" }, + { url = "https://files.pythonhosted.org/packages/de/1e/25ec5ab07528c0fbb215a61800a38eca05c8a99445515a02d7fa5debcb32/espeakng_loader-0.2.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08721baf27d13d461f6be6eed9a65277e70d68234ff484fd8b9897b222cdcb6d", size = 10078484, upload-time = "2025-01-17T01:22:43.373Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ad/1b768d8daffc2996e07bbcb6f534d8de3202cd75fce1f1c45eced1ce6465/espeakng_loader-0.2.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d1e798141b46a050cdb75fcf3c17db969bb2c40394f3f4a48910655d547508b9", size = 10037736, upload-time = "2025-01-17T01:22:42.576Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ed/a3d872fbad4f3a3f3db0e8c31768ab14e77cd77306de16b8b20b1e1df7ea/espeakng_loader-0.2.4-py3-none-win_amd64.whl", hash = "sha256:41f1e08ac9deda2efd1ea9de0b81dab9f5ae3c4b24284f76533d0a7b1dd7abd7", size = 9437292, upload-time = "2025-01-17T01:23:27.463Z" }, + { url = "https://files.pythonhosted.org/packages/29/64/0b75bc50ec53b4e000bac913625511215aa96124adf5dba8c4baa17c02cd/espeakng_loader-0.2.4-py3-none-win_arm64.whl", hash = "sha256:d7a2928843eaeb2df82f99a370f44e8a630f59b02f9b0d1f168a03c4eeb76b89", size = 9426841, upload-time = "2025-01-17T01:23:21.766Z" }, +] + [[package]] name = "eval-type-backport" version = "0.2.2" @@ -1310,42 +1558,30 @@ wheels = [ [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, -] - -[[package]] -name = "fal-client" -version = "0.5.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "httpx-sse" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/20/28/c5710df43dd0a14e23fe86e8a6ed284679b9604ac9d09c6c8efede6056ae/fal_client-0.5.9.tar.gz", hash = "sha256:238a5300293d8d8da1204f4455dc78b1539f2ff20122f870e7280ccc29f28922", size = 13924, upload-time = "2025-02-12T16:49:04.462Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/fe/82a277970bc4cd1711f526bea481e6a54c3e4036a25235deb30497529d41/fal_client-0.5.9-py3-none-any.whl", hash = "sha256:f45dae7553c5b85e00418957cc4c8531e24f64e5aa7c7dad862ed67e7cfb0f03", size = 10414, upload-time = "2025-02-12T16:49:02.464Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "fastapi" -version = "0.121.0" +version = "0.135.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/77a2df0946703973b9905fd0cde6172c15e0781984320123b4f5079e7113/fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa", size = 342412, upload-time = "2025-11-03T10:25:54.818Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload-time = "2025-11-03T10:25:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, ] [package.optional-dependencies] @@ -1355,27 +1591,26 @@ all = [ { name = "httpx" }, { name = "itsdangerous" }, { name = "jinja2" }, - { name = "orjson" }, { name = "pydantic-extra-types" }, { name = "pydantic-settings" }, { name = "python-multipart" }, { name = "pyyaml" }, - { name = "ujson" }, { name = "uvicorn", extra = ["standard"] }, ] [[package]] name = "fastapi-cli" -version = "0.0.13" +version = "0.0.24" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/4e/3f61850012473b097fc5297d681bd85788e186fadb8555b67baf4c7707f4/fastapi_cli-0.0.13.tar.gz", hash = "sha256:312addf3f57ba7139457cf0d345c03e2170cc5a034057488259c33cd7e494529", size = 17780, upload-time = "2025-09-20T16:37:31.089Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload-time = "2026-02-24T10:45:10.476Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/36/7432750f3638324b055496d2c952000bea824259fca70df5577a6a3c172f/fastapi_cli-0.0.13-py3-none-any.whl", hash = "sha256:219b73ccfde7622559cef1d43197da928516acb4f21f2ec69128c4b90057baba", size = 11142, upload-time = "2025-09-20T16:37:29.695Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload-time = "2026-02-24T10:45:09.552Z" }, ] [package.optional-dependencies] @@ -1386,9 +1621,10 @@ standard = [ [[package]] name = "fastapi-cloud-cli" -version = "0.3.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "fastar" }, { name = "httpx" }, { name = "pydantic", extra = ["email"] }, { name = "rich-toolkit" }, @@ -1397,14 +1633,135 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/5f/17b403148a23dd708e3166f534136f4d3918942e168aca66659311eb0678/fastapi_cloud_cli-0.3.0.tar.gz", hash = "sha256:17c7f8baa16b2f907696bf77d49df4a04e8715bbf5233024f273870f3ff1ca4d", size = 24388, upload-time = "2025-10-02T13:25:52.361Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/e1/05c44e7bbc619e980fab0236cff9f5f323ac1aaa79434b4906febf98b1d3/fastapi_cloud_cli-0.15.0.tar.gz", hash = "sha256:d02515231f3f505f7669c20920343934570a88a08af9f9a6463ca2807f27ffe5", size = 45309, upload-time = "2026-03-11T22:31:32.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/59/7d12c5173fe2eed21e99bb1a6eb7e4f301951db870a4d915d126e0b6062d/fastapi_cloud_cli-0.3.0-py3-none-any.whl", hash = "sha256:572677dbe38b6d4712d30097a8807b383d648ca09eb58e4a07cef4a517020832", size = 19921, upload-time = "2025-10-02T13:25:51.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/cc/1ccca747f5609be27186ea8c9219449142f40e3eded2c6089bba6a6ecc82/fastapi_cloud_cli-0.15.0-py3-none-any.whl", hash = "sha256:9ffcf90bd713747efa65447620d29cfbb7b3f7de38d97467952ca6346e418d70", size = 32267, upload-time = "2026-03-11T22:31:33.499Z" }, +] + +[[package]] +name = "fastar" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/e7/f89d54fb04104114dd0552836dc2b47914f416cc0e200b409dd04a33de5e/fastar-0.8.0.tar.gz", hash = "sha256:f4d4d68dbf1c4c2808f0e730fac5843493fc849f70fe3ad3af60dfbaf68b9a12", size = 68524, upload-time = "2025-11-26T02:36:00.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/e2/51d9ee443aabcd5aa581d45b18b6198ced364b5cd97e5504c5d782ceb82c/fastar-0.8.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c9f930cff014cf79d396d0541bd9f3a3f170c9b5e45d10d634d98f9ed08788c3", size = 708536, upload-time = "2025-11-26T02:34:35.236Z" }, + { url = "https://files.pythonhosted.org/packages/07/2a/edfc6274768b8a3859a5ca4f8c29cb7f614d7f27d2378e2c88aa91cda54e/fastar-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07b70f712d20622346531a4b46bb332569bea621f61314c0b7e80903a16d14cf", size = 632235, upload-time = "2025-11-26T02:34:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1e/3cfbaaec464caef196700ee2ffae1c03f94f7c5e2a85d0ec0ea9cdd1da81/fastar-0.8.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:330639db3bfba4c6d132421a2a4aeb81e7bea8ce9159cdb6e247fbc5fae97686", size = 871386, upload-time = "2025-11-26T02:33:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/224a674ad541054179e4e6e0b54bb6e162f04f698a2512b42a8085fc6b6f/fastar-0.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ea7ceb6231e48d7bb0d7dc13e946baa29c7f6873eaf4afb69725d6da349033", size = 764955, upload-time = "2025-11-26T02:32:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/4d/5e/4608184aa57cb6a54f62c1eb3e5133ba8d461fc7f13193c0255effbec12a/fastar-0.8.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a90695a601a78bbca910fdf2efcdf3103c55d0de5a5c6e93556d707bf886250b", size = 765987, upload-time = "2025-11-26T02:32:59.701Z" }, + { url = "https://files.pythonhosted.org/packages/e0/53/6afd2b680dddfa10df9a16bbcf6cabfee0d92435d5c7e3f4cfe3b1712662/fastar-0.8.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d0bf655ff4c9320b0ca8a5b128063d5093c0c8c1645a2b5f7167143fd8531aa", size = 930900, upload-time = "2025-11-26T02:33:16.059Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1e/b7a304bfcc1d06845cbfa4b464516f6fff9c8c6692f6ef80a3a86b04e199/fastar-0.8.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8df22cdd8d58e7689aa89b2e4a07e8e5fa4f88d2d9c2621f0e88a49be97ccea", size = 821523, upload-time = "2025-11-26T02:33:30.897Z" }, + { url = "https://files.pythonhosted.org/packages/1d/da/9ef8605c6d233cd6ca3a95f7f518ac22aa064903afe6afa57733bfb7c31b/fastar-0.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8a5e6ad722685128521c8fb44cf25bd38669650ba3a4b466b8903e5aa28e1a0", size = 821268, upload-time = "2025-11-26T02:34:04.003Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/ed37c78a6b4420de1677d82e79742787975c34847229c33dc376334c7283/fastar-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:31cd541231a2456e32104da891cf9962c3b40234d0465cbf9322a6bc8a1b05d5", size = 986286, upload-time = "2025-11-26T02:34:50.279Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a6/366b15f432d85d4089e6e4b52a09cc2a2bcf4d7a1f0771e3d3194deccb1e/fastar-0.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:175db2a98d67ced106468e8987975484f8bbbd5ad99201da823b38bafb565ed5", size = 1041921, upload-time = "2025-11-26T02:35:07.292Z" }, + { url = "https://files.pythonhosted.org/packages/f4/45/45f8e6991e3ce9f8aeefdc8d4c200daada41097a36808643d1703464c3e2/fastar-0.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ada877ab1c65197d772ce1b1c2e244d4799680d8b3f136a4308360f3d8661b23", size = 1047302, upload-time = "2025-11-26T02:35:24.995Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/a587796111a3cd4b78cd61ec3fc1252d8517d81f763f4164ed5680f84810/fastar-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:01084cb75f13ca6a8e80bd41584322523189f8e81b472053743d6e6c3062b5a6", size = 995141, upload-time = "2025-11-26T02:35:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/89/c0/7a8ec86695b0b77168e220cf2af1aa30592f5ecdbd0ce6d641d29c4a8bae/fastar-0.8.0-cp310-cp310-win32.whl", hash = "sha256:ca639b9909805e44364ea13cca2682b487e74826e4ad75957115ec693228d6b6", size = 456544, upload-time = "2025-11-26T02:36:23.801Z" }, + { url = "https://files.pythonhosted.org/packages/be/a9/8da4deb840121c59deabd939ce2dca3d6beec85576f3743d1144441938b5/fastar-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:fbc0f2ed0f4add7fb58034c576584d44d7eaaf93dee721dfb26dbed6e222dbac", size = 490701, upload-time = "2025-11-26T02:36:09.625Z" }, + { url = "https://files.pythonhosted.org/packages/cd/15/1c764530b81b266f6d27d78d49b6bef22a73b3300cd83a280bfd244908c5/fastar-0.8.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cd9c0d3ebf7a0a6f642f771cf41b79f7c98d40a3072a8abe1174fbd9bd615bd3", size = 708427, upload-time = "2025-11-26T02:34:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/41/fc/75d42c008516543219e4293e4d8ac55da57a5c63147484f10468bd1bc24e/fastar-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2875a077340fe4f8099bd3ed8fa90d9595e1ac3cd62ae19ab690d5bf550eeb35", size = 631740, upload-time = "2025-11-26T02:34:20.718Z" }, + { url = "https://files.pythonhosted.org/packages/50/8d/9632984f7824ed2210157dcebd8e9821ef6d4f2b28510d0516db6625ff9b/fastar-0.8.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a999263d9f87184bf2801833b2ecf105e03c0dd91cac78685673b70da564fd64", size = 871628, upload-time = "2025-11-26T02:33:49.279Z" }, + { url = "https://files.pythonhosted.org/packages/05/97/3eb6ea71b7544d45cd29cacb764ca23cde8ce0aed1a6a02251caa4c0a818/fastar-0.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c41111da56430f638cbfc498ebdcc7d30f63416e904b27b7695c29bd4889cb8", size = 765005, upload-time = "2025-11-26T02:32:45.833Z" }, + { url = "https://files.pythonhosted.org/packages/d6/45/3eb0ee945a0b5d5f9df7e7c25c037ce7fa441cd0b4d44f76d286e2f4396a/fastar-0.8.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3719541a12bb09ab1eae91d2c987a9b2b7d7149c52e7109ba6e15b74aabc49b1", size = 765587, upload-time = "2025-11-26T02:33:01.174Z" }, + { url = "https://files.pythonhosted.org/packages/51/bb/7defd6ec0d9570b1987d8ebde52d07d97f3f26e10b592fb3e12738eba39a/fastar-0.8.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7a9b0fff8079b18acdface7ef1b7f522fd9a589f65ca4a1a0dd7c92a0886c2a2", size = 931150, upload-time = "2025-11-26T02:33:17.374Z" }, + { url = "https://files.pythonhosted.org/packages/28/54/62e51e684dab347c61878afbf09e177029c1a91eb1e39ef244e6b3ef9efa/fastar-0.8.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac073576c1931959191cb20df38bab21dd152f66c940aa3ca8b22e39f753b2f3", size = 821354, upload-time = "2025-11-26T02:33:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/53/a8/12708ea4d21e3cf9f485b2a67d44ce84d949a6eddcc9aa5b3d324585ab43/fastar-0.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:003b59a7c3e405b6a7bff8fab17d31e0ccbc7f06730a8f8ca1694eeea75f3c76", size = 821626, upload-time = "2025-11-26T02:34:05.685Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/1b4d3347c7a759853f963410bf6baf42fe014d587c50c39c8e145f4bf1a0/fastar-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a7b96748425efd9fc155cd920d65088a1b0d754421962418ea73413d02ff515a", size = 986187, upload-time = "2025-11-26T02:34:52.047Z" }, + { url = "https://files.pythonhosted.org/packages/dc/59/2dbe0dc2570764475e60030403738faa261a9d3bff16b08629c378ab939a/fastar-0.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:90957a30e64418b02df5b4d525bea50403d98a4b1f29143ce5914ddfa7e54ee4", size = 1041536, upload-time = "2025-11-26T02:35:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0f/639b295669c7ca6fbc2b4be2a7832aaeac1a5e06923f15a8a6d6daecbc7d/fastar-0.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f6e784a8015623fbb7ccca1af372fd82cb511b408ddd2348dc929fc6e415df73", size = 1047149, upload-time = "2025-11-26T02:35:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/23e3a19e06d261d1894f98eca9458f98c090c505a0c712dafc0ff1fc2965/fastar-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a03eaf287bbc93064688a1220580ce261e7557c8898f687f4d0b281c85b28d3c", size = 994992, upload-time = "2025-11-26T02:35:44.009Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7a/3ea4726bae3ac9358d02107ae48f3e10ee186dbed554af79e00b7b498c44/fastar-0.8.0-cp311-cp311-win32.whl", hash = "sha256:661a47ed90762f419406c47e802f46af63a08254ba96abd1c8191e4ce967b665", size = 456449, upload-time = "2025-11-26T02:36:25.291Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3c/0142bee993c431ee91cf5535e6e4b079ad491f620c215fcd79b7e5ffeb2b/fastar-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:b48abd6056fef7bc3d414aafb453c5b07fdf06d2df5a2841d650288a3aa1e9d3", size = 490863, upload-time = "2025-11-26T02:36:11.114Z" }, + { url = "https://files.pythonhosted.org/packages/3b/18/d119944f6bdbf6e722e204e36db86390ea45684a1bf6be6e3aa42abd471f/fastar-0.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:50c18788b3c6ffb85e176dcb8548bb8e54616a0519dcdbbfba66f6bbc4316933", size = 462230, upload-time = "2025-11-26T02:36:01.917Z" }, + { url = "https://files.pythonhosted.org/packages/58/f1/5b2ff898abac7f1a418284aad285e3a4f68d189c572ab2db0f6c9079dd16/fastar-0.8.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f10d2adfe40f47ff228f4efaa32d409d732ded98580e03ed37c9535b5fc923d", size = 706369, upload-time = "2025-11-26T02:34:37.783Z" }, + { url = "https://files.pythonhosted.org/packages/23/60/8046a386dca39154f80c927cbbeeb4b1c1267a3271bffe61552eb9995757/fastar-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b930da9d598e3bc69513d131f397e6d6be4643926ef3de5d33d1e826631eb036", size = 629097, upload-time = "2025-11-26T02:34:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/22/7e/1ae005addc789924a9268da2394d3bb5c6f96836f7e37b7e3d23c2362675/fastar-0.8.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9d210da2de733ca801de83e931012349d209f38b92d9630ccaa94bd445bdc9b8", size = 868938, upload-time = "2025-11-26T02:33:51.119Z" }, + { url = "https://files.pythonhosted.org/packages/a6/77/290a892b073b84bf82e6b2259708dfe79c54f356e252c2dd40180b16fe07/fastar-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa02270721517078a5bd61a38719070ac2537a4aa6b6c48cf369cf2abc59174a", size = 765204, upload-time = "2025-11-26T02:32:47.02Z" }, + { url = "https://files.pythonhosted.org/packages/d0/00/c3155171b976003af3281f5258189f1935b15d1221bfc7467b478c631216/fastar-0.8.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83c391e5b789a720e4d0029b9559f5d6dee3226693c5b39c0eab8eaece997e0f", size = 764717, upload-time = "2025-11-26T02:33:02.453Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/405b7ad76207b2c11b7b59335b70eac19e4a2653977f5588a1ac8fed54f4/fastar-0.8.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3258d7a78a72793cdd081545da61cabe85b1f37634a1d0b97ffee0ff11d105ef", size = 931502, upload-time = "2025-11-26T02:33:18.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/8a/a3dde6d37cc3da4453f2845cdf16675b5686b73b164f37e2cc579b057c2c/fastar-0.8.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6eab95dd985cdb6a50666cbeb9e4814676e59cfe52039c880b69d67cfd44767", size = 821454, upload-time = "2025-11-26T02:33:33.427Z" }, + { url = "https://files.pythonhosted.org/packages/da/c1/904fe2468609c8990dce9fe654df3fbc7324a8d8e80d8240ae2c89757064/fastar-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:829b1854166141860887273c116c94e31357213fa8e9fe8baeb18bd6c38aa8d9", size = 821647, upload-time = "2025-11-26T02:34:07Z" }, + { url = "https://files.pythonhosted.org/packages/c8/73/a0642ab7a400bc07528091785e868ace598fde06fcd139b8f865ec1b6f3c/fastar-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1667eae13f9457a3c737f4376d68e8c3e548353538b28f7e4273a30cb3965cd", size = 986342, upload-time = "2025-11-26T02:34:53.371Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/60c1bfa6edab72366461a95f053d0f5f7ab1825fe65ca2ca367432cd8629/fastar-0.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b864a95229a7db0814cd9ef7987cb713fd43dce1b0d809dd17d9cd6f02fdde3e", size = 1040207, upload-time = "2025-11-26T02:35:10.65Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a0/0d624290dec622e7fa084b6881f456809f68777d54a314f5dde932714506/fastar-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c05fbc5618ce17675a42576fa49858d79734627f0a0c74c0875ab45ee8de340c", size = 1045031, upload-time = "2025-11-26T02:35:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/a7/74/cf663af53c4706ba88e6b4af44a6b0c3bd7d7ca09f079dc40647a8f06585/fastar-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7f41c51ee96f338662ee3c3df4840511ba3f9969606840f1b10b7cb633a3c716", size = 994877, upload-time = "2025-11-26T02:35:45.797Z" }, + { url = "https://files.pythonhosted.org/packages/52/17/444c8be6e77206050e350da7c338102b6cab384be937fa0b1d6d1f9ede73/fastar-0.8.0-cp312-cp312-win32.whl", hash = "sha256:d949a1a2ea7968b734632c009df0571c94636a5e1622c87a6e2bf712a7334f47", size = 455996, upload-time = "2025-11-26T02:36:26.938Z" }, + { url = "https://files.pythonhosted.org/packages/dc/34/fc3b5e56d71a17b1904800003d9251716e8fd65f662e1b10a26881698a74/fastar-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc645994d5b927d769121094e8a649b09923b3c13a8b0b98696d8f853f23c532", size = 490429, upload-time = "2025-11-26T02:36:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/35/a8/5608cc837417107c594e2e7be850b9365bcb05e99645966a5d6a156285fe/fastar-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:d81ee82e8dc78a0adb81728383bd39611177d642a8fa2d601d4ad5ad59e5f3bd", size = 461297, upload-time = "2025-11-26T02:36:03.546Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a5/79ecba3646e22d03eef1a66fb7fc156567213e2e4ab9faab3bbd4489e483/fastar-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a3253a06845462ca2196024c7a18f5c0ba4de1532ab1c4bad23a40b332a06a6a", size = 706112, upload-time = "2025-11-26T02:34:39.237Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/4f883bce878218a8676c2d7ca09b50c856a5470bb3b7f63baf9521ea6995/fastar-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5cbeb3ebfa0980c68ff8b126295cc6b208ccd81b638aebc5a723d810a7a0e5d2", size = 628954, upload-time = "2025-11-26T02:34:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f1/892e471f156b03d10ba48ace9384f5a896702a54506137462545f38e40b8/fastar-0.8.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1c0d5956b917daac77d333d48b3f0f3ff927b8039d5b32d8125462782369f761", size = 868685, upload-time = "2025-11-26T02:33:53.077Z" }, + { url = "https://files.pythonhosted.org/packages/39/ba/e24915045852e30014ec6840446975c03f4234d1c9270394b51d3ad18394/fastar-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27b404db2b786b65912927ce7f3790964a4bcbde42cdd13091b82a89cd655e1c", size = 765044, upload-time = "2025-11-26T02:32:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/1aa11ac21a99984864c2fca4994e094319ff3a2046e7a0343c39317bd5b9/fastar-0.8.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0902fc89dcf1e7f07b8563032a4159fe2b835e4c16942c76fd63451d0e5f76a3", size = 764322, upload-time = "2025-11-26T02:33:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f0/4b91902af39fe2d3bae7c85c6d789586b9fbcf618d7fdb3d37323915906d/fastar-0.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:069347e2f0f7a8b99bbac8cd1bc0e06c7b4a31dc964fc60d84b95eab3d869dc1", size = 931016, upload-time = "2025-11-26T02:33:19.902Z" }, + { url = "https://files.pythonhosted.org/packages/c9/97/8fc43a5a9c0a2dc195730f6f7a0f367d171282cd8be2511d0e87c6d2dad0/fastar-0.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd135306f6bfe9a835918280e0eb440b70ab303e0187d90ab51ca86e143f70d", size = 821308, upload-time = "2025-11-26T02:33:34.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e9/058615b63a7fd27965e8c5966f393ed0c169f7ff5012e1674f21684de3ba/fastar-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d06d6897f43c27154b5f2d0eb930a43a81b7eec73f6f0b0114814d4a10ab38", size = 821171, upload-time = "2025-11-26T02:34:08.498Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cf/69e16a17961570a755c37ffb5b5aa7610d2e77807625f537989da66f2a9d/fastar-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a922f8439231fa0c32b15e8d70ff6d415619b9d40492029dabbc14a0c53b5f18", size = 986227, upload-time = "2025-11-26T02:34:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/fb/83/2100192372e59b56f4ace37d7d9cabda511afd71b5febad1643d1c334271/fastar-0.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a739abd51eb766384b4caff83050888e80cd75bbcfec61e6d1e64875f94e4a40", size = 1039395, upload-time = "2025-11-26T02:35:12.166Z" }, + { url = "https://files.pythonhosted.org/packages/75/15/cdd03aca972f55872efbb7cf7540c3fa7b97a75d626303a3ea46932163dc/fastar-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5a65f419d808b23ac89d5cd1b13a2f340f15bc5d1d9af79f39fdb77bba48ff1b", size = 1044766, upload-time = "2025-11-26T02:35:29.62Z" }, + { url = "https://files.pythonhosted.org/packages/3d/29/945e69e4e2652329ace545999334ec31f1431fbae3abb0105587e11af2ae/fastar-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7bb2ae6c0cce58f0db1c9f20495e7557cca2c1ee9c69bbd90eafd54f139171c5", size = 994740, upload-time = "2025-11-26T02:35:47.887Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5d/dbfe28f8cd1eb484bba0c62e5259b2cf6fea229d6ef43e05c06b5a78c034/fastar-0.8.0-cp313-cp313-win32.whl", hash = "sha256:b28753e0d18a643272597cb16d39f1053842aa43131ad3e260c03a2417d38401", size = 455990, upload-time = "2025-11-26T02:36:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/e1/01/e965740bd36e60ef4c5aa2cbe42b6c4eb1dc3551009238a97c2e5e96bd23/fastar-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:620e5d737dce8321d49a5ebb7997f1fd0047cde3512082c27dc66d6ac8c1927a", size = 490227, upload-time = "2025-11-26T02:36:14.363Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/c99202719b83e5249f26902ae53a05aea67d840eeb242019322f20fc171c/fastar-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:c4c4bd08df563120cd33e854fe0a93b81579e8571b11f9b7da9e84c37da2d6b6", size = 461078, upload-time = "2025-11-26T02:36:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9573b87a0ef07580ed111e7230259aec31bb33ca3667963ebee77022ec61/fastar-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:50b36ce654ba44b0e13fae607ae17ee6e1597b69f71df1bee64bb8328d881dfc", size = 706041, upload-time = "2025-11-26T02:34:40.638Z" }, + { url = "https://files.pythonhosted.org/packages/4a/19/f95444a1d4f375333af49300aa75ee93afa3335c0e40fda528e460ed859c/fastar-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63a892762683d7ab00df0227d5ea9677c62ff2cde9b875e666c0be569ed940f3", size = 628617, upload-time = "2025-11-26T02:34:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c9/b51481b38b7e3f16ef2b9e233b1a3623386c939d745d6e41bbd389eaae30/fastar-0.8.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4ae6a145c1bff592644bde13f2115e0239f4b7babaf506d14e7d208483cf01a5", size = 869299, upload-time = "2025-11-26T02:33:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/bf/02/3ba1267ee5ba7314e29c431cf82eaa68586f2c40cdfa08be3632b7d07619/fastar-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ae0ff7c0a1c7e1428404b81faee8aebef466bfd0be25bfe4dabf5d535c68741", size = 764667, upload-time = "2025-11-26T02:32:49.606Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/bf33530fd015b5d7c2cc69e0bce4a38d736754a6955487005aab1af6adcd/fastar-0.8.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dbfd87dbd217b45c898b2dbcd0169aae534b2c1c5cbe3119510881f6a5ac8ef5", size = 763993, upload-time = "2025-11-26T02:33:05.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/9564d24e7cea6321a8d921c6d2a457044a476ef197aa4708e179d3d97f0d/fastar-0.8.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5abd99fcba83ef28c8fe6ae2927edc79053db43a0457a962ed85c9bf150d37", size = 930153, upload-time = "2025-11-26T02:33:21.53Z" }, + { url = "https://files.pythonhosted.org/packages/35/b1/6f57fcd8d6e192cfebf97e58eb27751640ad93784c857b79039e84387b51/fastar-0.8.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91d4c685620c3a9d6b5ae091dbabab4f98b20049b7ecc7976e19cc9016c0d5d6", size = 821177, upload-time = "2025-11-26T02:33:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b3/78/9e004ea9f3aa7466f5ddb6f9518780e1d2f0ed3ca55f093632982598bace/fastar-0.8.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f77c2f2cad76e9dc7b6701297adb1eba87d0485944b416fc2ccf5516c01219a3", size = 820652, upload-time = "2025-11-26T02:34:09.776Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/b604ed536544005c9f1aee7c4c74b00150db3d8d535cd8232dc20f947063/fastar-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e7f07c4a3dada7757a8fc430a5b4a29e6ef696d2212747213f57086ffd970316", size = 985961, upload-time = "2025-11-26T02:34:56.401Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7b/fa9d4d96a5d494bdb8699363bb9de8178c0c21a02e1d89cd6f913d127018/fastar-0.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:90c0c3fe55105c0aed8a83135dbdeb31e683455dbd326a1c48fa44c378b85616", size = 1039316, upload-time = "2025-11-26T02:35:13.807Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f9/8462789243bc3f33e8401378ec6d54de4e20cfa60c96a0e15e3e9d1389bb/fastar-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fb9ee51e5bffe0dab3d3126d3a4fac8d8f7235cedcb4b8e74936087ce1c157f3", size = 1045028, upload-time = "2025-11-26T02:35:31.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/71/9abb128777e616127194b509e98fcda3db797d76288c1a8c23dd22afc14f/fastar-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e380b1e8d30317f52406c43b11e98d11e1d68723bbd031e18049ea3497b59a6d", size = 994677, upload-time = "2025-11-26T02:35:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/de/c1/b81b3f194853d7ad232a67a1d768f5f51a016f165cfb56cb31b31bbc6177/fastar-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1c4ffc06e9c4a8ca498c07e094670d8d8c0d25b17ca6465b9774da44ea997ab1", size = 456687, upload-time = "2025-11-26T02:36:30.205Z" }, + { url = "https://files.pythonhosted.org/packages/cb/87/9e0cd4768a98181d56f0cdbab2363404cc15deb93f4aad3b99cd2761bbaa/fastar-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:5517a8ad4726267c57a3e0e2a44430b782e00b230bf51c55b5728e758bb3a692", size = 490578, upload-time = "2025-11-26T02:36:16.218Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/580a76cf91847654f2ad6520e956e93218f778540975bc4190d363f709e2/fastar-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:58030551046ff4a8616931e52a36c83545ff05996db5beb6e0cd2b7e748aa309", size = 461473, upload-time = "2025-11-26T02:36:06.373Z" }, + { url = "https://files.pythonhosted.org/packages/58/4c/bdb5c6efe934f68708529c8c9d4055ebef5c4be370621966438f658b29bd/fastar-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1e7d29b6bfecb29db126a08baf3c04a5ab667f6cea2b7067d3e623a67729c4a6", size = 705570, upload-time = "2025-11-26T02:34:42.01Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/f01ac7e71d5a37621bd13598a26e948a12b85ca8042f7ee1a0a8c9f59cda/fastar-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05eb7b96940f9526b485f1d0b02393839f0f61cac4b1f60024984f8b326d2640", size = 627761, upload-time = "2025-11-26T02:34:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/06/45/6df0ecda86ea9d2e95053c1a655d153dee55fc121b6e13ea6d1e246a50b6/fastar-0.8.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:619352d8ac011794e2345c462189dc02ba634750d23cd9d86a9267dd71b1f278", size = 869414, upload-time = "2025-11-26T02:33:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b2/72/486421f5a8c0c377cc82e7a50c8a8ea899a6ec2aa72bde8f09fb667a2dc8/fastar-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74ebfecef3fe6d7a90355fac1402fd30636988332a1d33f3e80019a10782bb24", size = 763863, upload-time = "2025-11-26T02:32:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/39f654dbb41a3867fb1f2c8081c014d8f1d32ea10585d84cacbef0b32995/fastar-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2975aca5a639e26a3ab0d23b4b0628d6dd6d521146c3c11486d782be621a35aa", size = 763065, upload-time = "2025-11-26T02:33:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bd/c011a34fb3534c4c3301f7c87c4ffd7e47f6113c904c092ddc8a59a303ea/fastar-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afc438eaed8ff0dcdd9308268be5cb38c1db7e94c3ccca7c498ca13a4a4535a3", size = 930530, upload-time = "2025-11-26T02:33:23.117Z" }, + { url = "https://files.pythonhosted.org/packages/55/9d/aa6e887a7033c571b1064429222bbe09adc9a3c1e04f3d1788ba5838ebd5/fastar-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ced0a5399cc0a84a858ef0a31ca2d0c24d3bbec4bcda506a9192d8119f3590a", size = 820572, upload-time = "2025-11-26T02:33:37.542Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9c/7a3a2278a1052e1a5d98646de7c095a00cffd2492b3b84ce730e2f1cd93a/fastar-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec9b23da8c4c039da3fe2e358973c66976a0c8508aa06d6626b4403cb5666c19", size = 820649, upload-time = "2025-11-26T02:34:11.108Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d38edc1f4438cd047e56137c26d94783ffade42e1b3bde620ccf17b771ef/fastar-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dfba078fcd53478032fd0ceed56960ec6b7ff0511cfc013a8a3a4307e3a7bac4", size = 985653, upload-time = "2025-11-26T02:34:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/69/d9/2147d0c19757e165cd62d41cec3f7b38fad2ad68ab784978b5f81716c7ea/fastar-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ade56c94c14be356d295fecb47a3fcd473dd43a8803ead2e2b5b9e58feb6dcfa", size = 1038140, upload-time = "2025-11-26T02:35:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/ec4c717ffb8a308871e9602ec3197d957e238dc0227127ac573ec9bca952/fastar-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e48d938f9366db5e59441728f70b7f6c1ccfab7eff84f96f9b7e689b07786c52", size = 1045195, upload-time = "2025-11-26T02:35:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/637334dc8c8f3bb391388b064ae13f0ad9402bc5a6c3e77b8887d0c31921/fastar-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:79c441dc1482ff51a54fb3f57ae6f7bb3d2cff88fa2cc5d196c519f8aab64a56", size = 994686, upload-time = "2025-11-26T02:35:51.392Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e2/dfa19a4b260b8ab3581b7484dcb80c09b25324f4daa6b6ae1c7640d1607a/fastar-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:187f61dc739afe45ac8e47ed7fd1adc45d52eac110cf27d579155720507d6fbe", size = 455767, upload-time = "2025-11-26T02:36:34.758Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/df65c72afc1297797b255f90c4778b5d6f1f0f80282a134d5ab610310ed9/fastar-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:40e9d763cf8bf85ce2fa256e010aa795c0fe3d3bd1326d5c3084e6ce7857127e", size = 489971, upload-time = "2025-11-26T02:36:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/25/9f/6eaa810c240236eff2edf736cd50a17c97dbab1693cda4f7bcea09d13418/fastar-0.8.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2127cf2e80ffd49744a160201e0e2f55198af6c028a7b3f750026e0b1f1caa4e", size = 710544, upload-time = "2025-11-26T02:34:46.195Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a5/58ff9e49a1cd5fbfc8f1238226cbf83b905376a391a6622cdd396b2cfa29/fastar-0.8.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ff85094f10003801339ac4fa9b20a3410c2d8f284d4cba2dc99de6e98c877812", size = 634020, upload-time = "2025-11-26T02:34:31.085Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/f839257c6600a83fbdb5a7fcc06319599086137b25ba38ca3d2c0fe14562/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:3dbca235f0bd804cca6602fe055d3892bebf95fb802e6c6c7d872fb10f7abc6c", size = 871735, upload-time = "2025-11-26T02:34:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/4124c54260f7ee5cb7034bfe499eff2f8512b052d54be4671e59d4f25a4f/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e54bfdee6c81a0005e147319e93d8797f442308032c92fa28d03ef8fda076", size = 766779, upload-time = "2025-11-26T02:32:55.109Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/043b263c4126bf6557c942d099503989af9c5c7ee5cca9a04e00f754816f/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a78e5221b94a80800930b7fd0d0e797ae73aadf7044c05ed46cb9bdf870f022", size = 766755, upload-time = "2025-11-26T02:33:11.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/ff/29a5dc06f2940439ebf98661ecc98d48d3f22fed8d6a2d5dc985d1e8da24/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:997092d31ff451de8d0568f6773f3517cb87dcd0bc76184edb65d7154390a6f8", size = 932732, upload-time = "2025-11-26T02:33:27.122Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e8/2218830f422b37aad52c24b53cb84b5d88bd6fd6ad411bd6689b1a32500d/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:558e8fcf8fe574541df5db14a46cd98bfbed14a811b7014a54f2b714c0cfac42", size = 822571, upload-time = "2025-11-26T02:33:42.986Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/ba6dfeff77cddfe58d85c490b1735c002b81c0d6f826916a8b6c4f8818bc/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1d2a54f87e2908cc19e1a6ee249620174fbefc54a219aba1eaa6f31657683c3", size = 822440, upload-time = "2025-11-26T02:34:15.439Z" }, + { url = "https://files.pythonhosted.org/packages/a7/57/54d5740c84b35de0eb12975397ecc16785b5ad8bed2dbac38b8c8a7c1edd/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:ef94901537be277f9ec59db939eb817960496c6351afede5b102699b5098604d", size = 987424, upload-time = "2025-11-26T02:35:02.742Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c7/18115927f16deb1ddffdbd4ae992e7e33064bc6defa2b92a147948f8bc0c/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:0afbb92f78bf29d5e9db76fb46cbabc429e49015cddf72ab9e761afbe88ac100", size = 1042675, upload-time = "2025-11-26T02:35:20.252Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1a/ca884fc7973ec6d765e87af23a4dd25784fb0a36ac2df825f18c3630bbab/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:fb59c7925e7710ad178d9e1a3e65edf295d9a042a0cdcb673b4040949eb8ad0a", size = 1047098, upload-time = "2025-11-26T02:35:37.643Z" }, + { url = "https://files.pythonhosted.org/packages/44/ee/25cd645db749b206bb95e1512e57e75d56ccbbb8ec3536f52a7979deab6b/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e6c4d6329da568ec36b1347b0c09c4d27f9dfdeddf9f438ddb16799ecf170098", size = 997397, upload-time = "2025-11-26T02:35:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/6c46aa7f8c8734e7f96ee5141acd3877667ce66f34eea10703aa7571d191/fastar-0.8.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:998e3fa4b555b63eb134e6758437ed739ad1652fdd2a61dfe1dacbfddc35fe66", size = 710662, upload-time = "2025-11-26T02:34:47.593Z" }, + { url = "https://files.pythonhosted.org/packages/70/27/fd622442f2fbd4ff5459677987481ef1c60e077cb4e63a2ed4d8dce6f869/fastar-0.8.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5f83e60d845091f3a12bc37f412774264d161576eaf810ed8b43567eb934b7e5", size = 634049, upload-time = "2025-11-26T02:34:32.365Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ee/aa4d08aea25b5419a7277132e738ab1cd775f26aebddce11413b07e2fdff/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:299672e1c74d8b73c61684fac9159cfc063d35f4b165996a88facb0e26862cb5", size = 872055, upload-time = "2025-11-26T02:34:01.377Z" }, + { url = "https://files.pythonhosted.org/packages/92/9a/2bf2f77aade575e67997e0c759fd55cb1c66b7a5b437b1cd0e97d8b241bc/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3d3a27066b84d015deab5faee78565509bb33b137896443e4144cb1be1a5f90", size = 766787, upload-time = "2025-11-26T02:32:57.161Z" }, + { url = "https://files.pythonhosted.org/packages/0b/90/23a3f6c252f11b10c70f854bce09abc61f71b5a0e6a4b0eac2bcb9a2c583/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef0bcf4385bbdd3c1acecce2d9ea7dab7cc9b8ee0581bbccb7ab11908a7ce288", size = 766861, upload-time = "2025-11-26T02:33:12.824Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/beeb9078380acd4484db5c957d066171695d9340e3526398eb230127b0c2/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f10ef62b6eda6cb6fd9ba8e1fe08a07d7b2bdcc8eaa00eb91566143b92ed7eee", size = 932667, upload-time = "2025-11-26T02:33:28.405Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6d/b034cc637bd0ee638d5a85d08e941b0b8ffd44cf391fb751ba98233734f7/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c4f6c82a8ee98c17aa48585ee73b51c89c1b010e5c951af83e07c3436180e3fc", size = 822712, upload-time = "2025-11-26T02:33:44.27Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2b/7d183c63f59227c4689792042d6647f2586a5e7273b55e81745063088d81/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6129067fcb86276635b5857010f4e9b9c7d5d15dd571bb03c6c1ed73c40fd92", size = 822659, upload-time = "2025-11-26T02:34:16.815Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f9/716e0cd9de2427fdf766bc68176f76226cd01fffef3a56c5046fa863f5f0/fastar-0.8.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4cc9e77019e489f1ddac446b6a5b9dfb5c3d9abd142652c22a1d9415dbcc0e47", size = 987412, upload-time = "2025-11-26T02:35:04.259Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b9/9a8c3fd59958c1c8027bc075af11722cdc62c4968bb277e841d131232289/fastar-0.8.0-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:382bfe82c026086487cb17fee12f4c1e2b4e67ce230f2e04487d3e7ddfd69031", size = 1042911, upload-time = "2025-11-26T02:35:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2f/c3f30963b47022134b8a231c12845f4d7cfba520f59bbc1a82468aea77c7/fastar-0.8.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:908d2b9a1ff3d549cc304b32f95706a536da8f0bcb0bc0f9e4c1cce39b80e218", size = 1047464, upload-time = "2025-11-26T02:35:39.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/218ab6d9a2bab3b07718e6cd8405529600edc1e9c266320e8524c8f63251/fastar-0.8.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:1aa7dbde2d2d73eb5b6203d0f74875cb66350f0f1b4325b4839fc8fbbf5d074e", size = 997309, upload-time = "2025-11-26T02:35:57.722Z" }, ] [[package]] name = "faster-whisper" -version = "1.1.1" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "av" }, @@ -1414,201 +1771,217 @@ dependencies = [ { name = "tokenizers" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/53/195e5b42ede5f09453828d3b00d52bd952ed0e07a8e5c6497affefcfa3be/faster-whisper-1.1.1.tar.gz", hash = "sha256:50d27571970c1be0c2b2680a2593d5d12f9f5d2f10484f242a1afbe7cb946604", size = 1124684, upload-time = "2025-01-01T14:47:21.712Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/69/28359d152f9e2ec1ff4dff3da47011b6346e9a472f89b409bb13017a7d1f/faster_whisper-1.1.1-py3-none-any.whl", hash = "sha256:5808dc334fb64fb4336921450abccfe5e313a859b31ba61def0ac7f639383d90", size = 1118368, upload-time = "2025-01-01T14:47:16.131Z" }, + { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, ] [[package]] name = "filelock" -version = "3.19.1" +version = "3.25.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] [[package]] name = "flatbuffers" -version = "25.9.23" +version = "25.12.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/1f/3ee70b0a55137442038f2a33469cc5fddd7e0ad2abf83d7497c18a2b6923/flatbuffers-25.9.23.tar.gz", hash = "sha256:676f9fa62750bb50cf531b42a0a2a118ad8f7f797a511eda12881c016f093b12", size = 22067, upload-time = "2025-09-24T05:25:30.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/1b/00a78aa2e8fbd63f9af08c9c19e6deb3d5d66b4dda677a0f61654680ee89/flatbuffers-25.9.23-py2.py3-none-any.whl", hash = "sha256:255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2", size = 30869, upload-time = "2025-09-24T05:25:28.912Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] [[package]] name = "fonttools" -version = "4.60.1" +version = "4.62.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/70/03e9d89a053caff6ae46053890eba8e4a5665a7c5638279ed4492e6d4b8b/fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28", size = 2810747, upload-time = "2025-09-29T21:10:59.653Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/449ad5aff9670ab0df0f61ee593906b67a36d7e0b4d0cd7fa41ac0325bf5/fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15", size = 2346909, upload-time = "2025-09-29T21:11:02.882Z" }, - { url = "https://files.pythonhosted.org/packages/9a/18/e5970aa96c8fad1cb19a9479cc3b7602c0c98d250fcdc06a5da994309c50/fonttools-4.60.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c", size = 4864572, upload-time = "2025-09-29T21:11:05.096Z" }, - { url = "https://files.pythonhosted.org/packages/ce/20/9b2b4051b6ec6689480787d506b5003f72648f50972a92d04527a456192c/fonttools-4.60.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea", size = 4794635, upload-time = "2025-09-29T21:11:08.651Z" }, - { url = "https://files.pythonhosted.org/packages/10/52/c791f57347c1be98f8345e3dca4ac483eb97666dd7c47f3059aeffab8b59/fonttools-4.60.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652", size = 4843878, upload-time = "2025-09-29T21:11:10.893Z" }, - { url = "https://files.pythonhosted.org/packages/69/e9/35c24a8d01644cee8c090a22fad34d5b61d1e0a8ecbc9945ad785ebf2e9e/fonttools-4.60.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a", size = 4954555, upload-time = "2025-09-29T21:11:13.24Z" }, - { url = "https://files.pythonhosted.org/packages/f7/86/fb1e994971be4bdfe3a307de6373ef69a9df83fb66e3faa9c8114893d4cc/fonttools-4.60.1-cp310-cp310-win32.whl", hash = "sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce", size = 2232019, upload-time = "2025-09-29T21:11:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/40/84/62a19e2bd56f0e9fb347486a5b26376bade4bf6bbba64dda2c103bd08c94/fonttools-4.60.1-cp310-cp310-win_amd64.whl", hash = "sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038", size = 2276803, upload-time = "2025-09-29T21:11:18.152Z" }, - { url = "https://files.pythonhosted.org/packages/ea/85/639aa9bface1537e0fb0f643690672dde0695a5bbbc90736bc571b0b1941/fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f", size = 2831872, upload-time = "2025-09-29T21:11:20.329Z" }, - { url = "https://files.pythonhosted.org/packages/6b/47/3c63158459c95093be9618794acb1067b3f4d30dcc5c3e8114b70e67a092/fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2", size = 2356990, upload-time = "2025-09-29T21:11:22.754Z" }, - { url = "https://files.pythonhosted.org/packages/94/dd/1934b537c86fcf99f9761823f1fc37a98fbd54568e8e613f29a90fed95a9/fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914", size = 5042189, upload-time = "2025-09-29T21:11:25.061Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d2/9f4e4c4374dd1daa8367784e1bd910f18ba886db1d6b825b12edf6db3edc/fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1", size = 4978683, upload-time = "2025-09-29T21:11:27.693Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c4/0fb2dfd1ecbe9a07954cc13414713ed1eab17b1c0214ef07fc93df234a47/fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d", size = 5021372, upload-time = "2025-09-29T21:11:30.257Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d5/495fc7ae2fab20223cc87179a8f50f40f9a6f821f271ba8301ae12bb580f/fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa", size = 5132562, upload-time = "2025-09-29T21:11:32.737Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fa/021dab618526323c744e0206b3f5c8596a2e7ae9aa38db5948a131123e83/fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258", size = 2230288, upload-time = "2025-09-29T21:11:35.015Z" }, - { url = "https://files.pythonhosted.org/packages/bb/78/0e1a6d22b427579ea5c8273e1c07def2f325b977faaf60bb7ddc01456cb1/fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf", size = 2278184, upload-time = "2025-09-29T21:11:37.434Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953, upload-time = "2025-09-29T21:11:39.616Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706, upload-time = "2025-09-29T21:11:41.826Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716, upload-time = "2025-09-29T21:11:43.893Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175, upload-time = "2025-09-29T21:11:46.439Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031, upload-time = "2025-09-29T21:11:48.977Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966, upload-time = "2025-09-29T21:11:54.344Z" }, - { url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750, upload-time = "2025-09-29T21:11:56.601Z" }, - { url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026, upload-time = "2025-09-29T21:11:58.852Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5b/cdd2c612277b7ac7ec8c0c9bc41812c43dc7b2d5f2b0897e15fdf5a1f915/fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb", size = 2825777, upload-time = "2025-09-29T21:12:01.22Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8a/de9cc0540f542963ba5e8f3a1f6ad48fa211badc3177783b9d5cadf79b5d/fonttools-4.60.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4", size = 2348080, upload-time = "2025-09-29T21:12:03.785Z" }, - { url = "https://files.pythonhosted.org/packages/2d/8b/371ab3cec97ee3fe1126b3406b7abd60c8fec8975fd79a3c75cdea0c3d83/fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c", size = 4903082, upload-time = "2025-09-29T21:12:06.382Z" }, - { url = "https://files.pythonhosted.org/packages/04/05/06b1455e4bc653fcb2117ac3ef5fa3a8a14919b93c60742d04440605d058/fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77", size = 4960125, upload-time = "2025-09-29T21:12:09.314Z" }, - { url = "https://files.pythonhosted.org/packages/8e/37/f3b840fcb2666f6cb97038793606bdd83488dca2d0b0fc542ccc20afa668/fonttools-4.60.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199", size = 4901454, upload-time = "2025-09-29T21:12:11.931Z" }, - { url = "https://files.pythonhosted.org/packages/fd/9e/eb76f77e82f8d4a46420aadff12cec6237751b0fb9ef1de373186dcffb5f/fonttools-4.60.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c", size = 5044495, upload-time = "2025-09-29T21:12:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b3/cede8f8235d42ff7ae891bae8d619d02c8ac9fd0cfc450c5927a6200c70d/fonttools-4.60.1-cp313-cp313-win32.whl", hash = "sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272", size = 2217028, upload-time = "2025-09-29T21:12:17.96Z" }, - { url = "https://files.pythonhosted.org/packages/75/4d/b022c1577807ce8b31ffe055306ec13a866f2337ecee96e75b24b9b753ea/fonttools-4.60.1-cp313-cp313-win_amd64.whl", hash = "sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac", size = 2266200, upload-time = "2025-09-29T21:12:20.14Z" }, - { url = "https://files.pythonhosted.org/packages/9a/83/752ca11c1aa9a899b793a130f2e466b79ea0cf7279c8d79c178fc954a07b/fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3", size = 2822830, upload-time = "2025-09-29T21:12:24.406Z" }, - { url = "https://files.pythonhosted.org/packages/57/17/bbeab391100331950a96ce55cfbbff27d781c1b85ebafb4167eae50d9fe3/fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85", size = 2345524, upload-time = "2025-09-29T21:12:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2e/d4831caa96d85a84dd0da1d9f90d81cec081f551e0ea216df684092c6c97/fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537", size = 4843490, upload-time = "2025-09-29T21:12:29.123Z" }, - { url = "https://files.pythonhosted.org/packages/49/13/5e2ea7c7a101b6fc3941be65307ef8df92cbbfa6ec4804032baf1893b434/fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003", size = 4944184, upload-time = "2025-09-29T21:12:31.414Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2b/cf9603551c525b73fc47c52ee0b82a891579a93d9651ed694e4e2cd08bb8/fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08", size = 4890218, upload-time = "2025-09-29T21:12:33.936Z" }, - { url = "https://files.pythonhosted.org/packages/fd/2f/933d2352422e25f2376aae74f79eaa882a50fb3bfef3c0d4f50501267101/fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99", size = 4999324, upload-time = "2025-09-29T21:12:36.637Z" }, - { url = "https://files.pythonhosted.org/packages/38/99/234594c0391221f66216bc2c886923513b3399a148defaccf81dc3be6560/fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6", size = 2220861, upload-time = "2025-09-29T21:12:39.108Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1d/edb5b23726dde50fc4068e1493e4fc7658eeefcaf75d4c5ffce067d07ae5/fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987", size = 2270934, upload-time = "2025-09-29T21:12:41.339Z" }, - { url = "https://files.pythonhosted.org/packages/fb/da/1392aaa2170adc7071fe7f9cfd181a5684a7afcde605aebddf1fb4d76df5/fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299", size = 2894340, upload-time = "2025-09-29T21:12:43.774Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a7/3b9f16e010d536ce567058b931a20b590d8f3177b2eda09edd92e392375d/fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01", size = 2375073, upload-time = "2025-09-29T21:12:46.437Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b5/e9bcf51980f98e59bb5bb7c382a63c6f6cac0eec5f67de6d8f2322382065/fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801", size = 4849758, upload-time = "2025-09-29T21:12:48.694Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/1d2cf7d1cba82264b2f8385db3f5960e3d8ce756b4dc65b700d2c496f7e9/fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc", size = 5085598, upload-time = "2025-09-29T21:12:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/5d/4d/279e28ba87fb20e0c69baf72b60bbf1c4d873af1476806a7b5f2b7fac1ff/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc", size = 4957603, upload-time = "2025-09-29T21:12:53.423Z" }, - { url = "https://files.pythonhosted.org/packages/78/d4/ff19976305e0c05aa3340c805475abb00224c954d3c65e82c0a69633d55d/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed", size = 4974184, upload-time = "2025-09-29T21:12:55.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/22/8553ff6166f5cd21cfaa115aaacaa0dc73b91c079a8cfd54a482cbc0f4f5/fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259", size = 2282241, upload-time = "2025-09-29T21:12:58.179Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cb/fa7b4d148e11d5a72761a22e595344133e83a9507a4c231df972e657579b/fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c", size = 2345760, upload-time = "2025-09-29T21:13:00.375Z" }, - { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" }, + { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" }, + { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" }, + { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, + { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, + { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, + { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, ] [[package]] name = "frozenlist" -version = "1.7.0" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" }, - { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" }, - { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" }, - { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" }, - { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" }, - { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" }, - { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" }, - { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" }, - { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" }, - { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" }, - { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" }, - { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, - { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, - { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, - { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, - { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, - { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, - { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, - { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, - { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, - { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, - { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, - { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, - { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, - { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, - { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, - { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, - { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, - { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, - { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, - { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, - { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, - { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, - { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, - { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, - { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, - { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, - { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, - { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, - { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, - { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, - { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, - { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, - { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, - { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, - { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, - { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, - { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, - { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, - { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, - { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, - { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, - { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, - { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, - { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "fsspec" -version = "2025.9.0" +version = "2026.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/e0/bab50af11c2d75c9c4a2a26a5254573c0bd97cea152254401510950486fa/fsspec-2025.9.0.tar.gz", hash = "sha256:19fd429483d25d28b65ec68f9f4adc16c17ea2c7c7bf54ec61360d478fb19c19", size = 304847, upload-time = "2025-09-02T19:10:49.215Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289, upload-time = "2025-09-02T19:10:47.708Z" }, -] - -[[package]] -name = "future" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] [[package]] name = "google-api-core" -version = "2.25.1" +version = "2.25.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -1617,9 +1990,9 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/21/e9d043e88222317afdbdb567165fdbc3b0aad90064c7e0c9eb0ad9955ad8/google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8", size = 165443, upload-time = "2025-06-12T20:52:20.439Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266, upload-time = "2025-10-03T00:07:34.778Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/4b/ead00905132820b623732b175d66354e9d3e69fcf2a5dcdab780664e7896/google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7", size = 160807, upload-time = "2025-06-12T20:52:19.334Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d8/894716a5423933f5c8d2d5f04b16f052a515f78e815dab0c2c6f1fd105dc/google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7", size = 162489, upload-time = "2025-10-03T00:07:32.924Z" }, ] [package.optional-dependencies] @@ -1630,16 +2003,15 @@ grpc = [ [[package]] name = "google-auth" -version = "2.41.1" +version = "2.49.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cachetools" }, + { name = "cryptography" }, { name = "pyasn1-modules" }, - { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/af/5129ce5b2f9688d2fa49b463e544972a7c82b0fdb50980dafee92e121d9f/google_auth-2.41.1.tar.gz", hash = "sha256:b76b7b1f9e61f0cb7e88870d14f6a94aeef248959ef6992670efee37709cbfd2", size = 292284, upload-time = "2025-09-30T22:51:26.363Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl", hash = "sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d", size = 221302, upload-time = "2025-09-30T22:51:24.212Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, ] [package.optional-dependencies] @@ -1679,134 +2051,135 @@ wheels = [ [[package]] name = "google-crc32c" -version = "1.7.1" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/ae/87802e6d9f9d69adfaedfcfd599266bf386a54d0be058b532d04c794f76d/google_crc32c-1.7.1.tar.gz", hash = "sha256:2bff2305f98846f3e825dbeec9ee406f89da7962accdb29356e4eadc251bd472", size = 14495, upload-time = "2025-03-26T14:29:13.32Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/69/b1b05cf415df0d86691d6a8b4b7e60ab3a6fb6efb783ee5cd3ed1382bfd3/google_crc32c-1.7.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:b07d48faf8292b4db7c3d64ab86f950c2e94e93a11fd47271c28ba458e4a0d76", size = 30467, upload-time = "2025-03-26T14:31:11.92Z" }, - { url = "https://files.pythonhosted.org/packages/44/3d/92f8928ecd671bd5b071756596971c79d252d09b835cdca5a44177fa87aa/google_crc32c-1.7.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:7cc81b3a2fbd932a4313eb53cc7d9dde424088ca3a0337160f35d91826880c1d", size = 30311, upload-time = "2025-03-26T14:53:14.161Z" }, - { url = "https://files.pythonhosted.org/packages/33/42/c2d15a73df79d45ed6b430b9e801d0bd8e28ac139a9012d7d58af50a385d/google_crc32c-1.7.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1c67ca0a1f5b56162951a9dae987988679a7db682d6f97ce0f6381ebf0fbea4c", size = 37889, upload-time = "2025-03-26T14:41:27.83Z" }, - { url = "https://files.pythonhosted.org/packages/57/ea/ac59c86a3c694afd117bb669bde32aaf17d0de4305d01d706495f09cbf19/google_crc32c-1.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc5319db92daa516b653600794d5b9f9439a9a121f3e162f94b0e1891c7933cb", size = 33028, upload-time = "2025-03-26T14:41:29.141Z" }, - { url = "https://files.pythonhosted.org/packages/60/44/87e77e8476767a4a93f6cf271157c6d948eacec63688c093580af13b04be/google_crc32c-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcdf5a64adb747610140572ed18d011896e3b9ae5195f2514b7ff678c80f1603", size = 38026, upload-time = "2025-03-26T14:41:29.921Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bf/21ac7bb305cd7c1a6de9c52f71db0868e104a5b573a4977cd9d0ff830f82/google_crc32c-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:754561c6c66e89d55754106739e22fdaa93fafa8da7221b29c8b8e8270c6ec8a", size = 33476, upload-time = "2025-03-26T14:29:09.086Z" }, - { url = "https://files.pythonhosted.org/packages/f7/94/220139ea87822b6fdfdab4fb9ba81b3fff7ea2c82e2af34adc726085bffc/google_crc32c-1.7.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6fbab4b935989e2c3610371963ba1b86afb09537fd0c633049be82afe153ac06", size = 30468, upload-time = "2025-03-26T14:32:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/789b23bdeeb9d15dc2904660463ad539d0318286d7633fe2760c10ed0c1c/google_crc32c-1.7.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:ed66cbe1ed9cbaaad9392b5259b3eba4a9e565420d734e6238813c428c3336c9", size = 30313, upload-time = "2025-03-26T14:57:38.758Z" }, - { url = "https://files.pythonhosted.org/packages/81/b8/976a2b843610c211e7ccb3e248996a61e87dbb2c09b1499847e295080aec/google_crc32c-1.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee6547b657621b6cbed3562ea7826c3e11cab01cd33b74e1f677690652883e77", size = 33048, upload-time = "2025-03-26T14:41:30.679Z" }, - { url = "https://files.pythonhosted.org/packages/c9/16/a3842c2cf591093b111d4a5e2bfb478ac6692d02f1b386d2a33283a19dc9/google_crc32c-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d68e17bad8f7dd9a49181a1f5a8f4b251c6dbc8cc96fb79f1d321dfd57d66f53", size = 32669, upload-time = "2025-03-26T14:41:31.432Z" }, - { url = "https://files.pythonhosted.org/packages/04/17/ed9aba495916fcf5fe4ecb2267ceb851fc5f273c4e4625ae453350cfd564/google_crc32c-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:6335de12921f06e1f774d0dd1fbea6bf610abe0887a1638f64d694013138be5d", size = 33476, upload-time = "2025-03-26T14:29:10.211Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b7/787e2453cf8639c94b3d06c9d61f512234a82e1d12d13d18584bd3049904/google_crc32c-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2d73a68a653c57281401871dd4aeebbb6af3191dcac751a76ce430df4d403194", size = 30470, upload-time = "2025-03-26T14:34:31.655Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b4/6042c2b0cbac3ec3a69bb4c49b28d2f517b7a0f4a0232603c42c58e22b44/google_crc32c-1.7.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:22beacf83baaf59f9d3ab2bbb4db0fb018da8e5aebdce07ef9f09fce8220285e", size = 30315, upload-time = "2025-03-26T15:01:54.634Z" }, - { url = "https://files.pythonhosted.org/packages/29/ad/01e7a61a5d059bc57b702d9ff6a18b2585ad97f720bd0a0dbe215df1ab0e/google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19eafa0e4af11b0a4eb3974483d55d2d77ad1911e6cf6f832e1574f6781fd337", size = 33180, upload-time = "2025-03-26T14:41:32.168Z" }, - { url = "https://files.pythonhosted.org/packages/3b/a5/7279055cf004561894ed3a7bfdf5bf90a53f28fadd01af7cd166e88ddf16/google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d86616faaea68101195c6bdc40c494e4d76f41e07a37ffdef270879c15fb65", size = 32794, upload-time = "2025-03-26T14:41:33.264Z" }, - { url = "https://files.pythonhosted.org/packages/0f/d6/77060dbd140c624e42ae3ece3df53b9d811000729a5c821b9fd671ceaac6/google_crc32c-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:b7491bdc0c7564fcf48c0179d2048ab2f7c7ba36b84ccd3a3e1c3f7a72d3bba6", size = 33477, upload-time = "2025-03-26T14:29:10.94Z" }, - { url = "https://files.pythonhosted.org/packages/8b/72/b8d785e9184ba6297a8620c8a37cf6e39b81a8ca01bb0796d7cbb28b3386/google_crc32c-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:df8b38bdaf1629d62d51be8bdd04888f37c451564c2042d36e5812da9eff3c35", size = 30467, upload-time = "2025-03-26T14:36:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/34/25/5f18076968212067c4e8ea95bf3b69669f9fc698476e5f5eb97d5b37999f/google_crc32c-1.7.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:e42e20a83a29aa2709a0cf271c7f8aefaa23b7ab52e53b322585297bb94d4638", size = 30309, upload-time = "2025-03-26T15:06:15.318Z" }, - { url = "https://files.pythonhosted.org/packages/92/83/9228fe65bf70e93e419f38bdf6c5ca5083fc6d32886ee79b450ceefd1dbd/google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:905a385140bf492ac300026717af339790921f411c0dfd9aa5a9e69a08ed32eb", size = 33133, upload-time = "2025-03-26T14:41:34.388Z" }, - { url = "https://files.pythonhosted.org/packages/c3/ca/1ea2fd13ff9f8955b85e7956872fdb7050c4ace8a2306a6d177edb9cf7fe/google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b211ddaf20f7ebeec5c333448582c224a7c90a9d98826fbab82c0ddc11348e6", size = 32773, upload-time = "2025-03-26T14:41:35.19Z" }, - { url = "https://files.pythonhosted.org/packages/89/32/a22a281806e3ef21b72db16f948cad22ec68e4bdd384139291e00ff82fe2/google_crc32c-1.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:0f99eaa09a9a7e642a61e06742856eec8b19fc0037832e03f941fe7cf0c8e4db", size = 33475, upload-time = "2025-03-26T14:29:11.771Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c5/002975aff514e57fc084ba155697a049b3f9b52225ec3bc0f542871dd524/google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32d1da0d74ec5634a05f53ef7df18fc646666a25efaaca9fc7dcfd4caf1d98c3", size = 33243, upload-time = "2025-03-26T14:41:35.975Z" }, - { url = "https://files.pythonhosted.org/packages/61/cb/c585282a03a0cea70fcaa1bf55d5d702d0f2351094d663ec3be1c6c67c52/google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e10554d4abc5238823112c2ad7e4560f96c7bf3820b202660373d769d9e6e4c9", size = 32870, upload-time = "2025-03-26T14:41:37.08Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/31e57ce04530794917dfe25243860ec141de9fadf4aa9783dffe7dac7c39/google_crc32c-1.7.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8e9afc74168b0b2232fb32dd202c93e46b7d5e4bf03e66ba5dc273bb3559589", size = 28242, upload-time = "2025-03-26T14:41:42.858Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f3/8b84cd4e0ad111e63e30eb89453f8dd308e3ad36f42305cf8c202461cdf0/google_crc32c-1.7.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa8136cc14dd27f34a3221c0f16fd42d8a40e4778273e61a3c19aedaa44daf6b", size = 28049, upload-time = "2025-03-26T14:41:44.651Z" }, - { url = "https://files.pythonhosted.org/packages/16/1b/1693372bf423ada422f80fd88260dbfd140754adb15cbc4d7e9a68b1cb8e/google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85fef7fae11494e747c9fd1359a527e5970fc9603c90764843caabd3a16a0a48", size = 28241, upload-time = "2025-03-26T14:41:45.898Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3c/2a19a60a473de48717b4efb19398c3f914795b64a96cf3fbe82588044f78/google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6efb97eb4369d52593ad6f75e7e10d053cf00c48983f7a973105bc70b0ac4d82", size = 28048, upload-time = "2025-03-26T14:41:46.696Z" }, + { url = "https://files.pythonhosted.org/packages/95/ac/6f7bc93886a823ab545948c2dd48143027b2355ad1944c7cf852b338dc91/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff", size = 31296, upload-time = "2025-12-16T00:19:07.261Z" }, + { url = "https://files.pythonhosted.org/packages/f7/97/a5accde175dee985311d949cfcb1249dcbb290f5ec83c994ea733311948f/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288", size = 30870, upload-time = "2025-12-16T00:29:17.669Z" }, + { url = "https://files.pythonhosted.org/packages/3d/63/bec827e70b7a0d4094e7476f863c0dbd6b5f0f1f91d9c9b32b76dcdfeb4e/google_crc32c-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d", size = 33214, upload-time = "2025-12-16T00:40:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/11b70614df04c289128d782efc084b9035ef8466b3d0a8757c1b6f5cf7ac/google_crc32c-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092", size = 33589, upload-time = "2025-12-16T00:40:20.7Z" }, + { url = "https://files.pythonhosted.org/packages/3e/00/a08a4bc24f1261cc5b0f47312d8aebfbe4b53c2e6307f1b595605eed246b/google_crc32c-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733", size = 34437, upload-time = "2025-12-16T00:35:19.437Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, + { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, ] [[package]] name = "google-genai" -version = "1.53.0" +version = "1.67.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "distro" }, { name = "google-auth", extra = ["requests"] }, { name = "httpx" }, { name = "pydantic" }, { name = "requests" }, + { name = "sniffio" }, { name = "tenacity" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/b3/36fbfde2e21e6d3bc67780b61da33632f495ab1be08076cf0a16af74098f/google_genai-1.53.0.tar.gz", hash = "sha256:938a26d22f3fd32c6eeeb4276ef204ef82884e63af9842ce3eac05ceb39cbd8d", size = 260102, upload-time = "2025-12-03T17:21:23.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/07/59a498f81f2c7b0649eacda2ea470b7fd8bd7149f20caba22962081bdd51/google_genai-1.67.0.tar.gz", hash = "sha256:897195a6a9742deb6de240b99227189ada8b2d901d61bdfba836c3092021eab6", size = 506972, upload-time = "2026-03-12T20:39:16.241Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/f2/97fefdd1ad1f3428321bac819ae7a83ccc59f6439616054736b7819fa56c/google_genai-1.53.0-py3-none-any.whl", hash = "sha256:65a3f99e5c03c372d872cda7419f5940e723374bb12a2f3ffd5e3e56e8eb2094", size = 262015, upload-time = "2025-12-03T17:21:21.934Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/562aa1f086e53529ffbeb5b43d5d8bc42c1b968102b5e2163fad005ce298/google_genai-1.67.0-py3-none-any.whl", hash = "sha256:58b0484ff2d4335fa53c724b489e9f807fcca8115d9cdbd8fdf341121fbd6d2d", size = 733542, upload-time = "2026-03-12T20:39:14.615Z" }, ] [[package]] name = "googleapis-common-protos" -version = "1.70.0" +version = "1.73.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, ] [[package]] name = "greenlet" -version = "3.2.4" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +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/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" }, - { url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7c/e7833dbcd8f376f3326bd728c845d31dcde4c84268d3921afcae77d90d08/greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", size = 636703, upload-time = "2025-08-07T13:53:12.622Z" }, - { url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" }, - { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, - { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, - { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, - { url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" }, - { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, - { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, - { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, - { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, - { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, - { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, - { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, - { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" }, - { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, - { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, - { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, - { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, - { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, - { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, - { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, - { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, - { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, - { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, - { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, - { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, - { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, - { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, - { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, - { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, - { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, - { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, - { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, - { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, + { 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/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, + { 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/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, + { 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/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { 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/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { 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/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { 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/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { 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]] name = "groq" -version = "0.23.1" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1816,9 +2189,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/b1/653567a92d876e3e52cdce6780ac3f6dfec5101b6c81a577e3c5abddeebe/groq-0.23.1.tar.gz", hash = "sha256:952e34895f9bfb78ab479e495d77b32180262e5c42f531ce3a1722d6e5a04dfb", size = 125359, upload-time = "2025-04-24T18:59:32.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/bc/7ad1d9967c58b21cdec0c94f26f40fc37b07ba60715d6cbc7c7ef775d927/groq-1.1.1.tar.gz", hash = "sha256:ea971eca72d88e875a78567904bfb46a2f2e43907bfe400fc36a81150a4066d8", size = 150783, upload-time = "2026-03-11T09:11:32.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/9a/54948664261f707a24377ee7e280dbca52b7265fce8613c52dae0cbf5cf5/groq-0.23.1-py3-none-any.whl", hash = "sha256:05fa38c3d0ad03c19c6185f98f6a73901c2a463e844fd067b79f7b05c8346946", size = 127351, upload-time = "2025-04-24T18:59:30.809Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1d/0749c5f0ed76693f6a3a40e2b0c40201fa23e1ccb00e69d5aa63e3f5b0ff/groq-1.1.1-py3-none-any.whl", hash = "sha256:6b7932c0fd3189ad1842fbc294f57fbf014713e01f72037451cb60a138c4b846", size = 139650, upload-time = "2026-03-11T09:11:29.87Z" }, ] [[package]] @@ -1952,17 +2325,34 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.1.10" +version = "1.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/31/feeddfce1748c4a233ec1aa5b7396161c07ae1aa9b7bdbc9a72c3c7dd768/hf_xet-1.1.10.tar.gz", hash = "sha256:408aef343800a2102374a883f283ff29068055c111f003ff840733d3b715bb97", size = 487910, upload-time = "2025-09-12T20:10:27.12Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea", size = 672357, upload-time = "2026-03-13T06:58:51.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/a2/343e6d05de96908366bdc0081f2d8607d61200be2ac802769c4284cc65bd/hf_xet-1.1.10-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:686083aca1a6669bc85c21c0563551cbcdaa5cf7876a91f3d074a030b577231d", size = 2761466, upload-time = "2025-09-12T20:10:22.836Z" }, - { url = "https://files.pythonhosted.org/packages/31/f9/6215f948ac8f17566ee27af6430ea72045e0418ce757260248b483f4183b/hf_xet-1.1.10-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:71081925383b66b24eedff3013f8e6bbd41215c3338be4b94ba75fd75b21513b", size = 2623807, upload-time = "2025-09-12T20:10:21.118Z" }, - { url = "https://files.pythonhosted.org/packages/15/07/86397573efefff941e100367bbda0b21496ffcdb34db7ab51912994c32a2/hf_xet-1.1.10-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b6bceb6361c80c1cc42b5a7b4e3efd90e64630bcf11224dcac50ef30a47e435", size = 3186960, upload-time = "2025-09-12T20:10:19.336Z" }, - { url = "https://files.pythonhosted.org/packages/01/a7/0b2e242b918cc30e1f91980f3c4b026ff2eedaf1e2ad96933bca164b2869/hf_xet-1.1.10-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eae7c1fc8a664e54753ffc235e11427ca61f4b0477d757cc4eb9ae374b69f09c", size = 3087167, upload-time = "2025-09-12T20:10:17.255Z" }, - { url = "https://files.pythonhosted.org/packages/4a/25/3e32ab61cc7145b11eee9d745988e2f0f4fafda81b25980eebf97d8cff15/hf_xet-1.1.10-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0a0005fd08f002180f7a12d4e13b22be277725bc23ed0529f8add5c7a6309c06", size = 3248612, upload-time = "2025-09-12T20:10:24.093Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3d/ab7109e607ed321afaa690f557a9ada6d6d164ec852fd6bf9979665dc3d6/hf_xet-1.1.10-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f900481cf6e362a6c549c61ff77468bd59d6dd082f3170a36acfef2eb6a6793f", size = 3353360, upload-time = "2025-09-12T20:10:25.563Z" }, - { url = "https://files.pythonhosted.org/packages/ee/0e/471f0a21db36e71a2f1752767ad77e92d8cde24e974e03d662931b1305ec/hf_xet-1.1.10-cp37-abi3-win_amd64.whl", hash = "sha256:5f54b19cc347c13235ae7ee98b330c26dd65ef1df47e5316ffb1e87713ca7045", size = 2804691, upload-time = "2025-09-12T20:10:28.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/e8cf74c3c48e5485c7acc5a990d0d8516cdfb5fdf80f799174f1287cc1b5/hf_xet-1.4.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ac8202ae1e664b2c15cdfc7298cbb25e80301ae596d602ef7870099a126fcad4", size = 3796125, upload-time = "2026-03-13T06:58:33.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/d4/b73ebab01cbf60777323b7de9ef05550790451eb5172a220d6b9845385ec/hf_xet-1.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6d2f8ee39fa9fba9af929f8c0d0482f8ee6e209179ad14a909b6ad78ffcb7c81", size = 3555985, upload-time = "2026-03-13T06:58:31.797Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e7/ded6d1bd041c3f2bca9e913a0091adfe32371988e047dd3a68a2463c15a2/hf_xet-1.4.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4642a6cf249c09da8c1f87fe50b24b2a3450b235bf8adb55700b52f0ea6e2eb6", size = 4212085, upload-time = "2026-03-13T06:58:24.323Z" }, + { url = "https://files.pythonhosted.org/packages/97/c1/a0a44d1f98934f7bdf17f7a915b934f9fca44bb826628c553589900f6df8/hf_xet-1.4.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:769431385e746c92dc05492dde6f687d304584b89c33d79def8367ace06cb555", size = 3988266, upload-time = "2026-03-13T06:58:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/7a/82/be713b439060e7d1f1d93543c8053d4ef2fe7e6922c5b31642eaa26f3c4b/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c9dd1c1bc4cc56168f81939b0e05b4c36dd2d28c13dc1364b17af89aa0082496", size = 4188513, upload-time = "2026-03-13T06:58:40.858Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/cbd4188b22abd80ebd0edbb2b3e87f2633e958983519980815fb8314eae5/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fca58a2ae4e6f6755cc971ac6fcdf777ea9284d7e540e350bb000813b9a3008d", size = 4428287, upload-time = "2026-03-13T06:58:42.601Z" }, + { url = "https://files.pythonhosted.org/packages/b2/4e/84e45b25e2e3e903ed3db68d7eafa96dae9a1d1f6d0e7fc85120347a852f/hf_xet-1.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:163aab46854ccae0ab6a786f8edecbbfbaa38fcaa0184db6feceebf7000c93c0", size = 3665574, upload-time = "2026-03-13T06:58:53.881Z" }, + { url = "https://files.pythonhosted.org/packages/ee/71/c5ac2b9a7ae39c14e91973035286e73911c31980fe44e7b1d03730c00adc/hf_xet-1.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:09b138422ecbe50fd0c84d4da5ff537d27d487d3607183cd10e3e53f05188e82", size = 3528760, upload-time = "2026-03-13T06:58:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0f/fcd2504015eab26358d8f0f232a1aed6b8d363a011adef83fe130bff88f7/hf_xet-1.4.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:949dcf88b484bb9d9276ca83f6599e4aa03d493c08fc168c124ad10b2e6f75d7", size = 3796493, upload-time = "2026-03-13T06:58:39.267Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/19c25105ff81731ca6d55a188b5de2aa99d7a2644c7aa9de1810d5d3b726/hf_xet-1.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:41659966020d59eb9559c57de2cde8128b706a26a64c60f0531fa2318f409418", size = 3555797, upload-time = "2026-03-13T06:58:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/8933c073186849b5e06762aa89847991d913d10a95d1603eb7f2c3834086/hf_xet-1.4.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c588e21d80010119458dd5d02a69093f0d115d84e3467efe71ffb2c67c19146", size = 4212127, upload-time = "2026-03-13T06:58:30.539Z" }, + { url = "https://files.pythonhosted.org/packages/eb/01/f89ebba4e369b4ed699dcb60d3152753870996f41c6d22d3d7cac01310e1/hf_xet-1.4.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a296744d771a8621ad1d50c098d7ab975d599800dae6d48528ba3944e5001ba0", size = 3987788, upload-time = "2026-03-13T06:58:29.139Z" }, + { url = "https://files.pythonhosted.org/packages/84/4d/8a53e5ffbc2cc33bbf755382ac1552c6d9af13f623ed125fe67cc3e6772f/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f563f7efe49588b7d0629d18d36f46d1658fe7e08dce3fa3d6526e1c98315e2d", size = 4188315, upload-time = "2026-03-13T06:58:48.017Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b8/b7a1c1b5592254bd67050632ebbc1b42cc48588bf4757cb03c2ef87e704a/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5b2e0132c56d7ee1bf55bdb638c4b62e7106f6ac74f0b786fed499d5548c5570", size = 4428306, upload-time = "2026-03-13T06:58:49.502Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/40779e45b20e11c7c5821a94135e0207080d6b3d76e7b78ccb413c6f839b/hf_xet-1.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:2f45c712c2fa1215713db10df6ac84b49d0e1c393465440e9cb1de73ecf7bbf6", size = 3665826, upload-time = "2026-03-13T06:58:59.88Z" }, + { url = "https://files.pythonhosted.org/packages/51/4c/e2688c8ad1760d7c30f7c429c79f35f825932581bc7c9ec811436d2f21a0/hf_xet-1.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6d53df40616f7168abfccff100d232e9d460583b9d86fa4912c24845f192f2b8", size = 3529113, upload-time = "2026-03-13T06:58:58.491Z" }, + { url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339, upload-time = "2026-03-13T06:58:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664, upload-time = "2026-03-13T06:58:34.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422, upload-time = "2026-03-13T06:58:27.472Z" }, + { url = "https://files.pythonhosted.org/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271", size = 3992847, upload-time = "2026-03-13T06:58:25.989Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2", size = 4193843, upload-time = "2026-03-13T06:58:44.59Z" }, + { url = "https://files.pythonhosted.org/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04", size = 4432751, upload-time = "2026-03-13T06:58:46.533Z" }, + { url = "https://files.pythonhosted.org/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f", size = 3671149, upload-time = "2026-03-13T06:58:57.07Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" }, ] [[package]] @@ -1989,38 +2379,45 @@ wheels = [ [[package]] name = "httptools" -version = "0.6.4" +version = "0.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0", size = 198780, upload-time = "2024-10-16T19:44:06.882Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/17c672b4bc5c7ba7f201eada4e96c71d0a59fbc185e60e42580093a86f21/httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da", size = 103297, upload-time = "2024-10-16T19:44:08.129Z" }, - { url = "https://files.pythonhosted.org/packages/92/5e/b4a826fe91971a0b68e8c2bd4e7db3e7519882f5a8ccdb1194be2b3ab98f/httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1", size = 443130, upload-time = "2024-10-16T19:44:09.45Z" }, - { url = "https://files.pythonhosted.org/packages/b0/51/ce61e531e40289a681a463e1258fa1e05e0be54540e40d91d065a264cd8f/httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50", size = 442148, upload-time = "2024-10-16T19:44:11.539Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/270b7d767849b0c96f275c695d27ca76c30671f8eb8cc1bab6ced5c5e1d0/httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959", size = 415949, upload-time = "2024-10-16T19:44:13.388Z" }, - { url = "https://files.pythonhosted.org/packages/81/86/ced96e3179c48c6f656354e106934e65c8963d48b69be78f355797f0e1b3/httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4", size = 417591, upload-time = "2024-10-16T19:44:15.258Z" }, - { url = "https://files.pythonhosted.org/packages/75/73/187a3f620ed3175364ddb56847d7a608a6fc42d551e133197098c0143eca/httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c", size = 88344, upload-time = "2024-10-16T19:44:16.54Z" }, - { url = "https://files.pythonhosted.org/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069", size = 199029, upload-time = "2024-10-16T19:44:18.427Z" }, - { url = "https://files.pythonhosted.org/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a", size = 103492, upload-time = "2024-10-16T19:44:19.515Z" }, - { url = "https://files.pythonhosted.org/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975", size = 462891, upload-time = "2024-10-16T19:44:21.067Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636", size = 459788, upload-time = "2024-10-16T19:44:22.958Z" }, - { url = "https://files.pythonhosted.org/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721", size = 433214, upload-time = "2024-10-16T19:44:24.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988", size = 434120, upload-time = "2024-10-16T19:44:26.295Z" }, - { url = "https://files.pythonhosted.org/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17", size = 88565, upload-time = "2024-10-16T19:44:29.188Z" }, - { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" }, - { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" }, - { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" }, - { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" }, - { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, - { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, - { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, - { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, - { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] [[package]] @@ -2045,16 +2442,16 @@ http2 = [ [[package]] name = "httpx-sse" -version = "0.4.0" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] [[package]] name = "huggingface-hub" -version = "0.35.3" +version = "0.36.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -2066,9 +2463,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/7e/a0a97de7c73671863ca6b3f61fa12518caf35db37825e43d63a70956738c/huggingface_hub-0.35.3.tar.gz", hash = "sha256:350932eaa5cc6a4747efae85126ee220e4ef1b54e29d31c3b45c5612ddf0b32a", size = 461798, upload-time = "2025-09-29T14:29:58.625Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/a0/651f93d154cb72323358bf2bbae3e642bdb5d2f1bfc874d096f7cb159fa0/huggingface_hub-0.35.3-py3-none-any.whl", hash = "sha256:0e3a01829c19d86d03793e4577816fe3bdfc1602ac62c7fb220d593d351224ba", size = 564262, upload-time = "2025-09-29T14:29:55.813Z" }, + { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, ] [[package]] @@ -2085,16 +2482,16 @@ wheels = [ [[package]] name = "humanize" -version = "4.14.0" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/43/50033d25ad96a7f3845f40999b4778f753c3901a11808a584fed7c00d9f5/humanize-4.14.0.tar.gz", hash = "sha256:2fa092705ea640d605c435b1ca82b2866a1b601cdf96f076d70b79a855eba90d", size = 82939, upload-time = "2025-10-15T13:04:51.214Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/5b/9512c5fb6c8218332b530f13500c6ff5f3ce3342f35e0dd7be9ac3856fd3/humanize-4.14.0-py3-none-any.whl", hash = "sha256:d57701248d040ad456092820e6fde56c930f17749956ac47f4f655c0c547bfff", size = 132092, upload-time = "2025-10-15T13:04:49.404Z" }, + { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, ] [[package]] name = "hume" -version = "0.12.1" +version = "0.13.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2106,9 +2503,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/31/044339cd301ac210de9a86fe1562fb4165da510ba4de3b2729248dcddf5a/hume-0.12.1.tar.gz", hash = "sha256:a4a6b7057be3b39526d9d3f202f36735c1acae29425bb08a59ec7f9d0987465f", size = 124244, upload-time = "2025-10-01T21:33:52.985Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/d7/97845c3903ef5782b6f4581138f06a595513c2e129b2cbeacfc6e3645f61/hume-0.13.10.tar.gz", hash = "sha256:425596d17bd8b85bdf4f27bd0d3680c50ce50b4339f64adf39f69557907dc41c", size = 144063, upload-time = "2026-02-27T21:06:17.913Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/c25ac9ca9acf4ba737857d8ad51ba4dbdf8eb9357e8ade846627d7baed1e/hume-0.12.1-py3-none-any.whl", hash = "sha256:b83822ccbdb0ec449d31d64cb76611ded20a605264798e049fc768dc79e2c776", size = 306097, upload-time = "2025-10-01T21:33:51.509Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ee/52598b811660f874f84b880b2b46481c78f8f7df2d9cff95b8130af95826/hume-0.13.10-py3-none-any.whl", hash = "sha256:a724b6cd9fc2278dff0b831276b1b2c82604edece3e036e0d46c312aea2d70b8", size = 355071, upload-time = "2026-02-27T21:06:14.847Z" }, ] [[package]] @@ -2122,20 +2519,20 @@ wheels = [ [[package]] name = "identify" -version = "2.6.15" +version = "2.6.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] @@ -2149,116 +2546,132 @@ wheels = [ [[package]] name = "ijson" -version = "3.4.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/4f/1cfeada63f5fce87536651268ddf5cca79b8b4bbb457aee4e45777964a0a/ijson-3.4.0.tar.gz", hash = "sha256:5f74dcbad9d592c428d3ca3957f7115a42689ee7ee941458860900236ae9bb13", size = 65782, upload-time = "2025-05-08T02:37:20.135Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/57/60d1a6a512f2f0508d0bc8b4f1cc5616fd3196619b66bd6a01f9155a1292/ijson-3.5.0.tar.gz", hash = "sha256:94688760720e3f5212731b3cb8d30267f9a045fb38fb3870254e7b9504246f31", size = 68658, upload-time = "2026-02-24T03:58:30.974Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/6b/a247ba44004154aaa71f9e6bd9f05ba412f490cc4043618efb29314f035e/ijson-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e27e50f6dcdee648f704abc5d31b976cd2f90b4642ed447cf03296d138433d09", size = 87609, upload-time = "2025-05-08T02:35:20.535Z" }, - { url = "https://files.pythonhosted.org/packages/3c/1d/8d2009d74373b7dec2a49b1167e396debb896501396c70a674bb9ccc41ff/ijson-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2a753be681ac930740a4af9c93cfb4edc49a167faed48061ea650dc5b0f406f1", size = 59243, upload-time = "2025-05-08T02:35:21.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b2/a85a21ebaba81f64a326c303a94625fb94b84890c52d9efdd8acb38b6312/ijson-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a07c47aed534e0ec198e6a2d4360b259d32ac654af59c015afc517ad7973b7fb", size = 59309, upload-time = "2025-05-08T02:35:23.317Z" }, - { url = "https://files.pythonhosted.org/packages/b1/35/273dfa1f27c38eeaba105496ecb54532199f76c0120177b28315daf5aec3/ijson-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c55f48181e11c597cd7146fb31edc8058391201ead69f8f40d2ecbb0b3e4fc6", size = 131213, upload-time = "2025-05-08T02:35:24.735Z" }, - { url = "https://files.pythonhosted.org/packages/4d/37/9d3bb0e200a103ca9f8e9315c4d96ecaca43a3c1957c1ac069ea9dc9c6ba/ijson-3.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd5669f96f79d8a2dd5ae81cbd06770a4d42c435fd4a75c74ef28d9913b697d", size = 125456, upload-time = "2025-05-08T02:35:25.896Z" }, - { url = "https://files.pythonhosted.org/packages/00/54/8f015c4df30200fd14435dec9c67bf675dff0fee44a16c084a8ec0f82922/ijson-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e3ddd46d16b8542c63b1b8af7006c758d4e21cc1b86122c15f8530fae773461", size = 130192, upload-time = "2025-05-08T02:35:27.367Z" }, - { url = "https://files.pythonhosted.org/packages/88/01/46a0540ad3461332edcc689a8874fa13f0a4c00f60f02d155b70e36f5e0b/ijson-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1504cec7fe04be2bb0cc33b50c9dd3f83f98c0540ad4991d4017373b7853cfe6", size = 132217, upload-time = "2025-05-08T02:35:28.545Z" }, - { url = "https://files.pythonhosted.org/packages/d7/da/8f8df42f3fd7ef279e20eae294738eed62d41ed5b6a4baca5121abc7cf0f/ijson-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2f2ff456adeb216603e25d7915f10584c1b958b6eafa60038d76d08fc8a5fb06", size = 127118, upload-time = "2025-05-08T02:35:29.726Z" }, - { url = "https://files.pythonhosted.org/packages/82/0a/a410d9d3b082cc2ec9738d54935a589974cbe54c0f358e4d17465594d660/ijson-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0ab00d75d61613a125fbbb524551658b1ad6919a52271ca16563ca5bc2737bb1", size = 129808, upload-time = "2025-05-08T02:35:31.247Z" }, - { url = "https://files.pythonhosted.org/packages/2e/c6/a3e2a446b8bd2cf91cb4ca7439f128d2b379b5a79794d0ea25e379b0f4f3/ijson-3.4.0-cp310-cp310-win32.whl", hash = "sha256:ada421fd59fe2bfa4cfa64ba39aeba3f0753696cdcd4d50396a85f38b1d12b01", size = 51160, upload-time = "2025-05-08T02:35:32.964Z" }, - { url = "https://files.pythonhosted.org/packages/18/7c/e6620603df42d2ef8a92076eaa5cd2b905366e86e113adf49e7b79970bd3/ijson-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:8c75e82cec05d00ed3a4af5f4edf08f59d536ed1a86ac7e84044870872d82a33", size = 53710, upload-time = "2025-05-08T02:35:34.033Z" }, - { url = "https://files.pythonhosted.org/packages/1a/0d/3e2998f4d7b7d2db2d511e4f0cf9127b6e2140c325c3cb77be46ae46ff1d/ijson-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e369bf5a173ca51846c243002ad8025d32032532523b06510881ecc8723ee54", size = 87643, upload-time = "2025-05-08T02:35:35.693Z" }, - { url = "https://files.pythonhosted.org/packages/e9/7b/afef2b08af2fee5ead65fcd972fadc3e31f9ae2b517fe2c378d50a9bf79b/ijson-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:26e7da0a3cd2a56a1fde1b34231867693f21c528b683856f6691e95f9f39caec", size = 59260, upload-time = "2025-05-08T02:35:37.166Z" }, - { url = "https://files.pythonhosted.org/packages/da/4a/39f583a2a13096f5063028bb767622f09cafc9ec254c193deee6c80af59f/ijson-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1c28c7f604729be22aa453e604e9617b665fa0c24cd25f9f47a970e8130c571a", size = 59311, upload-time = "2025-05-08T02:35:38.538Z" }, - { url = "https://files.pythonhosted.org/packages/3c/58/5b80efd54b093e479c98d14b31d7794267281f6a8729f2c94fbfab661029/ijson-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bed8bcb84d3468940f97869da323ba09ae3e6b950df11dea9b62e2b231ca1e3", size = 136125, upload-time = "2025-05-08T02:35:39.976Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f5/f37659b1647ecc3992216277cd8a45e2194e84e8818178f77c99e1d18463/ijson-3.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:296bc824f4088f2af814aaf973b0435bc887ce3d9f517b1577cc4e7d1afb1cb7", size = 130699, upload-time = "2025-05-08T02:35:41.483Z" }, - { url = "https://files.pythonhosted.org/packages/ee/2f/4c580ac4bb5eda059b672ad0a05e4bafdae5182a6ec6ab43546763dafa91/ijson-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8145f8f40617b6a8aa24e28559d0adc8b889e56a203725226a8a60fa3501073f", size = 134963, upload-time = "2025-05-08T02:35:43.017Z" }, - { url = "https://files.pythonhosted.org/packages/6d/9e/64ec39718609faab6ed6e1ceb44f9c35d71210ad9c87fff477c03503e8f8/ijson-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b674a97bd503ea21bc85103e06b6493b1b2a12da3372950f53e1c664566a33a4", size = 137405, upload-time = "2025-05-08T02:35:44.618Z" }, - { url = "https://files.pythonhosted.org/packages/71/b2/f0bf0e4a0962845597996de6de59c0078bc03a1f899e03908220039f4cf6/ijson-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8bc731cf1c3282b021d3407a601a5a327613da9ad3c4cecb1123232623ae1826", size = 131861, upload-time = "2025-05-08T02:35:46.22Z" }, - { url = "https://files.pythonhosted.org/packages/17/83/4a2e3611e2b4842b413ec84d2e54adea55ab52e4408ea0f1b1b927e19536/ijson-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42ace5e940e0cf58c9de72f688d6829ddd815096d07927ee7e77df2648006365", size = 134297, upload-time = "2025-05-08T02:35:47.401Z" }, - { url = "https://files.pythonhosted.org/packages/38/75/2d332911ac765b44cd7da0cb2b06143521ad5e31dfcc8d8587e6e6168bc8/ijson-3.4.0-cp311-cp311-win32.whl", hash = "sha256:5be39a0df4cd3f02b304382ea8885391900ac62e95888af47525a287c50005e9", size = 51161, upload-time = "2025-05-08T02:35:49.164Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ba/4ad571f9f7fcf5906b26e757b130c1713c5f0198a1e59568f05d53a0816c/ijson-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b1be1781792291e70d2e177acf564ec672a7907ba74f313583bdf39fe81f9b7", size = 53710, upload-time = "2025-05-08T02:35:50.323Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ec/317ee5b2d13e50448833ead3aa906659a32b376191f6abc2a7c6112d2b27/ijson-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:956b148f88259a80a9027ffbe2d91705fae0c004fbfba3e5a24028fbe72311a9", size = 87212, upload-time = "2025-05-08T02:35:51.835Z" }, - { url = "https://files.pythonhosted.org/packages/f8/43/b06c96ced30cacecc5d518f89b0fd1c98c294a30ff88848b70ed7b7f72a1/ijson-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b89960f5c721106394c7fba5760b3f67c515b8eb7d80f612388f5eca2f4621", size = 59175, upload-time = "2025-05-08T02:35:52.988Z" }, - { url = "https://files.pythonhosted.org/packages/e9/df/b4aeafb7ecde463130840ee9be36130823ec94a00525049bf700883378b8/ijson-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a0bb591cf250dd7e9dfab69d634745a7f3272d31cfe879f9156e0a081fd97ee", size = 59011, upload-time = "2025-05-08T02:35:54.394Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/a80b8e361641609507f62022089626d4b8067f0826f51e1c09e4ba86eba8/ijson-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72e92de999977f4c6b660ffcf2b8d59604ccd531edcbfde05b642baf283e0de8", size = 146094, upload-time = "2025-05-08T02:35:55.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/44/fa416347b9a802e3646c6ff377fc3278bd7d6106e17beb339514b6a3184e/ijson-3.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e9602157a5b869d44b6896e64f502c712a312fcde044c2e586fccb85d3e316e", size = 137903, upload-time = "2025-05-08T02:35:56.814Z" }, - { url = "https://files.pythonhosted.org/packages/24/c6/41a9ad4d42df50ff6e70fdce79b034f09b914802737ebbdc141153d8d791/ijson-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e83660edb931a425b7ff662eb49db1f10d30ca6d4d350e5630edbed098bc01", size = 148339, upload-time = "2025-05-08T02:35:58.595Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/7d01efda415b8502dce67e067ed9e8a124f53e763002c02207e542e1a2f1/ijson-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:49bf8eac1c7b7913073865a859c215488461f7591b4fa6a33c14b51cb73659d0", size = 149383, upload-time = "2025-05-08T02:36:00.197Z" }, - { url = "https://files.pythonhosted.org/packages/95/6c/0d67024b9ecb57916c5e5ab0350251c9fe2f86dc9c8ca2b605c194bdad6a/ijson-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:160b09273cb42019f1811469508b0a057d19f26434d44752bde6f281da6d3f32", size = 141580, upload-time = "2025-05-08T02:36:01.998Z" }, - { url = "https://files.pythonhosted.org/packages/06/43/e10edcc1c6a3b619294de835e7678bfb3a1b8a75955f3689fd66a1e9e7b4/ijson-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2019ff4e6f354aa00c76c8591bd450899111c61f2354ad55cc127e2ce2492c44", size = 150280, upload-time = "2025-05-08T02:36:03.926Z" }, - { url = "https://files.pythonhosted.org/packages/07/84/1cbeee8e8190a1ebe6926569a92cf1fa80ddb380c129beb6f86559e1bb24/ijson-3.4.0-cp312-cp312-win32.whl", hash = "sha256:931c007bf6bb8330705429989b2deed6838c22b63358a330bf362b6e458ba0bf", size = 51512, upload-time = "2025-05-08T02:36:05.595Z" }, - { url = "https://files.pythonhosted.org/packages/66/13/530802bc391c95be6fe9f96e9aa427d94067e7c0b7da7a9092344dc44c4b/ijson-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:71523f2b64cb856a820223e94d23e88369f193017ecc789bb4de198cc9d349eb", size = 54081, upload-time = "2025-05-08T02:36:07.099Z" }, - { url = "https://files.pythonhosted.org/packages/77/b3/b1d2eb2745e5204ec7a25365a6deb7868576214feb5e109bce368fb692c9/ijson-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e8d96f88d75196a61c9d9443de2b72c2d4a7ba9456ff117b57ae3bba23a54256", size = 87216, upload-time = "2025-05-08T02:36:08.414Z" }, - { url = "https://files.pythonhosted.org/packages/b1/cd/cd6d340087617f8cc9bedbb21d974542fe2f160ed0126b8288d3499a469b/ijson-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c45906ce2c1d3b62f15645476fc3a6ca279549127f01662a39ca5ed334a00cf9", size = 59170, upload-time = "2025-05-08T02:36:09.604Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4d/32d3a9903b488d3306e3c8288f6ee4217d2eea82728261db03a1045eb5d1/ijson-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4ab4bc2119b35c4363ea49f29563612237cae9413d2fbe54b223be098b97bc9e", size = 59013, upload-time = "2025-05-08T02:36:10.696Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c8/db15465ab4b0b477cee5964c8bfc94bf8c45af8e27a23e1ad78d1926e587/ijson-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97b0a9b5a15e61dfb1f14921ea4e0dba39f3a650df6d8f444ddbc2b19b479ff1", size = 146564, upload-time = "2025-05-08T02:36:11.916Z" }, - { url = "https://files.pythonhosted.org/packages/c4/d8/0755545bc122473a9a434ab90e0f378780e603d75495b1ca3872de757873/ijson-3.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3047bb994dabedf11de11076ed1147a307924b6e5e2df6784fb2599c4ad8c60", size = 137917, upload-time = "2025-05-08T02:36:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c6/aeb89c8939ebe3f534af26c8c88000c5e870dbb6ae33644c21a4531f87d2/ijson-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68c83161b052e9f5dc8191acbc862bb1e63f8a35344cb5cd0db1afd3afd487a6", size = 148897, upload-time = "2025-05-08T02:36:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/be/0e/7ef6e9b372106f2682a4a32b3c65bf86bb471a1670e4dac242faee4a7d3f/ijson-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1eebd9b6c20eb1dffde0ae1f0fbb4aeacec2eb7b89adb5c7c0449fc9fd742760", size = 149711, upload-time = "2025-05-08T02:36:16.476Z" }, - { url = "https://files.pythonhosted.org/packages/d1/5d/9841c3ed75bcdabf19b3202de5f862a9c9c86ce5c7c9d95fa32347fdbf5f/ijson-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13fb6d5c35192c541421f3ee81239d91fc15a8d8f26c869250f941f4b346a86c", size = 141691, upload-time = "2025-05-08T02:36:18.044Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d2/ce74e17218dba292e9be10a44ed0c75439f7958cdd263adb0b5b92d012d5/ijson-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:28b7196ff7b37c4897c547a28fa4876919696739fc91c1f347651c9736877c69", size = 150738, upload-time = "2025-05-08T02:36:19.483Z" }, - { url = "https://files.pythonhosted.org/packages/4e/43/dcc480f94453b1075c9911d4755b823f3ace275761bb37b40139f22109ca/ijson-3.4.0-cp313-cp313-win32.whl", hash = "sha256:3c2691d2da42629522140f77b99587d6f5010440d58d36616f33bc7bdc830cc3", size = 51512, upload-time = "2025-05-08T02:36:20.99Z" }, - { url = "https://files.pythonhosted.org/packages/35/dd/d8c5f15efd85ba51e6e11451ebe23d779361a9ec0d192064c2a8c3cdfcb8/ijson-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:c4554718c275a044c47eb3874f78f2c939f300215d9031e785a6711cc51b83fc", size = 54074, upload-time = "2025-05-08T02:36:22.075Z" }, - { url = "https://files.pythonhosted.org/packages/79/73/24ad8cd106203419c4d22bed627e02e281d66b83e91bc206a371893d0486/ijson-3.4.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:915a65e3f3c0eee2ea937bc62aaedb6c14cc1e8f0bb9f3f4fb5a9e2bbfa4b480", size = 91694, upload-time = "2025-05-08T02:36:23.289Z" }, - { url = "https://files.pythonhosted.org/packages/17/2d/f7f680984bcb7324a46a4c2df3bd73cf70faef0acfeb85a3f811abdfd590/ijson-3.4.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:afbe9748707684b6c5adc295c4fdcf27765b300aec4d484e14a13dca4e5c0afa", size = 61390, upload-time = "2025-05-08T02:36:24.42Z" }, - { url = "https://files.pythonhosted.org/packages/09/a1/f3ca7bab86f95bdb82494739e71d271410dfefce4590785d511669127145/ijson-3.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d823f8f321b4d8d5fa020d0a84f089fec5d52b7c0762430476d9f8bf95bbc1a9", size = 61140, upload-time = "2025-05-08T02:36:26.708Z" }, - { url = "https://files.pythonhosted.org/packages/51/79/dd340df3d4fc7771c95df29997956b92ed0570fe7b616d1792fea9ad93f2/ijson-3.4.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8a0a2c54f3becf76881188beefd98b484b1d3bd005769a740d5b433b089fa23", size = 214739, upload-time = "2025-05-08T02:36:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/59/f0/85380b7f51d1f5fb7065d76a7b623e02feca920cc678d329b2eccc0011e0/ijson-3.4.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ced19a83ab09afa16257a0b15bc1aa888dbc555cb754be09d375c7f8d41051f2", size = 198338, upload-time = "2025-05-08T02:36:29.496Z" }, - { url = "https://files.pythonhosted.org/packages/a5/cd/313264cf2ec42e0f01d198c49deb7b6fadeb793b3685e20e738eb6b3fa13/ijson-3.4.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8100f9885eff1f38d35cef80ef759a1bbf5fc946349afa681bd7d0e681b7f1a0", size = 207515, upload-time = "2025-05-08T02:36:30.981Z" }, - { url = "https://files.pythonhosted.org/packages/12/94/bf14457aa87ea32641f2db577c9188ef4e4ae373478afef422b31fc7f309/ijson-3.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d7bcc3f7f21b0f703031ecd15209b1284ea51b2a329d66074b5261de3916c1eb", size = 210081, upload-time = "2025-05-08T02:36:32.403Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b4/eaee39e290e40e52d665db9bd1492cfdce86bd1e47948e0440db209c6023/ijson-3.4.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2dcb190227b09dd171bdcbfe4720fddd574933c66314818dfb3960c8a6246a77", size = 199253, upload-time = "2025-05-08T02:36:33.861Z" }, - { url = "https://files.pythonhosted.org/packages/c5/9c/e09c7b9ac720a703ab115b221b819f149ed54c974edfff623c1e925e57da/ijson-3.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:eda4cfb1d49c6073a901735aaa62e39cb7ab47f3ad7bb184862562f776f1fa8a", size = 203816, upload-time = "2025-05-08T02:36:35.348Z" }, - { url = "https://files.pythonhosted.org/packages/7c/14/acd304f412e32d16a2c12182b9d78206bb0ae35354d35664f45db05c1b3b/ijson-3.4.0-cp313-cp313t-win32.whl", hash = "sha256:0772638efa1f3b72b51736833404f1cbd2f5beeb9c1a3d392e7d385b9160cba7", size = 53760, upload-time = "2025-05-08T02:36:36.608Z" }, - { url = "https://files.pythonhosted.org/packages/2f/24/93dd0a467191590a5ed1fc2b35842bca9d09900d001e00b0b497c0208ef6/ijson-3.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3d8a0d67f36e4fb97c61a724456ef0791504b16ce6f74917a31c2e92309bbeb9", size = 56948, upload-time = "2025-05-08T02:36:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/a7/22/da919f16ca9254f8a9ea0ba482d2c1d012ce6e4c712dcafd8adb16b16c63/ijson-3.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:54e989c35dba9cf163d532c14bcf0c260897d5f465643f0cd1fba9c908bed7ef", size = 56480, upload-time = "2025-05-08T02:36:54.942Z" }, - { url = "https://files.pythonhosted.org/packages/6d/54/c2afd289e034d11c4909f4ea90c9dae55053bed358064f310c3dd5033657/ijson-3.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:494eeb8e87afef22fbb969a4cb81ac2c535f30406f334fb6136e9117b0bb5380", size = 55956, upload-time = "2025-05-08T02:36:56.178Z" }, - { url = "https://files.pythonhosted.org/packages/43/d6/18799b0fca9ecb8a47e22527eedcea3267e95d4567b564ef21d0299e2d12/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81603de95de1688958af65cd2294881a4790edae7de540b70c65c8253c5dc44a", size = 69394, upload-time = "2025-05-08T02:36:57.699Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d6/c58032c69e9e977bf6d954f22cad0cd52092db89c454ea98926744523665/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8524be12c1773e1be466034cc49c1ecbe3d5b47bb86217bd2a57f73f970a6c19", size = 70378, upload-time = "2025-05-08T02:36:58.98Z" }, - { url = "https://files.pythonhosted.org/packages/da/03/07c6840454d5d228bb5b4509c9a7ac5b9c0b8258e2b317a53f97372be1eb/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17994696ec895d05e0cfa21b11c68c920c82634b4a3d8b8a1455d6fe9fdee8f7", size = 67770, upload-time = "2025-05-08T02:37:00.162Z" }, - { url = "https://files.pythonhosted.org/packages/32/c7/da58a9840380308df574dfdb0276c9d802b12f6125f999e92bcef36db552/ijson-3.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0b67727aaee55d43b2e82b6a866c3cbcb2b66a5e9894212190cbd8773d0d9857", size = 53858, upload-time = "2025-05-08T02:37:01.691Z" }, - { url = "https://files.pythonhosted.org/packages/a3/9b/0bc0594d357600c03c3b5a3a34043d764fc3ad3f0757d2f3aae5b28f6c1c/ijson-3.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cdc8c5ca0eec789ed99db29c68012dda05027af0860bb360afd28d825238d69d", size = 56483, upload-time = "2025-05-08T02:37:03.274Z" }, - { url = "https://files.pythonhosted.org/packages/00/1f/506cf2574673da1adcc8a794ebb85bf857cabe6294523978637e646814de/ijson-3.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8e6b44b6ec45d5b1a0ee9d97e0e65ab7f62258727004cbbe202bf5f198bc21f7", size = 55957, upload-time = "2025-05-08T02:37:04.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3d/a7cd8d8a6de0f3084fe4d457a8f76176e11b013867d1cad16c67d25e8bec/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b51e239e4cb537929796e840d349fc731fdc0d58b1a0683ce5465ad725321e0f", size = 69394, upload-time = "2025-05-08T02:37:06.142Z" }, - { url = "https://files.pythonhosted.org/packages/32/51/aa30abc02aabfc41c95887acf5f1f88da569642d7197fbe5aa105545226d/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed05d43ec02be8ddb1ab59579761f6656b25d241a77fd74f4f0f7ec09074318a", size = 70377, upload-time = "2025-05-08T02:37:07.353Z" }, - { url = "https://files.pythonhosted.org/packages/c7/37/7773659b8d8d98b34234e1237352f6b446a3c12941619686c7d4a8a5c69c/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfeca1aaa59d93fd0a3718cbe5f7ef0effff85cf837e0bceb71831a47f39cc14", size = 67767, upload-time = "2025-05-08T02:37:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/cd/1f/dd52a84ed140e31a5d226cd47d98d21aa559aead35ef7bae479eab4c494c/ijson-3.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7ca72ca12e9a1dd4252c97d952be34282907f263f7e28fcdff3a01b83981e837", size = 53864, upload-time = "2025-05-08T02:37:10.044Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/21c1b47a1afb7319944d0b9685c0997a9d574a77b030c82f6a1ac2cef4eb/ijson-3.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ea8dcac10d86adaeead454bc25c97b68d0bda573d5fd6f86f5e21cf8f7906f88", size = 88935, upload-time = "2026-02-24T03:56:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/6ac7ebbb3cd767c87cdcbb950a6754afd1c0977756347bfe03eb8e5b866d/ijson-3.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:92b0495bbb2150bbf14fc5d98fb6d76bcd1c526605a172709e602e6fedc96495", size = 60567, upload-time = "2026-02-24T03:56:41.919Z" }, + { url = "https://files.pythonhosted.org/packages/c4/98/1140de9ae872468a8bc2e87c171228e25e58b1eb696b7fb430f7590fea44/ijson-3.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7af0c4c8943be8b09a4e57bdc1da6001dae7b36526d4154fe5c8224738d0921f", size = 60620, upload-time = "2026-02-24T03:56:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/60/e1/67dfe0774e4c7ca6ec8702e280e8764d356f3db54358999818cda6df7679/ijson-3.5.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:45887d5e84ff0d2b138c926cebd9071830733968afe8d9d12080b3c178c7f918", size = 126558, upload-time = "2026-02-24T03:56:43.922Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ef/23d614fc773d428caeb6e197218b7e32adcc668ff5b98777039149571208/ijson-3.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a70b575be8e57a28c80e90ed349ad3a851c3478524c70e36e07d6092ecd12c9", size = 133091, upload-time = "2026-02-24T03:56:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b8/80/99727603cd8a1d32edafa4392f4056b2420bf48c15afd34481c68a2d4435/ijson-3.5.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2adeecd45830bfd5580ca79a584154713aabef0b9607e16249133df5d2859813", size = 130249, upload-time = "2026-02-24T03:56:46.333Z" }, + { url = "https://files.pythonhosted.org/packages/0b/94/3a3d623ca80768e834be8a834ef05960e3b9e79af1a911704ff10c9e8792/ijson-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d873e72889e7fc5962ab58909f1adff338d7c2f49e450e5b5fe844eff8155a14", size = 133501, upload-time = "2026-02-24T03:56:47.54Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f6/df2c14ad340834eccee379046f155e4b66a16ddafd445429dee7b3323614/ijson-3.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9a88c559456a79708592234d697645d92b599718f4cbbeaa6515f83ac63ca0ae", size = 128438, upload-time = "2026-02-24T03:56:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7e/9ff5b8b5fee113f5607bc4149b707382a898eeb545153189b075e5ec8d59/ijson-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf83f58ad50dc0d39a2105cb26d4f359b38f42cef68b913170d4d47d97d97ba5", size = 131116, upload-time = "2026-02-24T03:56:49.737Z" }, + { url = "https://files.pythonhosted.org/packages/64/20/954ce0d440d7cf72a3d8361b14406f9cdbf624b1625c10f8488857c769d6/ijson-3.5.0-cp310-cp310-win32.whl", hash = "sha256:aec4580a7712a19b1f95cd41bed260fc6a31266d37ef941827772a4c199e8143", size = 52724, upload-time = "2026-02-24T03:56:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/24/33/ece87d60502c6115642cbabeb8c122fa982212b392bc4f4ff5aab8e02dac/ijson-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:9a9c4c70501e23e8eb1675330686d1598eebfa14b6f0dbc8f00c2e081cc628fa", size = 55125, upload-time = "2026-02-24T03:56:51.942Z" }, + { url = "https://files.pythonhosted.org/packages/65/da/644343198abca5e0f6e2486063f8d8f3c443ca0ef5e5c890e51ef6032e33/ijson-3.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5616311404b858d32740b7ad8b9a799c62165f5ecb85d0a8ed16c21665a90533", size = 88964, upload-time = "2026-02-24T03:56:53.099Z" }, + { url = "https://files.pythonhosted.org/packages/5b/63/8621190aa2baf96156dfd4c632b6aa9f1464411e50b98750c09acc0505ea/ijson-3.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e9733f94029dd41702d573ef64752e2556e72aea14623d6dbb7a44ca1ccf30fd", size = 60582, upload-time = "2026-02-24T03:56:54.261Z" }, + { url = "https://files.pythonhosted.org/packages/20/31/6a3f041fdd17dacff33b7d7d3ba3df6dca48740108340c6042f974b2ad20/ijson-3.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db8398c6721b98412a4f618da8022550c8b9c5d9214040646071b5deb4d4a393", size = 60632, upload-time = "2026-02-24T03:56:55.159Z" }, + { url = "https://files.pythonhosted.org/packages/e4/68/474541998abbdecfd46a744536878335de89aceb9f085bff1aaf35575ceb/ijson-3.5.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c061314845c08163b1784b6076ea5f075372461a32e6916f4e5f211fd4130b64", size = 131988, upload-time = "2026-02-24T03:56:56.35Z" }, + { url = "https://files.pythonhosted.org/packages/cd/32/e05ff8b72a44fe9d192f41c5dcbc35cfa87efc280cdbfe539ffaf4a7535e/ijson-3.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1111a1c5ac79119c5d6e836f900c1a53844b50a18af38311baa6bb61e2645aca", size = 138669, upload-time = "2026-02-24T03:56:57.555Z" }, + { url = "https://files.pythonhosted.org/packages/49/b5/955a83b031102c7a602e2c06d03aff0a0e584212f09edb94ccc754d203ac/ijson-3.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e74aff8c681c24002b61b1822f9511d4c384f324f7dbc08c78538e01fdc9fcb", size = 135093, upload-time = "2026-02-24T03:56:59.267Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f2/30250cfcb4d2766669b31f6732689aab2bb91de426a15a3ebe482df7ee48/ijson-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:739a7229b1b0cc5f7e2785a6e7a5fc915e850d3fed9588d0e89a09f88a417253", size = 138715, upload-time = "2026-02-24T03:57:00.491Z" }, + { url = "https://files.pythonhosted.org/packages/a2/05/785a145d7e75e04e04480d59b6323cd4b1d9013a6cd8643fa635fbc93490/ijson-3.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ef88712160360cab3ca6471a4e5418243f8b267cf1fe1620879d1b5558babc71", size = 133194, upload-time = "2026-02-24T03:57:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/14/eb/80d6f8a748dead4034cea0939494a67d10ccf88d6413bf6e860393139676/ijson-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ca0d1b6b5f8166a6248f4309497585fb8553b04bc8179a0260fad636cfdb798", size = 135588, upload-time = "2026-02-24T03:57:03.131Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a8/bbc21f9400ebdbca48fab272593e0d1f875691be1e927d264d90d48b8c47/ijson-3.5.0-cp311-cp311-win32.whl", hash = "sha256:966039cf9047c7967febf7b9a52ec6f38f5464a4c7fbb5565e0224b7376fefff", size = 52721, upload-time = "2026-02-24T03:57:04.365Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2e/4e8c0208b8f920ee80c88c956f93e78318f2cfb646455353b182738b490c/ijson-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:6bad6a1634cb7c9f3f4c7e52325283b35b565f5b6cc27d42660c6912ce883422", size = 55121, upload-time = "2026-02-24T03:57:05.498Z" }, + { url = "https://files.pythonhosted.org/packages/aa/17/9c63c7688025f3a8c47ea717b8306649c8c7244e49e20a2be4e3515dc75c/ijson-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1ebefbe149a6106cc848a3eaf536af51a9b5ccc9082de801389f152dba6ab755", size = 88536, upload-time = "2026-02-24T03:57:06.809Z" }, + { url = "https://files.pythonhosted.org/packages/6f/dd/e15c2400244c117b06585452ebc63ae254f5a6964f712306afd1422daae0/ijson-3.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e30d9f00f82e64de689c0b8651b9cfed879c184b139d7e1ea5030cec401c21", size = 60499, upload-time = "2026-02-24T03:57:09.155Z" }, + { url = "https://files.pythonhosted.org/packages/77/a9/bf4fe3538a0c965f16b406f180a06105b875da83f0743e36246be64ef550/ijson-3.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a04a33ee78a6f27b9b8528c1ca3c207b1df3b8b867a4cf2fcc4109986f35c227", size = 60330, upload-time = "2026-02-24T03:57:10.574Z" }, + { url = "https://files.pythonhosted.org/packages/31/76/6f91bdb019dd978fce1bc5ea1cd620cfc096d258126c91db2c03a20a7f34/ijson-3.5.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d48dc2984af02eb3c56edfb3f13b3f62f2f3e4fe36f058c8cfc75d93adf4fed", size = 138977, upload-time = "2026-02-24T03:57:11.932Z" }, + { url = "https://files.pythonhosted.org/packages/11/be/bbc983059e48a54b0121ee60042979faed7674490bbe7b2c41560db3f436/ijson-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1e73a44844d9adbca9cf2c4132cd875933e83f3d4b23881fcaf82be83644c7d", size = 149785, upload-time = "2026-02-24T03:57:13.255Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/2fee58f9024a3449aee83edfa7167fb5ccd7e1af2557300e28531bb68e16/ijson-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7389a56b8562a19948bdf1d7bae3a2edc8c7f86fb59834dcb1c4c722818e645a", size = 149729, upload-time = "2026-02-24T03:57:14.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/56/f1706761fcc096c9d414b3dcd000b1e6e5c24364c21cfba429837f98ee8d/ijson-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3176f23f8ebec83f374ed0c3b4e5a0c4db7ede54c005864efebbed46da123608", size = 150697, upload-time = "2026-02-24T03:57:15.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6e/ee0d9c875a0193b632b3e9ccd1b22a50685fb510256ad57ba483b6529f77/ijson-3.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6babd88e508630c6ef86c9bebaaf13bb2fb8ec1d8f8868773a03c20253f599bc", size = 142873, upload-time = "2026-02-24T03:57:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bf/f9d4399d0e6e3fd615035290a71e97c843f17f329b43638c0a01cf112d73/ijson-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dc1b3836b174b6db2fa8319f1926fb5445abd195dc963368092103f8579cb8ed", size = 151583, upload-time = "2026-02-24T03:57:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/a7254a065933c0e2ffd3586f46187d84830d3d7b6f41cfa5901820a4f87d/ijson-3.5.0-cp312-cp312-win32.whl", hash = "sha256:6673de9395fb9893c1c79a43becd8c8fbee0a250be6ea324bfd1487bb5e9ee4c", size = 53079, upload-time = "2026-02-24T03:57:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7b/2edca79b359fc9f95d774616867a03ecccdf333797baf5b3eea79733918c/ijson-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f4f7fabd653459dcb004175235f310435959b1bb5dfa8878578391c6cc9ad944", size = 55500, upload-time = "2026-02-24T03:57:20.428Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/d67e764a712c3590627480643a3b51efcc3afa4ef3cb54ee4c989073c97e/ijson-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e9cedc10e40dd6023c351ed8bfc7dcfce58204f15c321c3c1546b9c7b12562a4", size = 88544, upload-time = "2026-02-24T03:57:21.293Z" }, + { url = "https://files.pythonhosted.org/packages/1a/39/f1c299371686153fa3cf5c0736b96247a87a1bee1b7145e6d21f359c505a/ijson-3.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3647649f782ee06c97490b43680371186651f3f69bebe64c6083ee7615d185e5", size = 60495, upload-time = "2026-02-24T03:57:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/16/94/b1438e204d75e01541bebe3e668fe3e68612d210e9931ae1611062dd0a56/ijson-3.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90e74be1dce05fce73451c62d1118671f78f47c9f6be3991c82b91063bf01fc9", size = 60325, upload-time = "2026-02-24T03:57:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/30/e2/4aa9c116fa86cc8b0f574f3c3a47409edc1cd4face05d0e589a5a176b05d/ijson-3.5.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:78e9ad73e7be2dd80627504bd5cbf512348c55ce2c06e362ed7683b5220e8568", size = 138774, upload-time = "2026-02-24T03:57:24.683Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d2/738b88752a70c3be1505faa4dcd7110668c2712e582a6a36488ed1e295d4/ijson-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9577449313cc94be89a4fe4b3e716c65f09cc19636d5a6b2861c4e80dddebd58", size = 149820, upload-time = "2026-02-24T03:57:26.062Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/0b3ab9f393ca8f72ea03bc896ba9fdc987e90ae08cdb51c32a4ee0c14d5e/ijson-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e4c1178fb50aff5f5701a30a5152ead82a14e189ce0f6102fa1b5f10b2f54ff", size = 149747, upload-time = "2026-02-24T03:57:27.308Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a3/b0037119f75131b78cb00acc2657b1a9d0435475f1f2c5f8f5a170b66b9c/ijson-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0eb402ab026ffb37a918d75af2b7260fe6cfbce13232cc83728a714dd30bd81d", size = 151027, upload-time = "2026-02-24T03:57:28.522Z" }, + { url = "https://files.pythonhosted.org/packages/22/a0/cb344de1862bf09d8f769c9d25c944078c87dd59a1b496feec5ad96309a4/ijson-3.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5b08ee08355f9f729612a8eb9bf69cc14f9310c3b2a487c6f1c3c65d85216ec4", size = 142996, upload-time = "2026-02-24T03:57:29.774Z" }, + { url = "https://files.pythonhosted.org/packages/ca/32/a8ffd67182e02ea61f70f62daf43ded4fa8a830a2520a851d2782460aba8/ijson-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bda62b6d48442903e7bf56152108afb7f0f1293c2b9bef2f2c369defea76ab18", size = 152068, upload-time = "2026-02-24T03:57:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/3578df8e75d446aab0ae92e27f641341f586b85e1988536adebc65300cb4/ijson-3.5.0-cp313-cp313-win32.whl", hash = "sha256:8d073d9b13574cfa11083cc7267c238b7a6ed563c2661e79192da4a25f09c82c", size = 53065, upload-time = "2026-02-24T03:57:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a2/f7cdaf5896710da3e69e982e44f015a83d168aa0f3a89b6f074b5426779d/ijson-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:2419f9e32e0968a876b04d8f26aeac042abd16f582810b576936bbc4c6015069", size = 55499, upload-time = "2026-02-24T03:57:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/13e2492d17e19a2084523e18716dc2809159f2287fd2700c735f311e76c4/ijson-3.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4d4b0cd676b8c842f7648c1a783448fac5cd3b98289abd83711b3e275e143524", size = 93019, upload-time = "2026-02-24T03:57:33.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/483fc97ece0c3f1cecabf48f6a7a36e89d19369eec462faaeaa34c788992/ijson-3.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:252dec3680a48bb82d475e36b4ae1b3a9d7eb690b951bb98a76c5fe519e30188", size = 62714, upload-time = "2026-02-24T03:57:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/4b/88/793fe020a0fe9d9eed4c285cf4a5cfdb0a935708b3bde0d72f35c794b513/ijson-3.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:aa1b5dca97d323931fde2501172337384c958914d81a9dac7f00f0d4bfc76bc7", size = 62460, upload-time = "2026-02-24T03:57:35.874Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/f1a2690aa8d4df1f4e262b385e65a933ffdc250b091531bac9a449c19e16/ijson-3.5.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7a5ec7fd86d606094bba6f6f8f87494897102fa4584ef653f3005c51a784c320", size = 199273, upload-time = "2026-02-24T03:57:37.07Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a2/f1346d5299e79b988ab472dc773d5381ec2d57c23cb2f1af3ede4a810e62/ijson-3.5.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:009f41443e1521847701c6d87fa3923c0b1961be3c7e7de90947c8cb92ea7c44", size = 216884, upload-time = "2026-02-24T03:57:38.346Z" }, + { url = "https://files.pythonhosted.org/packages/28/3c/8b637e869be87799e6c2c3c275a30a546f086b1aed77e2b7f11512168c5a/ijson-3.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4c3651d1f9fe2839a93fdf8fd1d5ca3a54975349894249f3b1b572bcc4bd577", size = 207306, upload-time = "2026-02-24T03:57:39.718Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/18b1c1df6951ca056782d7580ec40cea4ff9a27a0947d92640d1cc8c4ae3/ijson-3.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:945b7abcfcfeae2cde17d8d900870f03536494245dda7ad4f8d056faa303256c", size = 211364, upload-time = "2026-02-24T03:57:40.953Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/e795812e82851574a9dba8a53fde045378f531ef14110c6fb55dbd23b443/ijson-3.5.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0574b0a841ff97495c13e9d7260fbf3d85358b061f540c52a123db9dbbaa2ed6", size = 200608, upload-time = "2026-02-24T03:57:42.272Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/013c85b4749b57a4cb4c2670014d1b32b8db4ab1a7be92ea7aeb5d7fe7b5/ijson-3.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f969ffb2b89c5cdf686652d7fb66252bc72126fa54d416317411497276056a18", size = 205127, upload-time = "2026-02-24T03:57:43.286Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7c/faf643733e3ab677f180018f6a855c4ef70b7c46540987424c563c959e42/ijson-3.5.0-cp313-cp313t-win32.whl", hash = "sha256:59d3f9f46deed1332ad669518b8099920512a78bda64c1f021fcd2aff2b36693", size = 55282, upload-time = "2026-02-24T03:57:44.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/22/94ddb47c24b491377aca06cd8fc9202cad6ab50619842457d2beefde21ea/ijson-3.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c2839fa233746d8aad3b8cd2354e441613f5df66d721d59da4a09394bd1db2b", size = 58016, upload-time = "2026-02-24T03:57:45.237Z" }, + { url = "https://files.pythonhosted.org/packages/7a/93/0868efe753dc1df80cc405cf0c1f2527a6991643607c741bff8dcb899b3b/ijson-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:25a5a6b2045c90bb83061df27cfa43572afa43ba9408611d7bfe237c20a731a9", size = 89094, upload-time = "2026-02-24T03:57:46.115Z" }, + { url = "https://files.pythonhosted.org/packages/24/94/fd5a832a0df52ef5e4e740f14ac8640725d61034a1b0c561e8b5fb424706/ijson-3.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8976c54c0b864bc82b951bae06567566ac77ef63b90a773a69cd73aab47f4f4f", size = 60715, upload-time = "2026-02-24T03:57:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/1b9a90af5732491f9eec751ee211b86b11011e1158c555c06576d52c3919/ijson-3.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:859eb2038f7f1b0664df4241957694cc35e6295992d71c98659b22c69b3cbc10", size = 60638, upload-time = "2026-02-24T03:57:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/23/6f/2c551ea980fe56f68710a8d5389cfbd015fc45aaafd17c3c52c346db6aa1/ijson-3.5.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c911aa02991c7c0d3639b6619b93a93210ff1e7f58bf7225d613abea10adc78e", size = 140667, upload-time = "2026-02-24T03:57:49.314Z" }, + { url = "https://files.pythonhosted.org/packages/25/0e/27b887879ba6a5bc29766e3c5af4942638c952220fd63e1e442674f7883a/ijson-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:903cbdc350173605220edc19796fbea9b2203c8b3951fb7335abfa8ed37afda8", size = 149850, upload-time = "2026-02-24T03:57:50.329Z" }, + { url = "https://files.pythonhosted.org/packages/da/1e/23e10e1bc04bf31193b21e2960dce14b17dbd5d0c62204e8401c59d62c08/ijson-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4549d96ded5b8efa71639b2160235415f6bdb8c83367615e2dbabcb72755c33", size = 149206, upload-time = "2026-02-24T03:57:51.261Z" }, + { url = "https://files.pythonhosted.org/packages/8e/90/e552f6495063b235cf7fa2c592f6597c057077195e517b842a0374fd470c/ijson-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6b2dcf6349e6042d83f3f8c39ce84823cf7577eba25bac5aae5e39bbbbbe9c1c", size = 150438, upload-time = "2026-02-24T03:57:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/5c/18/45bf8f297c41b42a1c231d261141097babd953d2c28a07be57ae4c3a1a02/ijson-3.5.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e44af39e6f8a17e5627dcd89715d8279bf3474153ff99aae031a936e5c5572e5", size = 144369, upload-time = "2026-02-24T03:57:53.22Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/deb9772bb2c0cead7ad64f00c3598eec9072bdf511818e70e2c512eeabbe/ijson-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9260332304b7e7828db56d43f08fc970a3ab741bf84ff10189361ea1b60c395b", size = 151352, upload-time = "2026-02-24T03:57:54.375Z" }, + { url = "https://files.pythonhosted.org/packages/e4/51/67f4d80cd58ad7eab0cd1af5fe28b961886338956b2f88c0979e21914346/ijson-3.5.0-cp314-cp314-win32.whl", hash = "sha256:63bc8121bb422f6969ced270173a3fa692c29d4ae30c860a2309941abd81012a", size = 53610, upload-time = "2026-02-24T03:57:55.655Z" }, + { url = "https://files.pythonhosted.org/packages/70/d3/263672ea22983ba3940f1534316dbc9200952c1c2a2332d7a664e4eaa7ae/ijson-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:01b6dad72b7b7df225ef970d334556dfad46c696a2c6767fb5d9ed8889728bca", size = 56301, upload-time = "2026-02-24T03:57:56.584Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/86f7fac35e0835faa188085ae0579e813493d5261ce056484015ad533445/ijson-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2ea4b676ec98e374c1df400a47929859e4fa1239274339024df4716e802aa7e4", size = 93069, upload-time = "2026-02-24T03:57:57.849Z" }, + { url = "https://files.pythonhosted.org/packages/33/d2/e7366ed9c6e60228d35baf4404bac01a126e7775ea8ce57f560125ed190a/ijson-3.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:014586eec043e23c80be9a923c56c3a0920a0f1f7d17478ce7bc20ba443968ef", size = 62767, upload-time = "2026-02-24T03:57:58.758Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/3e703e8cc4b3ada79f13b28070b51d9550c578f76d1968657905857b2ddd/ijson-3.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5b8b886b0248652d437f66e7c5ac318bbdcb2c7137a7e5327a68ca00b286f5f", size = 62467, upload-time = "2026-02-24T03:58:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/21/42/0c91af32c1ee8a957fdac2e051b5780756d05fd34e4b60d94a08d51bac1d/ijson-3.5.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:498fd46ae2349297e43acf97cdc421e711dbd7198418677259393d2acdc62d78", size = 200447, upload-time = "2026-02-24T03:58:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/80/796ea0e391b7e2d45c5b1b451734bba03f81c2984cf955ea5eaa6c4920ad/ijson-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22a51b4f9b81f12793731cf226266d1de2112c3c04ba4a04117ad4e466897e05", size = 217820, upload-time = "2026-02-24T03:58:02.598Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/52b6613fdda4078c62eb5b4fe3efc724ddc55a4ad524c93de51830107aa3/ijson-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9636c710dc4ac4a281baa266a64f323b4cc165cec26836af702c44328b59a515", size = 208310, upload-time = "2026-02-24T03:58:04.759Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ad/8b3105a78774fd4a65e534a21d975ef3a77e189489fe3029ebcaeba5e243/ijson-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f7168a39e8211107666d71b25693fd1b2bac0b33735ef744114c403c6cac21e1", size = 211843, upload-time = "2026-02-24T03:58:05.836Z" }, + { url = "https://files.pythonhosted.org/packages/36/ab/a2739f6072d6e1160581bc3ed32da614c8cced023dcd519d9c5fa66e0425/ijson-3.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8696454245415bc617ab03b0dc3ae4c86987df5dc6a90bad378fe72c5409d89e", size = 200906, upload-time = "2026-02-24T03:58:07.788Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5e/e06c2de3c3d4a9cfb655c1ad08a68fb72838d271072cdd3196576ac4431a/ijson-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c21bfb61f71f191565885bf1bc29e0a186292d866b4880637b833848360bdc1b", size = 205495, upload-time = "2026-02-24T03:58:09.163Z" }, + { url = "https://files.pythonhosted.org/packages/7c/11/778201eb2e202ddd76b36b0fb29bf3d8e3c167389d8aa883c62524e49f47/ijson-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:a2619460d6795b70d0155e5bf016200ac8a63ab5397aa33588bb02b6c21759e6", size = 56280, upload-time = "2026-02-24T03:58:10.116Z" }, + { url = "https://files.pythonhosted.org/packages/23/28/96711503245339084c8086b892c47415895eba49782d6cc52d9f4ee50301/ijson-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4f24b78d4ef028d17eb57ad1b16c0aed4a17bdd9badbf232dc5d9305b7e13854", size = 58965, upload-time = "2026-02-24T03:58:11.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3b/d31ecfa63a218978617446159f3d77aab2417a5bd2885c425b176353ff78/ijson-3.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d64c624da0e9d692d6eb0ff63a79656b59d76bf80773a17c5b0f835e4e8ef627", size = 57715, upload-time = "2026-02-24T03:58:24.545Z" }, + { url = "https://files.pythonhosted.org/packages/30/51/b170e646d378e8cccf9637c05edb5419b00c2c4df64b0258c3af5355608e/ijson-3.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:876f7df73b7e0d6474f9caa729b9cdbfc8e76de9075a4887dfd689e29e85c4ca", size = 57205, upload-time = "2026-02-24T03:58:25.681Z" }, + { url = "https://files.pythonhosted.org/packages/ef/83/44dbd0231b0a8c6c14d27473d10c4e27dfbce7d5d9a833c79e3e6c33eb40/ijson-3.5.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e7dbff2c8d9027809b0cde663df44f3210da10ea377121d42896fb6ee405dd31", size = 71229, upload-time = "2026-02-24T03:58:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/cf84048b7c6cec888826e696a31f45bee7ebcac15e532b6be1fc4c2c9608/ijson-3.5.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4217a1edc278660679e1197c83a1a2a2d367792bfbb2a3279577f4b59b93730d", size = 71217, upload-time = "2026-02-24T03:58:28.021Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0a/e34c729a87ff67dc6540f6bcc896626158e691d433ab57db0086d73decd2/ijson-3.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04f0fc740311388ee745ba55a12292b722d6f52000b11acbb913982ba5fbdf87", size = 68618, upload-time = "2026-02-24T03:58:28.918Z" }, + { url = "https://files.pythonhosted.org/packages/c1/0f/e849d072f2e0afe49627de3995fc9dae54b4c804c70c0840f928d95c10e1/ijson-3.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fdeee6957f92e0c114f65c55cf8fe7eabb80cfacab64eea6864060913173f66d", size = 55369, upload-time = "2026-02-24T03:58:29.839Z" }, ] [[package]] name = "imagesize" -version = "1.4.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] -name = "iterators" -version = "0.2.0" +name = "isodate" +version = "0.7.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/c4/135b5bdb9f14f728fe1274361b336f77c5f1606af9a5622a765fe75f5fa0/iterators-0.2.0.tar.gz", hash = "sha256:e9927a1ea1ef081830fd1512f3916857c36bd4b37272819a6cd29d0f44431b97", size = 4284, upload-time = "2023-01-23T16:07:02.46Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/a1/9c29772ac9f3bdf9837c92ba5c1fc93f75da14c2e0c3fc41e10485f68feb/iterators-0.2.0-py3-none-any.whl", hash = "sha256:1d7ff03f576c9de0e01bac66209556c066d6b1fc45583a99cfc9f4645be7900e", size = 5022, upload-time = "2023-01-23T16:07:00.352Z" }, + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, ] [[package]] @@ -2284,93 +2697,117 @@ wheels = [ [[package]] name = "jiter" -version = "0.11.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/c0/a3bb4cc13aced219dd18191ea66e874266bd8aa7b96744e495e1c733aa2d/jiter-0.11.0.tar.gz", hash = "sha256:1d9637eaf8c1d6a63d6562f2a6e5ab3af946c66037eb1b894e8fad75422266e4", size = 167094, upload-time = "2025-09-15T09:20:38.212Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/21/7dd1235a19e26979be6098e87e4cced2e061752f3a40a17bbce6dea7fae1/jiter-0.11.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3893ce831e1c0094a83eeaf56c635a167d6fa8cc14393cc14298fd6fdc2a2449", size = 309875, upload-time = "2025-09-15T09:18:48.41Z" }, - { url = "https://files.pythonhosted.org/packages/71/f9/462b54708aa85b135733ccba70529dd68a18511bf367a87c5fd28676c841/jiter-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:25c625b9b61b5a8725267fdf867ef2e51b429687f6a4eef211f4612e95607179", size = 316505, upload-time = "2025-09-15T09:18:51.057Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/14e2eeaac6a47bff27d213834795472355fd39769272eb53cb7aa83d5aa8/jiter-0.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd4ca85fb6a62cf72e1c7f5e34ddef1b660ce4ed0886ec94a1ef9777d35eaa1f", size = 337613, upload-time = "2025-09-15T09:18:52.358Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ed/a5f1f8419c92b150a7c7fb5ccba1fb1e192887ad713d780e70874f0ce996/jiter-0.11.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:572208127034725e79c28437b82414028c3562335f2b4f451d98136d0fc5f9cd", size = 361438, upload-time = "2025-09-15T09:18:54.637Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f5/70682c023dfcdd463a53faf5d30205a7d99c51d70d3e303c932d0936e5a2/jiter-0.11.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:494ba627c7f550ad3dabb21862864b8f2216098dc18ff62f37b37796f2f7c325", size = 486180, upload-time = "2025-09-15T09:18:56.158Z" }, - { url = "https://files.pythonhosted.org/packages/7c/39/020d08cbab4eab48142ad88b837c41eb08a15c0767fdb7c0d3265128a44b/jiter-0.11.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8da18a99f58bca3ecc2d2bba99cac000a924e115b6c4f0a2b98f752b6fbf39a", size = 376681, upload-time = "2025-09-15T09:18:57.553Z" }, - { url = "https://files.pythonhosted.org/packages/52/10/b86733f6e594cf51dd142f37c602d8df87c554c5844958deaab0de30eb5d/jiter-0.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4ffd3b0fff3fabbb02cc09910c08144db6bb5697a98d227a074401e01ee63dd", size = 348685, upload-time = "2025-09-15T09:18:59.208Z" }, - { url = "https://files.pythonhosted.org/packages/fb/ee/8861665e83a9e703aa5f65fddddb6225428e163e6b0baa95a7f9a8fb9aae/jiter-0.11.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8fe6530aa738a4f7d4e4702aa8f9581425d04036a5f9e25af65ebe1f708f23be", size = 385573, upload-time = "2025-09-15T09:19:00.593Z" }, - { url = "https://files.pythonhosted.org/packages/25/74/05afec03600951f128293813b5a208c9ba1bf587c57a344c05a42a69e1b1/jiter-0.11.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e35d66681c133a03d7e974e7eedae89720fe8ca3bd09f01a4909b86a8adf31f5", size = 516669, upload-time = "2025-09-15T09:19:02.369Z" }, - { url = "https://files.pythonhosted.org/packages/93/d1/2e5bfe147cfbc2a5eef7f73eb75dc5c6669da4fa10fc7937181d93af9495/jiter-0.11.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c59459beca2fbc9718b6f1acb7bfb59ebc3eb4294fa4d40e9cb679dafdcc6c60", size = 508767, upload-time = "2025-09-15T09:19:04.011Z" }, - { url = "https://files.pythonhosted.org/packages/87/50/597f71307e10426b5c082fd05d38c615ddbdd08c3348d8502963307f0652/jiter-0.11.0-cp310-cp310-win32.whl", hash = "sha256:b7b0178417b0dcfc5f259edbc6db2b1f5896093ed9035ee7bab0f2be8854726d", size = 205476, upload-time = "2025-09-15T09:19:05.594Z" }, - { url = "https://files.pythonhosted.org/packages/c7/86/1e5214b3272e311754da26e63edec93a183811d4fc2e0118addec365df8b/jiter-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:11df2bf99fb4754abddd7f5d940a48e51f9d11624d6313ca4314145fcad347f0", size = 204708, upload-time = "2025-09-15T09:19:06.955Z" }, - { url = "https://files.pythonhosted.org/packages/38/55/a69fefeef09c2eaabae44b935a1aa81517e49639c0a0c25d861cb18cd7ac/jiter-0.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cb5d9db02979c3f49071fce51a48f4b4e4cf574175fb2b11c7a535fa4867b222", size = 309503, upload-time = "2025-09-15T09:19:08.191Z" }, - { url = "https://files.pythonhosted.org/packages/bd/d5/a6aba9e6551f32f9c127184f398208e4eddb96c59ac065c8a92056089d28/jiter-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1dc6a123f3471c4730db7ca8ba75f1bb3dcb6faeb8d46dd781083e7dee88b32d", size = 317688, upload-time = "2025-09-15T09:19:09.918Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f3/5e86f57c1883971cdc8535d0429c2787bf734840a231da30a3be12850562/jiter-0.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09858f8d230f031c7b8e557429102bf050eea29c77ad9c34c8fe253c5329acb7", size = 337418, upload-time = "2025-09-15T09:19:11.078Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/a71d8a24c2a70664970574a8e0b766663f5ef788f7fe1cc20ee0c016d488/jiter-0.11.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dbe2196c4a0ce760925a74ab4456bf644748ab0979762139626ad138f6dac72d", size = 361423, upload-time = "2025-09-15T09:19:13.286Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e5/b09076f4e7fd9471b91e16f9f3dc7330b161b738f3b39b2c37054a36e26a/jiter-0.11.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5beb56d22b63647bafd0b74979216fdee80c580c0c63410be8c11053860ffd09", size = 486367, upload-time = "2025-09-15T09:19:14.546Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f1/98cb3a36f5e62f80cd860f0179f948d9eab5a316d55d3e1bab98d9767af5/jiter-0.11.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97025d09ef549795d8dc720a824312cee3253c890ac73c621721ddfc75066789", size = 376335, upload-time = "2025-09-15T09:19:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d8/ec74886497ea393c29dbd7651ddecc1899e86404a6b1f84a3ddab0ab59fd/jiter-0.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d50880a6da65d8c23a2cf53c412847d9757e74cc9a3b95c5704a1d1a24667347", size = 348981, upload-time = "2025-09-15T09:19:17.568Z" }, - { url = "https://files.pythonhosted.org/packages/24/93/d22ad7fa3b86ade66c86153ceea73094fc2af8b20c59cb7fceab9fea4704/jiter-0.11.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:452d80a1c86c095a242007bd9fc5d21b8a8442307193378f891cb8727e469648", size = 385797, upload-time = "2025-09-15T09:19:19.121Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bd/e25ff4a4df226e9b885f7cb01ee4b9dc74e3000e612d6f723860d71a1f34/jiter-0.11.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e84e58198d4894668eec2da660ffff60e0f3e60afa790ecc50cb12b0e02ca1d4", size = 516597, upload-time = "2025-09-15T09:19:20.301Z" }, - { url = "https://files.pythonhosted.org/packages/be/fb/beda613db7d93ffa2fdd2683f90f2f5dce8daf4bc2d0d2829e7de35308c6/jiter-0.11.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:df64edcfc5dd5279a791eea52aa113d432c933119a025b0b5739f90d2e4e75f1", size = 508853, upload-time = "2025-09-15T09:19:22.075Z" }, - { url = "https://files.pythonhosted.org/packages/20/64/c5b0d93490634e41e38e2a15de5d54fdbd2c9f64a19abb0f95305b63373c/jiter-0.11.0-cp311-cp311-win32.whl", hash = "sha256:144fc21337d21b1d048f7f44bf70881e1586401d405ed3a98c95a114a9994982", size = 205140, upload-time = "2025-09-15T09:19:23.351Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e6/c347c0e6f5796e97d4356b7e5ff0ce336498b7f4ef848fae621a56f1ccf3/jiter-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:b0f32e644d241293b892b1a6dd8f0b9cc029bfd94c97376b2681c36548aabab7", size = 204311, upload-time = "2025-09-15T09:19:24.591Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b5/3009b112b8f673e568ef79af9863d8309a15f0a8cdcc06ed6092051f377e/jiter-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb7b377688cc3850bbe5c192a6bd493562a0bc50cbc8b047316428fbae00ada", size = 305510, upload-time = "2025-09-15T09:19:25.893Z" }, - { url = "https://files.pythonhosted.org/packages/fe/82/15514244e03b9e71e086bbe2a6de3e4616b48f07d5f834200c873956fb8c/jiter-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a1b7cbe3f25bd0d8abb468ba4302a5d45617ee61b2a7a638f63fee1dc086be99", size = 316521, upload-time = "2025-09-15T09:19:27.525Z" }, - { url = "https://files.pythonhosted.org/packages/92/94/7a2e905f40ad2d6d660e00b68d818f9e29fb87ffe82774f06191e93cbe4a/jiter-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0a7f0ec81d5b7588c5cade1eb1925b91436ae6726dc2df2348524aeabad5de6", size = 338214, upload-time = "2025-09-15T09:19:28.727Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9c/5791ed5bdc76f12110158d3316a7a3ec0b1413d018b41c5ed399549d3ad5/jiter-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07630bb46ea2a6b9c6ed986c6e17e35b26148cce2c535454b26ee3f0e8dcaba1", size = 361280, upload-time = "2025-09-15T09:19:30.013Z" }, - { url = "https://files.pythonhosted.org/packages/d4/7f/b7d82d77ff0d2cb06424141000176b53a9e6b16a1125525bb51ea4990c2e/jiter-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7764f27d28cd4a9cbc61704dfcd80c903ce3aad106a37902d3270cd6673d17f4", size = 487895, upload-time = "2025-09-15T09:19:31.424Z" }, - { url = "https://files.pythonhosted.org/packages/42/44/10a1475d46f1fc1fd5cc2e82c58e7bca0ce5852208e0fa5df2f949353321/jiter-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d4a6c4a737d486f77f842aeb22807edecb4a9417e6700c7b981e16d34ba7c72", size = 378421, upload-time = "2025-09-15T09:19:32.746Z" }, - { url = "https://files.pythonhosted.org/packages/9a/5f/0dc34563d8164d31d07bc09d141d3da08157a68dcd1f9b886fa4e917805b/jiter-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf408d2a0abd919b60de8c2e7bc5eeab72d4dafd18784152acc7c9adc3291591", size = 347932, upload-time = "2025-09-15T09:19:34.612Z" }, - { url = "https://files.pythonhosted.org/packages/f7/de/b68f32a4fcb7b4a682b37c73a0e5dae32180140cd1caf11aef6ad40ddbf2/jiter-0.11.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cdef53eda7d18e799625023e1e250dbc18fbc275153039b873ec74d7e8883e09", size = 386959, upload-time = "2025-09-15T09:19:35.994Z" }, - { url = "https://files.pythonhosted.org/packages/76/0a/c08c92e713b6e28972a846a81ce374883dac2f78ec6f39a0dad9f2339c3a/jiter-0.11.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:53933a38ef7b551dd9c7f1064f9d7bb235bb3168d0fa5f14f0798d1b7ea0d9c5", size = 517187, upload-time = "2025-09-15T09:19:37.426Z" }, - { url = "https://files.pythonhosted.org/packages/89/b5/4a283bec43b15aad54fcae18d951f06a2ec3f78db5708d3b59a48e9c3fbd/jiter-0.11.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:11840d2324c9ab5162fc1abba23bc922124fedcff0d7b7f85fffa291e2f69206", size = 509461, upload-time = "2025-09-15T09:19:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/34/a5/f8bad793010534ea73c985caaeef8cc22dfb1fedb15220ecdf15c623c07a/jiter-0.11.0-cp312-cp312-win32.whl", hash = "sha256:4f01a744d24a5f2bb4a11657a1b27b61dc038ae2e674621a74020406e08f749b", size = 206664, upload-time = "2025-09-15T09:19:40.096Z" }, - { url = "https://files.pythonhosted.org/packages/ed/42/5823ec2b1469395a160b4bf5f14326b4a098f3b6898fbd327366789fa5d3/jiter-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:29fff31190ab3a26de026da2f187814f4b9c6695361e20a9ac2123e4d4378a4c", size = 203520, upload-time = "2025-09-15T09:19:41.798Z" }, - { url = "https://files.pythonhosted.org/packages/97/c4/d530e514d0f4f29b2b68145e7b389cbc7cac7f9c8c23df43b04d3d10fa3e/jiter-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4441a91b80a80249f9a6452c14b2c24708f139f64de959943dfeaa6cb915e8eb", size = 305021, upload-time = "2025-09-15T09:19:43.523Z" }, - { url = "https://files.pythonhosted.org/packages/7a/77/796a19c567c5734cbfc736a6f987affc0d5f240af8e12063c0fb93990ffa/jiter-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff85fc6d2a431251ad82dbd1ea953affb5a60376b62e7d6809c5cd058bb39471", size = 314384, upload-time = "2025-09-15T09:19:44.849Z" }, - { url = "https://files.pythonhosted.org/packages/14/9c/824334de0b037b91b6f3fa9fe5a191c83977c7ec4abe17795d3cb6d174cf/jiter-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5e86126d64706fd28dfc46f910d496923c6f95b395138c02d0e252947f452bd", size = 337389, upload-time = "2025-09-15T09:19:46.094Z" }, - { url = "https://files.pythonhosted.org/packages/a2/95/ed4feab69e6cf9b2176ea29d4ef9d01a01db210a3a2c8a31a44ecdc68c38/jiter-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ad8bd82165961867a10f52010590ce0b7a8c53da5ddd8bbb62fef68c181b921", size = 360519, upload-time = "2025-09-15T09:19:47.494Z" }, - { url = "https://files.pythonhosted.org/packages/b5/0c/2ad00f38d3e583caba3909d95b7da1c3a7cd82c0aa81ff4317a8016fb581/jiter-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b42c2cd74273455ce439fd9528db0c6e84b5623cb74572305bdd9f2f2961d3df", size = 487198, upload-time = "2025-09-15T09:19:49.116Z" }, - { url = "https://files.pythonhosted.org/packages/ea/8b/919b64cf3499b79bdfba6036da7b0cac5d62d5c75a28fb45bad7819e22f0/jiter-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0062dab98172dd0599fcdbf90214d0dcde070b1ff38a00cc1b90e111f071982", size = 377835, upload-time = "2025-09-15T09:19:50.468Z" }, - { url = "https://files.pythonhosted.org/packages/29/7f/8ebe15b6e0a8026b0d286c083b553779b4dd63db35b43a3f171b544de91d/jiter-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb948402821bc76d1f6ef0f9e19b816f9b09f8577844ba7140f0b6afe994bc64", size = 347655, upload-time = "2025-09-15T09:19:51.726Z" }, - { url = "https://files.pythonhosted.org/packages/8e/64/332127cef7e94ac75719dda07b9a472af6158ba819088d87f17f3226a769/jiter-0.11.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25a5b1110cca7329fd0daf5060faa1234be5c11e988948e4f1a1923b6a457fe1", size = 386135, upload-time = "2025-09-15T09:19:53.075Z" }, - { url = "https://files.pythonhosted.org/packages/20/c8/557b63527442f84c14774159948262a9d4fabb0d61166f11568f22fc60d2/jiter-0.11.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:bf11807e802a214daf6c485037778843fadd3e2ec29377ae17e0706ec1a25758", size = 516063, upload-time = "2025-09-15T09:19:54.447Z" }, - { url = "https://files.pythonhosted.org/packages/86/13/4164c819df4a43cdc8047f9a42880f0ceef5afeb22e8b9675c0528ebdccd/jiter-0.11.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:dbb57da40631c267861dd0090461222060960012d70fd6e4c799b0f62d0ba166", size = 508139, upload-time = "2025-09-15T09:19:55.764Z" }, - { url = "https://files.pythonhosted.org/packages/fa/70/6e06929b401b331d41ddb4afb9f91cd1168218e3371972f0afa51c9f3c31/jiter-0.11.0-cp313-cp313-win32.whl", hash = "sha256:8e36924dad32c48d3c5e188d169e71dc6e84d6cb8dedefea089de5739d1d2f80", size = 206369, upload-time = "2025-09-15T09:19:57.048Z" }, - { url = "https://files.pythonhosted.org/packages/f4/0d/8185b8e15de6dce24f6afae63380e16377dd75686d56007baa4f29723ea1/jiter-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:452d13e4fd59698408087235259cebe67d9d49173b4dacb3e8d35ce4acf385d6", size = 202538, upload-time = "2025-09-15T09:19:58.35Z" }, - { url = "https://files.pythonhosted.org/packages/13/3a/d61707803260d59520721fa326babfae25e9573a88d8b7b9cb54c5423a59/jiter-0.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:089f9df9f69532d1339e83142438668f52c97cd22ee2d1195551c2b1a9e6cf33", size = 313737, upload-time = "2025-09-15T09:19:59.638Z" }, - { url = "https://files.pythonhosted.org/packages/cd/cc/c9f0eec5d00f2a1da89f6bdfac12b8afdf8d5ad974184863c75060026457/jiter-0.11.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29ed1fe69a8c69bf0f2a962d8d706c7b89b50f1332cd6b9fbda014f60bd03a03", size = 346183, upload-time = "2025-09-15T09:20:01.442Z" }, - { url = "https://files.pythonhosted.org/packages/a6/87/fc632776344e7aabbab05a95a0075476f418c5d29ab0f2eec672b7a1f0ac/jiter-0.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a4d71d7ea6ea8786291423fe209acf6f8d398a0759d03e7f24094acb8ab686ba", size = 204225, upload-time = "2025-09-15T09:20:03.102Z" }, - { url = "https://files.pythonhosted.org/packages/ee/3b/e7f45be7d3969bdf2e3cd4b816a7a1d272507cd0edd2d6dc4b07514f2d9a/jiter-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9a6dff27eca70930bdbe4cbb7c1a4ba8526e13b63dc808c0670083d2d51a4a72", size = 304414, upload-time = "2025-09-15T09:20:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/06/32/13e8e0d152631fcc1907ceb4943711471be70496d14888ec6e92034e2caf/jiter-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ae2a7593a62132c7d4c2abbee80bbbb94fdc6d157e2c6cc966250c564ef774", size = 314223, upload-time = "2025-09-15T09:20:05.631Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7e/abedd5b5a20ca083f778d96bba0d2366567fcecb0e6e34ff42640d5d7a18/jiter-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b13a431dba4b059e9e43019d3022346d009baf5066c24dcdea321a303cde9f0", size = 337306, upload-time = "2025-09-15T09:20:06.917Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e2/30d59bdc1204c86aa975ec72c48c482fee6633120ee9c3ab755e4dfefea8/jiter-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:af62e84ca3889604ebb645df3b0a3f3bcf6b92babbff642bd214616f57abb93a", size = 360565, upload-time = "2025-09-15T09:20:08.283Z" }, - { url = "https://files.pythonhosted.org/packages/fe/88/567288e0d2ed9fa8f7a3b425fdaf2cb82b998633c24fe0d98f5417321aa8/jiter-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6f3b32bb723246e6b351aecace52aba78adb8eeb4b2391630322dc30ff6c773", size = 486465, upload-time = "2025-09-15T09:20:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/18/6e/7b72d09273214cadd15970e91dd5ed9634bee605176107db21e1e4205eb1/jiter-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:adcab442f4a099a358a7f562eaa54ed6456fb866e922c6545a717be51dbed7d7", size = 377581, upload-time = "2025-09-15T09:20:10.884Z" }, - { url = "https://files.pythonhosted.org/packages/58/52/4db456319f9d14deed325f70102577492e9d7e87cf7097bda9769a1fcacb/jiter-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9967c2ab338ee2b2c0102fd379ec2693c496abf71ffd47e4d791d1f593b68e2", size = 347102, upload-time = "2025-09-15T09:20:12.175Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b4/433d5703c38b26083aec7a733eb5be96f9c6085d0e270a87ca6482cbf049/jiter-0.11.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e7d0bed3b187af8b47a981d9742ddfc1d9b252a7235471ad6078e7e4e5fe75c2", size = 386477, upload-time = "2025-09-15T09:20:13.428Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7a/a60bfd9c55b55b07c5c441c5085f06420b6d493ce9db28d069cc5b45d9f3/jiter-0.11.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:f6fe0283e903ebc55f1a6cc569b8c1f3bf4abd026fed85e3ff8598a9e6f982f0", size = 516004, upload-time = "2025-09-15T09:20:14.848Z" }, - { url = "https://files.pythonhosted.org/packages/2e/46/f8363e5ecc179b4ed0ca6cb0a6d3bfc266078578c71ff30642ea2ce2f203/jiter-0.11.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:4ee5821e3d66606b29ae5b497230b304f1376f38137d69e35f8d2bd5f310ff73", size = 507855, upload-time = "2025-09-15T09:20:16.176Z" }, - { url = "https://files.pythonhosted.org/packages/90/33/396083357d51d7ff0f9805852c288af47480d30dd31d8abc74909b020761/jiter-0.11.0-cp314-cp314-win32.whl", hash = "sha256:c2d13ba7567ca8799f17c76ed56b1d49be30df996eb7fa33e46b62800562a5e2", size = 205802, upload-time = "2025-09-15T09:20:17.661Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ab/eb06ca556b2551d41de7d03bf2ee24285fa3d0c58c5f8d95c64c9c3281b1/jiter-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fb4790497369d134a07fc763cc88888c46f734abdd66f9fdf7865038bf3a8f40", size = 313405, upload-time = "2025-09-15T09:20:18.918Z" }, - { url = "https://files.pythonhosted.org/packages/af/22/7ab7b4ec3a1c1f03aef376af11d23b05abcca3fb31fbca1e7557053b1ba2/jiter-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e2bbf24f16ba5ad4441a9845e40e4ea0cb9eed00e76ba94050664ef53ef4406", size = 347102, upload-time = "2025-09-15T09:20:20.16Z" }, - { url = "https://files.pythonhosted.org/packages/70/f3/ce100253c80063a7b8b406e1d1562657fd4b9b4e1b562db40e68645342fb/jiter-0.11.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:902b43386c04739229076bd1c4c69de5d115553d982ab442a8ae82947c72ede7", size = 336380, upload-time = "2025-09-15T09:20:36.867Z" }, + { url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" }, + { url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" }, + { url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" }, + { url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] [[package]] name = "jmespath" -version = "1.0.1" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] [[package]] name = "joblib" -version = "1.5.2" +version = "1.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/5d/447af5ea094b9e4c4054f82e223ada074c552335b9b4b2d14bd9b35a67c4/joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55", size = 331077, upload-time = "2025-08-27T12:15:46.575Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/e8/685f47e0d754320684db4425a0967f7d3fa70126bffd76110b7009a0090f/joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241", size = 308396, upload-time = "2025-08-27T12:15:45.188Z" }, + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] [[package]] @@ -2396,7 +2833,7 @@ wheels = [ [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -2404,9 +2841,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] @@ -2423,115 +2860,147 @@ wheels = [ [[package]] name = "kiwisolver" -version = "1.4.9" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/5d/8ce64e36d4e3aac5ca96996457dcf33e34e6051492399a3f1fec5657f30b/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b", size = 124159, upload-time = "2025-08-10T21:25:35.472Z" }, - { url = "https://files.pythonhosted.org/packages/96/1e/22f63ec454874378175a5f435d6ea1363dd33fb2af832c6643e4ccea0dc8/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f", size = 66578, upload-time = "2025-08-10T21:25:36.73Z" }, - { url = "https://files.pythonhosted.org/packages/41/4c/1925dcfff47a02d465121967b95151c82d11027d5ec5242771e580e731bd/kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf", size = 65312, upload-time = "2025-08-10T21:25:37.658Z" }, - { url = "https://files.pythonhosted.org/packages/d4/42/0f333164e6307a0687d1eb9ad256215aae2f4bd5d28f4653d6cd319a3ba3/kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9", size = 1628458, upload-time = "2025-08-10T21:25:39.067Z" }, - { url = "https://files.pythonhosted.org/packages/86/b6/2dccb977d651943995a90bfe3495c2ab2ba5cd77093d9f2318a20c9a6f59/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415", size = 1225640, upload-time = "2025-08-10T21:25:40.489Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/362ebd3eec46c850ccf2bfe3e30f2fc4c008750011f38a850f088c56a1c6/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b", size = 1244074, upload-time = "2025-08-10T21:25:42.221Z" }, - { url = "https://files.pythonhosted.org/packages/6f/bb/f09a1e66dab8984773d13184a10a29fe67125337649d26bdef547024ed6b/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154", size = 1293036, upload-time = "2025-08-10T21:25:43.801Z" }, - { url = "https://files.pythonhosted.org/packages/ea/01/11ecf892f201cafda0f68fa59212edaea93e96c37884b747c181303fccd1/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48", size = 2175310, upload-time = "2025-08-10T21:25:45.045Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5f/bfe11d5b934f500cc004314819ea92427e6e5462706a498c1d4fc052e08f/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220", size = 2270943, upload-time = "2025-08-10T21:25:46.393Z" }, - { url = "https://files.pythonhosted.org/packages/3d/de/259f786bf71f1e03e73d87e2db1a9a3bcab64d7b4fd780167123161630ad/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586", size = 2440488, upload-time = "2025-08-10T21:25:48.074Z" }, - { url = "https://files.pythonhosted.org/packages/1b/76/c989c278faf037c4d3421ec07a5c452cd3e09545d6dae7f87c15f54e4edf/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634", size = 2246787, upload-time = "2025-08-10T21:25:49.442Z" }, - { url = "https://files.pythonhosted.org/packages/a2/55/c2898d84ca440852e560ca9f2a0d28e6e931ac0849b896d77231929900e7/kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611", size = 73730, upload-time = "2025-08-10T21:25:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/e8/09/486d6ac523dd33b80b368247f238125d027964cfacb45c654841e88fb2ae/kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536", size = 65036, upload-time = "2025-08-10T21:25:52.063Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, - { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, - { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, - { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, - { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, - { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, - { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, - { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, - { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, - { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, - { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, - { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, - { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, - { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, - { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, - { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, - { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, - { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, - { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, - { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, - { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, - { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, - { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, - { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, - { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, - { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, - { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, - { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, - { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, - { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, - { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, - { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, - { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, - { url = "https://files.pythonhosted.org/packages/a2/63/fde392691690f55b38d5dd7b3710f5353bf7a8e52de93a22968801ab8978/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527", size = 60183, upload-time = "2025-08-10T21:27:37.669Z" }, - { url = "https://files.pythonhosted.org/packages/27/b1/6aad34edfdb7cced27f371866f211332bba215bfd918ad3322a58f480d8b/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771", size = 58675, upload-time = "2025-08-10T21:27:39.031Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1a/23d855a702bb35a76faed5ae2ba3de57d323f48b1f6b17ee2176c4849463/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e", size = 80277, upload-time = "2025-08-10T21:27:40.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5b/5239e3c2b8fb5afa1e8508f721bb77325f740ab6994d963e61b2b7abcc1e/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9", size = 77994, upload-time = "2025-08-10T21:27:41.181Z" }, - { url = "https://files.pythonhosted.org/packages/f9/1c/5d4d468fb16f8410e596ed0eac02d2c68752aa7dc92997fe9d60a7147665/kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb", size = 73744, upload-time = "2025-08-10T21:27:42.254Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, - { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "kokoro-onnx" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "espeakng-loader" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "onnxruntime" }, + { name = "phonemizer-fork" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/18/277bd18aeeceaf5c46a51bb50c1cbdccdad8cab7fd1d58f0173bbeeec708/kokoro_onnx-0.5.0.tar.gz", hash = "sha256:5beb15f085e2828ed8d493f792c079af857103ab2dceaa1e112b1760587ac96a", size = 84570, upload-time = "2026-01-30T03:05:45.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/55/0bfcb4aa50033c89e5ac132af3d07fac0543824ce6eaefd4d1bfdcc3795b/kokoro_onnx-0.5.0-py3-none-any.whl", hash = "sha256:4e1c38a296db5dbc1f722f69f5e3c13f2e2877b3e5b145287f56ec057013e357", size = 17448, upload-time = "2026-01-30T03:05:46.931Z" }, ] [[package]] name = "langchain" -version = "0.3.27" +version = "0.3.28" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11'" }, @@ -2543,14 +3012,14 @@ dependencies = [ { name = "requests" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/f6/f4f7f3a56626fe07e2bb330feb61254dbdf06c506e6b59a536a337da51cf/langchain-0.3.27.tar.gz", hash = "sha256:aa6f1e6274ff055d0fd36254176770f356ed0a8994297d1df47df341953cec62", size = 10233809, upload-time = "2025-07-24T14:42:32.959Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/bb/a65e29c8e4aaf0348c2617962e427c8e760d82a67adbd197019e49c7769d/langchain-0.3.28.tar.gz", hash = "sha256:30a32f44cc6690bcc6a6fb7c14d61a15406d5eda1a0e7eab60b3660944888741", size = 10242473, upload-time = "2026-03-06T22:45:17.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/d5/4861816a95b2f6993f1360cfb605aacb015506ee2090433a71de9cca8477/langchain-0.3.27-py3-none-any.whl", hash = "sha256:7b20c4f338826acb148d885b20a73a16e410ede9ee4f19bb02011852d5f98798", size = 1018194, upload-time = "2025-07-24T14:42:30.23Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f5/ecd71e5b78e67944b2600a155ef63000bc00148e6794e8e7809b2453887a/langchain-0.3.28-py3-none-any.whl", hash = "sha256:1ba1244477b67b812b775f346209fa596e78bf055a34e45ce22acb7a45842a32", size = 1024717, upload-time = "2026-03-06T22:45:15.545Z" }, ] [[package]] name = "langchain-community" -version = "0.3.30" +version = "0.3.31" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -2559,21 +3028,22 @@ dependencies = [ { name = "langchain" }, { name = "langchain-core" }, { name = "langsmith" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pydantic-settings" }, { name = "pyyaml" }, { name = "requests" }, { name = "sqlalchemy" }, { name = "tenacity" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/32/852facdba14140bbfc9b02e6dcb00fe2e0c5f50901d512a473351cf013e2/langchain_community-0.3.30.tar.gz", hash = "sha256:df68fbde7f7fa5142ab93b0cbc104916b12ab4163e200edd933ee93e67956ee9", size = 33240417, upload-time = "2025-09-26T05:52:49.588Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/49/2ff5354273809e9811392bc24bcffda545a196070666aef27bc6aacf1c21/langchain_community-0.3.31.tar.gz", hash = "sha256:250e4c1041539130f6d6ac6f9386cb018354eafccd917b01a4cff1950b80fd81", size = 33241237, upload-time = "2025-10-07T20:17:57.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/1b/3c7930361567825a473da10deacf261e029258eb450c9fa8cb98368548ce/langchain_community-0.3.30-py3-none-any.whl", hash = "sha256:a49dcedbf8f320d9868d5944d0991c7bcc9f2182a602e5d5e872d315183c11c3", size = 2532469, upload-time = "2025-09-26T05:52:47.037Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0a/b8848db67ad7c8d4652cb6f4cb78d49b5b5e6e8e51d695d62025aa3f7dbc/langchain_community-0.3.31-py3-none-any.whl", hash = "sha256:1c727e3ebbacd4d891b07bd440647668001cea3e39cbe732499ad655ec5cb569", size = 2532920, upload-time = "2025-10-07T20:17:54.91Z" }, ] [[package]] name = "langchain-core" -version = "0.3.79" +version = "0.3.83" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -2583,10 +3053,11 @@ dependencies = [ { name = "pyyaml" }, { name = "tenacity" }, { name = "typing-extensions" }, + { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/99/f926495f467e0f43289f12e951655d267d1eddc1136c3cf4dd907794a9a7/langchain_core-0.3.79.tar.gz", hash = "sha256:024ba54a346dd9b13fb8b2342e0c83d0111e7f26fa01f545ada23ad772b55a60", size = 580895, upload-time = "2025-10-09T21:59:08.359Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/a4/24f2d787bfcf56e5990924cacefe6f6e7971a3629f97c8162fc7a2a3d851/langchain_core-0.3.83.tar.gz", hash = "sha256:a0a4c7b6ea1c446d3b432116f405dc2afa1fe7891c44140d3d5acca221909415", size = 597965, upload-time = "2026-01-13T01:19:23.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/71/46b0efaf3fc6ad2c2bd600aef500f1cb2b7038a4042f58905805630dd29d/langchain_core-0.3.79-py3-none-any.whl", hash = "sha256:92045bfda3e741f8018e1356f83be203ec601561c6a7becfefe85be5ddc58fdb", size = 449779, upload-time = "2025-10-09T21:59:06.493Z" }, + { url = "https://files.pythonhosted.org/packages/5a/db/d71b80d3bd6193812485acea4001cdf86cf95a44bbf942f7a240120ff762/langchain_core-0.3.83-py3-none-any.whl", hash = "sha256:8c92506f8b53fc1958b1c07447f58c5783eb8833dd3cb6dc75607c80891ab1ae", size = 458890, upload-time = "2026-01-13T01:19:21.748Z" }, ] [[package]] @@ -2617,7 +3088,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.4.31" +version = "0.7.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2626,35 +3097,47 @@ dependencies = [ { name = "pydantic" }, { name = "requests" }, { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/f5/edbdf89a162ee025348b3b2080fb3b88f4a1040a5a186f32d34aca913994/langsmith-0.4.31.tar.gz", hash = "sha256:5fb3729e22bd9a225391936cb9d1080322e6c375bb776514af06b56d6c46ed3e", size = 959698, upload-time = "2025-09-25T04:18:19.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/a8/31e9e7b42089cf194a0d873cbfc1ecec6d6dd0cc693808f1cb64494cbd0c/langsmith-0.7.18.tar.gz", hash = "sha256:d7e6e1f9c9300ee83b9f201c9254b4a32799218de102a5b1d2b217e00be2dfa2", size = 1134635, upload-time = "2026-03-16T18:54:19.131Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/8e/e7a43d907a147e1f87eebdd6737483f9feba52a5d4b20f69d0bd6f2fa22f/langsmith-0.4.31-py3-none-any.whl", hash = "sha256:64f340bdead21defe5f4a6ca330c11073e35444989169f669508edf45a19025f", size = 386347, upload-time = "2025-09-25T04:18:16.69Z" }, + { url = "https://files.pythonhosted.org/packages/29/58/244a14e29c7feccf06ed3929c9ab65a747a9ee94d5ac43d40862053b2f54/langsmith-0.7.18-py3-none-any.whl", hash = "sha256:3253c171fe2f6506056a42f9077983a34749b7a1629e41d8fb8e2005d8960886", size = 359268, upload-time = "2026-03-16T18:54:17.397Z" }, +] + +[[package]] +name = "language-tags" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/7e/b6a0efe4fee11e9742c1baaedf7c574084238a70b03c1d8eb2761383848f/language_tags-1.2.0.tar.gz", hash = "sha256:e934acba3e3dc85f867703eca421847a9ab7b7679b11b5d5cfd096febbf8bde6", size = 207901, upload-time = "2023-01-11T18:38:07.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/42/327554649ed2dd5ce59d3f5da176c7be20f9352c7c6c51597293660b7b08/language_tags-1.2.0-py3-none-any.whl", hash = "sha256:d815604622242fdfbbfd747b40c31213617fd03734a267f2e39ee4bd73c88722", size = 213449, upload-time = "2023-01-11T18:38:05.692Z" }, ] [[package]] name = "livekit" -version = "1.0.13" +version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "protobuf" }, { name = "types-protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/b7/5853f35ac3e71a5521d2ab3d07c8f4b842a93fdadb32e53f17d3551dda53/livekit-1.0.13.tar.gz", hash = "sha256:eb50b59b7320b1e960ea8f71b8e52fb832fb867e42806845659918dbe13e6a10", size = 311194, upload-time = "2025-09-12T17:29:07.772Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/9c/0a9b9e1f88226df372b9ccc58868ce9a1eb97c8bf5257463a643dda2a6da/livekit-1.1.2.tar.gz", hash = "sha256:ef826e94dd039767fcabc2f0d810b1b2335c9cf249f52320b6ab018b06d5ccd7", size = 320006, upload-time = "2026-02-17T01:18:46.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/2b/815638da21eca01a4e364e17a977943f9a4dfd88b1cac1fc40f1bc1b97b9/livekit-1.0.13-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:7174723d75544e6942e1c1a99fb297bfee538d0f7b9bd3f3cdebf06e42a72abc", size = 10826141, upload-time = "2025-09-12T17:28:56.875Z" }, - { url = "https://files.pythonhosted.org/packages/ff/00/309d84b560dddc178f82e48d02ba046fb76d0bfabfe9368305094a987efe/livekit-1.0.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ef1f641bc622c0b15adf0e91dfc62740d20db51d09369d3a7f84e8314b0ce067", size = 9532473, upload-time = "2025-09-12T17:28:59.406Z" }, - { url = "https://files.pythonhosted.org/packages/2d/32/0aa6a226325004068c1623c8d312b2afdb2bf91e01cebcd13505591bd06d/livekit-1.0.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d40a8b9d5cc931736e82bb723e1ae27436e0b2d20b0217627341030400784dc2", size = 10614983, upload-time = "2025-09-12T17:29:01.533Z" }, - { url = "https://files.pythonhosted.org/packages/be/ff/491b550eba5c2ca4039b2ed61b10d018a258464247bf2c31d2e45aa0b006/livekit-1.0.13-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:d73bb327a1a711b09e0b39d574fb04af9b2f38381c6267330df8a713e44e1be3", size = 12154433, upload-time = "2025-09-12T17:29:03.719Z" }, - { url = "https://files.pythonhosted.org/packages/45/cc/ed1c73ee9453e38038268200029b26940c95cd9f518d04b49dcf52a32f70/livekit-1.0.13-py3-none-win_amd64.whl", hash = "sha256:bbb2d17203d74991aac23a5d0519e33984f8b0c0d53b2182c837086742d1b813", size = 11437427, upload-time = "2025-09-12T17:29:05.702Z" }, + { url = "https://files.pythonhosted.org/packages/07/7b/1b2d448d8976b14794bfba3d003e1451c79afa77e6b9fa1593f19175ef90/livekit-1.1.2-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:78be23f3f6315354aaacee664eb19b793009bc06faa8184ad9c07cffbe8d7f74", size = 9844157, upload-time = "2026-02-17T01:18:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fb/19f7fc4a7df3b1385ba2e32b907c5a502fe01c10e6ee2fb76629c068667d/livekit-1.1.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:656f4d9e4692f3d7ab2e51b0bbf4ec03b356a487b7ff220576dab496e60f99f3", size = 8651703, upload-time = "2026-02-17T01:18:36.416Z" }, + { url = "https://files.pythonhosted.org/packages/29/59/ac6a3987bfd11687f86156627a8c7f6a0047a72877748facbd427e63b157/livekit-1.1.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a20e681d8a4929e27df69f818888ae649700c9d52a262abbec296c84937bc337", size = 14085891, upload-time = "2026-02-17T01:18:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a5/680548ef7bf5034144ae01f1f742a6c49e4428942b3119ac6553207fea9b/livekit-1.1.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:0e4d8105a0c317b513a118b48340b5773239103cb6305768451ef99ac7567823", size = 11448902, upload-time = "2026-02-17T01:18:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/00/05/e484f8fc079c5de7690e58ed48f44e77436b8732c3050da742bdfca51a5c/livekit-1.1.2-py3-none-win_amd64.whl", hash = "sha256:dd4a436fa16de589353bfbabde91068ab64241afd05b04f21fb1f22bfe155dc0", size = 10394562, upload-time = "2026-02-17T01:18:44.589Z" }, ] [[package]] name = "livekit-api" -version = "1.0.6" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -2663,50 +3146,50 @@ dependencies = [ { name = "pyjwt" }, { name = "types-protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/4c/4245f4e5329c9e774318a298a248f3d3a4c6c4251b088d54294a5e0c5505/livekit_api-1.0.6.tar.gz", hash = "sha256:1a7d7e5b5f4b70a48f0d8899dd195186af0b6aa563cf52a002d58b6f33aa39b6", size = 15983, upload-time = "2025-10-01T17:18:52.759Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/0a/ad3cce124e608c056d6390244ec4dd18c8a4b5f055693a95831da2119af7/livekit_api-1.1.0.tar.gz", hash = "sha256:f94c000534d3a9b506e6aed2f35eb88db1b23bdea33bb322f0144c4e9f73934e", size = 16649, upload-time = "2025-12-02T19:37:11.452Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/a5/76195f4538491872d7e8a92910dc08f896790936528b33e96685d1b7c899/livekit_api-1.0.6-py3-none-any.whl", hash = "sha256:577e103881a260abe737ec5ce44f5ff59193618d7db77052f9c7e86903d36fe4", size = 18329, upload-time = "2025-10-01T17:18:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b9/8d8515e3e0e629ab07d399cf858b8fc7e0a02bbf6384a6592b285264b4b9/livekit_api-1.1.0-py3-none-any.whl", hash = "sha256:bfc1c2c65392eb3f580a2c28108269f0e79873f053578a677eee7bb1de8aa8fb", size = 19620, upload-time = "2025-12-02T19:37:10.075Z" }, ] [[package]] name = "livekit-protocol" -version = "1.0.7" +version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, { name = "types-protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/bd/36adf176d8dfdd861c8c6a677e430fa05c124020b7bdbe1b2b1ffcf57269/livekit_protocol-1.0.7.tar.gz", hash = "sha256:e3721f62893a10409f71895e4926edb794c0339e1a69ad0f622306c1f39ed486", size = 58293, upload-time = "2025-10-01T17:19:02.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/6c/f5f7cb226441b3a357c2ea5444899b133dd13a5875894c6a9cd52fc5aa74/livekit_protocol-1.1.2.tar.gz", hash = "sha256:4550bf78fb9d365f19ea9875e565d86a2fb798854c8bd2e9100d7f7640dd9072", size = 79620, upload-time = "2026-01-20T01:27:23.437Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/ab/dbc666d2be9e4235b361761ee19a809845a29de632f84dfe20242791f9ec/livekit_protocol-1.0.7-py3-none-any.whl", hash = "sha256:392b0b633c9b03512d36bb71e105574ef4f1c0ed1e65b694a7cbdf7cd0953c31", size = 68451, upload-time = "2025-10-01T17:19:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/40e81d1b126d79a00b4a8a472a4f7c655b0a7736bb4d08f936be550b3bd8/livekit_protocol-1.1.2-py3-none-any.whl", hash = "sha256:8a26d592a87f5f70fee23aa88e47490727158ee8799c82742585aa8f73b160c5", size = 98854, upload-time = "2026-01-20T01:27:22.139Z" }, ] [[package]] name = "llvmlite" -version = "0.44.0" +version = "0.46.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/6a/95a3d3610d5c75293d5dbbb2a76480d5d4eeba641557b69fe90af6c5b84e/llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4", size = 171880, upload-time = "2025-01-20T11:14:41.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/cd/08ae687ba099c7e3d21fe2ea536500563ef1943c5105bf6ab4ee3829f68e/llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb", size = 193456, upload-time = "2025-12-08T18:15:36.295Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/75/d4863ddfd8ab5f6e70f4504cf8cc37f4e986ec6910f4ef8502bb7d3c1c71/llvmlite-0.44.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:9fbadbfba8422123bab5535b293da1cf72f9f478a65645ecd73e781f962ca614", size = 28132306, upload-time = "2025-01-20T11:12:18.634Z" }, - { url = "https://files.pythonhosted.org/packages/37/d9/6e8943e1515d2f1003e8278819ec03e4e653e2eeb71e4d00de6cfe59424e/llvmlite-0.44.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cccf8eb28f24840f2689fb1a45f9c0f7e582dd24e088dcf96e424834af11f791", size = 26201096, upload-time = "2025-01-20T11:12:24.544Z" }, - { url = "https://files.pythonhosted.org/packages/aa/46/8ffbc114def88cc698906bf5acab54ca9fdf9214fe04aed0e71731fb3688/llvmlite-0.44.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7202b678cdf904823c764ee0fe2dfe38a76981f4c1e51715b4cb5abb6cf1d9e8", size = 42361859, upload-time = "2025-01-20T11:12:31.839Z" }, - { url = "https://files.pythonhosted.org/packages/30/1c/9366b29ab050a726af13ebaae8d0dff00c3c58562261c79c635ad4f5eb71/llvmlite-0.44.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40526fb5e313d7b96bda4cbb2c85cd5374e04d80732dd36a282d72a560bb6408", size = 41184199, upload-time = "2025-01-20T11:12:40.049Z" }, - { url = "https://files.pythonhosted.org/packages/69/07/35e7c594b021ecb1938540f5bce543ddd8713cff97f71d81f021221edc1b/llvmlite-0.44.0-cp310-cp310-win_amd64.whl", hash = "sha256:41e3839150db4330e1b2716c0be3b5c4672525b4c9005e17c7597f835f351ce2", size = 30332381, upload-time = "2025-01-20T11:12:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e2/86b245397052386595ad726f9742e5223d7aea999b18c518a50e96c3aca4/llvmlite-0.44.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:eed7d5f29136bda63b6d7804c279e2b72e08c952b7c5df61f45db408e0ee52f3", size = 28132305, upload-time = "2025-01-20T11:12:53.936Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ec/506902dc6870249fbe2466d9cf66d531265d0f3a1157213c8f986250c033/llvmlite-0.44.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ace564d9fa44bb91eb6e6d8e7754977783c68e90a471ea7ce913bff30bd62427", size = 26201090, upload-time = "2025-01-20T11:12:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/99/fe/d030f1849ebb1f394bb3f7adad5e729b634fb100515594aca25c354ffc62/llvmlite-0.44.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5d22c3bfc842668168a786af4205ec8e3ad29fb1bc03fd11fd48460d0df64c1", size = 42361858, upload-time = "2025-01-20T11:13:07.623Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7a/ce6174664b9077fc673d172e4c888cb0b128e707e306bc33fff8c2035f0d/llvmlite-0.44.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f01a394e9c9b7b1d4e63c327b096d10f6f0ed149ef53d38a09b3749dcf8c9610", size = 41184200, upload-time = "2025-01-20T11:13:20.058Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c6/258801143975a6d09a373f2641237992496e15567b907a4d401839d671b8/llvmlite-0.44.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8489634d43c20cd0ad71330dde1d5bc7b9966937a263ff1ec1cebb90dc50955", size = 30331193, upload-time = "2025-01-20T11:13:26.976Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/e3c3195b92e6e492458f16d233e58a1a812aa2bfbef9bdd0fbafcec85c60/llvmlite-0.44.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:1d671a56acf725bf1b531d5ef76b86660a5ab8ef19bb6a46064a705c6ca80aad", size = 28132297, upload-time = "2025-01-20T11:13:32.57Z" }, - { url = "https://files.pythonhosted.org/packages/d6/53/373b6b8be67b9221d12b24125fd0ec56b1078b660eeae266ec388a6ac9a0/llvmlite-0.44.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f79a728e0435493611c9f405168682bb75ffd1fbe6fc360733b850c80a026db", size = 26201105, upload-time = "2025-01-20T11:13:38.744Z" }, - { url = "https://files.pythonhosted.org/packages/cb/da/8341fd3056419441286c8e26bf436923021005ece0bff5f41906476ae514/llvmlite-0.44.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0143a5ef336da14deaa8ec26c5449ad5b6a2b564df82fcef4be040b9cacfea9", size = 42361901, upload-time = "2025-01-20T11:13:46.711Z" }, - { url = "https://files.pythonhosted.org/packages/53/ad/d79349dc07b8a395a99153d7ce8b01d6fcdc9f8231355a5df55ded649b61/llvmlite-0.44.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d752f89e31b66db6f8da06df8b39f9b91e78c5feea1bf9e8c1fba1d1c24c065d", size = 41184247, upload-time = "2025-01-20T11:13:56.159Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3b/a9a17366af80127bd09decbe2a54d8974b6d8b274b39bf47fbaedeec6307/llvmlite-0.44.0-cp312-cp312-win_amd64.whl", hash = "sha256:eae7e2d4ca8f88f89d315b48c6b741dcb925d6a1042da694aa16ab3dd4cbd3a1", size = 30332380, upload-time = "2025-01-20T11:14:02.442Z" }, - { url = "https://files.pythonhosted.org/packages/89/24/4c0ca705a717514c2092b18476e7a12c74d34d875e05e4d742618ebbf449/llvmlite-0.44.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:319bddd44e5f71ae2689859b7203080716448a3cd1128fb144fe5c055219d516", size = 28132306, upload-time = "2025-01-20T11:14:09.035Z" }, - { url = "https://files.pythonhosted.org/packages/01/cf/1dd5a60ba6aee7122ab9243fd614abcf22f36b0437cbbe1ccf1e3391461c/llvmlite-0.44.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c58867118bad04a0bb22a2e0068c693719658105e40009ffe95c7000fcde88e", size = 26201090, upload-time = "2025-01-20T11:14:15.401Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1b/656f5a357de7135a3777bd735cc7c9b8f23b4d37465505bd0eaf4be9befe/llvmlite-0.44.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46224058b13c96af1365290bdfebe9a6264ae62fb79b2b55693deed11657a8bf", size = 42361904, upload-time = "2025-01-20T11:14:22.949Z" }, - { url = "https://files.pythonhosted.org/packages/d8/e1/12c5f20cb9168fb3464a34310411d5ad86e4163c8ff2d14a2b57e5cc6bac/llvmlite-0.44.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0097052c32bf721a4efc03bd109d335dfa57d9bffb3d4c24cc680711b8b4fc", size = 41184245, upload-time = "2025-01-20T11:14:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/d0/81/e66fc86539293282fd9cb7c9417438e897f369e79ffb62e1ae5e5154d4dd/llvmlite-0.44.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fb7c4f2fb86cbae6dca3db9ab203eeea0e22d73b99bc2341cdf9de93612e930", size = 30331193, upload-time = "2025-01-20T11:14:38.578Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a4/3959e1c61c5ca9db7921e5fd115b344c29b9d57a5dadd87bef97963ca1a5/llvmlite-0.46.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4323177e936d61ae0f73e653e2e614284d97d14d5dd12579adc92b6c2b0597b0", size = 37232766, upload-time = "2025-12-08T18:14:34.765Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a5/a4d916f1015106e1da876028606a8e87fd5d5c840f98c87bc2d5153b6a2f/llvmlite-0.46.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a2d461cb89537b7c20feb04c46c32e12d5ad4f0896c9dfc0f60336219ff248e", size = 56275176, upload-time = "2025-12-08T18:14:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/79/7f/a7f2028805dac8c1a6fae7bda4e739b7ebbcd45b29e15bf6d21556fcd3d5/llvmlite-0.46.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1f6595a35b7b39c3518b85a28bf18f45e075264e4b2dce3f0c2a4f232b4a910", size = 55128629, upload-time = "2025-12-08T18:14:41.674Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bc/4689e1ba0c073c196b594471eb21be0aa51d9e64b911728aa13cd85ef0ae/llvmlite-0.46.0-cp310-cp310-win_amd64.whl", hash = "sha256:e7a34d4aa6f9a97ee006b504be6d2b8cb7f755b80ab2f344dda1ef992f828559", size = 38138651, upload-time = "2025-12-08T18:14:45.845Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a1/2ad4b2367915faeebe8447f0a057861f646dbf5fbbb3561db42c65659cf3/llvmlite-0.46.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82f3d39b16f19aa1a56d5fe625883a6ab600d5cc9ea8906cca70ce94cabba067", size = 37232766, upload-time = "2025-12-08T18:14:48.836Z" }, + { url = "https://files.pythonhosted.org/packages/12/b5/99cf8772fdd846c07da4fd70f07812a3c8fd17ea2409522c946bb0f2b277/llvmlite-0.46.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3df43900119803bbc52720e758c76f316a9a0f34612a886862dfe0a5591a17e", size = 56275175, upload-time = "2025-12-08T18:14:51.604Z" }, + { url = "https://files.pythonhosted.org/packages/38/f2/ed806f9c003563732da156139c45d970ee435bd0bfa5ed8de87ba972b452/llvmlite-0.46.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de183fefc8022d21b0aa37fc3e90410bc3524aed8617f0ff76732fc6c3af5361", size = 55128630, upload-time = "2025-12-08T18:14:55.107Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/8f5a37a65fc9b7b17408508145edd5f86263ad69c19d3574e818f533a0eb/llvmlite-0.46.0-cp311-cp311-win_amd64.whl", hash = "sha256:e8b10bc585c58bdffec9e0c309bb7d51be1f2f15e169a4b4d42f2389e431eb93", size = 38138652, upload-time = "2025-12-08T18:14:58.171Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f8/4db016a5e547d4e054ff2f3b99203d63a497465f81ab78ec8eb2ff7b2304/llvmlite-0.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b9588ad4c63b4f0175a3984b85494f0c927c6b001e3a246a3a7fb3920d9a137", size = 37232767, upload-time = "2025-12-08T18:15:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/4890a7c14b4fa54400945cb52ac3cd88545bbdb973c440f98ca41591cdc5/llvmlite-0.46.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3535bd2bb6a2d7ae4012681ac228e5132cdb75fefb1bcb24e33f2f3e0c865ed4", size = 56275176, upload-time = "2025-12-08T18:15:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/6a/07/3d31d39c1a1a08cd5337e78299fca77e6aebc07c059fbd0033e3edfab45c/llvmlite-0.46.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cbfd366e60ff87ea6cc62f50bc4cd800ebb13ed4c149466f50cf2163a473d1e", size = 55128630, upload-time = "2025-12-08T18:15:07.196Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6b/d139535d7590a1bba1ceb68751bef22fadaa5b815bbdf0e858e3875726b2/llvmlite-0.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:398b39db462c39563a97b912d4f2866cd37cba60537975a09679b28fbbc0fb38", size = 38138940, upload-time = "2025-12-08T18:15:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:30b60892d034bc560e0ec6654737aaa74e5ca327bd8114d82136aa071d611172", size = 37232767, upload-time = "2025-12-08T18:15:13.22Z" }, + { url = "https://files.pythonhosted.org/packages/0e/54/737755c0a91558364b9200702c3c9c15d70ed63f9b98a2c32f1c2aa1f3ba/llvmlite-0.46.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6cc19b051753368a9c9f31dc041299059ee91aceec81bd57b0e385e5d5bf1a54", size = 56275176, upload-time = "2025-12-08T18:15:16.339Z" }, + { url = "https://files.pythonhosted.org/packages/e6/91/14f32e1d70905c1c0aa4e6609ab5d705c3183116ca02ac6df2091868413a/llvmlite-0.46.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bca185892908f9ede48c0acd547fe4dc1bafefb8a4967d47db6cf664f9332d12", size = 55128629, upload-time = "2025-12-08T18:15:19.493Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a7/d526ae86708cea531935ae777b6dbcabe7db52718e6401e0fb9c5edea80e/llvmlite-0.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:67438fd30e12349ebb054d86a5a1a57fd5e87d264d2451bcfafbbbaa25b82a35", size = 38138941, upload-time = "2025-12-08T18:15:22.536Z" }, + { url = "https://files.pythonhosted.org/packages/95/ae/af0ffb724814cc2ea64445acad05f71cff5f799bb7efb22e47ee99340dbc/llvmlite-0.46.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:d252edfb9f4ac1fcf20652258e3f102b26b03eef738dc8a6ffdab7d7d341d547", size = 37232768, upload-time = "2025-12-08T18:15:25.055Z" }, + { url = "https://files.pythonhosted.org/packages/c9/19/5018e5352019be753b7b07f7759cdabb69ca5779fea2494be8839270df4c/llvmlite-0.46.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379fdd1c59badeff8982cb47e4694a6143bec3bb49aa10a466e095410522064d", size = 56275173, upload-time = "2025-12-08T18:15:28.109Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c9/d57877759d707e84c082163c543853245f91b70c804115a5010532890f18/llvmlite-0.46.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e8cbfff7f6db0fa2c771ad24154e2a7e457c2444d7673e6de06b8b698c3b269", size = 55128628, upload-time = "2025-12-08T18:15:31.098Z" }, + { url = "https://files.pythonhosted.org/packages/30/a8/e61a8c2b3cc7a597073d9cde1fcbb567e9d827f1db30c93cf80422eac70d/llvmlite-0.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:7821eda3ec1f18050f981819756631d60b6d7ab1a6cf806d9efefbe3f4082d61", size = 39153056, upload-time = "2025-12-08T18:15:33.938Z" }, ] [[package]] @@ -2724,11 +3207,11 @@ wheels = [ [[package]] name = "markdown" -version = "3.9" +version = "3.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] [[package]] @@ -2830,19 +3313,19 @@ wheels = [ [[package]] name = "marshmallow" -version = "3.26.1" +version = "3.26.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/5e/5e53d26b42ab75491cda89b871dab9e97c840bf12c63ec58a1919710cd06/marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6", size = 221825, upload-time = "2025-02-03T15:32:25.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload-time = "2025-02-03T15:32:22.295Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] [[package]] name = "matplotlib" -version = "3.10.6" +version = "3.10.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -2850,73 +3333,74 @@ dependencies = [ { name = "cycler" }, { name = "fonttools" }, { name = "kiwisolver" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, { name = "pillow" }, { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/59/c3e6453a9676ffba145309a73c462bb407f4400de7de3f2b41af70720a3c/matplotlib-3.10.6.tar.gz", hash = "sha256:ec01b645840dd1996df21ee37f208cd8ba57644779fa20464010638013d3203c", size = 34804264, upload-time = "2025-08-30T00:14:25.137Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/dc/ab89f7a5efd0cbaaebf2c3cf1881f4cba20c8925bb43f64511059df76895/matplotlib-3.10.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bc7316c306d97463a9866b89d5cc217824e799fa0de346c8f68f4f3d27c8693d", size = 8247159, upload-time = "2025-08-30T00:12:30.507Z" }, - { url = "https://files.pythonhosted.org/packages/30/a5/ddaee1a383ab28174093644fff7438eddb87bf8dbd58f7b85f5cdd6b2485/matplotlib-3.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d00932b0d160ef03f59f9c0e16d1e3ac89646f7785165ce6ad40c842db16cc2e", size = 8108011, upload-time = "2025-08-30T00:12:32.771Z" }, - { url = "https://files.pythonhosted.org/packages/75/5b/a53f69bb0522db352b1135bb57cd9fe00fd7252072409392d991d3a755d0/matplotlib-3.10.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fa4c43d6bfdbfec09c733bca8667de11bfa4970e8324c471f3a3632a0301c15", size = 8680518, upload-time = "2025-08-30T00:12:34.387Z" }, - { url = "https://files.pythonhosted.org/packages/5f/31/e059ddce95f68819b005a2d6820b2d6ed0307827a04598891f00649bed2d/matplotlib-3.10.6-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea117a9c1627acaa04dbf36265691921b999cbf515a015298e54e1a12c3af837", size = 9514997, upload-time = "2025-08-30T00:12:36.272Z" }, - { url = "https://files.pythonhosted.org/packages/66/d5/28b408a7c0f07b41577ee27e4454fe329e78ca21fe46ae7a27d279165fb5/matplotlib-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08fc803293b4e1694ee325896030de97f74c141ccff0be886bb5915269247676", size = 9566440, upload-time = "2025-08-30T00:12:41.675Z" }, - { url = "https://files.pythonhosted.org/packages/2d/99/8325b3386b479b1d182ab1a7fd588fd393ff00a99dc04b7cf7d06668cf0f/matplotlib-3.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:2adf92d9b7527fbfb8818e050260f0ebaa460f79d61546374ce73506c9421d09", size = 8108186, upload-time = "2025-08-30T00:12:43.621Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/5d3665aa44c49005aaacaa68ddea6fcb27345961cd538a98bb0177934ede/matplotlib-3.10.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:905b60d1cb0ee604ce65b297b61cf8be9f4e6cfecf95a3fe1c388b5266bc8f4f", size = 8257527, upload-time = "2025-08-30T00:12:45.31Z" }, - { url = "https://files.pythonhosted.org/packages/8c/af/30ddefe19ca67eebd70047dabf50f899eaff6f3c5e6a1a7edaecaf63f794/matplotlib-3.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bac38d816637343e53d7185d0c66677ff30ffb131044a81898b5792c956ba76", size = 8119583, upload-time = "2025-08-30T00:12:47.236Z" }, - { url = "https://files.pythonhosted.org/packages/d3/29/4a8650a3dcae97fa4f375d46efcb25920d67b512186f8a6788b896062a81/matplotlib-3.10.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:942a8de2b5bfff1de31d95722f702e2966b8a7e31f4e68f7cd963c7cd8861cf6", size = 8692682, upload-time = "2025-08-30T00:12:48.781Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d3/b793b9cb061cfd5d42ff0f69d1822f8d5dbc94e004618e48a97a8373179a/matplotlib-3.10.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3276c85370bc0dfca051ec65c5817d1e0f8f5ce1b7787528ec8ed2d524bbc2f", size = 9521065, upload-time = "2025-08-30T00:12:50.602Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c5/53de5629f223c1c66668d46ac2621961970d21916a4bc3862b174eb2a88f/matplotlib-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9df5851b219225731f564e4b9e7f2ac1e13c9e6481f941b5631a0f8e2d9387ce", size = 9576888, upload-time = "2025-08-30T00:12:52.92Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8e/0a18d6d7d2d0a2e66585032a760d13662e5250c784d53ad50434e9560991/matplotlib-3.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:abb5d9478625dd9c9eb51a06d39aae71eda749ae9b3138afb23eb38824026c7e", size = 8115158, upload-time = "2025-08-30T00:12:54.863Z" }, - { url = "https://files.pythonhosted.org/packages/07/b3/1a5107bb66c261e23b9338070702597a2d374e5aa7004b7adfc754fbed02/matplotlib-3.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:886f989ccfae63659183173bb3fced7fd65e9eb793c3cc21c273add368536951", size = 7992444, upload-time = "2025-08-30T00:12:57.067Z" }, - { url = "https://files.pythonhosted.org/packages/ea/1a/7042f7430055d567cc3257ac409fcf608599ab27459457f13772c2d9778b/matplotlib-3.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31ca662df6a80bd426f871105fdd69db7543e28e73a9f2afe80de7e531eb2347", size = 8272404, upload-time = "2025-08-30T00:12:59.112Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5d/1d5f33f5b43f4f9e69e6a5fe1fb9090936ae7bc8e2ff6158e7a76542633b/matplotlib-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1678bb61d897bb4ac4757b5ecfb02bfb3fddf7f808000fb81e09c510712fda75", size = 8128262, upload-time = "2025-08-30T00:13:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/67/c3/135fdbbbf84e0979712df58e5e22b4f257b3f5e52a3c4aacf1b8abec0d09/matplotlib-3.10.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:56cd2d20842f58c03d2d6e6c1f1cf5548ad6f66b91e1e48f814e4fb5abd1cb95", size = 8697008, upload-time = "2025-08-30T00:13:03.24Z" }, - { url = "https://files.pythonhosted.org/packages/9c/be/c443ea428fb2488a3ea7608714b1bd85a82738c45da21b447dc49e2f8e5d/matplotlib-3.10.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:662df55604a2f9a45435566d6e2660e41efe83cd94f4288dfbf1e6d1eae4b0bb", size = 9530166, upload-time = "2025-08-30T00:13:05.951Z" }, - { url = "https://files.pythonhosted.org/packages/a9/35/48441422b044d74034aea2a3e0d1a49023f12150ebc58f16600132b9bbaf/matplotlib-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:08f141d55148cd1fc870c3387d70ca4df16dee10e909b3b038782bd4bda6ea07", size = 9593105, upload-time = "2025-08-30T00:13:08.356Z" }, - { url = "https://files.pythonhosted.org/packages/45/c3/994ef20eb4154ab84cc08d033834555319e4af970165e6c8894050af0b3c/matplotlib-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:590f5925c2d650b5c9d813c5b3b5fc53f2929c3f8ef463e4ecfa7e052044fb2b", size = 8122784, upload-time = "2025-08-30T00:13:10.367Z" }, - { url = "https://files.pythonhosted.org/packages/57/b8/5c85d9ae0e40f04e71bedb053aada5d6bab1f9b5399a0937afb5d6b02d98/matplotlib-3.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:f44c8d264a71609c79a78d50349e724f5d5fc3684ead7c2a473665ee63d868aa", size = 7992823, upload-time = "2025-08-30T00:13:12.24Z" }, - { url = "https://files.pythonhosted.org/packages/a0/db/18380e788bb837e724358287b08e223b32bc8dccb3b0c12fa8ca20bc7f3b/matplotlib-3.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:819e409653c1106c8deaf62e6de6b8611449c2cd9939acb0d7d4e57a3d95cc7a", size = 8273231, upload-time = "2025-08-30T00:13:13.881Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0f/38dd49445b297e0d4f12a322c30779df0d43cb5873c7847df8a82e82ec67/matplotlib-3.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:59c8ac8382fefb9cb71308dde16a7c487432f5255d8f1fd32473523abecfecdf", size = 8128730, upload-time = "2025-08-30T00:13:15.556Z" }, - { url = "https://files.pythonhosted.org/packages/e5/b8/9eea6630198cb303d131d95d285a024b3b8645b1763a2916fddb44ca8760/matplotlib-3.10.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84e82d9e0fd70c70bc55739defbd8055c54300750cbacf4740c9673a24d6933a", size = 8698539, upload-time = "2025-08-30T00:13:17.297Z" }, - { url = "https://files.pythonhosted.org/packages/71/34/44c7b1f075e1ea398f88aeabcc2907c01b9cc99e2afd560c1d49845a1227/matplotlib-3.10.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25f7a3eb42d6c1c56e89eacd495661fc815ffc08d9da750bca766771c0fd9110", size = 9529702, upload-time = "2025-08-30T00:13:19.248Z" }, - { url = "https://files.pythonhosted.org/packages/b5/7f/e5c2dc9950c7facaf8b461858d1b92c09dd0cf174fe14e21953b3dda06f7/matplotlib-3.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9c862d91ec0b7842920a4cfdaaec29662195301914ea54c33e01f1a28d014b2", size = 9593742, upload-time = "2025-08-30T00:13:21.181Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1d/70c28528794f6410ee2856cd729fa1f1756498b8d3126443b0a94e1a8695/matplotlib-3.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:1b53bd6337eba483e2e7d29c5ab10eee644bc3a2491ec67cc55f7b44583ffb18", size = 8122753, upload-time = "2025-08-30T00:13:23.44Z" }, - { url = "https://files.pythonhosted.org/packages/e8/74/0e1670501fc7d02d981564caf7c4df42974464625935424ca9654040077c/matplotlib-3.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:cbd5eb50b7058b2892ce45c2f4e92557f395c9991f5c886d1bb74a1582e70fd6", size = 7992973, upload-time = "2025-08-30T00:13:26.632Z" }, - { url = "https://files.pythonhosted.org/packages/b1/4e/60780e631d73b6b02bd7239f89c451a72970e5e7ec34f621eda55cd9a445/matplotlib-3.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:acc86dd6e0e695c095001a7fccff158c49e45e0758fdf5dcdbb0103318b59c9f", size = 8316869, upload-time = "2025-08-30T00:13:28.262Z" }, - { url = "https://files.pythonhosted.org/packages/f8/15/baa662374a579413210fc2115d40c503b7360a08e9cc254aa0d97d34b0c1/matplotlib-3.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e228cd2ffb8f88b7d0b29e37f68ca9aaf83e33821f24a5ccc4f082dd8396bc27", size = 8178240, upload-time = "2025-08-30T00:13:30.007Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3f/3c38e78d2aafdb8829fcd0857d25aaf9e7dd2dfcf7ec742765b585774931/matplotlib-3.10.6-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:658bc91894adeab669cf4bb4a186d049948262987e80f0857216387d7435d833", size = 8711719, upload-time = "2025-08-30T00:13:31.72Z" }, - { url = "https://files.pythonhosted.org/packages/96/4b/2ec2bbf8cefaa53207cc56118d1fa8a0f9b80642713ea9390235d331ede4/matplotlib-3.10.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8913b7474f6dd83ac444c9459c91f7f0f2859e839f41d642691b104e0af056aa", size = 9541422, upload-time = "2025-08-30T00:13:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/83/7d/40255e89b3ef11c7871020563b2dd85f6cb1b4eff17c0f62b6eb14c8fa80/matplotlib-3.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:091cea22e059b89f6d7d1a18e2c33a7376c26eee60e401d92a4d6726c4e12706", size = 9594068, upload-time = "2025-08-30T00:13:35.833Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a9/0213748d69dc842537a113493e1c27daf9f96bd7cc316f933dc8ec4de985/matplotlib-3.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:491e25e02a23d7207629d942c666924a6b61e007a48177fdd231a0097b7f507e", size = 8200100, upload-time = "2025-08-30T00:13:37.668Z" }, - { url = "https://files.pythonhosted.org/packages/be/15/79f9988066ce40b8a6f1759a934ea0cde8dc4adc2262255ee1bc98de6ad0/matplotlib-3.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3d80d60d4e54cda462e2cd9a086d85cd9f20943ead92f575ce86885a43a565d5", size = 8042142, upload-time = "2025-08-30T00:13:39.426Z" }, - { url = "https://files.pythonhosted.org/packages/7c/58/e7b6d292beae6fb4283ca6fb7fa47d7c944a68062d6238c07b497dd35493/matplotlib-3.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:70aaf890ce1d0efd482df969b28a5b30ea0b891224bb315810a3940f67182899", size = 8273802, upload-time = "2025-08-30T00:13:41.006Z" }, - { url = "https://files.pythonhosted.org/packages/9f/f6/7882d05aba16a8cdd594fb9a03a9d3cca751dbb6816adf7b102945522ee9/matplotlib-3.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1565aae810ab79cb72e402b22facfa6501365e73ebab70a0fdfb98488d2c3c0c", size = 8131365, upload-time = "2025-08-30T00:13:42.664Z" }, - { url = "https://files.pythonhosted.org/packages/94/bf/ff32f6ed76e78514e98775a53715eca4804b12bdcf35902cdd1cf759d324/matplotlib-3.10.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3b23315a01981689aa4e1a179dbf6ef9fbd17143c3eea77548c2ecfb0499438", size = 9533961, upload-time = "2025-08-30T00:13:44.372Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/6bf88c2fc2da7708a2ff8d2eeb5d68943130f50e636d5d3dcf9d4252e971/matplotlib-3.10.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30fdd37edf41a4e6785f9b37969de57aea770696cb637d9946eb37470c94a453", size = 9804262, upload-time = "2025-08-30T00:13:46.614Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7a/e05e6d9446d2d577b459427ad060cd2de5742d0e435db3191fea4fcc7e8b/matplotlib-3.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bc31e693da1c08012c764b053e702c1855378e04102238e6a5ee6a7117c53a47", size = 9595508, upload-time = "2025-08-30T00:13:48.731Z" }, - { url = "https://files.pythonhosted.org/packages/39/fb/af09c463ced80b801629fd73b96f726c9f6124c3603aa2e480a061d6705b/matplotlib-3.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:05be9bdaa8b242bc6ff96330d18c52f1fc59c6fb3a4dd411d953d67e7e1baf98", size = 8252742, upload-time = "2025-08-30T00:13:50.539Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f9/b682f6db9396d9ab8f050c0a3bfbb5f14fb0f6518f08507c04cc02f8f229/matplotlib-3.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:f56a0d1ab05d34c628592435781d185cd99630bdfd76822cd686fb5a0aecd43a", size = 8124237, upload-time = "2025-08-30T00:13:54.3Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d2/b69b4a0923a3c05ab90527c60fdec899ee21ca23ede7f0fb818e6620d6f2/matplotlib-3.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:94f0b4cacb23763b64b5dace50d5b7bfe98710fed5f0cef5c08135a03399d98b", size = 8316956, upload-time = "2025-08-30T00:13:55.932Z" }, - { url = "https://files.pythonhosted.org/packages/28/e9/dc427b6f16457ffaeecb2fc4abf91e5adb8827861b869c7a7a6d1836fa73/matplotlib-3.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cc332891306b9fb39462673d8225d1b824c89783fee82840a709f96714f17a5c", size = 8178260, upload-time = "2025-08-30T00:14:00.942Z" }, - { url = "https://files.pythonhosted.org/packages/c4/89/1fbd5ad611802c34d1c7ad04607e64a1350b7fb9c567c4ec2c19e066ed35/matplotlib-3.10.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee1d607b3fb1590deb04b69f02ea1d53ed0b0bf75b2b1a5745f269afcbd3cdd3", size = 9541422, upload-time = "2025-08-30T00:14:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/65fec8716025b22c1d72d5a82ea079934c76a547696eaa55be6866bc89b1/matplotlib-3.10.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:376a624a218116461696b27b2bbf7a8945053e6d799f6502fc03226d077807bf", size = 9803678, upload-time = "2025-08-30T00:14:04.741Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b0/40fb2b3a1ab9381bb39a952e8390357c8be3bdadcf6d5055d9c31e1b35ae/matplotlib-3.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:83847b47f6524c34b4f2d3ce726bb0541c48c8e7692729865c3df75bfa0f495a", size = 9594077, upload-time = "2025-08-30T00:14:07.012Z" }, - { url = "https://files.pythonhosted.org/packages/76/34/c4b71b69edf5b06e635eee1ed10bfc73cf8df058b66e63e30e6a55e231d5/matplotlib-3.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c7e0518e0d223683532a07f4b512e2e0729b62674f1b3a1a69869f98e6b1c7e3", size = 8342822, upload-time = "2025-08-30T00:14:09.041Z" }, - { url = "https://files.pythonhosted.org/packages/e8/62/aeabeef1a842b6226a30d49dd13e8a7a1e81e9ec98212c0b5169f0a12d83/matplotlib-3.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:4dd83e029f5b4801eeb87c64efd80e732452781c16a9cf7415b7b63ec8f374d7", size = 8172588, upload-time = "2025-08-30T00:14:11.166Z" }, - { url = "https://files.pythonhosted.org/packages/17/6f/2551e45bea2938e0363ccdd54fa08dae7605ce782d4332497d31a7b97672/matplotlib-3.10.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:13fcd07ccf17e354398358e0307a1f53f5325dca22982556ddb9c52837b5af41", size = 8241220, upload-time = "2025-08-30T00:14:12.888Z" }, - { url = "https://files.pythonhosted.org/packages/54/7e/0f4c6e8b98105fdb162a4efde011af204ca47d7c05d735aff480ebfead1b/matplotlib-3.10.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:470fc846d59d1406e34fa4c32ba371039cd12c2fe86801159a965956f2575bd1", size = 8104624, upload-time = "2025-08-30T00:14:14.511Z" }, - { url = "https://files.pythonhosted.org/packages/27/27/c29696702b9317a6ade1ba6f8861e02d7423f18501729203d7a80b686f23/matplotlib-3.10.6-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7173f8551b88f4ef810a94adae3128c2530e0d07529f7141be7f8d8c365f051", size = 8682271, upload-time = "2025-08-30T00:14:17.273Z" }, - { url = "https://files.pythonhosted.org/packages/12/bb/02c35a51484aae5f49bd29f091286e7af5f3f677a9736c58a92b3c78baeb/matplotlib-3.10.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f2d684c3204fa62421bbf770ddfebc6b50130f9cad65531eeba19236d73bb488", size = 8252296, upload-time = "2025-08-30T00:14:19.49Z" }, - { url = "https://files.pythonhosted.org/packages/7d/85/41701e3092005aee9a2445f5ee3904d9dbd4a7df7a45905ffef29b7ef098/matplotlib-3.10.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6f4a69196e663a41d12a728fab8751177215357906436804217d6d9cf0d4d6cf", size = 8116749, upload-time = "2025-08-30T00:14:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/16/53/8d8fa0ea32a8c8239e04d022f6c059ee5e1b77517769feccd50f1df43d6d/matplotlib-3.10.6-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d6ca6ef03dfd269f4ead566ec6f3fb9becf8dab146fb999022ed85ee9f6b3eb", size = 8693933, upload-time = "2025-08-30T00:14:22.942Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, + { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, + { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, ] [[package]] name = "mcp" -version = "1.16.0" +version = "1.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2925,15 +3409,18 @@ dependencies = [ { name = "jsonschema" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/a1/b1f328da3b153683d2ec34f849b4b6eac2790fb240e3aef06ff2fab3df9d/mcp-1.16.0.tar.gz", hash = "sha256:39b8ca25460c578ee2cdad33feeea122694cfdf73eef58bee76c42f6ef0589df", size = 472918, upload-time = "2025-10-02T16:58:20.631Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/0e/7cebc88e17daf94ebe28c95633af595ccb2864dc2ee7abd75542d98495cc/mcp-1.16.0-py3-none-any.whl", hash = "sha256:ec917be9a5d31b09ba331e1768aa576e0af45470d657a0319996a20a57d7d633", size = 167266, upload-time = "2025-10-02T16:58:19.039Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] [package.optional-dependencies] @@ -2953,7 +3440,7 @@ wheels = [ [[package]] name = "mem0ai" -version = "0.1.116" +version = "0.1.118" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "openai" }, @@ -2964,45 +3451,54 @@ dependencies = [ { name = "qdrant-client" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/a0/10482cc437e96d609d5fbbb65ad8eae144fc84f0cb2655d913bfb58d7dff/mem0ai-0.1.116.tar.gz", hash = "sha256:c33e08c5464f96b1cf109893dba5d394d8cc5788a8400d85cb1ceed696ee3204", size = 122053, upload-time = "2025-08-13T20:19:41.119Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/1d/b7797ee607d0de2979d2a8b4c0c102989d5e1a1c9d67478dc6a2e2e0b2a8/mem0ai-0.1.118.tar.gz", hash = "sha256:d62497286616357f8726b849afc20031cd0ab56d1cf312fa289b006be33c3ce7", size = 159324, upload-time = "2025-09-25T20:53:00.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/70/810bd12d76576402e7c447ffb683f40fdab8cf49eaae6df3db4af48b358f/mem0ai-0.1.116-py3-none-any.whl", hash = "sha256:245b08f1e615e057ebacc52462ab729a7282abe05e8d4957236d893b3d32a990", size = 190315, upload-time = "2025-08-13T20:19:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/78/70/e648ab026aa6505b920ed405a422727777bebdc5135691b2ca6350a02062/mem0ai-0.1.118-py3-none-any.whl", hash = "sha256:c2b371224a340fd5529d608dfbd2e77c610c7ffe421005ff7e862fd6f322cca8", size = 239476, upload-time = "2025-09-25T20:52:58.32Z" }, ] [[package]] name = "mlx" -version = "0.29.2" +version = "0.31.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mlx-metal", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/f0/2c2f99a91ed9dfcc78d31d9e5d3bb2f5305a8d65953cbc41f34f8056c49a/mlx-0.29.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:b46c1a24b9b8f7145e4d84410552ddfa03f40f9afdbe8f819f6b4b52b4db5d30", size = 547369, upload-time = "2025-09-26T22:21:33.668Z" }, - { url = "https://files.pythonhosted.org/packages/3b/06/0edddf0a5facb58c17616cd33da6de2722636da8d8d3927272a5a88658e4/mlx-0.29.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:18c5b63c6e4b35f75f477f74d3942870e0bc9c9f1c6a071d8a058bbd46681bf4", size = 547367, upload-time = "2025-09-26T22:21:36.63Z" }, - { url = "https://files.pythonhosted.org/packages/e1/cd/8c089cf1678a752ed4175a7ad5f08823ceef75d797217e12a44a71d6c062/mlx-0.29.2-cp310-cp310-macosx_15_0_arm64.whl", hash = "sha256:ed53b8383ad4ee4311400558e0a5ec61105fc4553950b5732c66e7081cd1a9e8", size = 547365, upload-time = "2025-09-26T22:21:32.585Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/9a14ac57a251f0ee5047f42214fa50d1d5223d805f0b8b6627639c3692eb/mlx-0.29.2-cp310-cp310-manylinux_2_35_x86_64.whl", hash = "sha256:fff27af8dca74546422e8400d32ab848baf42405123cbcfdf62674a9c0560ebe", size = 652302, upload-time = "2025-09-26T22:26:24.575Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f0/f57349f37cf5dd53f95127e141fc59fc435e4b6bfabba5a84c65de4d3597/mlx-0.29.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:e74965369227230374b3e8e8c8d46e209e5221a9b76bbb0fa788617e2c68f73c", size = 547581, upload-time = "2025-09-26T22:21:39.24Z" }, - { url = "https://files.pythonhosted.org/packages/66/04/e016ca28dc9e0738a2541581420125cfe6bba24466a64420600bdd6fd52c/mlx-0.29.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0f79194eeac78e85b96439d3bbc17aae5aba045a2af083c000b4fbbc501f253e", size = 547581, upload-time = "2025-09-26T22:21:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b3/e2595e70ef8d4438dff694857745b0e108911e5b5fb83259dde6e5dc5bd1/mlx-0.29.2-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:33bbbb0fd24895d5ff080bb4d10e3e77017bba675d9a12466c8866eaf9b47854", size = 547578, upload-time = "2025-09-26T22:21:22.041Z" }, - { url = "https://files.pythonhosted.org/packages/11/8c/5d51543ab128c2dff5e4b44ca799db8db5aa4f4ffc34af6531fd73627b54/mlx-0.29.2-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:32e159f2772be893bec580d2d50c0e6b32ad71a19ded7307bf6c871c8aaa9cf2", size = 651900, upload-time = "2025-09-26T22:26:11.133Z" }, - { url = "https://files.pythonhosted.org/packages/f3/84/7250237039e91d8e44ca0cf3522f189164844c196f262509afd29ef54710/mlx-0.29.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:eec950bf7118ad0865d0fc4686bd85d99bf8463fc717d836a5132e1a08b4f129", size = 548336, upload-time = "2025-09-26T22:21:44.914Z" }, - { url = "https://files.pythonhosted.org/packages/13/47/428ac8d9b0cb5c136e5ce6c726cfdd55caa5b9497dafb6221acfee18f145/mlx-0.29.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bef7333268d6d02e50a9ac6b10f661b711cd02da4a5e2d7619cf198a7e530308", size = 548334, upload-time = "2025-09-26T22:21:21.41Z" }, - { url = "https://files.pythonhosted.org/packages/14/f0/7d5d3527ca3fdc664c900b4b822028691739e58c8e8f7975b33df4d3536e/mlx-0.29.2-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:f622fc6a84542a08ad2136e9251822d2c08106e5a1a0bd5d249a2d72bccd6577", size = 548330, upload-time = "2025-09-26T22:21:41.182Z" }, - { url = "https://files.pythonhosted.org/packages/09/18/e202e0f6232822f6768995cdbf50eda202137bb6547368f6e3993dbee00b/mlx-0.29.2-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:a1aa1aee8e1b6bd1e51361e6b692c70d281b8187b2e859e70ecc11daab306dac", size = 648728, upload-time = "2025-09-26T22:25:49.159Z" }, - { url = "https://files.pythonhosted.org/packages/a0/9a/91f6f5d031f109fa8c00ba9dd4f7a3fc42e1097a57c26783ce000069c264/mlx-0.29.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:05ea54173f4bde11b2c93e673d65d72523f5d850f5112d3874156a6fc74ca591", size = 548297, upload-time = "2025-09-26T22:21:41.991Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2d/dae7ca0b7fa68c6c1f2b896dfe1b8060647f144d5c5da2d53388e38809b1/mlx-0.29.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:199dd029b5e55b6d94f1ce366d0137824e46e4333891424dd00413c739f50ae9", size = 548305, upload-time = "2025-09-26T22:21:41.083Z" }, - { url = "https://files.pythonhosted.org/packages/b1/56/f02f5c9e1fc11c020982501a763fa92b497ea50671a587760543987ba8c8/mlx-0.29.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:b6dd4e5f227414882b1676d99250d99389228d1bdc14e4e4e88c95d4903810b7", size = 548302, upload-time = "2025-09-26T22:21:30.546Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b4/b61eeb92c424947675492dec3a411bdbeae307dfd78162d65ab47e8c3b4f/mlx-0.29.2-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:c3b9a9aee13f346d060966472954eebe99d9f1b295c9a237c9a000f1ef9adf2c", size = 648709, upload-time = "2025-09-26T22:26:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f9/f1663dafd45af02467f4f41777c13ec34b9104b2b0450d870c3f906285cd/mlx-0.31.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:bc46c911cc060d2eaf21b9e24a1712dc56763b660b53631b9057a32ab1c0271a", size = 574137, upload-time = "2026-03-12T02:15:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/c6/26/1fd632f537a5160a21475a70aaef252090c62f9629f45ad20f5acfe810f3/mlx-0.31.1-cp310-cp310-macosx_15_0_arm64.whl", hash = "sha256:fa132def5b3d959362077521c80f1fc80f64c45060d2940dc1d66a1aa19ce5f6", size = 574140, upload-time = "2026-03-12T02:15:56.709Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c9/e790fa8ddc1b27fea7ba749699883f31c65e166b18e4598beab4574e4686/mlx-0.31.1-cp310-cp310-macosx_26_0_arm64.whl", hash = "sha256:877ff2f98debd035b922825a0d7e7e1be0959fc5ca1d24cb5020a23e510ff16d", size = 574124, upload-time = "2026-03-12T02:15:58.323Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7375fc2be05d026640c5ced085a9e71066a33100638e5762347dae5d680/mlx-0.31.1-cp310-cp310-manylinux_2_35_aarch64.whl", hash = "sha256:931c9316ec47b45ec0e737519f4f4c90eb69cbbdaaecadd6dd2ccdf1a85d4e61", size = 641428, upload-time = "2026-03-12T02:15:59.743Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3f/ab060661d966d435e41212d4f6d6e9d1202da8b9043b1c18c343ab7d1b08/mlx-0.31.1-cp310-cp310-manylinux_2_35_x86_64.whl", hash = "sha256:dec00ce7b094d6bc2876996291fd76c9e28326bc1a9853440903f2a06946ce1f", size = 674521, upload-time = "2026-03-12T02:16:01.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/32/25dc2eae1d6f867224ef2bca2c644e3e913fe8067991f8394c090b720e3e/mlx-0.31.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8863835fb36c7c4f65008b1426ddb9ff7931a13c975e0ef58a40002ae8048922", size = 574311, upload-time = "2026-03-12T02:16:02.651Z" }, + { url = "https://files.pythonhosted.org/packages/9b/bf/c5aa1d1154f5a216139c8162cd3e6568b7eb427390d655f7f5ae3a1a61e7/mlx-0.31.1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:0de504c1f1fe73b32fc3cf457b8eac30d1f7ce22440ef075c1970f96712e6fff", size = 574312, upload-time = "2026-03-12T02:16:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/3a/88/ef57747552c9e9da0c28465d9266c05a0009b698d90fb0bc63eb81840b8d/mlx-0.31.1-cp311-cp311-macosx_26_0_arm64.whl", hash = "sha256:10715b895e1f3e984c2c54257b7db956ff8af1fa93255412794a3724fe2dd3b1", size = 574385, upload-time = "2026-03-12T02:16:05.528Z" }, + { url = "https://files.pythonhosted.org/packages/ac/51/dbea4bbe7a2e4cd05226965b34198d49459cfaef8b9b37b72f006a9811ab/mlx-0.31.1-cp311-cp311-manylinux_2_35_aarch64.whl", hash = "sha256:d065625ab3101adcd7f5824297243fe40a0615099a06f5597ab67284483aa2f8", size = 641347, upload-time = "2026-03-12T02:16:07.013Z" }, + { url = "https://files.pythonhosted.org/packages/c5/86/3db98e8805637fb56f078311d622e9500f5c9088f6d79a6e304ec8235b47/mlx-0.31.1-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:b2cf8502d9d64dc6851034fcd4a656cbb26be20c36f190f2971f4ac0caed89cb", size = 674769, upload-time = "2026-03-12T02:16:08.51Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/71fe1f68756f515856e6930973c23245810d4aa3cd22fddd719d86a709dc/mlx-0.31.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8a63b31a398c9519f2bb0c81cf3865d9baca4ff573ffc31ead465d18286184e8", size = 574308, upload-time = "2026-03-12T02:16:10.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/be/70654a2cee0d71fd10bd237a50a79d06ae51679a194db6a3b16c0c84e6a5/mlx-0.31.1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:a7a9347df4dcc41f0d16ff70b65650820af4879f686534b233b16826a22afa00", size = 574309, upload-time = "2026-03-12T02:16:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/ad/69/c7bc7b04f76b0cbd678f328011d1634bd0bcfc2da45aba06e084cb031127/mlx-0.31.1-cp312-cp312-macosx_26_0_arm64.whl", hash = "sha256:6cdb797ea31787d1ce9e5be77991c4bd5cbf129ab15f7253b78e09737f535fce", size = 574289, upload-time = "2026-03-12T02:16:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/55/f7/dcc129228faab4d406041d91413c5999250ab79da6fe5417ac84f1616ff1/mlx-0.31.1-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:1ed1991c8e39f841d5756c0c543beb819763a2f80fba3f4b150bc6cad4d973de", size = 626439, upload-time = "2026-03-12T02:16:14.741Z" }, + { url = "https://files.pythonhosted.org/packages/90/1d/8b32e46ea98ab5c1c15cf1b37ac97af651977f84e72e1800412a700c51d9/mlx-0.31.1-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:195c5cb27328380287c0ffe9ef48f860ab75ec5d3dfce153d475dc2c99369708", size = 668679, upload-time = "2026-03-12T02:16:16.012Z" }, + { url = "https://files.pythonhosted.org/packages/44/45/04465da443634b23fb11670bbd2f7538b1ed43ffc5e0de44a95b3c29e9c1/mlx-0.31.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9a6d3410fc951bd28508fed9c1ab5d9903f6f6bb101c3a5d63d4191d49a384a1", size = 574268, upload-time = "2026-03-12T02:16:17.27Z" }, + { url = "https://files.pythonhosted.org/packages/85/7b/84956960356ff36e8c1bbed68fac96709e98e6a1adbc8e3d0ff71022d84e/mlx-0.31.1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:20bd7ba19882603ac22711092d0e799f1ff7b5183c2c641d417dab4d2423d99e", size = 574265, upload-time = "2026-03-12T02:16:18.479Z" }, + { url = "https://files.pythonhosted.org/packages/86/01/d6f0ef5b8c0b390af08246d1301e9717dfb076b3920012b53105a888ed8c/mlx-0.31.1-cp313-cp313-macosx_26_0_arm64.whl", hash = "sha256:4c4565d6f4f8ce295613ee342d313ee5ab0b0eab9a6272954450f8343f7876bc", size = 574172, upload-time = "2026-03-12T02:16:19.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/eb29e9eb0cff9c7dfd872e26663e6e9512629730740e1db629086c80ac5a/mlx-0.31.1-cp313-cp313-manylinux_2_35_aarch64.whl", hash = "sha256:9dc564a8b38b9aec279a1c7d34551068b1cc1f8e43b5ac044b56b2a9a4205195", size = 626558, upload-time = "2026-03-12T02:16:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/25/45/ecb746fbb6acb75d03760e41cc7bd21c2e2b544528b3033f7d70402334ac/mlx-0.31.1-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:78f51ab929278366006ee7793dbb5c942b121542c793c33eb9b894a2ce8e27e1", size = 668625, upload-time = "2026-03-12T02:16:23.103Z" }, + { url = "https://files.pythonhosted.org/packages/99/65/208f511acd5fb1ed0b08f047bd6229583845cc6f4b5aa6547a3219332dbb/mlx-0.31.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:bba9d471ba20e050676292b1089a355c8042d3fc9462e4c1738a9735d7d40cfa", size = 576300, upload-time = "2026-03-12T02:16:24.545Z" }, + { url = "https://files.pythonhosted.org/packages/98/58/2d925cb3fa3cd28d279ed6f44508ab7fbbf7359b17359914aa3652a7d734/mlx-0.31.1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:d90b0529b22553eb1353b113b7233aa391ca55e24b1ba69024c732fcc21c5c49", size = 576303, upload-time = "2026-03-12T02:16:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/e1/17/abec0bd0f9347dae13e60b33325cb199312798842901953495e19f3bb3c8/mlx-0.31.1-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:69bc88b41ddd61b44cd6a4d417790f9971ba3fdf58d824934cea95a95b9b4031", size = 576275, upload-time = "2026-03-12T02:16:27.57Z" }, + { url = "https://files.pythonhosted.org/packages/a2/91/85c73f7cc3a661416d05315623458c719eda7de958b05f4e10ba40c52d07/mlx-0.31.1-cp314-cp314-manylinux_2_35_aarch64.whl", hash = "sha256:b973506fd49ba39df6dc4ff655b77bd35ea193cee878e71d6ee3d1a951d2b3a6", size = 628701, upload-time = "2026-03-12T02:16:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/7d/e9/d87638e00a44dcf346fe838caaf1e2dae96a88d5779edbd66ce27d4bbdcc/mlx-0.31.1-cp314-cp314-manylinux_2_35_x86_64.whl", hash = "sha256:3987282a1e63252bdd7c636138812c67316c3f7c7a7acad08e76c8843648a056", size = 668959, upload-time = "2026-03-12T02:16:30.41Z" }, ] [[package]] name = "mlx-metal" -version = "0.29.2" +version = "0.31.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/a5/a045006546fed791f6e9a74ed4451dac871d3c35f9e54a3a25d820668a85/mlx_metal-0.29.2-py3-none-macosx_13_0_arm64.whl", hash = "sha256:cf8f83a521e620357185c57945142718d526b9312ee112e5a89eb5600480f4d6", size = 35056194, upload-time = "2025-09-26T22:23:47.201Z" }, - { url = "https://files.pythonhosted.org/packages/4c/8c/4bdd3a7d04ed477b32aec30d30236dfca9f9ac27706cb309511278ddd281/mlx_metal-0.29.2-py3-none-macosx_14_0_arm64.whl", hash = "sha256:fa944001970813b296e8aff5616f2fa9daeda6bc1d190c17fbe8a7ca838ecef0", size = 34791708, upload-time = "2025-09-26T22:23:30.599Z" }, - { url = "https://files.pythonhosted.org/packages/b1/11/12e158848fe4d3316c999ffb6c2d88f554bde98d69022b3385e25ece997e/mlx_metal-0.29.2-py3-none-macosx_15_0_arm64.whl", hash = "sha256:08d8b7fe305425a14b74ebf36cee176575bfd4cd8d34a2aaae8f05b9983d2d71", size = 34784506, upload-time = "2025-09-26T22:23:29.207Z" }, + { url = "https://files.pythonhosted.org/packages/39/66/2313497fdbc7fbadf8e026c09366e3f049f9114e65ca4edc23cdb8699186/mlx_metal-0.31.1-py3-none-macosx_14_0_arm64.whl", hash = "sha256:70741174131dbf7fdd479cb730e06e08c358eac3bf7905d9e884e7960cfdd5b8", size = 38624074, upload-time = "2026-03-12T02:15:48.036Z" }, + { url = "https://files.pythonhosted.org/packages/c7/34/4c3c6890ce6095b2ab2ba2f5f15c9a7ba17208d47f8cacb572885a2dc0eb/mlx_metal-0.31.1-py3-none-macosx_15_0_arm64.whl", hash = "sha256:6c56bd8cd27743e635f5a90a22535af7c31bd22b4b126d46b6da2da52d72e413", size = 38618950, upload-time = "2026-03-12T02:15:51.908Z" }, + { url = "https://files.pythonhosted.org/packages/51/bc/987cb99e3aafb296aa11ce5133838a10eae8447edd53168d0804d4fb3a14/mlx_metal-0.31.1-py3-none-macosx_26_0_arm64.whl", hash = "sha256:e7324b7c56b519ae67c025d3ced07e5d35bc3a9f19d4c45fe4927f385148c59e", size = 49256543, upload-time = "2026-03-12T02:15:54.851Z" }, ] [[package]] @@ -3014,9 +3510,10 @@ dependencies = [ { name = "mlx" }, { name = "more-itertools" }, { name = "numba" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.16.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "tiktoken" }, { name = "torch" }, { name = "tqdm" }, @@ -3045,104 +3542,140 @@ wheels = [ [[package]] name = "multidict" -version = "6.6.4" +version = "6.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/6b/86f353088c1358e76fd30b0146947fddecee812703b604ee901e85cd2a80/multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f", size = 77054, upload-time = "2025-08-11T12:06:02.99Z" }, - { url = "https://files.pythonhosted.org/packages/19/5d/c01dc3d3788bb877bd7f5753ea6eb23c1beeca8044902a8f5bfb54430f63/multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb", size = 44914, upload-time = "2025-08-11T12:06:05.264Z" }, - { url = "https://files.pythonhosted.org/packages/46/44/964dae19ea42f7d3e166474d8205f14bb811020e28bc423d46123ddda763/multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495", size = 44601, upload-time = "2025-08-11T12:06:06.627Z" }, - { url = "https://files.pythonhosted.org/packages/31/20/0616348a1dfb36cb2ab33fc9521de1f27235a397bf3f59338e583afadd17/multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8", size = 224821, upload-time = "2025-08-11T12:06:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/14/26/5d8923c69c110ff51861af05bd27ca6783011b96725d59ccae6d9daeb627/multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7", size = 242608, upload-time = "2025-08-11T12:06:09.697Z" }, - { url = "https://files.pythonhosted.org/packages/5c/cc/e2ad3ba9459aa34fa65cf1f82a5c4a820a2ce615aacfb5143b8817f76504/multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796", size = 222324, upload-time = "2025-08-11T12:06:10.905Z" }, - { url = "https://files.pythonhosted.org/packages/19/db/4ed0f65701afbc2cb0c140d2d02928bb0fe38dd044af76e58ad7c54fd21f/multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db", size = 253234, upload-time = "2025-08-11T12:06:12.658Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5160c9813269e39ae14b73debb907bfaaa1beee1762da8c4fb95df4764ed/multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0", size = 251613, upload-time = "2025-08-11T12:06:13.97Z" }, - { url = "https://files.pythonhosted.org/packages/05/a9/48d1bd111fc2f8fb98b2ed7f9a115c55a9355358432a19f53c0b74d8425d/multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877", size = 241649, upload-time = "2025-08-11T12:06:15.204Z" }, - { url = "https://files.pythonhosted.org/packages/85/2a/f7d743df0019408768af8a70d2037546a2be7b81fbb65f040d76caafd4c5/multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace", size = 239238, upload-time = "2025-08-11T12:06:16.467Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b8/4f4bb13323c2d647323f7919201493cf48ebe7ded971717bfb0f1a79b6bf/multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6", size = 233517, upload-time = "2025-08-11T12:06:18.107Z" }, - { url = "https://files.pythonhosted.org/packages/33/29/4293c26029ebfbba4f574febd2ed01b6f619cfa0d2e344217d53eef34192/multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb", size = 243122, upload-time = "2025-08-11T12:06:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/20/60/a1c53628168aa22447bfde3a8730096ac28086704a0d8c590f3b63388d0c/multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb", size = 248992, upload-time = "2025-08-11T12:06:20.661Z" }, - { url = "https://files.pythonhosted.org/packages/a3/3b/55443a0c372f33cae5d9ec37a6a973802884fa0ab3586659b197cf8cc5e9/multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987", size = 243708, upload-time = "2025-08-11T12:06:21.891Z" }, - { url = "https://files.pythonhosted.org/packages/7c/60/a18c6900086769312560b2626b18e8cca22d9e85b1186ba77f4755b11266/multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f", size = 237498, upload-time = "2025-08-11T12:06:23.206Z" }, - { url = "https://files.pythonhosted.org/packages/11/3d/8bdd8bcaff2951ce2affccca107a404925a2beafedd5aef0b5e4a71120a6/multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f", size = 41415, upload-time = "2025-08-11T12:06:24.77Z" }, - { url = "https://files.pythonhosted.org/packages/c0/53/cab1ad80356a4cd1b685a254b680167059b433b573e53872fab245e9fc95/multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0", size = 46046, upload-time = "2025-08-11T12:06:25.893Z" }, - { url = "https://files.pythonhosted.org/packages/cf/9a/874212b6f5c1c2d870d0a7adc5bb4cfe9b0624fa15cdf5cf757c0f5087ae/multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729", size = 43147, upload-time = "2025-08-11T12:06:27.534Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7f/90a7f01e2d005d6653c689039977f6856718c75c5579445effb7e60923d1/multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c", size = 76472, upload-time = "2025-08-11T12:06:29.006Z" }, - { url = "https://files.pythonhosted.org/packages/54/a3/bed07bc9e2bb302ce752f1dabc69e884cd6a676da44fb0e501b246031fdd/multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb", size = 44634, upload-time = "2025-08-11T12:06:30.374Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4b/ceeb4f8f33cf81277da464307afeaf164fb0297947642585884f5cad4f28/multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e", size = 44282, upload-time = "2025-08-11T12:06:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/03/35/436a5da8702b06866189b69f655ffdb8f70796252a8772a77815f1812679/multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded", size = 229696, upload-time = "2025-08-11T12:06:33.087Z" }, - { url = "https://files.pythonhosted.org/packages/b6/0e/915160be8fecf1fca35f790c08fb74ca684d752fcba62c11daaf3d92c216/multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683", size = 246665, upload-time = "2025-08-11T12:06:34.448Z" }, - { url = "https://files.pythonhosted.org/packages/08/ee/2f464330acd83f77dcc346f0b1a0eaae10230291450887f96b204b8ac4d3/multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a", size = 225485, upload-time = "2025-08-11T12:06:35.672Z" }, - { url = "https://files.pythonhosted.org/packages/71/cc/9a117f828b4d7fbaec6adeed2204f211e9caf0a012692a1ee32169f846ae/multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9", size = 257318, upload-time = "2025-08-11T12:06:36.98Z" }, - { url = "https://files.pythonhosted.org/packages/25/77/62752d3dbd70e27fdd68e86626c1ae6bccfebe2bb1f84ae226363e112f5a/multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50", size = 254689, upload-time = "2025-08-11T12:06:38.233Z" }, - { url = "https://files.pythonhosted.org/packages/00/6e/fac58b1072a6fc59af5e7acb245e8754d3e1f97f4f808a6559951f72a0d4/multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52", size = 246709, upload-time = "2025-08-11T12:06:39.517Z" }, - { url = "https://files.pythonhosted.org/packages/01/ef/4698d6842ef5e797c6db7744b0081e36fb5de3d00002cc4c58071097fac3/multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6", size = 243185, upload-time = "2025-08-11T12:06:40.796Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c9/d82e95ae1d6e4ef396934e9b0e942dfc428775f9554acf04393cce66b157/multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e", size = 237838, upload-time = "2025-08-11T12:06:42.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/cf/f94af5c36baaa75d44fab9f02e2a6bcfa0cd90acb44d4976a80960759dbc/multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3", size = 246368, upload-time = "2025-08-11T12:06:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/4a/fe/29f23460c3d995f6a4b678cb2e9730e7277231b981f0b234702f0177818a/multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c", size = 253339, upload-time = "2025-08-11T12:06:45.597Z" }, - { url = "https://files.pythonhosted.org/packages/29/b6/fd59449204426187b82bf8a75f629310f68c6adc9559dc922d5abe34797b/multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b", size = 246933, upload-time = "2025-08-11T12:06:46.841Z" }, - { url = "https://files.pythonhosted.org/packages/19/52/d5d6b344f176a5ac3606f7a61fb44dc746e04550e1a13834dff722b8d7d6/multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f", size = 242225, upload-time = "2025-08-11T12:06:48.588Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d3/5b2281ed89ff4d5318d82478a2a2450fcdfc3300da48ff15c1778280ad26/multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2", size = 41306, upload-time = "2025-08-11T12:06:49.95Z" }, - { url = "https://files.pythonhosted.org/packages/74/7d/36b045c23a1ab98507aefd44fd8b264ee1dd5e5010543c6fccf82141ccef/multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e", size = 46029, upload-time = "2025-08-11T12:06:51.082Z" }, - { url = "https://files.pythonhosted.org/packages/0f/5e/553d67d24432c5cd52b49047f2d248821843743ee6d29a704594f656d182/multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf", size = 43017, upload-time = "2025-08-11T12:06:52.243Z" }, - { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, - { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, - { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, - { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, - { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, - { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, - { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, - { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, - { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, - { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, - { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, - { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, - { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, - { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, - { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, - { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, - { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, - { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, - { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, - { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, - { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, - { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, - { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, - { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, - { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, - { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, - { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, - { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, - { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, - { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, - { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] @@ -3168,21 +3701,22 @@ wheels = [ [[package]] name = "networkx" -version = "3.5" +version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] [[package]] name = "nltk" -version = "3.9.2" +version = "3.9.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -3190,18 +3724,18 @@ dependencies = [ { name = "regex" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/76/3a5e4312c19a028770f86fd7c058cf9f4ec4321c6cf7526bab998a5b683c/nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419", size = 2887629, upload-time = "2025-10-01T07:19:23.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/8f/915e1c12df07c70ed779d18ab83d065718a926e70d3ea33eb0cd66ffb7c0/nltk-3.9.3.tar.gz", hash = "sha256:cb5945d6424a98d694c2b9a0264519fab4363711065a46aa0ae7a2195b92e71f", size = 2923673, upload-time = "2026-02-24T12:05:53.833Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a", size = 1513404, upload-time = "2025-10-01T07:19:21.648Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl", hash = "sha256:60b3db6e9995b3dd976b1f0fa7dec22069b2677e759c28eb69b62ddd44870522", size = 1525385, upload-time = "2026-02-24T12:05:46.54Z" }, ] [[package]] name = "nodeenv" -version = "1.9.1" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] @@ -3211,9 +3745,10 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "joblib" }, { name = "matplotlib" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.16.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "tqdm" }, ] sdist = { url = "https://files.pythonhosted.org/packages/11/08/539e3cff148b7f9bde5b4b060451a7445d708fa3fe5d8a2bc0c552976e52/noisereduce-3.0.3.tar.gz", hash = "sha256:ff64a28fb92e3c81f153cf29550e5c2db56b2523afa8f56f5e03c177cc5e918f", size = 20968, upload-time = "2024-10-06T13:43:45.431Z" } @@ -3223,40 +3758,44 @@ wheels = [ [[package]] name = "numba" -version = "0.61.2" +version = "0.64.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llvmlite" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/a0/e21f57604304aa03ebb8e098429222722ad99176a4f979d34af1d1ee80da/numba-0.61.2.tar.gz", hash = "sha256:8750ee147940a6637b80ecf7f95062185ad8726c8c28a2295b8ec1160a196f7d", size = 2820615, upload-time = "2025-04-09T02:58:07.659Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/c9/a0fb41787d01d621046138da30f6c2100d80857bf34b3390dd68040f27a3/numba-0.64.0.tar.gz", hash = "sha256:95e7300af648baa3308127b1955b52ce6d11889d16e8cfe637b4f85d2fca52b1", size = 2765679, upload-time = "2026-02-18T18:41:20.974Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/ca/f470be59552ccbf9531d2d383b67ae0b9b524d435fb4a0d229fef135116e/numba-0.61.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:cf9f9fc00d6eca0c23fc840817ce9f439b9f03c8f03d6246c0e7f0cb15b7162a", size = 2775663, upload-time = "2025-04-09T02:57:34.143Z" }, - { url = "https://files.pythonhosted.org/packages/f5/13/3bdf52609c80d460a3b4acfb9fdb3817e392875c0d6270cf3fd9546f138b/numba-0.61.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ea0247617edcb5dd61f6106a56255baab031acc4257bddaeddb3a1003b4ca3fd", size = 2778344, upload-time = "2025-04-09T02:57:36.609Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7d/bfb2805bcfbd479f04f835241ecf28519f6e3609912e3a985aed45e21370/numba-0.61.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae8c7a522c26215d5f62ebec436e3d341f7f590079245a2f1008dfd498cc1642", size = 3824054, upload-time = "2025-04-09T02:57:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/e3/27/797b2004745c92955470c73c82f0e300cf033c791f45bdecb4b33b12bdea/numba-0.61.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bd1e74609855aa43661edffca37346e4e8462f6903889917e9f41db40907daa2", size = 3518531, upload-time = "2025-04-09T02:57:39.709Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c6/c2fb11e50482cb310afae87a997707f6c7d8a48967b9696271347441f650/numba-0.61.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae45830b129c6137294093b269ef0a22998ccc27bf7cf096ab8dcf7bca8946f9", size = 2831612, upload-time = "2025-04-09T02:57:41.559Z" }, - { url = "https://files.pythonhosted.org/packages/3f/97/c99d1056aed767503c228f7099dc11c402906b42a4757fec2819329abb98/numba-0.61.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:efd3db391df53aaa5cfbee189b6c910a5b471488749fd6606c3f33fc984c2ae2", size = 2775825, upload-time = "2025-04-09T02:57:43.442Z" }, - { url = "https://files.pythonhosted.org/packages/95/9e/63c549f37136e892f006260c3e2613d09d5120672378191f2dc387ba65a2/numba-0.61.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:49c980e4171948ffebf6b9a2520ea81feed113c1f4890747ba7f59e74be84b1b", size = 2778695, upload-time = "2025-04-09T02:57:44.968Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/8740616c8436c86c1b9a62e72cb891177d2c34c2d24ddcde4c390371bf4c/numba-0.61.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3945615cd73c2c7eba2a85ccc9c1730c21cd3958bfcf5a44302abae0fb07bb60", size = 3829227, upload-time = "2025-04-09T02:57:46.63Z" }, - { url = "https://files.pythonhosted.org/packages/fc/06/66e99ae06507c31d15ff3ecd1f108f2f59e18b6e08662cd5f8a5853fbd18/numba-0.61.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbfdf4eca202cebade0b7d43896978e146f39398909a42941c9303f82f403a18", size = 3523422, upload-time = "2025-04-09T02:57:48.222Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a4/2b309a6a9f6d4d8cfba583401c7c2f9ff887adb5d54d8e2e130274c0973f/numba-0.61.2-cp311-cp311-win_amd64.whl", hash = "sha256:76bcec9f46259cedf888041b9886e257ae101c6268261b19fda8cfbc52bec9d1", size = 2831505, upload-time = "2025-04-09T02:57:50.108Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/c6b7b9c615cfa3b98c4c63f4316e3f6b3bbe2387740277006551784218cd/numba-0.61.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:34fba9406078bac7ab052efbf0d13939426c753ad72946baaa5bf9ae0ebb8dd2", size = 2776626, upload-time = "2025-04-09T02:57:51.857Z" }, - { url = "https://files.pythonhosted.org/packages/92/4a/fe4e3c2ecad72d88f5f8cd04e7f7cff49e718398a2fac02d2947480a00ca/numba-0.61.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ddce10009bc097b080fc96876d14c051cc0c7679e99de3e0af59014dab7dfe8", size = 2779287, upload-time = "2025-04-09T02:57:53.658Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2d/e518df036feab381c23a624dac47f8445ac55686ec7f11083655eb707da3/numba-0.61.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b1bb509d01f23d70325d3a5a0e237cbc9544dd50e50588bc581ba860c213546", size = 3885928, upload-time = "2025-04-09T02:57:55.206Z" }, - { url = "https://files.pythonhosted.org/packages/10/0f/23cced68ead67b75d77cfcca3df4991d1855c897ee0ff3fe25a56ed82108/numba-0.61.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:48a53a3de8f8793526cbe330f2a39fe9a6638efcbf11bd63f3d2f9757ae345cd", size = 3577115, upload-time = "2025-04-09T02:57:56.818Z" }, - { url = "https://files.pythonhosted.org/packages/68/1d/ddb3e704c5a8fb90142bf9dc195c27db02a08a99f037395503bfbc1d14b3/numba-0.61.2-cp312-cp312-win_amd64.whl", hash = "sha256:97cf4f12c728cf77c9c1d7c23707e4d8fb4632b46275f8f3397de33e5877af18", size = 2831929, upload-time = "2025-04-09T02:57:58.45Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f3/0fe4c1b1f2569e8a18ad90c159298d862f96c3964392a20d74fc628aee44/numba-0.61.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:3a10a8fc9afac40b1eac55717cece1b8b1ac0b946f5065c89e00bde646b5b154", size = 2771785, upload-time = "2025-04-09T02:57:59.96Z" }, - { url = "https://files.pythonhosted.org/packages/e9/71/91b277d712e46bd5059f8a5866862ed1116091a7cb03bd2704ba8ebe015f/numba-0.61.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d3bcada3c9afba3bed413fba45845f2fb9cd0d2b27dd58a1be90257e293d140", size = 2773289, upload-time = "2025-04-09T02:58:01.435Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e0/5ea04e7ad2c39288c0f0f9e8d47638ad70f28e275d092733b5817cf243c9/numba-0.61.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdbca73ad81fa196bd53dc12e3aaf1564ae036e0c125f237c7644fe64a4928ab", size = 3893918, upload-time = "2025-04-09T02:58:02.933Z" }, - { url = "https://files.pythonhosted.org/packages/17/58/064f4dcb7d7e9412f16ecf80ed753f92297e39f399c905389688cf950b81/numba-0.61.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f154aaea625fb32cfbe3b80c5456d514d416fcdf79733dd69c0df3a11348e9e", size = 3584056, upload-time = "2025-04-09T02:58:04.538Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/6d3a0f2d3989e62a18749e1e9913d5fa4910bbb3e3311a035baea6caf26d/numba-0.61.2-cp313-cp313-win_amd64.whl", hash = "sha256:59321215e2e0ac5fa928a8020ab00b8e57cda8a97384963ac0dfa4d4e6aa54e7", size = 2831846, upload-time = "2025-04-09T02:58:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/4c/5e/604fed821cd7e3426bb3bc99a7ed6ac0bcb489f4cd93052256437d082f95/numba-0.64.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc09b79440952e3098eeebea4bf6e8d2355fb7f12734fcd9fc5039f0dca90727", size = 2683250, upload-time = "2026-02-18T18:40:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9f/9275a723d050b5f1a9b1c7fb7dbfce324fef301a8e50c5f88338569db06c/numba-0.64.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1afe3a80b8c2f376b211fb7a49e536ef9eafc92436afc95a2f41ea5392f8cc65", size = 3742168, upload-time = "2026-02-18T18:40:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d1/97ca7dddaa36b16f4c46319bdb6b4913ba15d0245317d0d8ccde7b2d7d92/numba-0.64.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23804194b93b8cd416c6444b5fbc4956082a45fed2d25436ef49c594666e7f7e", size = 3449103, upload-time = "2026-02-18T18:40:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/52/0a/b9e137ad78415373e3353564500e8bf29dbce3c0d73633bb384d4e5d7537/numba-0.64.0-cp310-cp310-win_amd64.whl", hash = "sha256:e2a9fe998bb2cf848960b34db02c2c3b5e02cf82c07a26d9eef3494069740278", size = 2749950, upload-time = "2026-02-18T18:40:51.536Z" }, + { url = "https://files.pythonhosted.org/packages/89/a3/1a4286a1c16136c8896d8e2090d950e79b3ec626d3a8dc9620f6234d5a38/numba-0.64.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:766156ee4b8afeeb2b2e23c81307c5d19031f18d5ce76ae2c5fb1429e72fa92b", size = 2682938, upload-time = "2026-02-18T18:40:52.897Z" }, + { url = "https://files.pythonhosted.org/packages/19/16/aa6e3ba3cd45435c117d1101b278b646444ed05b7c712af631b91353f573/numba-0.64.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d17071b4ffc9d39b75d8e6c101a36f0c81b646123859898c9799cb31807c8f78", size = 3747376, upload-time = "2026-02-18T18:40:54.925Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f1/dd2f25e18d75fdf897f730b78c5a7b00cc4450f2405564dbebfaf359f21f/numba-0.64.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ead5630434133bac87fa67526eacb264535e4e9a2d5ec780e0b4fc381a7d275", size = 3453292, upload-time = "2026-02-18T18:40:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/29/e09d5630578a50a2b3fa154990b6b839cf95327aa0709e2d50d0b6816cd1/numba-0.64.0-cp311-cp311-win_amd64.whl", hash = "sha256:f2b1fd93e7aaac07d6fbaed059c00679f591f2423885c206d8c1b55d65ca3f2d", size = 2749824, upload-time = "2026-02-18T18:40:58.392Z" }, + { url = "https://files.pythonhosted.org/packages/70/a6/9fc52cb4f0d5e6d8b5f4d81615bc01012e3cf24e1052a60f17a68deb8092/numba-0.64.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69440a8e8bc1a81028446f06b363e28635aa67bd51b1e498023f03b812e0ce68", size = 2683418, upload-time = "2026-02-18T18:40:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/1a74ea99b180b7a5587b0301ed1b183a2937c4b4b67f7994689b5d36fc34/numba-0.64.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13721011f693ba558b8dd4e4db7f2640462bba1b855bdc804be45bbeb55031a", size = 3804087, upload-time = "2026-02-18T18:41:01.699Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/583c647404b15f807410510fec1eb9b80cb8474165940b7749f026f21cbc/numba-0.64.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0b180b1133f2b5d8b3f09d96b6d7a9e51a7da5dda3c09e998b5bcfac85d222c", size = 3504309, upload-time = "2026-02-18T18:41:03.252Z" }, + { url = "https://files.pythonhosted.org/packages/85/23/0fce5789b8a5035e7ace21216a468143f3144e02013252116616c58339aa/numba-0.64.0-cp312-cp312-win_amd64.whl", hash = "sha256:e63dc94023b47894849b8b106db28ccb98b49d5498b98878fac1a38f83ac007a", size = 2752740, upload-time = "2026-02-18T18:41:05.097Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/2734de90f9300a6e2503b35ee50d9599926b90cbb7ac54f9e40074cd07f1/numba-0.64.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3bab2c872194dcd985f1153b70782ec0fbbe348fffef340264eacd3a76d59fd6", size = 2683392, upload-time = "2026-02-18T18:41:06.563Z" }, + { url = "https://files.pythonhosted.org/packages/42/e8/14b5853ebefd5b37723ef365c5318a30ce0702d39057eaa8d7d76392859d/numba-0.64.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:703a246c60832cad231d2e73c1182f25bf3cc8b699759ec8fe58a2dbc689a70c", size = 3812245, upload-time = "2026-02-18T18:41:07.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a2/f60dc6c96d19b7185144265a5fbf01c14993d37ff4cd324b09d0212aa7ce/numba-0.64.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2e49a7900ee971d32af7609adc0cfe6aa7477c6f6cccdf6d8138538cf7756f", size = 3511328, upload-time = "2026-02-18T18:41:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2a/fe7003ea7e7237ee7014f8eaeeb7b0d228a2db22572ca85bab2648cf52cb/numba-0.64.0-cp313-cp313-win_amd64.whl", hash = "sha256:396f43c3f77e78d7ec84cdfc6b04969c78f8f169351b3c4db814b97e7acf4245", size = 2752668, upload-time = "2026-02-18T18:41:11.455Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/77d26afe0988c592dd97cb8d4e80bfb3dfc7dbdacfca7d74a7c5c81dd8c2/numba-0.64.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f565d55eaeff382cbc86c63c8c610347453af3d1e7afb2b6569aac1c9b5c93ce", size = 2683590, upload-time = "2026-02-18T18:41:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/8e/4b/600b8b7cdbc7f9cebee9ea3d13bb70052a79baf28944024ffcb59f0712e3/numba-0.64.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9b55169b18892c783f85e9ad9e6f5297a6d12967e4414e6b71361086025ff0bb", size = 3781163, upload-time = "2026-02-18T18:41:15.377Z" }, + { url = "https://files.pythonhosted.org/packages/ff/73/53f2d32bfa45b7175e9944f6b816d8c32840178c3eee9325033db5bf838e/numba-0.64.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:196bcafa02c9dd1707e068434f6d5cedde0feb787e3432f7f1f0e993cc336c4c", size = 3481172, upload-time = "2026-02-18T18:41:17.281Z" }, + { url = "https://files.pythonhosted.org/packages/b5/00/aebd2f7f1e11e38814bb96e95a27580817a7b340608d3ac085fdbab83174/numba-0.64.0-cp314-cp314-win_amd64.whl", hash = "sha256:213e9acbe7f1c05090592e79020315c1749dd52517b90e94c517dca3f014d4a1", size = 2754700, upload-time = "2026-02-18T18:41:19.277Z" }, ] [[package]] name = "numpy" version = "2.2.6" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, @@ -3315,83 +3854,164 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, ] +[[package]] +name = "numpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/b5/7c/c061f3de0630941073d2598dc271ac2f6cbcf5c83c74a5870fea07488333/numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", size = 14968734, upload-time = "2026-03-09T07:56:00.494Z" }, + { url = "https://files.pythonhosted.org/packages/ef/27/d26c85cbcd86b26e4f125b0668e7a7c0542d19dd7d23ee12e87b550e95b5/numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", size = 5475288, upload-time = "2026-03-09T07:56:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/2b/09/3c4abbc1dcd8010bf1a611d174c7aa689fc505585ec806111b4406f6f1b1/numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", size = 6805253, upload-time = "2026-03-09T07:56:04.53Z" }, + { url = "https://files.pythonhosted.org/packages/21/bc/e7aa3f6817e40c3f517d407742337cbb8e6fc4b83ce0b55ab780c829243b/numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", size = 15969479, upload-time = "2026-03-09T07:56:06.638Z" }, + { url = "https://files.pythonhosted.org/packages/78/51/9f5d7a41f0b51649ddf2f2320595e15e122a40610b233d51928dd6c92353/numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", size = 16901035, upload-time = "2026-03-09T07:56:09.405Z" }, + { url = "https://files.pythonhosted.org/packages/64/6e/b221dd847d7181bc5ee4857bfb026182ef69499f9305eb1371cbb1aea626/numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", size = 17325657, upload-time = "2026-03-09T07:56:12.067Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b8/8f3fd2da596e1063964b758b5e3c970aed1949a05200d7e3d46a9d46d643/numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", size = 18635512, upload-time = "2026-03-09T07:56:14.629Z" }, + { url = "https://files.pythonhosted.org/packages/5c/24/2993b775c37e39d2f8ab4125b44337ab0b2ba106c100980b7c274a22bee7/numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", size = 6238100, upload-time = "2026-03-09T07:56:17.243Z" }, + { url = "https://files.pythonhosted.org/packages/76/1d/edccf27adedb754db7c4511d5eac8b83f004ae948fe2d3509e8b78097d4c/numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", size = 12609816, upload-time = "2026-03-09T07:56:19.089Z" }, + { url = "https://files.pythonhosted.org/packages/92/82/190b99153480076c8dce85f4cfe7d53ea84444145ffa54cb58dcd460d66b/numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67", size = 10485757, upload-time = "2026-03-09T07:56:21.753Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" }, + { url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" }, + { url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" }, + { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" }, + { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" }, + { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" }, + { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" }, + { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" }, + { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" }, + { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" }, + { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/64/e4/4dab9fb43c83719c29241c535d9e07be73bea4bc0c6686c5816d8e1b6689/numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", size = 16834892, upload-time = "2026-03-09T07:58:35.334Z" }, + { url = "https://files.pythonhosted.org/packages/c9/29/f8b6d4af90fed3dfda84ebc0df06c9833d38880c79ce954e5b661758aa31/numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", size = 14893070, upload-time = "2026-03-09T07:58:37.7Z" }, + { url = "https://files.pythonhosted.org/packages/9a/04/a19b3c91dbec0a49269407f15d5753673a09832daed40c45e8150e6fa558/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", size = 5399609, upload-time = "2026-03-09T07:58:39.853Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/4d73603f5420eab89ea8a67097b31364bf7c30f811d4dd84b1659c7476d9/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", size = 6714355, upload-time = "2026-03-09T07:58:42.365Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/1100d7229bb248394939a12a8074d485b655e8ed44207d328fdd7fcebc7b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", size = 15800434, upload-time = "2026-03-09T07:58:44.837Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fd/16d710c085d28ba4feaf29ac60c936c9d662e390344f94a6beaa2ac9899b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", size = 16729409, upload-time = "2026-03-09T07:58:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" }, +] + [[package]] name = "nvidia-cublas-cu12" -version = "12.6.4.1" +version = "12.8.4.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/eb/ff4b8c503fa1f1796679dce648854d58751982426e4e4b37d6fce49d259c/nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08ed2686e9875d01b58e3cb379c6896df8e76c75e0d4a7f7dace3d7b6d9ef8eb", size = 393138322, upload-time = "2024-11-20T17:40:25.65Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, ] [[package]] name = "nvidia-cuda-cupti-cu12" -version = "12.6.80" +version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/60/7b6497946d74bcf1de852a21824d63baad12cd417db4195fc1bfe59db953/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6768bad6cab4f19e8292125e5f1ac8aa7d1718704012a0e3272a6f61c4bce132", size = 8917980, upload-time = "2024-11-20T17:36:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/120ee57b218d9952c379d1e026c4479c9ece9997a4fb46303611ee48f038/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a3eff6cdfcc6a4c35db968a06fcadb061cbc7d6dde548609a941ff8701b98b73", size = 8917972, upload-time = "2024-10-01T16:58:06.036Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, ] [[package]] name = "nvidia-cuda-nvrtc-cu12" -version = "12.6.77" +version = "12.8.93" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/2e/46030320b5a80661e88039f59060d1790298b4718944a65a7f2aeda3d9e9/nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:35b0cc6ee3a9636d5409133e79273ce1f3fd087abb0532d2d2e8fff1fe9efc53", size = 23650380, upload-time = "2024-10-01T17:00:14.643Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, ] [[package]] name = "nvidia-cuda-runtime-cu12" -version = "12.6.77" +version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/23/e717c5ac26d26cf39a27fbc076240fad2e3b817e5889d671b67f4f9f49c5/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba3b56a4f896141e25e19ab287cd71e52a6a0f4b29d0d31609f60e3b4d5219b7", size = 897690, upload-time = "2024-11-20T17:35:30.697Z" }, - { url = "https://files.pythonhosted.org/packages/f0/62/65c05e161eeddbafeca24dc461f47de550d9fa8a7e04eb213e32b55cfd99/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a84d15d5e1da416dd4774cb42edf5e954a3e60cc945698dc1d5be02321c44dc8", size = 897678, upload-time = "2024-10-01T16:57:33.821Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, ] [[package]] name = "nvidia-cudnn-cu12" -version = "9.5.1.17" +version = "9.10.2.21" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nvidia-cublas-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/78/4535c9c7f859a64781e43c969a3a7e84c54634e319a996d43ef32ce46f83/nvidia_cudnn_cu12-9.5.1.17-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:30ac3869f6db17d170e0e556dd6cc5eee02647abc31ca856634d5a40f82c15b2", size = 570988386, upload-time = "2024-10-25T19:54:26.39Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, ] [[package]] name = "nvidia-cufft-cu12" -version = "11.3.0.4" +version = "11.3.3.83" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/16/73727675941ab8e6ffd86ca3a4b7b47065edcca7a997920b831f8147c99d/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ccba62eb9cef5559abd5e0d54ceed2d9934030f51163df018532142a8ec533e5", size = 200221632, upload-time = "2024-11-20T17:41:32.357Z" }, - { url = "https://files.pythonhosted.org/packages/60/de/99ec247a07ea40c969d904fc14f3a356b3e2a704121675b75c366b694ee1/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.whl", hash = "sha256:768160ac89f6f7b459bee747e8d175dbf53619cfe74b2a5636264163138013ca", size = 200221622, upload-time = "2024-10-01T17:03:58.79Z" }, + { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, ] [[package]] name = "nvidia-cufile-cu12" -version = "1.11.1.6" +version = "1.13.1.3" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/66/cc9876340ac68ae71b15c743ddb13f8b30d5244af344ec8322b449e35426/nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc23469d1c7e52ce6c1d55253273d32c565dd22068647f3aa59b3c6b005bf159", size = 1142103, upload-time = "2024-11-20T17:42:11.83Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, ] [[package]] name = "nvidia-curand-cu12" -version = "10.3.7.77" +version = "10.3.9.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/1b/44a01c4e70933637c93e6e1a8063d1e998b50213a6b65ac5a9169c47e98e/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a42cd1344297f70b9e39a1e4f467a4e1c10f1da54ff7a85c12197f6c652c8bdf", size = 56279010, upload-time = "2024-11-20T17:42:50.958Z" }, - { url = "https://files.pythonhosted.org/packages/4a/aa/2c7ff0b5ee02eaef890c0ce7d4f74bc30901871c5e45dee1ae6d0083cd80/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:99f1a32f1ac2bd134897fc7a203f779303261268a65762a623bf30cc9fe79117", size = 56279000, upload-time = "2024-10-01T17:04:45.274Z" }, + { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, ] [[package]] name = "nvidia-cusolver-cu12" -version = "11.7.1.2" +version = "11.7.3.90" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nvidia-cublas-cu12" }, @@ -3399,104 +4019,110 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/6e/c2cf12c9ff8b872e92b4a5740701e51ff17689c4d726fca91875b07f655d/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9e49843a7707e42022babb9bcfa33c29857a93b88020c4e4434656a655b698c", size = 158229790, upload-time = "2024-11-20T17:43:43.211Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/baba53585da791d043c10084cf9553e074548408e04ae884cfe9193bd484/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6cf28f17f64107a0c4d7802be5ff5537b2130bfc112f25d5a30df227058ca0e6", size = 158229780, upload-time = "2024-10-01T17:05:39.875Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, ] [[package]] name = "nvidia-cusparse-cu12" -version = "12.5.4.2" +version = "12.5.8.93" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/06/1e/b8b7c2f4099a37b96af5c9bb158632ea9e5d9d27d7391d7eb8fc45236674/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7556d9eca156e18184b94947ade0fba5bb47d69cec46bf8660fd2c71a4b48b73", size = 216561367, upload-time = "2024-11-20T17:44:54.824Z" }, - { url = "https://files.pythonhosted.org/packages/43/ac/64c4316ba163e8217a99680c7605f779accffc6a4bcd0c778c12948d3707/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:23749a6571191a215cb74d1cdbff4a86e7b19f1200c071b3fcf844a5bea23a2f", size = 216561357, upload-time = "2024-10-01T17:06:29.861Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, ] [[package]] name = "nvidia-cusparselt-cu12" -version = "0.6.3" +version = "0.7.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/9a/72ef35b399b0e183bc2e8f6f558036922d453c4d8237dab26c666a04244b/nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5c8a26c36445dd2e6812f1177978a24e2d37cacce7e090f297a688d1ec44f46", size = 156785796, upload-time = "2024-10-15T21:29:17.709Z" }, + { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, ] [[package]] name = "nvidia-nccl-cu12" -version = "2.26.2" +version = "2.27.5" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/ca/f42388aed0fddd64ade7493dbba36e1f534d4e6fdbdd355c6a90030ae028/nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:694cf3879a206553cc9d7dbda76b13efaf610fdb70a50cba303de1b0d1530ac6", size = 201319755, upload-time = "2025-03-13T00:29:55.296Z" }, + { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" }, ] [[package]] name = "nvidia-nvjitlink-cu12" -version = "12.6.85" +version = "12.8.93" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/d7/c5383e47c7e9bf1c99d5bd2a8c935af2b6d705ad831a7ec5c97db4d82f4f/nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:eedc36df9e88b682efe4309aa16b5b4e78c2407eac59e8c10a6a47535164369a", size = 19744971, upload-time = "2024-11-20T17:46:53.366Z" }, + { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu12" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" }, ] [[package]] name = "nvidia-nvtx-cu12" -version = "12.6.77" +version = "12.8.90" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/9a/fff8376f8e3d084cd1530e1ef7b879bb7d6d265620c95c1b322725c694f4/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b90bed3df379fa79afbd21be8e04a0314336b8ae16768b58f2d34cb1d04cd7d2", size = 89276, upload-time = "2024-11-20T17:38:27.621Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4e/0d0c945463719429b7bd21dece907ad0bde437a2ff12b9b12fee94722ab0/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6574241a3ec5fdc9334353ab8c479fe75841dbe8f4532a8fc97ce63503330ba1", size = 89265, upload-time = "2024-10-01T17:00:38.172Z" }, + { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, ] [[package]] name = "nvidia-riva-client" -version = "2.21.1" +version = "2.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "grpcio-tools" }, { name = "setuptools" }, + { name = "websockets" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/c9/a91f7b8e03e9040c1621719a0bdc1b62a76fcf350d818a658e15daa8ac63/nvidia_riva_client-2.21.1-1-py3-none-any.whl", hash = "sha256:a1519c09370db981b3ce2cb2dc7133724713b1ab6f54a6b1d98438d38694f8f9", size = 47240, upload-time = "2025-07-08T06:26:58.526Z" }, - { url = "https://files.pythonhosted.org/packages/7a/17/448da283d6f18c1adb306a7d1055235424ee838ff61366d202961621a2c0/nvidia_riva_client-2.21.1-py3-none-any.whl", hash = "sha256:154cc9e913a4f54a9ed4fe0b83e1961e9cb20637a5e302cb183314c0edf4d54a", size = 47229, upload-time = "2025-07-04T07:59:46.017Z" }, + { url = "https://files.pythonhosted.org/packages/43/93/03debd92d685cede54e860409d38cff866ffa401d3433338928c18e1cbd3/nvidia_riva_client-2.25.0-py3-none-any.whl", hash = "sha256:2e7272da558e7c959af1ba255eca5ddb629652b1eb956018b455e659b85ea3df", size = 55364, upload-time = "2026-03-02T11:06:25.392Z" }, ] [[package]] name = "onnxruntime" -version = "1.23.0" +version = "1.23.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coloredlogs" }, { name = "flatbuffers" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, { name = "protobuf" }, { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/28/4c76b7feca063d47880e76bee235e829bcc4adb87cc26ecff248ece31f17/onnxruntime-1.23.0-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:009bf5ecad107a7f11af8214fcff19e844214887b38c6673bd63a25af2f6121f", size = 17078761, upload-time = "2025-09-25T19:16:41.541Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b0/740cec5d5f664930fecb1e7a6d7bdf8f0d81982f7cb04184dd80db8036d6/onnxruntime-1.23.0-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:9f875c93891200a946a3387d2c66c66668b9b60a1a053a83d4ee025d8b8892de", size = 19022963, upload-time = "2025-09-25T18:56:29.734Z" }, - { url = "https://files.pythonhosted.org/packages/54/18/73cc152ae160023a4199de11d69641be0b9250967d5853e4b08d56b19c0f/onnxruntime-1.23.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c613fd9280e506d237f7701c1275b6ff30f517a523ced62d1def11a8cf5acf7c", size = 15141554, upload-time = "2025-09-25T18:56:09.899Z" }, - { url = "https://files.pythonhosted.org/packages/e8/aa/bcd3326406f11c5d196a3362daa9904624d77786468cb9d39e4f01e70c2b/onnxruntime-1.23.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8984f38de1a2d57fead5c791c5a6e1921dadfe0bc9f5ea26a5acfcc78908e3e9", size = 17268356, upload-time = "2025-09-25T19:16:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/f9/dd/162a2dd2ae0bcfc2a858f966a71eb2206e1a179bc2bf9d681e4fc28369dd/onnxruntime-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:08efde1dd5c4881aaf49e79cd2f03d0cd977e8f657217e2796c343c06fefac51", size = 13389724, upload-time = "2025-09-25T19:16:31.701Z" }, - { url = "https://files.pythonhosted.org/packages/0b/00/8083a5fd84cdb1119b26530daf5d89d8214c2078096a5a065d8ca5ec8959/onnxruntime-1.23.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:ecf8c589d7d55bd645237442a97c9a2b4bd35bab35b20fc7f2bc81b70c062071", size = 17082400, upload-time = "2025-09-25T19:16:43.875Z" }, - { url = "https://files.pythonhosted.org/packages/e8/19/1f87efecc03df75e1042cceb0d0b4645b121801c4b8022bd9d6c710fd214/onnxruntime-1.23.0-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:b703c42e6aee8d58d23b39ea856c4202173fcd4260e87fe08fc1d4e983d76f92", size = 19024671, upload-time = "2025-09-25T18:56:32.096Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e3/eaba11c440b35ea6fc9e6bb744ee4a50abcbd2e48fb388f1b15a5e7d6083/onnxruntime-1.23.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8634c5f54774df1e4d1debfdf2ca8f3274fe4ffc816ff5f861c01c48468a2c4", size = 15141724, upload-time = "2025-09-25T18:56:12.851Z" }, - { url = "https://files.pythonhosted.org/packages/d0/5e/399ee9b1f2a9d17f23d5a8518ea45e42b6f4f7f5bbcc8526f74ca15e90bb/onnxruntime-1.23.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c681ab5ae4fce92d09f4a86ac088a18ea36f8739115b8abf55e557cb6729e97", size = 17268940, upload-time = "2025-09-25T19:16:08.874Z" }, - { url = "https://files.pythonhosted.org/packages/65/ed/286dfcabe1f929e23988a3dec2232b6140b19f8b8c72f445061b333772b4/onnxruntime-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:a91e14627c08fbbde3c54fbce21e0903ce07a985f664f24d097cbfb01a930a69", size = 13390920, upload-time = "2025-09-25T19:16:33.945Z" }, - { url = "https://files.pythonhosted.org/packages/fb/33/ec5395c9539423246e4976d6ec7c4e7a4624ad8bcbe783fea5c629d7980a/onnxruntime-1.23.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:5921f2e106f5faf2b32095b2ecdfae047e445c3bce063e439dadc75c212e7be7", size = 17081368, upload-time = "2025-09-25T19:16:46.585Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3c/d1976a9933e075291a3d67f4e949c667ff36a3e3a4a0cbd883af3c4eae5a/onnxruntime-1.23.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:053df2f9c6522b258055bce4b776aa9ea3adb4b28d2530ab07b204a3d4b04bf9", size = 19028636, upload-time = "2025-09-25T18:56:34.457Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1f/5b76864a970a23dc85f8745d045b81a9151aa101bbb426af6fa489f59364/onnxruntime-1.23.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:974e327ca3b6d43da404b9a45df1f61e2503667fde46843ee7ad1567a98f3f0b", size = 15140544, upload-time = "2025-09-25T18:56:15.9Z" }, - { url = "https://files.pythonhosted.org/packages/0b/62/84f23952d01e07ce8aa02e657e3a0c8fa40aba0d5e11a0e9904a9063af76/onnxruntime-1.23.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f67edb93678cab5cd77eda89b65bb1b58f3d4c0742058742cfad8b172cfa83", size = 17274126, upload-time = "2025-09-25T19:16:11.21Z" }, - { url = "https://files.pythonhosted.org/packages/19/90/d5b4ea0bd6805f3f21aac2fe549a5b58ee10d1c99c499d867539620a002b/onnxruntime-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:e100f3869da4c12b17a9b942934a96a542406f860eb8beb74a68342ea43aaa55", size = 13392437, upload-time = "2025-09-25T19:16:36.066Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/dbd5731f2188c65c22f65e5b9dde45cf68510a14ecb1eb6fabd272da94c3/onnxruntime-1.23.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:b6659f17326e64f2902cd31aa5efc1af41d0e0e3bd1357a75985e358412c35ca", size = 17081033, upload-time = "2025-09-25T18:56:27.426Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fd/6a95d7ab505517192966da8df5aec491eff1b32559ce8981299192194ca3/onnxruntime-1.23.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:9ef62369a0261aa15b1399addaaf17ed398e4e2128c8548fafcd73aac13820fd", size = 19029223, upload-time = "2025-09-25T18:56:36.85Z" }, - { url = "https://files.pythonhosted.org/packages/11/51/673cf86f574a87a4fb9d4fb2cd1ccfcf362bc7c3f2ecb1919325e7fd0fd4/onnxruntime-1.23.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0edee45d4119f7a6f187dc1b63e177e3e6c76932446006fd4f3e81540f260dfa", size = 15140613, upload-time = "2025-09-25T18:56:22.824Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ab/898f87a633f3063269fcee2f94b1e8349223f1f14fa730822d2cf6021c76/onnxruntime-1.23.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2dc1993aa91d665faf2b17772e4e29a2999821e110c0e3d17e2b1c00d0e7f48", size = 17274274, upload-time = "2025-09-25T19:16:13.603Z" }, - { url = "https://files.pythonhosted.org/packages/9b/69/070eae0d0369562d1dec0046ec2e3dd7c523adfae0f30b3887f81ef98c3b/onnxruntime-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:e52c8603c4cc74746ece9966102e4fc6c2b355efc0102a9deb107f3ff86680af", size = 13392787, upload-time = "2025-09-25T19:16:38.871Z" }, - { url = "https://files.pythonhosted.org/packages/42/8c/6f1d8ec63c887a855f65648b1c743f673191da94703b5fd207d21f17c292/onnxruntime-1.23.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24ac2a8b2c6dd00a152a08a9cf1ba3f06b38915f6cb6cf1adbe714e16e5ff460", size = 15148462, upload-time = "2025-09-25T18:56:25.11Z" }, - { url = "https://files.pythonhosted.org/packages/eb/59/0db51308fa479f9325ade08c343a5164153ad01dbb83b62ff661e1129d2e/onnxruntime-1.23.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ed85686e08cfb29ee96365b9a49e8a350aff7557c13d63d9f07ca3ad68975074", size = 17281939, upload-time = "2025-09-25T19:16:16.16Z" }, + { url = "https://files.pythonhosted.org/packages/35/d6/311b1afea060015b56c742f3531168c1644650767f27ef40062569960587/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3", size = 17195934, upload-time = "2025-10-27T23:06:14.143Z" }, + { url = "https://files.pythonhosted.org/packages/db/db/81bf3d7cecfbfed9092b6b4052e857a769d62ed90561b410014e0aae18db/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:b28740f4ecef1738ea8f807461dd541b8287d5650b5be33bca7b474e3cbd1f36", size = 19153079, upload-time = "2025-10-27T23:05:57.686Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4d/a382452b17cf70a2313153c520ea4c96ab670c996cb3a95cc5d5ac7bfdac/onnxruntime-1.23.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f7d1fe034090a1e371b7f3ca9d3ccae2fabae8c1d8844fb7371d1ea38e8e8d2", size = 15219883, upload-time = "2025-10-22T03:46:21.66Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/179bf90679984c85b417664c26aae4f427cba7514bd2d65c43b181b7b08b/onnxruntime-1.23.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ca88747e708e5c67337b0f65eed4b7d0dd70d22ac332038c9fc4635760018f7", size = 17370357, upload-time = "2025-10-22T03:46:57.968Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6d/738e50c47c2fd285b1e6c8083f15dac1a5f6199213378a5f14092497296d/onnxruntime-1.23.2-cp310-cp310-win_amd64.whl", hash = "sha256:0be6a37a45e6719db5120e9986fcd30ea205ac8103fd1fb74b6c33348327a0cc", size = 13467651, upload-time = "2025-10-27T23:06:11.904Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113, upload-time = "2025-10-22T03:47:33.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857, upload-time = "2025-10-22T03:46:37.578Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095, upload-time = "2025-10-22T03:46:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080, upload-time = "2025-10-22T03:47:00.265Z" }, + { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349, upload-time = "2025-10-22T03:47:25.783Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" }, + { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" }, + { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337, upload-time = "2025-10-22T03:46:35.168Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691, upload-time = "2025-10-22T03:46:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898, upload-time = "2025-10-22T03:46:30.039Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518, upload-time = "2025-10-22T03:47:05.407Z" }, + { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276, upload-time = "2025-10-22T03:47:31.193Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610, upload-time = "2025-10-22T03:46:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184, upload-time = "2025-10-22T03:47:08.127Z" }, ] [[package]] @@ -3520,19 +4146,21 @@ wheels = [ [[package]] name = "opencv-python" -version = "4.12.0.88" +version = "4.13.0.92" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/71/25c98e634b6bdeca4727c7f6d6927b056080668c5008ad3c8fc9e7f8f6ec/opencv-python-4.12.0.88.tar.gz", hash = "sha256:8b738389cede219405f6f3880b851efa3415ccd674752219377353f017d2994d", size = 95373294, upload-time = "2025-07-07T09:20:52.389Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/68/3da40142e7c21e9b1d4e7ddd6c58738feb013203e6e4b803d62cdd9eb96b/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:f9a1f08883257b95a5764bf517a32d75aec325319c8ed0f89739a57fae9e92a5", size = 37877727, upload-time = "2025-07-07T09:13:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/33/7c/042abe49f58d6ee7e1028eefc3334d98ca69b030e3b567fe245a2b28ea6f/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:812eb116ad2b4de43ee116fcd8991c3a687f099ada0b04e68f64899c09448e81", size = 57326471, upload-time = "2025-07-07T09:13:41.26Z" }, - { url = "https://files.pythonhosted.org/packages/62/3a/440bd64736cf8116f01f3b7f9f2e111afb2e02beb2ccc08a6458114a6b5d/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:51fd981c7df6af3e8f70b1556696b05224c4e6b6777bdd2a46b3d4fb09de1a92", size = 45887139, upload-time = "2025-07-07T09:13:50.761Z" }, - { url = "https://files.pythonhosted.org/packages/68/1f/795e7f4aa2eacc59afa4fb61a2e35e510d06414dd5a802b51a012d691b37/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:092c16da4c5a163a818f120c22c5e4a2f96e0db4f24e659c701f1fe629a690f9", size = 67041680, upload-time = "2025-07-07T09:14:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/02/96/213fea371d3cb2f1d537612a105792aa0a6659fb2665b22cad709a75bd94/opencv_python-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:ff554d3f725b39878ac6a2e1fa232ec509c36130927afc18a1719ebf4fbf4357", size = 30284131, upload-time = "2025-07-07T09:14:08.819Z" }, - { url = "https://files.pythonhosted.org/packages/fa/80/eb88edc2e2b11cd2dd2e56f1c80b5784d11d6e6b7f04a1145df64df40065/opencv_python-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:d98edb20aa932fd8ebd276a72627dad9dc097695b3d435a4257557bbb49a79d2", size = 39000307, upload-time = "2025-07-07T09:14:16.641Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, ] [[package]] @@ -3553,20 +4181,20 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.37.0" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/04/05040d7ce33a907a2a02257e601992f0cdf11c73b33f13c4492bf6c3d6d5/opentelemetry_api-1.37.0.tar.gz", hash = "sha256:540735b120355bd5112738ea53621f8d5edb35ebcd6fe21ada3ab1c61d1cd9a7", size = 64923, upload-time = "2025-09-11T10:29:01.662Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/48/28ed9e55dcf2f453128df738210a980e09f4e468a456fa3c763dbc8be70a/opentelemetry_api-1.37.0-py3-none-any.whl", hash = "sha256:accf2024d3e89faec14302213bc39550ec0f4095d1cf5ca688e1bfb1c8612f47", size = 65732, upload-time = "2025-09-11T10:28:41.826Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.58b0" +version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -3574,155 +4202,187 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/36/7c307d9be8ce4ee7beb86d7f1d31027f2a6a89228240405a858d6e4d64f9/opentelemetry_instrumentation-0.58b0.tar.gz", hash = "sha256:df640f3ac715a3e05af145c18f527f4422c6ab6c467e40bd24d2ad75a00cb705", size = 31549, upload-time = "2025-09-11T11:42:14.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/db/5ff1cd6c5ca1d12ecf1b73be16fbb2a8af2114ee46d4b0e6d4b23f4f4db7/opentelemetry_instrumentation-0.58b0-py3-none-any.whl", hash = "sha256:50f97ac03100676c9f7fc28197f8240c7290ca1baa12da8bfbb9a1de4f34cc45", size = 33019, upload-time = "2025-09-11T11:41:00.624Z" }, + { url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" }, ] [[package]] name = "opentelemetry-instrumentation-threading" -version = "0.58b0" +version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/a9/3888cb0470e6eb48ea17b6802275ae71df411edd6382b9a8e8f391936fda/opentelemetry_instrumentation_threading-0.58b0.tar.gz", hash = "sha256:f68c61f77841f9ff6270176f4d496c10addbceacd782af434d705f83e4504862", size = 8770, upload-time = "2025-09-11T11:42:56.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/8f/8dedba66100cda58af057926449a5e58e6c008bec02bc2746c03c3d85dcd/opentelemetry_instrumentation_threading-0.61b0.tar.gz", hash = "sha256:38e0263c692d15a7a458b3fa0286d29290448fa4ac4c63045edac438c6113433", size = 9163, upload-time = "2026-03-04T14:20:50.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/54/add1076cb37980e617723a96e29c84006983e8ad6fc589dde7f69ddc57d4/opentelemetry_instrumentation_threading-0.58b0-py3-none-any.whl", hash = "sha256:eacc072881006aceb5b9b6831bcdce718c67ef6f31ac0b32bd6a23a94d979b4a", size = 9312, upload-time = "2025-09-11T11:41:58.603Z" }, + { url = "https://files.pythonhosted.org/packages/e8/77/c06d960aede1a014812aa4fafde0ae546d790f46416fbeafa2b32095aae3/opentelemetry_instrumentation_threading-0.61b0-py3-none-any.whl", hash = "sha256:735f4a1dc964202fc8aff475efc12bb64e6566f22dff52d5cb5de864b3fe1a70", size = 9337, upload-time = "2026-03-04T14:19:57.983Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.37.0" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/62/2e0ca80d7fe94f0b193135375da92c640d15fe81f636658d2acf373086bc/opentelemetry_sdk-1.37.0.tar.gz", hash = "sha256:cc8e089c10953ded765b5ab5669b198bbe0af1b3f89f1007d19acd32dc46dda5", size = 170404, upload-time = "2025-09-11T10:29:11.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/62/9f4ad6a54126fb00f7ed4bb5034964c6e4f00fcd5a905e115bd22707e20d/opentelemetry_sdk-1.37.0-py3-none-any.whl", hash = "sha256:8f3c3c22063e52475c5dbced7209495c2c16723d016d39287dfc215d1771257c", size = 131941, upload-time = "2025-09-11T10:28:57.83Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.58b0" +version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/1b/90701d91e6300d9f2fb352153fb1721ed99ed1f6ea14fa992c756016e63a/opentelemetry_semantic_conventions-0.58b0.tar.gz", hash = "sha256:6bd46f51264279c433755767bb44ad00f1c9e2367e1b42af563372c5a6fa0c25", size = 129867, upload-time = "2025-09-11T10:29:12.597Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/90/68152b7465f50285d3ce2481b3aec2f82822e3f52e5152eeeaf516bab841/opentelemetry_semantic_conventions-0.58b0-py3-none-any.whl", hash = "sha256:5564905ab1458b96684db1340232729fce3b5375a06e140e8904c78e4f815b28", size = 207954, upload-time = "2025-09-11T10:28:59.218Z" }, + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, ] [[package]] name = "orjson" -version = "3.11.3" +version = "3.11.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/4d/8df5f83256a809c22c4d6792ce8d43bb503be0fb7a8e4da9025754b09658/orjson-3.11.3.tar.gz", hash = "sha256:1c0603b1d2ffcd43a411d64797a19556ef76958aef1c182f22dc30860152a98a", size = 5482394, upload-time = "2025-08-26T17:46:43.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/64/4a3cef001c6cd9c64256348d4c13a7b09b857e3e1cbb5185917df67d8ced/orjson-3.11.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:29cb1f1b008d936803e2da3d7cba726fc47232c45df531b29edf0b232dd737e7", size = 238600, upload-time = "2025-08-26T17:44:36.875Z" }, - { url = "https://files.pythonhosted.org/packages/10/ce/0c8c87f54f79d051485903dc46226c4d3220b691a151769156054df4562b/orjson-3.11.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97dceed87ed9139884a55db8722428e27bd8452817fbf1869c58b49fecab1120", size = 123526, upload-time = "2025-08-26T17:44:39.574Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d0/249497e861f2d438f45b3ab7b7b361484237414945169aa285608f9f7019/orjson-3.11.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58533f9e8266cb0ac298e259ed7b4d42ed3fa0b78ce76860626164de49e0d467", size = 128075, upload-time = "2025-08-26T17:44:40.672Z" }, - { url = "https://files.pythonhosted.org/packages/e5/64/00485702f640a0fd56144042a1ea196469f4a3ae93681871564bf74fa996/orjson-3.11.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c212cfdd90512fe722fa9bd620de4d46cda691415be86b2e02243242ae81873", size = 130483, upload-time = "2025-08-26T17:44:41.788Z" }, - { url = "https://files.pythonhosted.org/packages/64/81/110d68dba3909171bf3f05619ad0cf187b430e64045ae4e0aa7ccfe25b15/orjson-3.11.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff835b5d3e67d9207343effb03760c00335f8b5285bfceefd4dc967b0e48f6a", size = 132539, upload-time = "2025-08-26T17:44:43.12Z" }, - { url = "https://files.pythonhosted.org/packages/79/92/dba25c22b0ddfafa1e6516a780a00abac28d49f49e7202eb433a53c3e94e/orjson-3.11.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5aa4682912a450c2db89cbd92d356fef47e115dffba07992555542f344d301b", size = 135390, upload-time = "2025-08-26T17:44:44.199Z" }, - { url = "https://files.pythonhosted.org/packages/44/1d/ca2230fd55edbd87b58a43a19032d63a4b180389a97520cc62c535b726f9/orjson-3.11.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d18dd34ea2e860553a579df02041845dee0af8985dff7f8661306f95504ddf", size = 132966, upload-time = "2025-08-26T17:44:45.719Z" }, - { url = "https://files.pythonhosted.org/packages/6e/b9/96bbc8ed3e47e52b487d504bd6861798977445fbc410da6e87e302dc632d/orjson-3.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8b11701bc43be92ea42bd454910437b355dfb63696c06fe953ffb40b5f763b4", size = 131349, upload-time = "2025-08-26T17:44:46.862Z" }, - { url = "https://files.pythonhosted.org/packages/c4/3c/418fbd93d94b0df71cddf96b7fe5894d64a5d890b453ac365120daec30f7/orjson-3.11.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:90368277087d4af32d38bd55f9da2ff466d25325bf6167c8f382d8ee40cb2bbc", size = 404087, upload-time = "2025-08-26T17:44:48.079Z" }, - { url = "https://files.pythonhosted.org/packages/5b/a9/2bfd58817d736c2f63608dec0c34857339d423eeed30099b126562822191/orjson-3.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd7ff459fb393358d3a155d25b275c60b07a2c83dcd7ea962b1923f5a1134569", size = 146067, upload-time = "2025-08-26T17:44:49.302Z" }, - { url = "https://files.pythonhosted.org/packages/33/ba/29023771f334096f564e48d82ed855a0ed3320389d6748a9c949e25be734/orjson-3.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8d902867b699bcd09c176a280b1acdab57f924489033e53d0afe79817da37e6", size = 135506, upload-time = "2025-08-26T17:44:50.558Z" }, - { url = "https://files.pythonhosted.org/packages/39/62/b5a1eca83f54cb3aa11a9645b8a22f08d97dbd13f27f83aae7c6666a0a05/orjson-3.11.3-cp310-cp310-win32.whl", hash = "sha256:bb93562146120bb51e6b154962d3dadc678ed0fce96513fa6bc06599bb6f6edc", size = 136352, upload-time = "2025-08-26T17:44:51.698Z" }, - { url = "https://files.pythonhosted.org/packages/e3/c0/7ebfaa327d9a9ed982adc0d9420dbce9a3fec45b60ab32c6308f731333fa/orjson-3.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:976c6f1975032cc327161c65d4194c549f2589d88b105a5e3499429a54479770", size = 131539, upload-time = "2025-08-26T17:44:52.974Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/360674cd817faef32e49276187922a946468579fcaf37afdfb6c07046e92/orjson-3.11.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d2ae0cc6aeb669633e0124531f342a17d8e97ea999e42f12a5ad4adaa304c5f", size = 238238, upload-time = "2025-08-26T17:44:54.214Z" }, - { url = "https://files.pythonhosted.org/packages/05/3d/5fa9ea4b34c1a13be7d9046ba98d06e6feb1d8853718992954ab59d16625/orjson-3.11.3-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ba21dbb2493e9c653eaffdc38819b004b7b1b246fb77bfc93dc016fe664eac91", size = 127713, upload-time = "2025-08-26T17:44:55.596Z" }, - { url = "https://files.pythonhosted.org/packages/e5/5f/e18367823925e00b1feec867ff5f040055892fc474bf5f7875649ecfa586/orjson-3.11.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00f1a271e56d511d1569937c0447d7dce5a99a33ea0dec76673706360a051904", size = 123241, upload-time = "2025-08-26T17:44:57.185Z" }, - { url = "https://files.pythonhosted.org/packages/0f/bd/3c66b91c4564759cf9f473251ac1650e446c7ba92a7c0f9f56ed54f9f0e6/orjson-3.11.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b67e71e47caa6680d1b6f075a396d04fa6ca8ca09aafb428731da9b3ea32a5a6", size = 127895, upload-time = "2025-08-26T17:44:58.349Z" }, - { url = "https://files.pythonhosted.org/packages/82/b5/dc8dcd609db4766e2967a85f63296c59d4722b39503e5b0bf7fd340d387f/orjson-3.11.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7d012ebddffcce8c85734a6d9e5f08180cd3857c5f5a3ac70185b43775d043d", size = 130303, upload-time = "2025-08-26T17:44:59.491Z" }, - { url = "https://files.pythonhosted.org/packages/48/c2/d58ec5fd1270b2aa44c862171891adc2e1241bd7dab26c8f46eb97c6c6f1/orjson-3.11.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd759f75d6b8d1b62012b7f5ef9461d03c804f94d539a5515b454ba3a6588038", size = 132366, upload-time = "2025-08-26T17:45:00.654Z" }, - { url = "https://files.pythonhosted.org/packages/73/87/0ef7e22eb8dd1ef940bfe3b9e441db519e692d62ed1aae365406a16d23d0/orjson-3.11.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6890ace0809627b0dff19cfad92d69d0fa3f089d3e359a2a532507bb6ba34efb", size = 135180, upload-time = "2025-08-26T17:45:02.424Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6a/e5bf7b70883f374710ad74faf99bacfc4b5b5a7797c1d5e130350e0e28a3/orjson-3.11.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9d4a5e041ae435b815e568537755773d05dac031fee6a57b4ba70897a44d9d2", size = 132741, upload-time = "2025-08-26T17:45:03.663Z" }, - { url = "https://files.pythonhosted.org/packages/bd/0c/4577fd860b6386ffaa56440e792af01c7882b56d2766f55384b5b0e9d39b/orjson-3.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d68bf97a771836687107abfca089743885fb664b90138d8761cce61d5625d55", size = 131104, upload-time = "2025-08-26T17:45:04.939Z" }, - { url = "https://files.pythonhosted.org/packages/66/4b/83e92b2d67e86d1c33f2ea9411742a714a26de63641b082bdbf3d8e481af/orjson-3.11.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bfc27516ec46f4520b18ef645864cee168d2a027dbf32c5537cb1f3e3c22dac1", size = 403887, upload-time = "2025-08-26T17:45:06.228Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e5/9eea6a14e9b5ceb4a271a1fd2e1dec5f2f686755c0fab6673dc6ff3433f4/orjson-3.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f66b001332a017d7945e177e282a40b6997056394e3ed7ddb41fb1813b83e824", size = 145855, upload-time = "2025-08-26T17:45:08.338Z" }, - { url = "https://files.pythonhosted.org/packages/45/78/8d4f5ad0c80ba9bf8ac4d0fc71f93a7d0dc0844989e645e2074af376c307/orjson-3.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:212e67806525d2561efbfe9e799633b17eb668b8964abed6b5319b2f1cfbae1f", size = 135361, upload-time = "2025-08-26T17:45:09.625Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5f/16386970370178d7a9b438517ea3d704efcf163d286422bae3b37b88dbb5/orjson-3.11.3-cp311-cp311-win32.whl", hash = "sha256:6e8e0c3b85575a32f2ffa59de455f85ce002b8bdc0662d6b9c2ed6d80ab5d204", size = 136190, upload-time = "2025-08-26T17:45:10.962Z" }, - { url = "https://files.pythonhosted.org/packages/09/60/db16c6f7a41dd8ac9fb651f66701ff2aeb499ad9ebc15853a26c7c152448/orjson-3.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:6be2f1b5d3dc99a5ce5ce162fc741c22ba9f3443d3dd586e6a1211b7bc87bc7b", size = 131389, upload-time = "2025-08-26T17:45:12.285Z" }, - { url = "https://files.pythonhosted.org/packages/3e/2a/bb811ad336667041dea9b8565c7c9faf2f59b47eb5ab680315eea612ef2e/orjson-3.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:fafb1a99d740523d964b15c8db4eabbfc86ff29f84898262bf6e3e4c9e97e43e", size = 126120, upload-time = "2025-08-26T17:45:13.515Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b0/a7edab2a00cdcb2688e1c943401cb3236323e7bfd2839815c6131a3742f4/orjson-3.11.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8c752089db84333e36d754c4baf19c0e1437012242048439c7e80eb0e6426e3b", size = 238259, upload-time = "2025-08-26T17:45:15.093Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c6/ff4865a9cc398a07a83342713b5932e4dc3cb4bf4bc04e8f83dedfc0d736/orjson-3.11.3-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:9b8761b6cf04a856eb544acdd82fc594b978f12ac3602d6374a7edb9d86fd2c2", size = 127633, upload-time = "2025-08-26T17:45:16.417Z" }, - { url = "https://files.pythonhosted.org/packages/6e/e6/e00bea2d9472f44fe8794f523e548ce0ad51eb9693cf538a753a27b8bda4/orjson-3.11.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b13974dc8ac6ba22feaa867fc19135a3e01a134b4f7c9c28162fed4d615008a", size = 123061, upload-time = "2025-08-26T17:45:17.673Z" }, - { url = "https://files.pythonhosted.org/packages/54/31/9fbb78b8e1eb3ac605467cb846e1c08d0588506028b37f4ee21f978a51d4/orjson-3.11.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f83abab5bacb76d9c821fd5c07728ff224ed0e52d7a71b7b3de822f3df04e15c", size = 127956, upload-time = "2025-08-26T17:45:19.172Z" }, - { url = "https://files.pythonhosted.org/packages/36/88/b0604c22af1eed9f98d709a96302006915cfd724a7ebd27d6dd11c22d80b/orjson-3.11.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6fbaf48a744b94091a56c62897b27c31ee2da93d826aa5b207131a1e13d4064", size = 130790, upload-time = "2025-08-26T17:45:20.586Z" }, - { url = "https://files.pythonhosted.org/packages/0e/9d/1c1238ae9fffbfed51ba1e507731b3faaf6b846126a47e9649222b0fd06f/orjson-3.11.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc779b4f4bba2847d0d2940081a7b6f7b5877e05408ffbb74fa1faf4a136c424", size = 132385, upload-time = "2025-08-26T17:45:22.036Z" }, - { url = "https://files.pythonhosted.org/packages/a3/b5/c06f1b090a1c875f337e21dd71943bc9d84087f7cdf8c6e9086902c34e42/orjson-3.11.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd4b909ce4c50faa2192da6bb684d9848d4510b736b0611b6ab4020ea6fd2d23", size = 135305, upload-time = "2025-08-26T17:45:23.4Z" }, - { url = "https://files.pythonhosted.org/packages/a0/26/5f028c7d81ad2ebbf84414ba6d6c9cac03f22f5cd0d01eb40fb2d6a06b07/orjson-3.11.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:524b765ad888dc5518bbce12c77c2e83dee1ed6b0992c1790cc5fb49bb4b6667", size = 132875, upload-time = "2025-08-26T17:45:25.182Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d4/b8df70d9cfb56e385bf39b4e915298f9ae6c61454c8154a0f5fd7efcd42e/orjson-3.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:84fd82870b97ae3cdcea9d8746e592b6d40e1e4d4527835fc520c588d2ded04f", size = 130940, upload-time = "2025-08-26T17:45:27.209Z" }, - { url = "https://files.pythonhosted.org/packages/da/5e/afe6a052ebc1a4741c792dd96e9f65bf3939d2094e8b356503b68d48f9f5/orjson-3.11.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fbecb9709111be913ae6879b07bafd4b0785b44c1eb5cac8ac76da048b3885a1", size = 403852, upload-time = "2025-08-26T17:45:28.478Z" }, - { url = "https://files.pythonhosted.org/packages/f8/90/7bbabafeb2ce65915e9247f14a56b29c9334003536009ef5b122783fe67e/orjson-3.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9dba358d55aee552bd868de348f4736ca5a4086d9a62e2bfbbeeb5629fe8b0cc", size = 146293, upload-time = "2025-08-26T17:45:29.86Z" }, - { url = "https://files.pythonhosted.org/packages/27/b3/2d703946447da8b093350570644a663df69448c9d9330e5f1d9cce997f20/orjson-3.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eabcf2e84f1d7105f84580e03012270c7e97ecb1fb1618bda395061b2a84a049", size = 135470, upload-time = "2025-08-26T17:45:31.243Z" }, - { url = "https://files.pythonhosted.org/packages/38/70/b14dcfae7aff0e379b0119c8a812f8396678919c431efccc8e8a0263e4d9/orjson-3.11.3-cp312-cp312-win32.whl", hash = "sha256:3782d2c60b8116772aea8d9b7905221437fdf53e7277282e8d8b07c220f96cca", size = 136248, upload-time = "2025-08-26T17:45:32.567Z" }, - { url = "https://files.pythonhosted.org/packages/35/b8/9e3127d65de7fff243f7f3e53f59a531bf6bb295ebe5db024c2503cc0726/orjson-3.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:79b44319268af2eaa3e315b92298de9a0067ade6e6003ddaef72f8e0bedb94f1", size = 131437, upload-time = "2025-08-26T17:45:34.949Z" }, - { url = "https://files.pythonhosted.org/packages/51/92/a946e737d4d8a7fd84a606aba96220043dcc7d6988b9e7551f7f6d5ba5ad/orjson-3.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:0e92a4e83341ef79d835ca21b8bd13e27c859e4e9e4d7b63defc6e58462a3710", size = 125978, upload-time = "2025-08-26T17:45:36.422Z" }, - { url = "https://files.pythonhosted.org/packages/fc/79/8932b27293ad35919571f77cb3693b5906cf14f206ef17546052a241fdf6/orjson-3.11.3-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:af40c6612fd2a4b00de648aa26d18186cd1322330bd3a3cc52f87c699e995810", size = 238127, upload-time = "2025-08-26T17:45:38.146Z" }, - { url = "https://files.pythonhosted.org/packages/1c/82/cb93cd8cf132cd7643b30b6c5a56a26c4e780c7a145db6f83de977b540ce/orjson-3.11.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:9f1587f26c235894c09e8b5b7636a38091a9e6e7fe4531937534749c04face43", size = 127494, upload-time = "2025-08-26T17:45:39.57Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b8/2d9eb181a9b6bb71463a78882bcac1027fd29cf62c38a40cc02fc11d3495/orjson-3.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61dcdad16da5bb486d7227a37a2e789c429397793a6955227cedbd7252eb5a27", size = 123017, upload-time = "2025-08-26T17:45:40.876Z" }, - { url = "https://files.pythonhosted.org/packages/b4/14/a0e971e72d03b509190232356d54c0f34507a05050bd026b8db2bf2c192c/orjson-3.11.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11c6d71478e2cbea0a709e8a06365fa63da81da6498a53e4c4f065881d21ae8f", size = 127898, upload-time = "2025-08-26T17:45:42.188Z" }, - { url = "https://files.pythonhosted.org/packages/8e/af/dc74536722b03d65e17042cc30ae586161093e5b1f29bccda24765a6ae47/orjson-3.11.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff94112e0098470b665cb0ed06efb187154b63649403b8d5e9aedeb482b4548c", size = 130742, upload-time = "2025-08-26T17:45:43.511Z" }, - { url = "https://files.pythonhosted.org/packages/62/e6/7a3b63b6677bce089fe939353cda24a7679825c43a24e49f757805fc0d8a/orjson-3.11.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b756575aaa2a855a75192f356bbda11a89169830e1439cfb1a3e1a6dde7be", size = 132377, upload-time = "2025-08-26T17:45:45.525Z" }, - { url = "https://files.pythonhosted.org/packages/fc/cd/ce2ab93e2e7eaf518f0fd15e3068b8c43216c8a44ed82ac2b79ce5cef72d/orjson-3.11.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9416cc19a349c167ef76135b2fe40d03cea93680428efee8771f3e9fb66079d", size = 135313, upload-time = "2025-08-26T17:45:46.821Z" }, - { url = "https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b822caf5b9752bc6f246eb08124c3d12bf2175b66ab74bac2ef3bbf9221ce1b2", size = 132908, upload-time = "2025-08-26T17:45:48.126Z" }, - { url = "https://files.pythonhosted.org/packages/eb/92/8f5182d7bc2a1bed46ed960b61a39af8389f0ad476120cd99e67182bfb6d/orjson-3.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:414f71e3bdd5573893bf5ecdf35c32b213ed20aa15536fe2f588f946c318824f", size = 130905, upload-time = "2025-08-26T17:45:49.414Z" }, - { url = "https://files.pythonhosted.org/packages/1a/60/c41ca753ce9ffe3d0f67b9b4c093bdd6e5fdb1bc53064f992f66bb99954d/orjson-3.11.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:828e3149ad8815dc14468f36ab2a4b819237c155ee1370341b91ea4c8672d2ee", size = 403812, upload-time = "2025-08-26T17:45:51.085Z" }, - { url = "https://files.pythonhosted.org/packages/dd/13/e4a4f16d71ce1868860db59092e78782c67082a8f1dc06a3788aef2b41bc/orjson-3.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac9e05f25627ffc714c21f8dfe3a579445a5c392a9c8ae7ba1d0e9fb5333f56e", size = 146277, upload-time = "2025-08-26T17:45:52.851Z" }, - { url = "https://files.pythonhosted.org/packages/8d/8b/bafb7f0afef9344754a3a0597a12442f1b85a048b82108ef2c956f53babd/orjson-3.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44fbe4000bd321d9f3b648ae46e0196d21577cf66ae684a96ff90b1f7c93633", size = 135418, upload-time = "2025-08-26T17:45:54.806Z" }, - { url = "https://files.pythonhosted.org/packages/60/d4/bae8e4f26afb2c23bea69d2f6d566132584d1c3a5fe89ee8c17b718cab67/orjson-3.11.3-cp313-cp313-win32.whl", hash = "sha256:2039b7847ba3eec1f5886e75e6763a16e18c68a63efc4b029ddf994821e2e66b", size = 136216, upload-time = "2025-08-26T17:45:57.182Z" }, - { url = "https://files.pythonhosted.org/packages/88/76/224985d9f127e121c8cad882cea55f0ebe39f97925de040b75ccd4b33999/orjson-3.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:29be5ac4164aa8bdcba5fa0700a3c9c316b411d8ed9d39ef8a882541bd452fae", size = 131362, upload-time = "2025-08-26T17:45:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cf/0dce7a0be94bd36d1346be5067ed65ded6adb795fdbe3abd234c8d576d01/orjson-3.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:18bd1435cb1f2857ceb59cfb7de6f92593ef7b831ccd1b9bfb28ca530e539dce", size = 125989, upload-time = "2025-08-26T17:45:59.95Z" }, - { url = "https://files.pythonhosted.org/packages/ef/77/d3b1fef1fc6aaeed4cbf3be2b480114035f4df8fa1a99d2dac1d40d6e924/orjson-3.11.3-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cf4b81227ec86935568c7edd78352a92e97af8da7bd70bdfdaa0d2e0011a1ab4", size = 238115, upload-time = "2025-08-26T17:46:01.669Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6d/468d21d49bb12f900052edcfbf52c292022d0a323d7828dc6376e6319703/orjson-3.11.3-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:bc8bc85b81b6ac9fc4dae393a8c159b817f4c2c9dee5d12b773bddb3b95fc07e", size = 127493, upload-time = "2025-08-26T17:46:03.466Z" }, - { url = "https://files.pythonhosted.org/packages/67/46/1e2588700d354aacdf9e12cc2d98131fb8ac6f31ca65997bef3863edb8ff/orjson-3.11.3-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:88dcfc514cfd1b0de038443c7b3e6a9797ffb1b3674ef1fd14f701a13397f82d", size = 122998, upload-time = "2025-08-26T17:46:04.803Z" }, - { url = "https://files.pythonhosted.org/packages/3b/94/11137c9b6adb3779f1b34fd98be51608a14b430dbc02c6d41134fbba484c/orjson-3.11.3-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d61cd543d69715d5fc0a690c7c6f8dcc307bc23abef9738957981885f5f38229", size = 132915, upload-time = "2025-08-26T17:46:06.237Z" }, - { url = "https://files.pythonhosted.org/packages/10/61/dccedcf9e9bcaac09fdabe9eaee0311ca92115699500efbd31950d878833/orjson-3.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2b7b153ed90ababadbef5c3eb39549f9476890d339cf47af563aea7e07db2451", size = 130907, upload-time = "2025-08-26T17:46:07.581Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/0e935539aa7b08b3ca0f817d73034f7eb506792aae5ecc3b7c6e679cdf5f/orjson-3.11.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7909ae2460f5f494fecbcd10613beafe40381fd0316e35d6acb5f3a05bfda167", size = 403852, upload-time = "2025-08-26T17:46:08.982Z" }, - { url = "https://files.pythonhosted.org/packages/4a/2b/50ae1a5505cd1043379132fdb2adb8a05f37b3e1ebffe94a5073321966fd/orjson-3.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2030c01cbf77bc67bee7eef1e7e31ecf28649353987775e3583062c752da0077", size = 146309, upload-time = "2025-08-26T17:46:10.576Z" }, - { url = "https://files.pythonhosted.org/packages/cd/1d/a473c158e380ef6f32753b5f39a69028b25ec5be331c2049a2201bde2e19/orjson-3.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0169ebd1cbd94b26c7a7ad282cf5c2744fce054133f959e02eb5265deae1872", size = 135424, upload-time = "2025-08-26T17:46:12.386Z" }, - { url = "https://files.pythonhosted.org/packages/da/09/17d9d2b60592890ff7382e591aa1d9afb202a266b180c3d4049b1ec70e4a/orjson-3.11.3-cp314-cp314-win32.whl", hash = "sha256:0c6d7328c200c349e3a4c6d8c83e0a5ad029bdc2d417f234152bf34842d0fc8d", size = 136266, upload-time = "2025-08-26T17:46:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/15/58/358f6846410a6b4958b74734727e582ed971e13d335d6c7ce3e47730493e/orjson-3.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:317bbe2c069bbc757b1a2e4105b64aacd3bc78279b66a6b9e51e846e4809f804", size = 131351, upload-time = "2025-08-26T17:46:15.27Z" }, - { url = "https://files.pythonhosted.org/packages/28/01/d6b274a0635be0468d4dbd9cafe80c47105937a0d42434e805e67cd2ed8b/orjson-3.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:e8f6a7a27d7b7bec81bd5924163e9af03d49bbb63013f107b48eb5d16db711bc", size = 125985, upload-time = "2025-08-26T17:46:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/de/1a/a373746fa6d0e116dd9e54371a7b54622c44d12296d5d0f3ad5e3ff33490/orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174", size = 229140, upload-time = "2026-02-02T15:37:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/52/a2/fa129e749d500f9b183e8a3446a193818a25f60261e9ce143ad61e975208/orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67", size = 128670, upload-time = "2026-02-02T15:37:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/08/93/1e82011cd1e0bd051ef9d35bed1aa7fb4ea1f0a055dc2c841b46b43a9ebd/orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11", size = 123832, upload-time = "2026-02-02T15:37:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d8/a26b431ef962c7d55736674dddade876822f3e33223c1f47a36879350d04/orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc", size = 129171, upload-time = "2026-02-02T15:37:11.112Z" }, + { url = "https://files.pythonhosted.org/packages/a7/19/f47819b84a580f490da260c3ee9ade214cf4cf78ac9ce8c1c758f80fdfc9/orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16", size = 141967, upload-time = "2026-02-02T15:37:12.282Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cd/37ece39a0777ba077fdcdbe4cccae3be8ed00290c14bf8afdc548befc260/orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222", size = 130991, upload-time = "2026-02-02T15:37:13.465Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ed/f2b5d66aa9b6b5c02ff5f120efc7b38c7c4962b21e6be0f00fd99a5c348e/orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa", size = 133674, upload-time = "2026-02-02T15:37:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6e/baa83e68d1aa09fa8c3e5b2c087d01d0a0bd45256de719ed7bc22c07052d/orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e", size = 138722, upload-time = "2026-02-02T15:37:16.501Z" }, + { url = "https://files.pythonhosted.org/packages/0c/47/7f8ef4963b772cd56999b535e553f7eb5cd27e9dd6c049baee6f18bfa05d/orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2", size = 409056, upload-time = "2026-02-02T15:37:17.895Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/2df104dd2244b3618f25325a656f85cc3277f74bbd91224752410a78f3c7/orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c", size = 144196, upload-time = "2026-02-02T15:37:19.349Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2a/ee41de0aa3a6686598661eae2b4ebdff1340c65bfb17fcff8b87138aab21/orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f", size = 134979, upload-time = "2026-02-02T15:37:20.906Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fa/92fc5d3d402b87a8b28277a9ed35386218a6a5287c7fe5ee9b9f02c53fb2/orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de", size = 127968, upload-time = "2026-02-02T15:37:23.178Z" }, + { url = "https://files.pythonhosted.org/packages/07/29/a576bf36d73d60df06904d3844a9df08e25d59eba64363aaf8ec2f9bff41/orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993", size = 125128, upload-time = "2026-02-02T15:37:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, + { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, + { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, + { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, + { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, ] [[package]] name = "ormsgpack" -version = "1.7.0" +version = "1.12.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/4c/562457de7aab8102c8505ac591ddfd117d554e0b31d7f1b88ca8ffed7703/ormsgpack-1.7.0.tar.gz", hash = "sha256:6b4c98839cb7fc2a212037d2258f3a22857155249eb293d45c45cb974cfba834", size = 55320, upload-time = "2024-12-08T17:44:02.441Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/85/f1b58b9856b136d37c988270a4b5404628d334695f5dc6402e6b632be7df/ormsgpack-1.7.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a0ca6a64d47073f22ecc1dd96b384e44f98796d3f88ee383e92dfbcdf18c2efd", size = 383796, upload-time = "2024-12-08T17:43:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/97/07/7ddf994123b4553c3ef8adc73d02c51b3283becb5ddf1ac998a5ae22109f/ormsgpack-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8385181bf195af80fc270e64fd477f1c414ffb05837320382e2ec9ca34be0ec", size = 209426, upload-time = "2024-12-08T17:43:26.778Z" }, - { url = "https://files.pythonhosted.org/packages/dd/14/e8b42e5f0be3be70063a79f3c50b06a748de3029c20e49fc0af60901e1d7/ormsgpack-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca4d35b694f32112eb33ac0b733cb903dbbc59f019d05ca3d74f6ad2f587b0bf", size = 216033, upload-time = "2024-12-08T17:43:28.035Z" }, - { url = "https://files.pythonhosted.org/packages/45/09/77c3cabe63f67eefeb850d17c8e5ba71579b8d72976486dee91a0aa6c427/ormsgpack-1.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e86124cdbc8ed249806347c2fba96843e8941122b161b429139a0c973d270de4", size = 220618, upload-time = "2024-12-08T17:43:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1e/5c727d2d48bb34b95b783bba155e49aff8c920b3fbb634c24914e0be3684/ormsgpack-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6d114652dadd81802b8a35a49e07a3e9ef2a47aed6123fb5031f2220d1c8e434", size = 125199, upload-time = "2024-12-08T17:43:31.109Z" }, - { url = "https://files.pythonhosted.org/packages/db/02/55d7c1b8040b26096189e001cd9b6dbc627c09440b82600480284812c966/ormsgpack-1.7.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2c22c62a6bc93bcb194b7f91864ca0b39455b2cbbfc1538a3da0f9ec3c11d184", size = 383797, upload-time = "2024-12-08T17:43:32.828Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2d/16934d165fe85e6ce8349879082f6306f9ca3022f59660beca3a52571688/ormsgpack-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9967a7f3647ad118751abf090f8397fda3e4bca6833340cab95a3f2bec598cd", size = 209426, upload-time = "2024-12-08T17:43:34.026Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/56eebfce626e4929eaf84f5aa39fe012fa43f2d5c04dccf0748e0c2da9ef/ormsgpack-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91ebb7d3609db249cdff629ffef83ec3d025b1384749a297cf3b6a8240cf22ac", size = 216033, upload-time = "2024-12-08T17:43:35.82Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3c/65fabc7f86d48e4a049a9e360829e0c67a8ee3cb23357c21ddf976b59e68/ormsgpack-1.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c683071bf4527ffa7b6cfcf28f750d1a82eb77846d106743c09261ab1b79b193", size = 220619, upload-time = "2024-12-08T17:43:37.151Z" }, - { url = "https://files.pythonhosted.org/packages/ee/05/87694d846e487b57a2f448bac635ce87e6253e1ad08a01d7eec19f13396a/ormsgpack-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:90aabfd816db60dadab1100d583d061e0238209015bf684f8170c0fca4eb445a", size = 125199, upload-time = "2024-12-08T17:43:38.937Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b1/a48ace13ae2c32abbcc53596a691a3c9dcc88f1229dc4ead258d4ba2f55c/ormsgpack-1.7.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77bc2ea387d85cfad045b9bcb8040bae43ad32dafe9363360f732cc19d489bbe", size = 384428, upload-time = "2024-12-08T17:43:40.721Z" }, - { url = "https://files.pythonhosted.org/packages/72/28/df2576518f1d11d8c05b24ab3744ee86b41439eeed05e86ab79f3cafd9cc/ormsgpack-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ec763096d978d35eedcef0af13991a10741717c2e236b26f4c2047b0740ea7b", size = 209563, upload-time = "2024-12-08T17:43:42.706Z" }, - { url = "https://files.pythonhosted.org/packages/a1/bd/44fcaa665a8340aea65db360f15e2090f834899e7acb88dacc91b5050e9d/ormsgpack-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22418a4d399027a72fb2e6b873559b1886cf2e63323ca7afc17b222c454413b7", size = 216208, upload-time = "2024-12-08T17:43:43.864Z" }, - { url = "https://files.pythonhosted.org/packages/bd/45/8c0269fde9dd4b04f07fdeb025f60e93ceeebb70911775e0263e6884e956/ormsgpack-1.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97723786755a7df85fcf6e68d7b5359dacea98d5c26b1d9af219a3cc05df4734", size = 220950, upload-time = "2024-12-08T17:43:45.103Z" }, - { url = "https://files.pythonhosted.org/packages/c6/38/ffbdf3936ca4976fb6dea597cccbcfb971bf22cd3175bac601d467ec474a/ormsgpack-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e6ada21f5c7a20ff7cf9b061c44e3814352f819947a12022ad8cb52a9f2a809", size = 125646, upload-time = "2024-12-08T17:43:46.392Z" }, - { url = "https://files.pythonhosted.org/packages/96/44/6de76ed10d506c156854f441ee5ba070ce0613650545d09446dfe38b9922/ormsgpack-1.7.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:462089a419dbde654915ccb0b859c0dbe3c178b0ac580018e82befea6ccd73f4", size = 384425, upload-time = "2024-12-08T17:43:47.496Z" }, - { url = "https://files.pythonhosted.org/packages/3a/48/4def290afde367f40bda418df7283b555f6a0a3f54ff1b18d48e6fdf2a43/ormsgpack-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b353204e99b56c1d33f1cf4767bd1fe1195596181a1cc789f25aa26c0b50f3d", size = 209564, upload-time = "2024-12-08T17:43:48.926Z" }, - { url = "https://files.pythonhosted.org/packages/c8/61/54d72fdd475e6269c517362aa825f451447b07ae6f687e2d436f430f3244/ormsgpack-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a5e12b51a590be47ccef67907905653e679fc2f920854b456edc216690ecc09c", size = 216208, upload-time = "2024-12-08T17:43:51.013Z" }, - { url = "https://files.pythonhosted.org/packages/55/1e/a264a7b680fe901323f0c97df5986c0c7543b3d5fe82ba4df9e4b19118d3/ormsgpack-1.7.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a6a97937d2cf21496d7689b90a43df83c5062bbe846aaa39197cc9ad73eaa7b", size = 220950, upload-time = "2024-12-08T17:43:52.92Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9b/378c1b209264e840294ecf97ea1f5cab05f3fc32bc5a1c79584ac5b06dc0/ormsgpack-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d301e47565fe0e52a60052e730a9bb7669dfbd2a94643b8be925e3928c64c15", size = 125645, upload-time = "2024-12-08T17:43:54.761Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, ] [[package]] @@ -3735,119 +4395,131 @@ wheels = [ ] [[package]] -name = "pillow" -version = "11.3.0" +name = "phonemizer-fork" +version = "3.3.2" 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" } +dependencies = [ + { name = "attrs" }, + { name = "dlinfo" }, + { name = "joblib" }, + { name = "segments" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/fa/9294d2f11890ca49d0bdac7a4da60cbe5686629bfd4987cae0ad75e051cc/phonemizer_fork-3.3.2.tar.gz", hash = "sha256:10e16e827d0443b087062e21b55e805c00989cf1343b2e81e734cae5f6c0cf69", size = 300989, upload-time = "2025-01-30T13:02:31.201Z" } 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/64/f1/0dcce21b0ae16a82df4b6583f8f3ad8e55b35f7e98b6bf536a4dd225fa08/phonemizer_fork-3.3.2-py3-none-any.whl", hash = "sha256:97305c76f4183b3825dae8f4c032265fe78c9946ce58c47d4b62161349264b74", size = 82700, upload-time = "2025-01-30T13:02:28.667Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +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/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]] name = "pip" -version = "25.2" +version = "26.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/16/650289cd3f43d5a2fadfd98c68bd1e1e7f2550a1a5326768cddfbcedb2c5/pip-25.2.tar.gz", hash = "sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2", size = 1840021, upload-time = "2025-07-30T21:50:15.401Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/3f/945ef7ab14dc4f9d7f40288d2df998d1837ee0888ec3659c813487572faa/pip-25.2-py3-none-any.whl", hash = "sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717", size = 1752557, upload-time = "2025-07-30T21:50:13.323Z" }, + { url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" }, ] [[package]] name = "pip-tools" -version = "7.4.1" +version = "7.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "build" }, @@ -3858,9 +4530,9 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/87/1ef453f10fb0772f43549686f924460cc0a2404b828b348f72c52cb2f5bf/pip-tools-7.4.1.tar.gz", hash = "sha256:864826f5073864450e24dbeeb85ce3920cdfb09848a3d69ebf537b521f14bcc9", size = 145417, upload-time = "2024-03-06T12:13:23.533Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/db/c6e2a02db5d98aa5f3250a305ce71e8bc3d1a022d1f47a54d14492ae23de/pip_tools-7.5.3.tar.gz", hash = "sha256:8fa364779ebc010cbfe17cb9de404457ac733e100840423f28f6955de7742d41", size = 176153, upload-time = "2026-02-11T18:25:07.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/dc/38f4ce065e92c66f058ea7a368a9c5de4e702272b479c0992059f7693941/pip_tools-7.4.1-py3-none-any.whl", hash = "sha256:4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9", size = 61235, upload-time = "2024-03-06T12:13:40.124Z" }, + { url = "https://files.pythonhosted.org/packages/6e/74/59906d876c6cb1137f42a137164f2fe683b06283cde84bfcf7f5dd43970b/pip_tools-7.5.3-py3-none-any.whl", hash = "sha256:3aac0c473240ae90db7213c033401f345b05197293ccbdd2704e52e7a783785e", size = 71359, upload-time = "2026-02-11T18:25:06.119Z" }, ] [[package]] @@ -3875,7 +4547,9 @@ dependencies = [ { name = "markdown" }, { name = "nltk" }, { name = "numba" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "onnxruntime" }, { name = "openai" }, { name = "pillow" }, { name = "protobuf" }, @@ -3883,6 +4557,7 @@ dependencies = [ { name = "pyloudnorm" }, { name = "resampy" }, { name = "soxr" }, + { name = "transformers" }, { name = "wait-for2", marker = "python_full_version < '3.12'" }, ] @@ -3909,8 +4584,10 @@ aws-nova-sonic = [ azure = [ { name = "azure-cognitiveservices-speech" }, ] +camb = [ + { name = "camb-sdk" }, +] cartesia = [ - { name = "cartesia" }, { name = "websockets" }, ] daily = [ @@ -3923,9 +4600,6 @@ deepgram = [ elevenlabs = [ { name = "websockets" }, ] -fal = [ - { name = "fal-client" }, -] fish = [ { name = "ormsgpack" }, { name = "websockets" }, @@ -3958,6 +4632,10 @@ hume = [ koala = [ { name = "pvkoala" }, ] +kokoro = [ + { name = "kokoro-onnx" }, + { name = "requests" }, +] krisp = [ { name = "pipecat-ai-krisp" }, ] @@ -3966,6 +4644,9 @@ langchain = [ { name = "langchain-community" }, { name = "langchain-openai" }, ] +lemonslice = [ + { name = "daily-python" }, +] livekit = [ { name = "livekit" }, { name = "livekit-api" }, @@ -3984,10 +4665,6 @@ local-smart-turn = [ { name = "torchaudio" }, { name = "transformers" }, ] -local-smart-turn-v3 = [ - { name = "onnxruntime" }, - { name = "transformers" }, -] mcp = [ { name = "mcp", extra = ["cli"] }, ] @@ -4019,7 +4696,11 @@ openai = [ openpipe = [ { name = "openpipe" }, ] -playht = [ +piper = [ + { name = "piper-tts" }, + { name = "requests" }, +] +resembleai = [ { name = "websockets" }, ] rime = [ @@ -4047,9 +4728,6 @@ sarvam = [ sentry = [ { name = "sentry-sdk" }, ] -silero = [ - { name = "onnxruntime" }, -] simli = [ { name = "simli-ai" }, ] @@ -4107,9 +4785,11 @@ dev = [ ] docs = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { 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.2.0", 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.9.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-markdown-builder" }, { name = "sphinx-rtd-theme" }, { name = "toml" }, @@ -4118,57 +4798,57 @@ docs = [ [package.metadata] requires-dist = [ { name = "accelerate", marker = "extra == 'moondream'", specifier = "~=1.10.0" }, - { name = "aic-sdk", marker = "extra == 'aic'", specifier = "~=1.2.0" }, - { name = "aioboto3", marker = "extra == 'aws'", specifier = "~=15.5.0" }, - { name = "aiofiles", specifier = ">=24.1.0,<25" }, + { name = "aic-sdk", marker = "extra == 'aic'", specifier = "~=2.1.0" }, + { name = "aioboto3", marker = "extra == 'aws'", specifier = ">=15.5.0,<16" }, + { name = "aiofiles", specifier = ">=24.1.0,<27" }, { name = "aiohttp", specifier = ">=3.11.12,<4" }, - { name = "aiortc", marker = "extra == 'webrtc'", specifier = ">=1.13.0,<2" }, - { name = "anthropic", marker = "extra == 'anthropic'", specifier = "~=0.49.0" }, + { name = "aiortc", marker = "extra == 'webrtc'", specifier = ">=1.14.0,<2" }, + { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.49.0,<1" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = "~=0.2.1" }, - { name = "aws-sdk-bedrock-runtime", marker = "python_full_version >= '3.12' and extra == 'aws-nova-sonic'", specifier = "~=0.2.0" }, + { name = "aws-sdk-bedrock-runtime", marker = "python_full_version >= '3.12' and extra == 'aws-nova-sonic'", specifier = "~=0.4.0" }, { name = "aws-sdk-sagemaker-runtime-http2", marker = "python_full_version >= '3.12' and extra == 'sagemaker'" }, - { name = "azure-cognitiveservices-speech", marker = "extra == 'azure'", specifier = "~=1.44.0" }, - { name = "cartesia", marker = "extra == 'cartesia'", specifier = "~=2.0.3" }, + { name = "azure-cognitiveservices-speech", marker = "extra == 'azure'", specifier = ">=1.47.0,<2" }, + { name = "camb-sdk", marker = "extra == 'camb'", specifier = ">=1.5.4,<2" }, { name = "coremltools", marker = "extra == 'local-smart-turn'", specifier = ">=8.0" }, - { name = "daily-python", marker = "extra == 'daily'", specifier = "~=0.23.0" }, - { name = "deepgram-sdk", marker = "extra == 'deepgram'", specifier = "~=4.7.0" }, - { name = "docstring-parser", specifier = "~=0.16" }, + { name = "daily-python", marker = "extra == 'daily'", specifier = "~=0.25.0" }, + { name = "deepgram-sdk", marker = "extra == 'deepgram'", specifier = ">=6.0.1,<7" }, + { name = "docstring-parser", specifier = ">=0.16,<1" }, { name = "einops", marker = "extra == 'moondream'", specifier = "~=0.8.0" }, - { name = "fal-client", marker = "extra == 'fal'", specifier = "~=0.5.9" }, - { name = "fastapi", marker = "extra == 'runner'", specifier = ">=0.115.6,<0.122.0" }, - { name = "fastapi", marker = "extra == 'websocket'", specifier = ">=0.115.6,<0.122.0" }, - { name = "faster-whisper", marker = "extra == 'whisper'", specifier = "~=1.1.1" }, + { name = "fastapi", marker = "extra == 'runner'", specifier = ">=0.115.6,<1" }, + { name = "fastapi", marker = "extra == 'websocket'", specifier = ">=0.115.6,<1" }, + { name = "faster-whisper", marker = "extra == 'whisper'", specifier = "~=1.2.1" }, { name = "google-cloud-speech", marker = "extra == 'google'", specifier = ">=2.33.0,<3" }, { name = "google-cloud-texttospeech", marker = "extra == 'google'", specifier = ">=2.31.0,<3" }, - { name = "google-genai", marker = "extra == 'google'", specifier = ">=1.51.0,<2" }, - { name = "groq", marker = "extra == 'groq'", specifier = "~=0.23.0" }, - { name = "hume", marker = "extra == 'hume'", specifier = ">=0.11.2" }, + { name = "google-genai", marker = "extra == 'google'", specifier = ">=1.57.0,<2" }, + { name = "groq", marker = "extra == 'groq'", specifier = ">=0.23.0,<2" }, + { name = "hume", marker = "extra == 'hume'", specifier = ">=0.11.2,<1" }, + { name = "kokoro-onnx", marker = "extra == 'kokoro'", specifier = ">=0.5.0,<1" }, { name = "langchain", marker = "extra == 'langchain'", specifier = "~=0.3.20" }, { name = "langchain-community", marker = "extra == 'langchain'", specifier = "~=0.3.20" }, { name = "langchain-openai", marker = "extra == 'langchain'", specifier = "~=0.3.9" }, - { name = "livekit", marker = "extra == 'heygen'", specifier = ">=1.0.13" }, - { name = "livekit", marker = "extra == 'livekit'", specifier = "~=1.0.13" }, - { name = "livekit-api", marker = "extra == 'livekit'", specifier = "~=1.0.5" }, + { name = "livekit", marker = "extra == 'heygen'", specifier = ">=1.0.13,<2" }, + { name = "livekit", marker = "extra == 'livekit'", specifier = ">=1.0.13,<2" }, + { name = "livekit-api", marker = "extra == 'livekit'", specifier = ">=1.0.5,<2" }, { name = "loguru", specifier = "~=0.7.3" }, { name = "markdown", specifier = ">=3.7,<4" }, { name = "mcp", extras = ["cli"], marker = "extra == 'mcp'", specifier = ">=1.11.0,<2" }, { name = "mem0ai", marker = "extra == 'mem0'", specifier = "~=0.1.94" }, { name = "mlx-whisper", marker = "extra == 'mlx-whisper'", specifier = "~=0.4.2" }, - { name = "nltk", specifier = ">=3.9.1,<4" }, + { name = "nltk", specifier = ">=3.9.3,<4" }, { name = "noisereduce", marker = "extra == 'noisereduce'", specifier = "~=3.0.3" }, - { name = "numba", specifier = "==0.61.2" }, + { name = "numba", specifier = ">=0.61.2,<1" }, { name = "numpy", specifier = ">=1.26.4,<3" }, - { name = "nvidia-riva-client", marker = "extra == 'nvidia'", specifier = "~=2.21.1" }, - { name = "onnxruntime", marker = "extra == 'local-smart-turn-v3'", specifier = ">=1.20.1,<2" }, - { name = "onnxruntime", marker = "extra == 'silero'", specifier = ">=1.20.1,<2" }, + { name = "nvidia-riva-client", marker = "extra == 'nvidia'", specifier = ">=2.21.1,<3" }, + { name = "onnxruntime", specifier = "~=1.23.2" }, { name = "openai", specifier = ">=1.74.0,<3" }, { name = "opencv-python", marker = "extra == 'webrtc'", specifier = ">=4.11.0.86,<5" }, { name = "openpipe", marker = "extra == 'openpipe'", specifier = ">=4.50.0,<6" }, - { name = "opentelemetry-api", marker = "extra == 'tracing'", specifier = ">=1.33.0" }, - { 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 = "opentelemetry-api", marker = "extra == 'tracing'", specifier = ">=1.33.0,<2" }, + { name = "opentelemetry-instrumentation", marker = "extra == 'tracing'", specifier = ">=0.54b0,<1" }, + { name = "opentelemetry-sdk", marker = "extra == 'tracing'", specifier = ">=1.33.0,<2" }, + { name = "ormsgpack", marker = "extra == 'fish'", specifier = ">=1.7.0,<2" }, + { name = "pillow", specifier = ">=11.1.0,<13" }, + { name = "pipecat-ai", extras = ["daily"], marker = "extra == 'lemonslice'" }, { name = "pipecat-ai", extras = ["nvidia"], marker = "extra == 'riva'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'assemblyai'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'asyncai'" }, @@ -4184,52 +4864,55 @@ requires-dist = [ { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'lmnt'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'neuphonic'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'openai'" }, - { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'playht'" }, + { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'resembleai'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'rime'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'sarvam'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'soniox'" }, { 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.0.4" }, - { name = "protobuf", specifier = "~=5.29.3" }, + { name = "pipecat-ai-small-webrtc-prebuilt", marker = "extra == 'runner'", specifier = ">=2.4.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" }, { name = "pyaudio", marker = "extra == 'local'", specifier = "~=0.2.14" }, { name = "pydantic", specifier = ">=2.10.6,<3" }, { name = "pygobject", marker = "extra == 'gstreamer'", specifier = "~=3.50.0" }, - { name = "pyjwt", marker = "extra == 'livekit'", specifier = ">=2.10.1" }, - { name = "pyloudnorm", specifier = "~=0.1.1" }, + { name = "pyjwt", marker = "extra == 'livekit'", specifier = ">=2.12.0,<3" }, + { name = "pyloudnorm", specifier = "~=0.2.0" }, { name = "pyrnnoise", marker = "extra == 'rnnoise'", specifier = "~=0.4.1" }, { name = "python-dotenv", marker = "extra == 'runner'", specifier = ">=1.0.0,<2.0.0" }, { name = "pyvips", extras = ["binary"], marker = "extra == 'moondream'", specifier = "~=3.0.0" }, + { 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.26" }, { 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.6" }, + { name = "soxr", specifier = "~=1.0.0" }, + { 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" }, { name = "torch", marker = "extra == 'local-smart-turn'", specifier = ">=2.5.0,<3" }, { name = "torchaudio", marker = "extra == 'local-smart-turn'", specifier = ">=2.5.0,<3" }, - { name = "transformers", marker = "extra == 'local-smart-turn'" }, - { name = "transformers", marker = "extra == 'local-smart-turn-v3'" }, - { name = "transformers", marker = "extra == 'moondream'", specifier = ">=4.48.0" }, + { name = "transformers", specifier = ">=4.48.0,<6" }, + { name = "transformers", marker = "extra == 'local-smart-turn'", specifier = ">=4.48.0,<6" }, + { name = "transformers", marker = "extra == 'moondream'", specifier = ">=4.48.0,<6" }, { name = "uvicorn", marker = "extra == 'runner'", specifier = ">=0.32.0,<1.0.0" }, - { name = "wait-for2", marker = "python_full_version < '3.12'", specifier = ">=0.4.1" }, + { name = "wait-for2", marker = "python_full_version < '3.12'", specifier = ">=0.4.1,<1" }, { name = "websockets", marker = "extra == 'websockets-base'", specifier = ">=13.1,<16.0" }, ] -provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "gradium", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "krisp", "langchain", "livekit", "lmnt", "local", "local-smart-turn", "local-smart-turn-v3", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "noisereduce", "nvidia", "openai", "rnnoise", "openpipe", "openrouter", "perplexity", "playht", "qwen", "remote-smart-turn", "rime", "riva", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"] +provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "camb", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "gradium", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "kokoro", "krisp", "langchain", "lemonslice", "livekit", "lmnt", "local", "local-smart-turn", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "noisereduce", "nvidia", "openai", "rnnoise", "openpipe", "openrouter", "perplexity", "piper", "qwen", "remote-smart-turn", "resembleai", "rime", "riva", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"] [package.metadata.requires-dev] dev = [ - { name = "build", specifier = "~=1.2.2" }, - { name = "coverage", specifier = "~=7.9.1" }, + { name = "build", specifier = "~=1.4.0" }, + { name = "coverage", specifier = "~=7.13.4" }, { name = "grpcio-tools", specifier = "~=1.67.1" }, - { name = "pip-tools", specifier = "~=7.4.1" }, - { name = "pre-commit", specifier = "~=4.2.0" }, + { name = "pip-tools", specifier = "~=7.5.3" }, + { name = "pre-commit", specifier = "~=4.5.1" }, { name = "pyright", specifier = ">=1.1.404,<1.2" }, { name = "pytest", specifier = "~=8.4.1" }, { name = "pytest-aiohttp", specifier = "==1.1.0" }, @@ -4253,29 +4936,46 @@ name = "pipecat-ai-krisp" version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/37/0f1d11d1dc33234a36de01992a9e5adc3c5e1dce71cc87b2bf909fa2f698/pipecat_ai_krisp-0.4.0.tar.gz", hash = "sha256:4f0e05e218dcf15874957e9851299e219c713a0aa8353d2fd811f1b54001a602", size = 13338, upload-time = "2025-06-09T16:13:08.209Z" } [[package]] name = "pipecat-ai-small-webrtc-prebuilt" -version = "2.0.4" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi", extra = ["all"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/88/57b26547ec45623718f1d5beb9e004dba55b7ab548a3b48748466e3e1769/pipecat_ai_small_webrtc_prebuilt-2.0.4.tar.gz", hash = "sha256:3c3447679007ea937c760223bb66579f2605cf94628a68c9da1d66787a96caad", size = 584994, upload-time = "2025-12-30T19:14:52.655Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/02/1e6e90f084ebb1fc954f37661c4614219e4c9fec3d305c8abe5141707b0c/pipecat_ai_small_webrtc_prebuilt-2.4.0.tar.gz", hash = "sha256:c5eddca4e061afb7c5f98cf52ccb85511978a8c834447f6c6d662029e02950c4", size = 472449, upload-time = "2026-03-13T14:17:08.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/6e/332b78d1c7888ff426bd528b150aad0da05024f4f91e56502c359726c07b/pipecat_ai_small_webrtc_prebuilt-2.0.4-py3-none-any.whl", hash = "sha256:054b3cee843fe69191859dbb0693560d9ca08f7d57a9ff0457d0bc741f36f4df", size = 585606, upload-time = "2025-12-30T19:14:50.595Z" }, + { url = "https://files.pythonhosted.org/packages/25/77/8f6f67142a153943fff31530d51dcf7a2374c39dfa9aba6ef163bf0c622f/pipecat_ai_small_webrtc_prebuilt-2.4.0-py3-none-any.whl", hash = "sha256:9e9a3aa24231b1bf4101a6a2b42c4164a186c0c3d3e49bd51f77280eaa402d12", size = 472792, upload-time = "2026-03-13T14:17:06.556Z" }, +] + +[[package]] +name = "piper-tts" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "onnxruntime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2f/df92a56383bbfb7a3a35ff539ba4081932bef0daf920ec089ab7269e1493/piper_tts-1.4.1.tar.gz", hash = "sha256:bf0640db9fe512392f0cf570d445f76b3894b29fbab6f81be42b784fd8f0afe0", size = 4364811, upload-time = "2026-02-05T09:58:25.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/1c/e9a6695e19aa5c80b3ac4f70dd432fe3dcf99519458ad149f73af5b0fa44/piper_tts-1.4.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76467df3abe0a0dd8d53e4e7d769ceb1669796e7188954182257be4cf79ddae0", size = 13822406, upload-time = "2026-02-05T09:58:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/633c64944a9ae13d5183989de1519e5eb30e5e6b668942d97ca03b04c53a/piper_tts-1.4.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:a99d93a2eb2805aa7059996069f8448c86ce7704200ec0bf9f9099f035494dc7", size = 13830418, upload-time = "2026-02-05T09:58:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/df/95/c4c4163cf0f636eec0ccb3adc63c70fb74334ff28e41e176f0ca0415496e/piper_tts-1.4.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dbc990b4e28c680a44e26dc7a880b3e1068e06ffc1deecc8690929895ffb005", size = 13841987, upload-time = "2026-02-05T09:58:18.259Z" }, + { url = "https://files.pythonhosted.org/packages/39/42/b44ae16ef80d86173518aafe2a493a826b46f9fe4fba1b82cd575117d5ac/piper_tts-1.4.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aa533364c15248d2932bcc362eb0740de7cd28dc34233de8df2ee3c6f2adf00", size = 13841873, upload-time = "2026-02-05T09:58:20.428Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/bc628449230681cf49af5e289974a076b22e98ecd017108ef0e679be4e00/piper_tts-1.4.1-cp39-abi3-win_amd64.whl", hash = "sha256:058c025f2a929180d034ed8c333f6b9dd286178703be2133efbafba7f4db13ff", size = 13831941, upload-time = "2026-02-05T09:58:22.562Z" }, ] [[package]] name = "platformdirs" -version = "4.4.0" +version = "4.9.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] [[package]] @@ -4301,7 +5001,7 @@ wheels = [ [[package]] name = "posthog" -version = "6.7.6" +version = "7.9.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -4311,14 +5011,14 @@ dependencies = [ { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/ce/11d6fa30ab517018796e1d675498992da585479e7079770ec8fa99a61561/posthog-6.7.6.tar.gz", hash = "sha256:ee5c5ad04b857d96d9b7a4f715e23916a2f206bfcf25e5a9d328a3d27664b0d3", size = 119129, upload-time = "2025-09-22T18:11:12.365Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/a7/2865487853061fbd62383492237b546d2d8f7c1846272350d2b9e14138cd/posthog-7.9.12.tar.gz", hash = "sha256:ebabf2eb2e1c1fbf22b0759df4644623fa43cc6c9dcbe9fd429b7937d14251ec", size = 176828, upload-time = "2026-03-12T09:01:15.184Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/84/586422d8861b5391c8414360b10f603c0b7859bb09ad688e64430ed0df7b/posthog-6.7.6-py3-none-any.whl", hash = "sha256:b09a7e65a042ec416c28874b397d3accae412a80a8b0ef3fa686fbffc99e4d4b", size = 137348, upload-time = "2025-09-22T18:11:10.807Z" }, + { url = "https://files.pythonhosted.org/packages/65/a9/7a803aed5a5649cf78ea7b31e90d0080181ba21f739243e1741a1e607f1f/posthog-7.9.12-py3-none-any.whl", hash = "sha256:7175bd1698a566bfea98a016c64e3456399f8046aeeca8f1d04ae5bf6c5a38d0", size = 202469, upload-time = "2026-03-12T09:01:13.38Z" }, ] [[package]] name = "pre-commit" -version = "4.2.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -4327,140 +5027,177 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] [[package]] name = "propcache" -version = "0.3.2" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" }, - { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" }, - { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" }, - { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" }, - { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" }, - { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" }, - { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" }, - { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, - { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, - { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, - { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, - { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, - { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, - { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, - { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, - { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, - { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, - { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, - { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, - { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, - { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, - { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, - { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, - { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, - { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, - { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, - { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, - { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, - { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, - { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, - { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, - { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, + { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, + { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] [[package]] name = "proto-plus" -version = "1.26.1" +version = "1.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142, upload-time = "2025-03-10T15:54:38.843Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" }, + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, ] [[package]] name = "protobuf" -version = "5.29.5" +version = "5.29.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/29/d09e70352e4e88c9c7a198d5645d7277811448d76c23b00345670f7c8a38/protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84", size = 425226, upload-time = "2025-05-28T23:51:59.82Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/11/6e40e9fc5bba02988a214c07cf324595789ca7820160bfd1f8be96e48539/protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079", size = 422963, upload-time = "2025-05-28T23:51:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/81/7f/73cefb093e1a2a7c3ffd839e6f9fcafb7a427d300c7f8aef9c64405d8ac6/protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc", size = 434818, upload-time = "2025-05-28T23:51:44.297Z" }, - { url = "https://files.pythonhosted.org/packages/dd/73/10e1661c21f139f2c6ad9b23040ff36fee624310dc28fba20d33fdae124c/protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671", size = 418091, upload-time = "2025-05-28T23:51:45.907Z" }, - { url = "https://files.pythonhosted.org/packages/6c/04/98f6f8cf5b07ab1294c13f34b4e69b3722bb609c5b701d6c169828f9f8aa/protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015", size = 319824, upload-time = "2025-05-28T23:51:47.545Z" }, - { url = "https://files.pythonhosted.org/packages/85/e4/07c80521879c2d15f321465ac24c70efe2381378c00bf5e56a0f4fbac8cd/protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61", size = 319942, upload-time = "2025-05-28T23:51:49.11Z" }, - { url = "https://files.pythonhosted.org/packages/7e/cc/7e77861000a0691aeea8f4566e5d3aa716f2b1dece4a24439437e41d3d25/protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5", size = 172823, upload-time = "2025-05-28T23:51:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" }, + { url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" }, + { url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" }, ] [[package]] name = "psutil" -version = "7.1.0" +version = "7.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660, upload-time = "2025-09-17T20:14:52.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242, upload-time = "2025-09-17T20:14:56.126Z" }, - { url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682, upload-time = "2025-09-17T20:14:58.25Z" }, - { url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994, upload-time = "2025-09-17T20:14:59.901Z" }, - { url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163, upload-time = "2025-09-17T20:15:01.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625, upload-time = "2025-09-17T20:15:04.492Z" }, - { url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812, upload-time = "2025-09-17T20:15:07.462Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965, upload-time = "2025-09-17T20:15:09.673Z" }, - { url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971, upload-time = "2025-09-17T20:15:12.262Z" }, + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] [[package]] @@ -4474,23 +5211,23 @@ wheels = [ [[package]] name = "pyaml" -version = "25.7.0" +version = "26.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/01/41f63d66a801a561c9e335523516bd5f761bc43cc61f8b75918306bf2da8/pyaml-25.7.0.tar.gz", hash = "sha256:e113a64ec16881bf2b092e2beb84b7dcf1bd98096ad17f5f14e8fb782a75d99b", size = 29814, upload-time = "2025-07-10T18:44:51.824Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/fb/2b9590512a9d7763620d87171c7531d5295678ce96e57393614b91da8998/pyaml-26.2.1.tar.gz", hash = "sha256:489dd82997235d4cfcf76a6287fce2f075487d77a6567c271e8d790583690c68", size = 30653, upload-time = "2026-02-06T13:49:30.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/ee/a878f2ad010cbccb311f947f0f2f09d38f613938ee28c34e60fceecc75a1/pyaml-25.7.0-py3-none-any.whl", hash = "sha256:ce5d7867cc2b455efdb9b0448324ff7b9f74d99f64650f12ca570102db6b985f", size = 26418, upload-time = "2025-07-10T18:44:50.679Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f3/1f8651f23101e6fae41d0d504414c9722b0140bf0fc6acf87ac52e18aa41/pyaml-26.2.1-py3-none-any.whl", hash = "sha256:6261c2f0a2f33245286c794ad6ec234be33a73d2b05427079fd343e2812a87cf", size = 27211, upload-time = "2026-02-06T13:49:29.652Z" }, ] [[package]] name = "pyasn1" -version = "0.6.1" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] @@ -4523,38 +5260,41 @@ wheels = [ [[package]] name = "pycairo" -version = "1.28.0" +version = "1.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/d9/412da520de9052b7e80bfc810ec10f5cb3dbfa4aa3e23c2820dc61cdb3d0/pycairo-1.28.0.tar.gz", hash = "sha256:26ec5c6126781eb167089a123919f87baa2740da2cca9098be8b3a6b91cc5fbc", size = 662477, upload-time = "2025-04-14T20:11:08.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/67/6e5d6328c6037f0479017f51e97f5cbb79a68ed6e93f671dac17cb47bf2e/pycairo-1.28.0-cp310-cp310-win32.whl", hash = "sha256:53e6dbc98456f789965dad49ef89ce2c62f9a10fc96c8d084e14da0ffb73d8a6", size = 750438, upload-time = "2025-04-14T20:10:43.722Z" }, - { url = "https://files.pythonhosted.org/packages/00/79/e941186c2275333643504f57711b157ba17e81a2be805346585ad7fd382e/pycairo-1.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:c8ab91a75025f984bc327ada335c787efb61c929ea0512063793cb36cee503d4", size = 841526, upload-time = "2025-04-14T20:10:45.557Z" }, - { url = "https://files.pythonhosted.org/packages/84/4b/3495baabd3c830bf80bf112a0e645342bfe8f3fc05ed6423444adf62d496/pycairo-1.28.0-cp310-cp310-win_arm64.whl", hash = "sha256:e955328c1a5147bf71ee94e206413ce15e12630296a79788fcd246c80e5337b8", size = 691866, upload-time = "2025-04-16T19:30:17.73Z" }, - { url = "https://files.pythonhosted.org/packages/c9/94/47d75f4eaac865f32e0550ed42869f8b3c4036f8e2b1e6163e9548f4d44f/pycairo-1.28.0-cp311-cp311-win32.whl", hash = "sha256:0fee15f5d72b13ba5fd065860312493dc1bca6ff2dce200ee9d704e11c94e60a", size = 750441, upload-time = "2025-04-14T20:10:48.311Z" }, - { url = "https://files.pythonhosted.org/packages/aa/02/1169a2917b043998585f9dd0f36da618947fdf9b6cc0e9ff9852aa4d548b/pycairo-1.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:6339979bfec8b58a06476094a9a5c104bd5a99932ddaff16ca0d9203d2f4482c", size = 841536, upload-time = "2025-04-14T20:10:51.112Z" }, - { url = "https://files.pythonhosted.org/packages/eb/99/13031086fb4d4ea71568d9ce74e03afbb2e18047f1e6b4e8674851f0414f/pycairo-1.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6ae15392e28ebfc0b35d8dc05d395d3b6be4bad9ad4caecf0fa12c8e7150225", size = 691895, upload-time = "2025-04-16T19:30:20.724Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d7/206d7945aac5c6c889e7fea4011410d1311455a1d8dfce66106d8d700b7f/pycairo-1.28.0-cp312-cp312-win32.whl", hash = "sha256:c00cfbb7f30eb7ca1d48886712932e2d91e8835a8496f4e423878296ceba573e", size = 750594, upload-time = "2025-04-14T20:10:53.72Z" }, - { url = "https://files.pythonhosted.org/packages/2e/ba/259fc9a4d3afdad1e5b9db52b9d8a5df67f70dd787167185e8ec8a1af007/pycairo-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:d50d190f5033992b55050b9f337ee42a45c3568445d5e5d7987bab96c278d8a6", size = 841767, upload-time = "2025-04-14T20:10:56.711Z" }, - { url = "https://files.pythonhosted.org/packages/c0/08/aa0903612ea0e8686ad6af58d4fb9cbab8ee0b0831201cddf7abd578d045/pycairo-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:957e0340ee1c279d197d4f7cfa96f6d8b48e453eec711fca999748d752468ff4", size = 691687, upload-time = "2025-04-16T19:30:23.729Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/c3e5ed55781dfe1b31eb4a2482aeae707671f3d36b0ea53a1722f4a3dfe9/pycairo-1.28.0-cp313-cp313-win32.whl", hash = "sha256:d13352429d8a08a1cb3607767d23d2fb32e4c4f9faa642155383980ec1478c24", size = 750594, upload-time = "2025-04-14T20:10:59.284Z" }, - { url = "https://files.pythonhosted.org/packages/8b/1c/ebadd290748aff3b6bc35431114d41e7a42f40a4b988c2aaf2dfed5d8156/pycairo-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:082aef6b3a9dcc328fa648d38ed6b0a31c863e903ead57dd184b2e5f86790140", size = 841774, upload-time = "2025-04-14T20:11:01.79Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ce/a3f5f1946613cd8a4654322b878c59f273c6e9b01dfadadd3f609070e0b9/pycairo-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:026afd53b75291917a7412d9fe46dcfbaa0c028febd46ff1132d44a53ac2c8b6", size = 691675, upload-time = "2025-04-16T19:30:26.565Z" }, - { url = "https://files.pythonhosted.org/packages/96/1b/1f5da2b2e44b33a5b269ff28af710b0a7903001d9cf8a40d00fc944a5092/pycairo-1.28.0-cp314-cp314-win32.whl", hash = "sha256:d0ab30585f536101ad6f09052fc3895e2a437ba57531ea07223d0e076248025d", size = 766699, upload-time = "2025-07-24T06:36:07.267Z" }, - { url = "https://files.pythonhosted.org/packages/c9/91/1d5f18bd3ed469b1de8f83bfbf8bd67d23439f075151b66740fd35356190/pycairo-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:94f2ed204999ab95a0671a0fa948ffbb9f3d6fb8731fe787917f6d022d9c1c0f", size = 868725, upload-time = "2025-07-24T06:36:09.597Z" }, + { url = "https://files.pythonhosted.org/packages/23/e2/c08847af2a103517f7785830706b6d1d55274494d76ab605eb744404c22f/pycairo-1.29.0-cp310-cp310-win32.whl", hash = "sha256:96c67e6caba72afd285c2372806a0175b1aa2f4537aa88fb4d9802d726effcd1", size = 751339, upload-time = "2025-11-11T19:11:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/2a934c6fd4f32d2011c4d9cc59a32e34e06a97dd9f4b138614078d39340b/pycairo-1.29.0-cp310-cp310-win_amd64.whl", hash = "sha256:65bddd944aee9f7d7d72821b1c87e97593856617c2820a78d589d66aa8afbd08", size = 845074, upload-time = "2025-11-11T19:11:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f0/ee0a887d8c8a6833940263b7234aaa63d8d95a27d6130a9a053867ff057c/pycairo-1.29.0-cp310-cp310-win_arm64.whl", hash = "sha256:15b36aea699e2ff215cb6a21501223246032e572a3a10858366acdd69c81a1c8", size = 694758, upload-time = "2025-11-11T19:11:32.635Z" }, + { url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" }, + { url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" }, + { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" }, + { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" }, + { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" }, + { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" }, + { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" }, + { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" }, ] [[package]] name = "pycparser" -version = "2.23" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] name = "pydantic" -version = "2.11.9" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -4562,9 +5302,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [package.optional-dependencies] @@ -4574,137 +5314,159 @@ email = [ [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[package]] name = "pydantic-extra-types" -version = "2.10.5" +version = "2.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/ba/4178111ec4116c54e1dc7ecd2a1ff8f54256cdbd250e576882911e8f710a/pydantic_extra_types-2.10.5.tar.gz", hash = "sha256:1dcfa2c0cf741a422f088e0dbb4690e7bfadaaf050da3d6f80d6c3cf58a2bad8", size = 138429, upload-time = "2025-06-02T09:31:52.713Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/1a/5f4fd9e7285f10c44095a4f9fe17d0f358d1702a7c74a9278c794e8a7537/pydantic_extra_types-2.10.5-py3-none-any.whl", hash = "sha256:b60c4e23d573a69a4f1a16dd92888ecc0ef34fb0e655b4f305530377fa70e7a8", size = 38315, upload-time = "2025-06-02T09:31:51.229Z" }, + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, ] [[package]] name = "pydantic-settings" -version = "2.11.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/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +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/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, -] - -[[package]] -name = "pydub" -version = "0.25.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, + { 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]] 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]] @@ -4718,78 +5480,87 @@ wheels = [ [[package]] name = "pygobject" -version = "3.50.1" +version = "3.50.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycairo" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/eb/53106840011df907781891a4968a35cfde42aef0e80f74c060367402a468/pygobject-3.50.1.tar.gz", hash = "sha256:a4df4e7adef7f4f01685a763d138eac9396585bfc68a7d31bbe4fbca2de0d7cb", size = 1081846, upload-time = "2025-05-25T14:53:01.761Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/5d/f2946cc6c1baf56dee6e942af8cfa16472538a8ad9d780d9f484e7554288/pygobject-3.50.2.tar.gz", hash = "sha256:ece6b860aab77cb649fdfc6e88d8a83765e7a62f7ffd39a628d6e2a0d397a7ff", size = 1085854, upload-time = "2025-10-18T13:44:45.634Z" } [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, ] [[package]] name = "pylibsrtp" -version = "0.12.0" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/c8/a59e61f5dd655f5f21033bd643dd31fe980a537ed6f373cdfb49d3a3bd32/pylibsrtp-0.12.0.tar.gz", hash = "sha256:f5c3c0fb6954e7bb74dc7e6398352740ca67327e6759a199fe852dbc7b84b8ac", size = 10878, upload-time = "2025-04-06T12:35:51.804Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/a6/6e532bec974aaecbf9fe4e12538489fb1c28456e65088a50f305aeab9f89/pylibsrtp-1.0.0.tar.gz", hash = "sha256:b39dff075b263a8ded5377f2490c60d2af452c9f06c4d061c7a2b640612b34d4", size = 10858, upload-time = "2025-10-13T16:12:31.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/f0/b818395c4cae2d5cc5a0c78fc47d694eae78e6a0d678baeb52a381a26327/pylibsrtp-0.12.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:5adde3cf9a5feef561d0eb7ed99dedb30b9bf1ce9a0c1770b2bf19fd0b98bc9a", size = 1727918, upload-time = "2025-04-06T12:35:36.456Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/ee553abe4431b7bd9bab18f078c0ad2298b94ea55e664da6ecb8700b1052/pylibsrtp-0.12.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d2c81d152606721331ece87c80ed17159ba6da55c7c61a6b750cff67ab7f63a5", size = 2057900, upload-time = "2025-04-06T12:35:38.253Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a2/2dd0188be58d3cba48c5eb4b3c787e5743c111cd0c9289de4b6f2798382a/pylibsrtp-0.12.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:242fa3d44219846bf1734d5df595563a2c8fbb0fb00ccc79ab0f569fc0af2c1b", size = 2567047, upload-time = "2025-04-06T12:35:39.797Z" }, - { url = "https://files.pythonhosted.org/packages/6c/3a/4bdab9fc1d78f2efa02c8a8f3e9c187bfa278e89481b5123f07c8dd69310/pylibsrtp-0.12.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b74aaf8fac1b119a3c762f54751c3d20e77227b84c26d85aae57c2c43129b49c", size = 2168775, upload-time = "2025-04-06T12:35:41.422Z" }, - { url = "https://files.pythonhosted.org/packages/d0/fc/0b1e1bfed420d79427d50aff84c370dcd78d81af9500c1e86fbcc5bf95e1/pylibsrtp-0.12.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e3e223102989b71f07e1deeb804170ed53fb4e1b283762eb031bd45bb425d4", size = 2225033, upload-time = "2025-04-06T12:35:43.03Z" }, - { url = "https://files.pythonhosted.org/packages/39/7b/e1021d27900315c2c077ec7d45f50274cedbdde067ff679d44df06f01a8a/pylibsrtp-0.12.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:36d07de64dbc82dbbb99fd77f36c8e23d6730bdbcccf09701945690a9a9a422a", size = 2606093, upload-time = "2025-04-06T12:35:44.587Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c2/0fae6687a06fcde210a778148ec808af49e431c36fe9908503a695c35479/pylibsrtp-0.12.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:ef03b4578577690f716fd023daed8914eee6de9a764fa128eda19a0e645cc032", size = 2193213, upload-time = "2025-04-06T12:35:46.167Z" }, - { url = "https://files.pythonhosted.org/packages/67/c2/2ed7a4a5c38b999fd34298f76b93d29f5ba8c06f85cfad3efd9468343715/pylibsrtp-0.12.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0a8421e9fe4d20ce48d439430e55149f12b1bca1b0436741972c362c49948c0a", size = 2256774, upload-time = "2025-04-06T12:35:47.704Z" }, - { url = "https://files.pythonhosted.org/packages/48/d7/f13fedce3b21d24f6f154d1dee7287464a34728dcb3b0c50f687dbad5765/pylibsrtp-0.12.0-cp39-abi3-win32.whl", hash = "sha256:cbc9bfbfb2597e993a1aa16b832ba16a9dd4647f70815421bb78484f8b50b924", size = 1156186, upload-time = "2025-04-06T12:35:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/9b/26/3a20b638a3a3995368f856eeb10701dd6c0e9ace9fb6665eeb1b95ccce19/pylibsrtp-0.12.0-cp39-abi3-win_amd64.whl", hash = "sha256:061ef1dbb5f08079ac6d7515b7e67ca48a3163e16e5b820beea6b01cb31d7e54", size = 1485072, upload-time = "2025-04-06T12:35:50.312Z" }, + { url = "https://files.pythonhosted.org/packages/aa/af/89e61a62fa3567f1b7883feb4d19e19564066c2fcd41c37e08d317b51881/pylibsrtp-1.0.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:822c30ea9e759b333dc1f56ceac778707c51546e97eb874de98d7d378c000122", size = 1865017, upload-time = "2025-10-13T16:12:15.62Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0e/8d215484a9877adcf2459a8b28165fc89668b034565277fd55d666edd247/pylibsrtp-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:aaad74e5c8cbc1c32056c3767fea494c1e62b3aea2c908eda2a1051389fdad76", size = 2182739, upload-time = "2025-10-13T16:12:17.121Z" }, + { url = "https://files.pythonhosted.org/packages/57/3f/76a841978877ae13eac0d4af412c13bbd5d83b3df2c1f5f2175f2e0f68e5/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9209b86e662ebbd17c8a9e8549ba57eca92a3e87fb5ba8c0e27b8c43cd08a767", size = 2732922, upload-time = "2025-10-13T16:12:18.348Z" }, + { url = "https://files.pythonhosted.org/packages/0e/14/cf5d2a98a66fdfe258f6b036cda570f704a644fa861d7883a34bc359501e/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc", size = 2434534, upload-time = "2025-10-13T16:12:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/bd/08/a3f6e86c04562f7dce6717cd2206a0f84ca85c5e38121d998e0e330194c3/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:81fb8879c2e522021a7cbd3f4bda1b37c192e1af939dfda3ff95b4723b329663", size = 2345818, upload-time = "2025-10-13T16:12:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d5/130c2b5b4b51df5631684069c6f0a6761c59d096a33d21503ac207cf0e47/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4ddb562e443cf2e557ea2dfaeef0d7e6b90e96dd38eb079b4ab2c8e34a79f50b", size = 2774490, upload-time = "2025-10-13T16:12:22.659Z" }, + { url = "https://files.pythonhosted.org/packages/91/e3/715a453bfee3bea92a243888ad359094a7727cc6d393f21281320fe7798c/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f02e616c9dfab2b03b32d8cc7b748f9d91814c0211086f987629a60f05f6e2cc", size = 2372603, upload-time = "2025-10-13T16:12:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/e3/56/52fa74294254e1f53a4ff170ee2006e57886cf4bb3db46a02b4f09e1d99f/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c134fa09e7b80a5b7fed626230c5bc257fd771bd6978e754343e7a61d96bc7e6", size = 2451269, upload-time = "2025-10-13T16:12:25.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/51/2e9b34f484cbdd3bac999bf1f48b696d7389433e900639089e8fc4e0da0d/pylibsrtp-1.0.0-cp310-abi3-win32.whl", hash = "sha256:bae377c3b402b17b9bbfbfe2534c2edba17aa13bea4c64ce440caacbe0858b55", size = 1247503, upload-time = "2025-10-13T16:12:27.39Z" }, + { url = "https://files.pythonhosted.org/packages/c3/70/43db21af194580aba2d9a6d4c7bd8c1a6e887fa52cd810b88f89096ecad2/pylibsrtp-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:8d6527c4a78a39a8d397f8862a8b7cdad4701ee866faf9de4ab8c70be61fd34d", size = 1601659, upload-time = "2025-10-13T16:12:29.037Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ec/6e02b2561d056ea5b33046e3cad21238e6a9097b97d6ccc0fbe52b50c858/pylibsrtp-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:2696bdb2180d53ac55d0eb7b58048a2aa30cd4836dd2ca683669889137a94d2a", size = 1159246, upload-time = "2025-10-13T16:12:30.285Z" }, ] [[package]] name = "pyloudnorm" -version = "0.1.1" +version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "future" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.16.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/b5/39d59c44ecd828fabfdbd796b50a561e6543ca90ef440ab307374f107856/pyloudnorm-0.1.1.tar.gz", hash = "sha256:63cd4e197dea4e7795160ea08ed02d318091bce883e436a6dbc5963326b71e1e", size = 8588, upload-time = "2023-01-05T16:11:28.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/00/f915eaa75326f4209941179c2b93ac477f2040e4aeff5bb21d16eb8058f9/pyloudnorm-0.2.0.tar.gz", hash = "sha256:8bf597658ea4e1975c275adf490f6deb5369ea409f2901f939915efa4b681b16", size = 14037, upload-time = "2026-01-04T11:43:35.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f5/6724805521ab4e723a12182f92374031032aff28a8a89dc8505c52b79032/pyloudnorm-0.1.1-py3-none-any.whl", hash = "sha256:d7f12ebdd097a464d87ce2878fc4d942f15f8233e26cc03f33fefa226f869a14", size = 9636, upload-time = "2023-01-05T16:11:27.331Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b6/65a49a05614b2548edbba3aab118f2ebe7441dfd778accdcdce9f6567f20/pyloudnorm-0.2.0-py3-none-any.whl", hash = "sha256:9bb69afb904f59d007a7f9ba3d75d16fb8aeef35c44d6df822a9f192d69cf13f", size = 10879, upload-time = "2026-01-04T11:43:34.534Z" }, ] [[package]] name = "pyopenssl" -version = "25.3.0" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, ] [[package]] name = "pyparsing" -version = "3.2.5" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] [[package]] @@ -4812,32 +5583,34 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.406" +version = "1.1.408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/16/6b4fbdd1fef59a0292cbb99f790b44983e390321eccbc5921b4d161da5d1/pyright-1.1.406.tar.gz", hash = "sha256:c4872bc58c9643dac09e8a2e74d472c62036910b3bd37a32813989ef7576ea2c", size = 4113151, upload-time = "2025-10-02T01:04:45.488Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/a2/e309afbb459f50507103793aaef85ca4348b66814c86bc73908bdeb66d12/pyright-1.1.406-py3-none-any.whl", hash = "sha256:1d81fb43c2407bf566e97e57abb01c811973fdb21b2df8df59f870f688bdca71", size = 5980982, upload-time = "2025-10-02T01:04:43.137Z" }, + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, ] [[package]] name = "pyrnnoise" -version = "0.4.1" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "audiolab" }, { name = "click" }, { name = "matplotlib" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "tqdm" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/59/49/7017ffa14230096e0271bd49dfd9ab60a32bfebe7e71399c2a0e38c6f859/pyrnnoise-0.4.1-py3-none-macosx_15_0_universal2.whl", hash = "sha256:c1fe407729190d0f84f3e3c9d9322ebbd33b27f3f5d9f7217379b71a4dd043e7", size = 13381833, upload-time = "2025-11-25T15:54:06.532Z" }, - { url = "https://files.pythonhosted.org/packages/8e/24/fb8b7bafb3dd9cbb46e134fa25c9597683c61b42c0133453fefeebeb0066/pyrnnoise-0.4.1-py3-none-manylinux1_x86_64.whl", hash = "sha256:ddd39b45221b65fb235f882a0ce127513a1012d41c5b3ba9dc4e9e991b22c205", size = 13273307, upload-time = "2025-11-25T15:54:04.076Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8e/eef9b2022fa5b9a111ba31d2f25ccd6e45da3daf16d20352e1fb18fd81dd/pyrnnoise-0.4.1-py3-none-win_amd64.whl", hash = "sha256:440e32359256eb7947e29fb080e800e984ba521fbe89a8b0b2f5dc196965e441", size = 13267076, upload-time = "2025-11-25T15:54:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/1f/90/51bb94bcfd8aab186fd08902e0706a6eda5813485fb57eff011ce6ae4c51/pyrnnoise-0.4.3-py3-none-macosx_15_0_universal2.whl", hash = "sha256:bdd8e933d32457362e6f4e56831afa8155208825040ab075c4223baed755fa4f", size = 13381834, upload-time = "2026-01-14T08:44:28.263Z" }, + { url = "https://files.pythonhosted.org/packages/04/51/993a25a8b5220e23e0a31ff98747b8fce4685336e0fc4e8e156feab5c4f1/pyrnnoise-0.4.3-py3-none-manylinux1_x86_64.whl", hash = "sha256:1b094777e73797c5dd647782902c691ebb9a3c456c878e742597f5b55535a3db", size = 13273307, upload-time = "2026-01-14T08:44:27.801Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e4/9a13ede6521360341314bf90d5b687cd3f1bd4259bfea740dbc88340484a/pyrnnoise-0.4.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:161c57e05257e0b51f1b21675dcb2debb8cc86903c1fe2ccc3feb4322e545732", size = 13267247, upload-time = "2026-01-14T08:44:30.119Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/795f8504fa7f07fc16e99e82413a6fe997df1999e18bb6fab0b428431a92/pyrnnoise-0.4.3-py3-none-win_amd64.whl", hash = "sha256:25e7d8d63f251238a439e6e3d54ad8cb147c9f2b7c7c56fc9d9a496f682d8b06", size = 13267061, upload-time = "2026-01-14T08:45:03.444Z" }, ] [[package]] @@ -4899,30 +5672,43 @@ wheels = [ ] [[package]] -name = "python-dotenv" -version = "1.1.1" +name = "python-discovery" +version = "1.1.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] [[package]] name = "pytz" -version = "2025.2" +version = "2026.1.post1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, ] [[package]] @@ -4941,23 +5727,23 @@ binary = [ [[package]] name = "pyvips-binary" -version = "8.17.2" +version = "8.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/d5/9a10b120a85f945eca0992442dcc90ff6154ad66f44d03ec611d10333252/pyvips_binary-8.17.2.tar.gz", hash = "sha256:6c7c2d4b541aa424b33ee9d2949542c2f5a5b32a04dd63f65ce0711c62498136", size = 3750, upload-time = "2025-09-15T17:12:04.548Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/94/65b69d93df3bef0b45f4ca83b7a231df3caeab110844ab7d0960158ac5bd/pyvips_binary-8.18.0.tar.gz", hash = "sha256:2f9e509de6d0cf04ea9b429ff0649130a9cf04de8a4f0887d2bcb72e3973225a", size = 3725, upload-time = "2026-01-01T11:16:57.306Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/e7/43c319108afdeff167f09e200ee8208dce59dbb1fddfefb22be86a5b4964/pyvips_binary-8.17.2-cp37-abi3-macosx_10_15_x86_64.whl", hash = "sha256:9511ec9b3d021429fd0646bb0a85ce4b89733077a55ae6f3358f99c188948174", size = 8177729, upload-time = "2025-09-15T17:11:45.395Z" }, - { url = "https://files.pythonhosted.org/packages/cc/ca/436cc80281d11c0f9f98b07d6859d68930e07abcfbc0003227c56dd74bd1/pyvips_binary-8.17.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:1025de708a35a6eebaccf9607574fde0259eb272e196286a9c0ad33ee8a257d7", size = 7285592, upload-time = "2025-09-15T17:11:47.159Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c5/e9c991ad1d92b49b1270987959f044c78d8496dbbeedce49c8e9b0aa7e9d/pyvips_binary-8.17.2-cp37-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de4c788e6f491edcc461a8be7f2f279d3cbd3406ba0d855f8286fb2faae03ee6", size = 7452770, upload-time = "2025-09-15T17:11:48.884Z" }, - { url = "https://files.pythonhosted.org/packages/29/12/f773cc7f596c99249c3cb3133e9bf7c9714627a0322c936caa51f7a13758/pyvips_binary-8.17.2-cp37-abi3-manylinux_2_26_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3382ea882aab23f03d0fd91aeb4315ca47b564a33776de24454d940ade4587d5", size = 7250104, upload-time = "2025-09-15T17:11:51.112Z" }, - { url = "https://files.pythonhosted.org/packages/0e/17/e20746cb20125b9017dc37ac88aeeb9f706e99d6d287d4a92e09ac01e125/pyvips_binary-8.17.2-cp37-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9389c0250091d9e2498fffa0ced75e44004f5117d40de4a11a3596b71a160d77", size = 7367434, upload-time = "2025-09-15T17:11:53.042Z" }, - { url = "https://files.pythonhosted.org/packages/e8/a6/b6aa293226f5be2414a83fbdda213b80e8a7dbf9d27296f43bf8c10c2dd4/pyvips_binary-8.17.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:13b8a8bace970fdba9ecbdb30ab41b60263f51f2a85cbc393e44c427b134dd3d", size = 7603299, upload-time = "2025-09-15T17:11:55.014Z" }, - { url = "https://files.pythonhosted.org/packages/67/3d/c301b2c29d4c4745c2a5c2e13ed592cf010a4ce60601d0a914091f154cc4/pyvips_binary-8.17.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e471ee1270c0655511cae20f91465a4f1800acfff93bf41bc40929c8dd0383cd", size = 7476941, upload-time = "2025-09-15T17:11:56.557Z" }, - { url = "https://files.pythonhosted.org/packages/a3/13/f12dcb91f9b351ff503428ed0b8ebec716c61bd376a8424e54440f8b0be2/pyvips_binary-8.17.2-cp37-abi3-win32.whl", hash = "sha256:beb5759d4b5b6f558a7312dba568e532ea52f5390e5e0353f5f28fb6a038de02", size = 8071776, upload-time = "2025-09-15T17:11:58.363Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8e/ed4d575f6b779193b56bae459aa07d16471d6d07fd8fb65afca6a516f297/pyvips_binary-8.17.2-cp37-abi3-win_amd64.whl", hash = "sha256:9b5d7e1618546389d02b6fc84ac4163aa524d1e321ef3180f9e69366bcf1ef32", size = 8045861, upload-time = "2025-09-15T17:12:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/c1/d6/f0c97c32088ee3e5ad51c380494f6daf9fc3d9ff5fad5de9c0ca90c25820/pyvips_binary-8.17.2-cp37-abi3-win_arm64.whl", hash = "sha256:60e34d2c587fc56291d2c17d6e865f21b68e2744123a9d2493cb10486b835c47", size = 7280426, upload-time = "2025-09-15T17:12:02.827Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d9/18563c9cccf5852d458e692ca15d87df08f0f06ce327a2388d01ef606009/pyvips_binary-8.18.0-cp37-abi3-macosx_10_15_x86_64.whl", hash = "sha256:6ff72bd6c60bb6cf75b7827083b64e275a15a7d862628b5716998350c17426c8", size = 8383964, upload-time = "2026-01-01T11:16:37.016Z" }, + { url = "https://files.pythonhosted.org/packages/35/96/3c642e25921217c51caff7c1cffcf26bc7f3a6c64f983f2949d8732bffc4/pyvips_binary-8.18.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:a570dbf76bb620efc9745d82d6493da504d56b21b035ccd876e358a0c182e018", size = 7500206, upload-time = "2026-01-01T11:16:39.577Z" }, + { url = "https://files.pythonhosted.org/packages/37/5d/01d77f7620b24dace147d11d7ee68a466c29f4463b2d7123376fd89d8a7a/pyvips_binary-8.18.0-cp37-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dad3012233b7b12f48180f2a407a50854e44654f37168fa8d42583d9e4f15882", size = 7645104, upload-time = "2026-01-01T11:16:41.491Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a7/8d8acdae7c507734d9d34c6076700606fec7557fb943cc125fcdfd451678/pyvips_binary-8.18.0-cp37-abi3-manylinux_2_26_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0906be336b8f775e2d33dfe61ffc480ff83c91c08d5eeff904c27c2c5164ff3a", size = 7400818, upload-time = "2026-01-01T11:16:43.595Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/065cdee9c5e004a3fc593e61b7ae56ca1675fd55f7714945f73546beedda/pyvips_binary-8.18.0-cp37-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4ddd4d344f758483d1630a9a08f201ab95162599acc6a8e6c62bb1563e94fe0", size = 7556718, upload-time = "2026-01-01T11:16:45.287Z" }, + { url = "https://files.pythonhosted.org/packages/05/35/3529e40931a92b879b7fa23e8228627dea0a56b0ddd0bd667d49e361ef89/pyvips_binary-8.18.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:076fb0affa2901af0fee90c728ded6eed2c72f00356af9895fa7a1fb6c9a2288", size = 7806440, upload-time = "2026-01-01T11:16:47.331Z" }, + { url = "https://files.pythonhosted.org/packages/21/a7/4588ab9bda60b0ed0d5b2be6caf9bd5f19216328b96825a4cd32ded9a1ff/pyvips_binary-8.18.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:659ef1e4af04b3472e7762a95caa1038fdeea530807c84a23a0f4c706af0338f", size = 7683956, upload-time = "2026-01-01T11:16:49.163Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6f/8ee7e74a878941c661d25b6518a8a9bf7a2b12c20b28040c0d047798aa21/pyvips_binary-8.18.0-cp37-abi3-win32.whl", hash = "sha256:fd331bcd75bff8651d73d09687d55ac8fb9014baa5682b770a4ea0fbcedf5f97", size = 8323911, upload-time = "2026-01-01T11:16:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/12550311fea85253acbb89808bed4b5f516f8e8245333ee3713d9d55ee52/pyvips_binary-8.18.0-cp37-abi3-win_amd64.whl", hash = "sha256:a67d73683f70c21bf2c336b6d5ddc2bd54ec36db72cc54ab63cb48bc2373feac", size = 8288206, upload-time = "2026-01-01T11:16:53.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a80cba68aef1faea4137d004548003074bc6468b07d5c8a974b6a64b8a8f/pyvips_binary-8.18.0-cp37-abi3-win_arm64.whl", hash = "sha256:0c1f9af910866bc8c2d55182e7a6e8684a828ee4d6084dd814e88e2ee9ec4be3", size = 7492382, upload-time = "2026-01-01T11:16:55.508Z" }, ] [[package]] @@ -5048,141 +5834,169 @@ wheels = [ [[package]] name = "qdrant-client" -version = "1.15.1" +version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "httpx", extra = ["http2"] }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "portalocker" }, { name = "protobuf" }, { name = "pydantic" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/8b/76c7d325e11d97cb8eb5e261c3759e9ed6664735afbf32fdded5b580690c/qdrant_client-1.15.1.tar.gz", hash = "sha256:631f1f3caebfad0fd0c1fba98f41be81d9962b7bf3ca653bed3b727c0e0cbe0e", size = 295297, upload-time = "2025-07-31T19:35:19.627Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/dd/f8a8261b83946af3cd65943c93c4f83e044f01184e8525404989d22a81a5/qdrant_client-1.17.1.tar.gz", hash = "sha256:22f990bbd63485ed97ba551a4c498181fcb723f71dcab5d6e4e43fe1050a2bc0", size = 344979, upload-time = "2026-03-13T17:13:44.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/33/d8df6a2b214ffbe4138db9a1efe3248f67dc3c671f82308bea1582ecbbb7/qdrant_client-1.15.1-py3-none-any.whl", hash = "sha256:2b975099b378382f6ca1cfb43f0d59e541be6e16a5892f282a4b8de7eff5cb63", size = 337331, upload-time = "2025-07-31T19:35:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/68/69/77d1a971c4b933e8c79403e99bcbb790463da5e48333cc4fd5d412c63c98/qdrant_client-1.17.1-py3-none-any.whl", hash = "sha256:6cda4064adfeaf211c751f3fbc00edbbdb499850918c7aff4855a9a759d56cbd", size = 389947, upload-time = "2026-03-13T17:13:43.156Z" }, +] + +[[package]] +name = "rdflib" +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/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/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]] name = "referencing" -version = "0.36.2" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] name = "regex" -version = "2025.9.18" +version = "2026.2.28" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/d3/eaa0d28aba6ad1827ad1e716d9a93e1ba963ada61887498297d3da715133/regex-2025.9.18.tar.gz", hash = "sha256:c5ba23274c61c6fef447ba6a39333297d0c247f53059dba0bca415cac511edc4", size = 400917, upload-time = "2025-09-19T00:38:35.79Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d8/7e06171db8e55f917c5b8e89319cea2d86982e3fc46b677f40358223dece/regex-2025.9.18-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:12296202480c201c98a84aecc4d210592b2f55e200a1d193235c4db92b9f6788", size = 484829, upload-time = "2025-09-19T00:35:05.215Z" }, - { url = "https://files.pythonhosted.org/packages/8d/70/bf91bb39e5bedf75ce730ffbaa82ca585584d13335306d637458946b8b9f/regex-2025.9.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:220381f1464a581f2ea988f2220cf2a67927adcef107d47d6897ba5a2f6d51a4", size = 288993, upload-time = "2025-09-19T00:35:08.154Z" }, - { url = "https://files.pythonhosted.org/packages/fe/89/69f79b28365eda2c46e64c39d617d5f65a2aa451a4c94de7d9b34c2dc80f/regex-2025.9.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87f681bfca84ebd265278b5daa1dcb57f4db315da3b5d044add7c30c10442e61", size = 286624, upload-time = "2025-09-19T00:35:09.717Z" }, - { url = "https://files.pythonhosted.org/packages/44/31/81e62955726c3a14fcc1049a80bc716765af6c055706869de5e880ddc783/regex-2025.9.18-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34d674cbba70c9398074c8a1fcc1a79739d65d1105de2a3c695e2b05ea728251", size = 780473, upload-time = "2025-09-19T00:35:11.013Z" }, - { url = "https://files.pythonhosted.org/packages/fb/23/07072b7e191fbb6e213dc03b2f5b96f06d3c12d7deaded84679482926fc7/regex-2025.9.18-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:385c9b769655cb65ea40b6eea6ff763cbb6d69b3ffef0b0db8208e1833d4e746", size = 849290, upload-time = "2025-09-19T00:35:12.348Z" }, - { url = "https://files.pythonhosted.org/packages/b3/f0/aec7f6a01f2a112210424d77c6401b9015675fb887ced7e18926df4ae51e/regex-2025.9.18-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8900b3208e022570ae34328712bef6696de0804c122933414014bae791437ab2", size = 897335, upload-time = "2025-09-19T00:35:14.058Z" }, - { url = "https://files.pythonhosted.org/packages/cc/90/2e5f9da89d260de7d0417ead91a1bc897f19f0af05f4f9323313b76c47f2/regex-2025.9.18-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c204e93bf32cd7a77151d44b05eb36f469d0898e3fba141c026a26b79d9914a0", size = 789946, upload-time = "2025-09-19T00:35:15.403Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d5/1c712c7362f2563d389be66bae131c8bab121a3fabfa04b0b5bfc9e73c51/regex-2025.9.18-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3acc471d1dd7e5ff82e6cacb3b286750decd949ecd4ae258696d04f019817ef8", size = 780787, upload-time = "2025-09-19T00:35:17.061Z" }, - { url = "https://files.pythonhosted.org/packages/4f/92/c54cdb4aa41009632e69817a5aa452673507f07e341076735a2f6c46a37c/regex-2025.9.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6479d5555122433728760e5f29edb4c2b79655a8deb681a141beb5c8a025baea", size = 773632, upload-time = "2025-09-19T00:35:18.57Z" }, - { url = "https://files.pythonhosted.org/packages/db/99/75c996dc6a2231a8652d7ad0bfbeaf8a8c77612d335580f520f3ec40e30b/regex-2025.9.18-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:431bd2a8726b000eb6f12429c9b438a24062a535d06783a93d2bcbad3698f8a8", size = 844104, upload-time = "2025-09-19T00:35:20.259Z" }, - { url = "https://files.pythonhosted.org/packages/1c/f7/25aba34cc130cb6844047dbfe9716c9b8f9629fee8b8bec331aa9241b97b/regex-2025.9.18-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0cc3521060162d02bd36927e20690129200e5ac9d2c6d32b70368870b122db25", size = 834794, upload-time = "2025-09-19T00:35:22.002Z" }, - { url = "https://files.pythonhosted.org/packages/51/eb/64e671beafa0ae29712268421597596d781704973551312b2425831d4037/regex-2025.9.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a021217b01be2d51632ce056d7a837d3fa37c543ede36e39d14063176a26ae29", size = 778535, upload-time = "2025-09-19T00:35:23.298Z" }, - { url = "https://files.pythonhosted.org/packages/26/33/c0ebc0b07bd0bf88f716cca240546b26235a07710ea58e271cfe390ae273/regex-2025.9.18-cp310-cp310-win32.whl", hash = "sha256:4a12a06c268a629cb67cc1d009b7bb0be43e289d00d5111f86a2efd3b1949444", size = 264115, upload-time = "2025-09-19T00:35:25.206Z" }, - { url = "https://files.pythonhosted.org/packages/59/39/aeb11a4ae68faaec2498512cadae09f2d8a91f1f65730fe62b9bffeea150/regex-2025.9.18-cp310-cp310-win_amd64.whl", hash = "sha256:47acd811589301298c49db2c56bde4f9308d6396da92daf99cba781fa74aa450", size = 276143, upload-time = "2025-09-19T00:35:26.785Z" }, - { url = "https://files.pythonhosted.org/packages/29/04/37f2d3fc334a1031fc2767c9d89cec13c2e72207c7e7f6feae8a47f4e149/regex-2025.9.18-cp310-cp310-win_arm64.whl", hash = "sha256:16bd2944e77522275e5ee36f867e19995bcaa533dcb516753a26726ac7285442", size = 268473, upload-time = "2025-09-19T00:35:28.39Z" }, - { url = "https://files.pythonhosted.org/packages/58/61/80eda662fc4eb32bfedc331f42390974c9e89c7eac1b79cd9eea4d7c458c/regex-2025.9.18-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:51076980cd08cd13c88eb7365427ae27f0d94e7cebe9ceb2bb9ffdae8fc4d82a", size = 484832, upload-time = "2025-09-19T00:35:30.011Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d9/33833d9abddf3f07ad48504ddb53fe3b22f353214bbb878a72eee1e3ddbf/regex-2025.9.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:828446870bd7dee4e0cbeed767f07961aa07f0ea3129f38b3ccecebc9742e0b8", size = 288994, upload-time = "2025-09-19T00:35:31.733Z" }, - { url = "https://files.pythonhosted.org/packages/2a/b3/526ee96b0d70ea81980cbc20c3496fa582f775a52e001e2743cc33b2fa75/regex-2025.9.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c28821d5637866479ec4cc23b8c990f5bc6dd24e5e4384ba4a11d38a526e1414", size = 286619, upload-time = "2025-09-19T00:35:33.221Z" }, - { url = "https://files.pythonhosted.org/packages/65/4f/c2c096b02a351b33442aed5895cdd8bf87d372498d2100927c5a053d7ba3/regex-2025.9.18-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:726177ade8e481db669e76bf99de0b278783be8acd11cef71165327abd1f170a", size = 792454, upload-time = "2025-09-19T00:35:35.361Z" }, - { url = "https://files.pythonhosted.org/packages/24/15/b562c9d6e47c403c4b5deb744f8b4bf6e40684cf866c7b077960a925bdff/regex-2025.9.18-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5cca697da89b9f8ea44115ce3130f6c54c22f541943ac8e9900461edc2b8bd4", size = 858723, upload-time = "2025-09-19T00:35:36.949Z" }, - { url = "https://files.pythonhosted.org/packages/f2/01/dba305409849e85b8a1a681eac4c03ed327d8de37895ddf9dc137f59c140/regex-2025.9.18-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dfbde38f38004703c35666a1e1c088b778e35d55348da2b7b278914491698d6a", size = 905899, upload-time = "2025-09-19T00:35:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d0/c51d1e6a80eab11ef96a4cbad17fc0310cf68994fb01a7283276b7e5bbd6/regex-2025.9.18-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2f422214a03fab16bfa495cfec72bee4aaa5731843b771860a471282f1bf74f", size = 798981, upload-time = "2025-09-19T00:35:40.416Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5e/72db90970887bbe02296612bd61b0fa31e6d88aa24f6a4853db3e96c575e/regex-2025.9.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a295916890f4df0902e4286bc7223ee7f9e925daa6dcdec4192364255b70561a", size = 781900, upload-time = "2025-09-19T00:35:42.077Z" }, - { url = "https://files.pythonhosted.org/packages/50/ff/596be45eea8e9bc31677fde243fa2904d00aad1b32c31bce26c3dbba0b9e/regex-2025.9.18-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5db95ff632dbabc8c38c4e82bf545ab78d902e81160e6e455598014f0abe66b9", size = 852952, upload-time = "2025-09-19T00:35:43.751Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1b/2dfa348fa551e900ed3f5f63f74185b6a08e8a76bc62bc9c106f4f92668b/regex-2025.9.18-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fb967eb441b0f15ae610b7069bdb760b929f267efbf522e814bbbfffdf125ce2", size = 844355, upload-time = "2025-09-19T00:35:45.309Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/aefb1def27fe33b8cbbb19c75c13aefccfbef1c6686f8e7f7095705969c7/regex-2025.9.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f04d2f20da4053d96c08f7fde6e1419b7ec9dbcee89c96e3d731fca77f411b95", size = 787254, upload-time = "2025-09-19T00:35:46.904Z" }, - { url = "https://files.pythonhosted.org/packages/e3/4e/8ef042e7cf0dbbb401e784e896acfc1b367b95dfbfc9ada94c2ed55a081f/regex-2025.9.18-cp311-cp311-win32.whl", hash = "sha256:895197241fccf18c0cea7550c80e75f185b8bd55b6924fcae269a1a92c614a07", size = 264129, upload-time = "2025-09-19T00:35:48.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/7d/c4fcabf80dcdd6821c0578ad9b451f8640b9110fb3dcb74793dd077069ff/regex-2025.9.18-cp311-cp311-win_amd64.whl", hash = "sha256:7e2b414deae99166e22c005e154a5513ac31493db178d8aec92b3269c9cce8c9", size = 276160, upload-time = "2025-09-19T00:36:00.45Z" }, - { url = "https://files.pythonhosted.org/packages/64/f8/0e13c8ae4d6df9d128afaba138342d532283d53a4c1e7a8c93d6756c8f4a/regex-2025.9.18-cp311-cp311-win_arm64.whl", hash = "sha256:fb137ec7c5c54f34a25ff9b31f6b7b0c2757be80176435bf367111e3f71d72df", size = 268471, upload-time = "2025-09-19T00:36:02.149Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/05859d87a66ae7098222d65748f11ef7f2dff51bfd7482a4e2256c90d72b/regex-2025.9.18-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:436e1b31d7efd4dcd52091d076482031c611dde58bf9c46ca6d0a26e33053a7e", size = 486335, upload-time = "2025-09-19T00:36:03.661Z" }, - { url = "https://files.pythonhosted.org/packages/97/7e/d43d4e8b978890932cf7b0957fce58c5b08c66f32698f695b0c2c24a48bf/regex-2025.9.18-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c190af81e5576b9c5fdc708f781a52ff20f8b96386c6e2e0557a78402b029f4a", size = 289720, upload-time = "2025-09-19T00:36:05.471Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3b/ff80886089eb5dcf7e0d2040d9aaed539e25a94300403814bb24cc775058/regex-2025.9.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e4121f1ce2b2b5eec4b397cc1b277686e577e658d8f5870b7eb2d726bd2300ab", size = 287257, upload-time = "2025-09-19T00:36:07.072Z" }, - { url = "https://files.pythonhosted.org/packages/ee/66/243edf49dd8720cba8d5245dd4d6adcb03a1defab7238598c0c97cf549b8/regex-2025.9.18-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:300e25dbbf8299d87205e821a201057f2ef9aa3deb29caa01cd2cac669e508d5", size = 797463, upload-time = "2025-09-19T00:36:08.399Z" }, - { url = "https://files.pythonhosted.org/packages/df/71/c9d25a1142c70432e68bb03211d4a82299cd1c1fbc41db9409a394374ef5/regex-2025.9.18-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b47fcf9f5316c0bdaf449e879407e1b9937a23c3b369135ca94ebc8d74b1742", size = 862670, upload-time = "2025-09-19T00:36:10.101Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8f/329b1efc3a64375a294e3a92d43372bf1a351aa418e83c21f2f01cf6ec41/regex-2025.9.18-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:57a161bd3acaa4b513220b49949b07e252165e6b6dc910ee7617a37ff4f5b425", size = 910881, upload-time = "2025-09-19T00:36:12.223Z" }, - { url = "https://files.pythonhosted.org/packages/35/9e/a91b50332a9750519320ed30ec378b74c996f6befe282cfa6bb6cea7e9fd/regex-2025.9.18-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f130c3a7845ba42de42f380fff3c8aebe89a810747d91bcf56d40a069f15352", size = 802011, upload-time = "2025-09-19T00:36:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1d/6be3b8d7856b6e0d7ee7f942f437d0a76e0d5622983abbb6d21e21ab9a17/regex-2025.9.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f96fa342b6f54dcba928dd452e8d8cb9f0d63e711d1721cd765bb9f73bb048d", size = 786668, upload-time = "2025-09-19T00:36:15.391Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ce/4a60e53df58bd157c5156a1736d3636f9910bdcc271d067b32b7fcd0c3a8/regex-2025.9.18-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f0d676522d68c207828dcd01fb6f214f63f238c283d9f01d85fc664c7c85b56", size = 856578, upload-time = "2025-09-19T00:36:16.845Z" }, - { url = "https://files.pythonhosted.org/packages/86/e8/162c91bfe7217253afccde112868afb239f94703de6580fb235058d506a6/regex-2025.9.18-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40532bff8a1a0621e7903ae57fce88feb2e8a9a9116d341701302c9302aef06e", size = 849017, upload-time = "2025-09-19T00:36:18.597Z" }, - { url = "https://files.pythonhosted.org/packages/35/34/42b165bc45289646ea0959a1bc7531733e90b47c56a72067adfe6b3251f6/regex-2025.9.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:039f11b618ce8d71a1c364fdee37da1012f5a3e79b1b2819a9f389cd82fd6282", size = 788150, upload-time = "2025-09-19T00:36:20.464Z" }, - { url = "https://files.pythonhosted.org/packages/79/5d/cdd13b1f3c53afa7191593a7ad2ee24092a5a46417725ffff7f64be8342d/regex-2025.9.18-cp312-cp312-win32.whl", hash = "sha256:e1dd06f981eb226edf87c55d523131ade7285137fbde837c34dc9d1bf309f459", size = 264536, upload-time = "2025-09-19T00:36:21.922Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f5/4a7770c9a522e7d2dc1fa3ffc83ab2ab33b0b22b447e62cffef186805302/regex-2025.9.18-cp312-cp312-win_amd64.whl", hash = "sha256:3d86b5247bf25fa3715e385aa9ff272c307e0636ce0c9595f64568b41f0a9c77", size = 275501, upload-time = "2025-09-19T00:36:23.4Z" }, - { url = "https://files.pythonhosted.org/packages/df/05/9ce3e110e70d225ecbed455b966003a3afda5e58e8aec2964042363a18f4/regex-2025.9.18-cp312-cp312-win_arm64.whl", hash = "sha256:032720248cbeeae6444c269b78cb15664458b7bb9ed02401d3da59fe4d68c3a5", size = 268601, upload-time = "2025-09-19T00:36:25.092Z" }, - { url = "https://files.pythonhosted.org/packages/d2/c7/5c48206a60ce33711cf7dcaeaed10dd737733a3569dc7e1dce324dd48f30/regex-2025.9.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a40f929cd907c7e8ac7566ac76225a77701a6221bca937bdb70d56cb61f57b2", size = 485955, upload-time = "2025-09-19T00:36:26.822Z" }, - { url = "https://files.pythonhosted.org/packages/e9/be/74fc6bb19a3c491ec1ace943e622b5a8539068771e8705e469b2da2306a7/regex-2025.9.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c90471671c2cdf914e58b6af62420ea9ecd06d1554d7474d50133ff26ae88feb", size = 289583, upload-time = "2025-09-19T00:36:28.577Z" }, - { url = "https://files.pythonhosted.org/packages/25/c4/9ceaa433cb5dc515765560f22a19578b95b92ff12526e5a259321c4fc1a0/regex-2025.9.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a351aff9e07a2dabb5022ead6380cff17a4f10e4feb15f9100ee56c4d6d06af", size = 287000, upload-time = "2025-09-19T00:36:30.161Z" }, - { url = "https://files.pythonhosted.org/packages/7d/e6/68bc9393cb4dc68018456568c048ac035854b042bc7c33cb9b99b0680afa/regex-2025.9.18-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc4b8e9d16e20ddfe16430c23468a8707ccad3365b06d4536142e71823f3ca29", size = 797535, upload-time = "2025-09-19T00:36:31.876Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1c/ebae9032d34b78ecfe9bd4b5e6575b55351dc8513485bb92326613732b8c/regex-2025.9.18-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b8cdbddf2db1c5e80338ba2daa3cfa3dec73a46fff2a7dda087c8efbf12d62f", size = 862603, upload-time = "2025-09-19T00:36:33.344Z" }, - { url = "https://files.pythonhosted.org/packages/3b/74/12332c54b3882557a4bcd2b99f8be581f5c6a43cf1660a85b460dd8ff468/regex-2025.9.18-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a276937d9d75085b2c91fb48244349c6954f05ee97bba0963ce24a9d915b8b68", size = 910829, upload-time = "2025-09-19T00:36:34.826Z" }, - { url = "https://files.pythonhosted.org/packages/86/70/ba42d5ed606ee275f2465bfc0e2208755b06cdabd0f4c7c4b614d51b57ab/regex-2025.9.18-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92a8e375ccdc1256401c90e9dc02b8642894443d549ff5e25e36d7cf8a80c783", size = 802059, upload-time = "2025-09-19T00:36:36.664Z" }, - { url = "https://files.pythonhosted.org/packages/da/c5/fcb017e56396a7f2f8357412638d7e2963440b131a3ca549be25774b3641/regex-2025.9.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0dc6893b1f502d73037cf807a321cdc9be29ef3d6219f7970f842475873712ac", size = 786781, upload-time = "2025-09-19T00:36:38.168Z" }, - { url = "https://files.pythonhosted.org/packages/c6/ee/21c4278b973f630adfb3bcb23d09d83625f3ab1ca6e40ebdffe69901c7a1/regex-2025.9.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a61e85bfc63d232ac14b015af1261f826260c8deb19401c0597dbb87a864361e", size = 856578, upload-time = "2025-09-19T00:36:40.129Z" }, - { url = "https://files.pythonhosted.org/packages/87/0b/de51550dc7274324435c8f1539373ac63019b0525ad720132866fff4a16a/regex-2025.9.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1ef86a9ebc53f379d921fb9a7e42b92059ad3ee800fcd9e0fe6181090e9f6c23", size = 849119, upload-time = "2025-09-19T00:36:41.651Z" }, - { url = "https://files.pythonhosted.org/packages/60/52/383d3044fc5154d9ffe4321696ee5b2ee4833a28c29b137c22c33f41885b/regex-2025.9.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d3bc882119764ba3a119fbf2bd4f1b47bc56c1da5d42df4ed54ae1e8e66fdf8f", size = 788219, upload-time = "2025-09-19T00:36:43.575Z" }, - { url = "https://files.pythonhosted.org/packages/20/bd/2614fc302671b7359972ea212f0e3a92df4414aaeacab054a8ce80a86073/regex-2025.9.18-cp313-cp313-win32.whl", hash = "sha256:3810a65675845c3bdfa58c3c7d88624356dd6ee2fc186628295e0969005f928d", size = 264517, upload-time = "2025-09-19T00:36:45.503Z" }, - { url = "https://files.pythonhosted.org/packages/07/0f/ab5c1581e6563a7bffdc1974fb2d25f05689b88e2d416525271f232b1946/regex-2025.9.18-cp313-cp313-win_amd64.whl", hash = "sha256:16eaf74b3c4180ede88f620f299e474913ab6924d5c4b89b3833bc2345d83b3d", size = 275481, upload-time = "2025-09-19T00:36:46.965Z" }, - { url = "https://files.pythonhosted.org/packages/49/22/ee47672bc7958f8c5667a587c2600a4fba8b6bab6e86bd6d3e2b5f7cac42/regex-2025.9.18-cp313-cp313-win_arm64.whl", hash = "sha256:4dc98ba7dd66bd1261927a9f49bd5ee2bcb3660f7962f1ec02617280fc00f5eb", size = 268598, upload-time = "2025-09-19T00:36:48.314Z" }, - { url = "https://files.pythonhosted.org/packages/e8/83/6887e16a187c6226cb85d8301e47d3b73ecc4505a3a13d8da2096b44fd76/regex-2025.9.18-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fe5d50572bc885a0a799410a717c42b1a6b50e2f45872e2b40f4f288f9bce8a2", size = 489765, upload-time = "2025-09-19T00:36:49.996Z" }, - { url = "https://files.pythonhosted.org/packages/51/c5/e2f7325301ea2916ff301c8d963ba66b1b2c1b06694191df80a9c4fea5d0/regex-2025.9.18-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b9d9a2d6cda6621551ca8cf7a06f103adf72831153f3c0d982386110870c4d3", size = 291228, upload-time = "2025-09-19T00:36:51.654Z" }, - { url = "https://files.pythonhosted.org/packages/91/60/7d229d2bc6961289e864a3a3cfebf7d0d250e2e65323a8952cbb7e22d824/regex-2025.9.18-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:13202e4c4ac0ef9a317fff817674b293c8f7e8c68d3190377d8d8b749f566e12", size = 289270, upload-time = "2025-09-19T00:36:53.118Z" }, - { url = "https://files.pythonhosted.org/packages/3c/d7/b4f06868ee2958ff6430df89857fbf3d43014bbf35538b6ec96c2704e15d/regex-2025.9.18-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874ff523b0fecffb090f80ae53dc93538f8db954c8bb5505f05b7787ab3402a0", size = 806326, upload-time = "2025-09-19T00:36:54.631Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e4/bca99034a8f1b9b62ccf337402a8e5b959dd5ba0e5e5b2ead70273df3277/regex-2025.9.18-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d13ab0490128f2bb45d596f754148cd750411afc97e813e4b3a61cf278a23bb6", size = 871556, upload-time = "2025-09-19T00:36:56.208Z" }, - { url = "https://files.pythonhosted.org/packages/6d/df/e06ffaf078a162f6dd6b101a5ea9b44696dca860a48136b3ae4a9caf25e2/regex-2025.9.18-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:05440bc172bc4b4b37fb9667e796597419404dbba62e171e1f826d7d2a9ebcef", size = 913817, upload-time = "2025-09-19T00:36:57.807Z" }, - { url = "https://files.pythonhosted.org/packages/9e/05/25b05480b63292fd8e84800b1648e160ca778127b8d2367a0a258fa2e225/regex-2025.9.18-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5514b8e4031fdfaa3d27e92c75719cbe7f379e28cacd939807289bce76d0e35a", size = 811055, upload-time = "2025-09-19T00:36:59.762Z" }, - { url = "https://files.pythonhosted.org/packages/70/97/7bc7574655eb651ba3a916ed4b1be6798ae97af30104f655d8efd0cab24b/regex-2025.9.18-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:65d3c38c39efce73e0d9dc019697b39903ba25b1ad45ebbd730d2cf32741f40d", size = 794534, upload-time = "2025-09-19T00:37:01.405Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c2/d5da49166a52dda879855ecdba0117f073583db2b39bb47ce9a3378a8e9e/regex-2025.9.18-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ae77e447ebc144d5a26d50055c6ddba1d6ad4a865a560ec7200b8b06bc529368", size = 866684, upload-time = "2025-09-19T00:37:03.441Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2d/0a5c4e6ec417de56b89ff4418ecc72f7e3feca806824c75ad0bbdae0516b/regex-2025.9.18-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e3ef8cf53dc8df49d7e28a356cf824e3623764e9833348b655cfed4524ab8a90", size = 853282, upload-time = "2025-09-19T00:37:04.985Z" }, - { url = "https://files.pythonhosted.org/packages/f4/8e/d656af63e31a86572ec829665d6fa06eae7e144771e0330650a8bb865635/regex-2025.9.18-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9feb29817df349c976da9a0debf775c5c33fc1c8ad7b9f025825da99374770b7", size = 797830, upload-time = "2025-09-19T00:37:06.697Z" }, - { url = "https://files.pythonhosted.org/packages/db/ce/06edc89df8f7b83ffd321b6071be4c54dc7332c0f77860edc40ce57d757b/regex-2025.9.18-cp313-cp313t-win32.whl", hash = "sha256:168be0d2f9b9d13076940b1ed774f98595b4e3c7fc54584bba81b3cc4181742e", size = 267281, upload-time = "2025-09-19T00:37:08.568Z" }, - { url = "https://files.pythonhosted.org/packages/83/9a/2b5d9c8b307a451fd17068719d971d3634ca29864b89ed5c18e499446d4a/regex-2025.9.18-cp313-cp313t-win_amd64.whl", hash = "sha256:d59ecf3bb549e491c8104fea7313f3563c7b048e01287db0a90485734a70a730", size = 278724, upload-time = "2025-09-19T00:37:10.023Z" }, - { url = "https://files.pythonhosted.org/packages/3d/70/177d31e8089a278a764f8ec9a3faac8d14a312d622a47385d4b43905806f/regex-2025.9.18-cp313-cp313t-win_arm64.whl", hash = "sha256:dbef80defe9fb21310948a2595420b36c6d641d9bea4c991175829b2cc4bc06a", size = 269771, upload-time = "2025-09-19T00:37:13.041Z" }, - { url = "https://files.pythonhosted.org/packages/44/b7/3b4663aa3b4af16819f2ab6a78c4111c7e9b066725d8107753c2257448a5/regex-2025.9.18-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c6db75b51acf277997f3adcd0ad89045d856190d13359f15ab5dda21581d9129", size = 486130, upload-time = "2025-09-19T00:37:14.527Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/4533f5d7ac9c6a02a4725fe8883de2aebc713e67e842c04cf02626afb747/regex-2025.9.18-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8f9698b6f6895d6db810e0bda5364f9ceb9e5b11328700a90cae573574f61eea", size = 289539, upload-time = "2025-09-19T00:37:16.356Z" }, - { url = "https://files.pythonhosted.org/packages/b8/8d/5ab6797c2750985f79e9995fad3254caa4520846580f266ae3b56d1cae58/regex-2025.9.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29cd86aa7cb13a37d0f0d7c21d8d949fe402ffa0ea697e635afedd97ab4b69f1", size = 287233, upload-time = "2025-09-19T00:37:18.025Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/95afcb02ba8d3a64e6ffeb801718ce73471ad6440c55d993f65a4a5e7a92/regex-2025.9.18-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c9f285a071ee55cd9583ba24dde006e53e17780bb309baa8e4289cd472bcc47", size = 797876, upload-time = "2025-09-19T00:37:19.609Z" }, - { url = "https://files.pythonhosted.org/packages/c8/fb/720b1f49cec1f3b5a9fea5b34cd22b88b5ebccc8c1b5de9cc6f65eed165a/regex-2025.9.18-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5adf266f730431e3be9021d3e5b8d5ee65e563fec2883ea8093944d21863b379", size = 863385, upload-time = "2025-09-19T00:37:21.65Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ca/e0d07ecf701e1616f015a720dc13b84c582024cbfbb3fc5394ae204adbd7/regex-2025.9.18-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1137cabc0f38807de79e28d3f6e3e3f2cc8cfb26bead754d02e6d1de5f679203", size = 910220, upload-time = "2025-09-19T00:37:23.723Z" }, - { url = "https://files.pythonhosted.org/packages/b6/45/bba86413b910b708eca705a5af62163d5d396d5f647ed9485580c7025209/regex-2025.9.18-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cc9e5525cada99699ca9223cce2d52e88c52a3d2a0e842bd53de5497c604164", size = 801827, upload-time = "2025-09-19T00:37:25.684Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/740fbd9fcac31a1305a8eed30b44bf0f7f1e042342be0a4722c0365ecfca/regex-2025.9.18-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bbb9246568f72dce29bcd433517c2be22c7791784b223a810225af3b50d1aafb", size = 786843, upload-time = "2025-09-19T00:37:27.62Z" }, - { url = "https://files.pythonhosted.org/packages/80/a7/0579e8560682645906da640c9055506465d809cb0f5415d9976f417209a6/regex-2025.9.18-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6a52219a93dd3d92c675383efff6ae18c982e2d7651c792b1e6d121055808743", size = 857430, upload-time = "2025-09-19T00:37:29.362Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9b/4dc96b6c17b38900cc9fee254fc9271d0dde044e82c78c0811b58754fde5/regex-2025.9.18-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ae9b3840c5bd456780e3ddf2f737ab55a79b790f6409182012718a35c6d43282", size = 848612, upload-time = "2025-09-19T00:37:31.42Z" }, - { url = "https://files.pythonhosted.org/packages/b3/6a/6f659f99bebb1775e5ac81a3fb837b85897c1a4ef5acffd0ff8ffe7e67fb/regex-2025.9.18-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d488c236ac497c46a5ac2005a952c1a0e22a07be9f10c3e735bc7d1209a34773", size = 787967, upload-time = "2025-09-19T00:37:34.019Z" }, - { url = "https://files.pythonhosted.org/packages/61/35/9e35665f097c07cf384a6b90a1ac11b0b1693084a0b7a675b06f760496c6/regex-2025.9.18-cp314-cp314-win32.whl", hash = "sha256:0c3506682ea19beefe627a38872d8da65cc01ffa25ed3f2e422dffa1474f0788", size = 269847, upload-time = "2025-09-19T00:37:35.759Z" }, - { url = "https://files.pythonhosted.org/packages/af/64/27594dbe0f1590b82de2821ebfe9a359b44dcb9b65524876cd12fabc447b/regex-2025.9.18-cp314-cp314-win_amd64.whl", hash = "sha256:57929d0f92bebb2d1a83af372cd0ffba2263f13f376e19b1e4fa32aec4efddc3", size = 278755, upload-time = "2025-09-19T00:37:37.367Z" }, - { url = "https://files.pythonhosted.org/packages/30/a3/0cd8d0d342886bd7d7f252d701b20ae1a3c72dc7f34ef4b2d17790280a09/regex-2025.9.18-cp314-cp314-win_arm64.whl", hash = "sha256:6a4b44df31d34fa51aa5c995d3aa3c999cec4d69b9bd414a8be51984d859f06d", size = 271873, upload-time = "2025-09-19T00:37:39.125Z" }, - { url = "https://files.pythonhosted.org/packages/99/cb/8a1ab05ecf404e18b54348e293d9b7a60ec2bd7aa59e637020c5eea852e8/regex-2025.9.18-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b176326bcd544b5e9b17d6943f807697c0cb7351f6cfb45bf5637c95ff7e6306", size = 489773, upload-time = "2025-09-19T00:37:40.968Z" }, - { url = "https://files.pythonhosted.org/packages/93/3b/6543c9b7f7e734d2404fa2863d0d710c907bef99d4598760ed4563d634c3/regex-2025.9.18-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0ffd9e230b826b15b369391bec167baed57c7ce39efc35835448618860995946", size = 291221, upload-time = "2025-09-19T00:37:42.901Z" }, - { url = "https://files.pythonhosted.org/packages/cd/91/e9fdee6ad6bf708d98c5d17fded423dcb0661795a49cba1b4ffb8358377a/regex-2025.9.18-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec46332c41add73f2b57e2f5b642f991f6b15e50e9f86285e08ffe3a512ac39f", size = 289268, upload-time = "2025-09-19T00:37:44.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/a6/bc3e8a918abe4741dadeaeb6c508e3a4ea847ff36030d820d89858f96a6c/regex-2025.9.18-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b80fa342ed1ea095168a3f116637bd1030d39c9ff38dc04e54ef7c521e01fc95", size = 806659, upload-time = "2025-09-19T00:37:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/2b/71/ea62dbeb55d9e6905c7b5a49f75615ea1373afcad95830047e4e310db979/regex-2025.9.18-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4d97071c0ba40f0cf2a93ed76e660654c399a0a04ab7d85472239460f3da84b", size = 871701, upload-time = "2025-09-19T00:37:48.882Z" }, - { url = "https://files.pythonhosted.org/packages/6a/90/fbe9dedb7dad24a3a4399c0bae64bfa932ec8922a0a9acf7bc88db30b161/regex-2025.9.18-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0ac936537ad87cef9e0e66c5144484206c1354224ee811ab1519a32373e411f3", size = 913742, upload-time = "2025-09-19T00:37:51.015Z" }, - { url = "https://files.pythonhosted.org/packages/f0/1c/47e4a8c0e73d41eb9eb9fdeba3b1b810110a5139a2526e82fd29c2d9f867/regex-2025.9.18-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dec57f96d4def58c422d212d414efe28218d58537b5445cf0c33afb1b4768571", size = 811117, upload-time = "2025-09-19T00:37:52.686Z" }, - { url = "https://files.pythonhosted.org/packages/2a/da/435f29fddfd015111523671e36d30af3342e8136a889159b05c1d9110480/regex-2025.9.18-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48317233294648bf7cd068857f248e3a57222259a5304d32c7552e2284a1b2ad", size = 794647, upload-time = "2025-09-19T00:37:54.626Z" }, - { url = "https://files.pythonhosted.org/packages/23/66/df5e6dcca25c8bc57ce404eebc7342310a0d218db739d7882c9a2b5974a3/regex-2025.9.18-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:274687e62ea3cf54846a9b25fc48a04459de50af30a7bd0b61a9e38015983494", size = 866747, upload-time = "2025-09-19T00:37:56.367Z" }, - { url = "https://files.pythonhosted.org/packages/82/42/94392b39b531f2e469b2daa40acf454863733b674481fda17462a5ffadac/regex-2025.9.18-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a78722c86a3e7e6aadf9579e3b0ad78d955f2d1f1a8ca4f67d7ca258e8719d4b", size = 853434, upload-time = "2025-09-19T00:37:58.39Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f8/dcc64c7f7bbe58842a8f89622b50c58c3598fbbf4aad0a488d6df2c699f1/regex-2025.9.18-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06104cd203cdef3ade989a1c45b6215bf42f8b9dd705ecc220c173233f7cba41", size = 798024, upload-time = "2025-09-19T00:38:00.397Z" }, - { url = "https://files.pythonhosted.org/packages/20/8d/edf1c5d5aa98f99a692313db813ec487732946784f8f93145e0153d910e5/regex-2025.9.18-cp314-cp314t-win32.whl", hash = "sha256:2e1eddc06eeaffd249c0adb6fafc19e2118e6308c60df9db27919e96b5656096", size = 273029, upload-time = "2025-09-19T00:38:02.383Z" }, - { url = "https://files.pythonhosted.org/packages/a7/24/02d4e4f88466f17b145f7ea2b2c11af3a942db6222429c2c146accf16054/regex-2025.9.18-cp314-cp314t-win_amd64.whl", hash = "sha256:8620d247fb8c0683ade51217b459cb4a1081c0405a3072235ba43a40d355c09a", size = 282680, upload-time = "2025-09-19T00:38:04.102Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a3/c64894858aaaa454caa7cc47e2f225b04d3ed08ad649eacf58d45817fad2/regex-2025.9.18-cp314-cp314t-win_arm64.whl", hash = "sha256:b7531a8ef61de2c647cdf68b3229b071e46ec326b3138b2180acb4275f470b01", size = 273034, upload-time = "2025-09-19T00:38:05.807Z" }, + { url = "https://files.pythonhosted.org/packages/70/b8/845a927e078f5e5cc55d29f57becbfde0003d52806544531ab3f2da4503c/regex-2026.2.28-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fc48c500838be6882b32748f60a15229d2dea96e59ef341eaa96ec83538f498d", size = 488461, upload-time = "2026-02-28T02:15:48.405Z" }, + { url = "https://files.pythonhosted.org/packages/32/f9/8a0034716684e38a729210ded6222249f29978b24b684f448162ef21f204/regex-2026.2.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2afa673660928d0b63d84353c6c08a8a476ddfc4a47e11742949d182e6863ce8", size = 290774, upload-time = "2026-02-28T02:15:51.738Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ba/b27feefffbb199528dd32667cd172ed484d9c197618c575f01217fbe6103/regex-2026.2.28-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7ab218076eb0944549e7fe74cf0e2b83a82edb27e81cc87411f76240865e04d5", size = 288737, upload-time = "2026-02-28T02:15:53.534Z" }, + { url = "https://files.pythonhosted.org/packages/18/c5/65379448ca3cbfe774fcc33774dc8295b1ee97dc3237ae3d3c7b27423c9d/regex-2026.2.28-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d63db12e45a9b9f064bfe4800cefefc7e5f182052e4c1b774d46a40ab1d9bb", size = 782675, upload-time = "2026-02-28T02:15:55.488Z" }, + { url = "https://files.pythonhosted.org/packages/aa/30/6fa55bef48090f900fbd4649333791fc3e6467380b9e775e741beeb3231f/regex-2026.2.28-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:195237dc327858a7721bf8b0bbbef797554bc13563c3591e91cd0767bacbe359", size = 850514, upload-time = "2026-02-28T02:15:57.509Z" }, + { url = "https://files.pythonhosted.org/packages/a9/28/9ca180fb3787a54150209754ac06a42409913571fa94994f340b3bba4e1e/regex-2026.2.28-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b387a0d092dac157fb026d737dde35ff3e49ef27f285343e7c6401851239df27", size = 896612, upload-time = "2026-02-28T02:15:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/46/b5/f30d7d3936d6deecc3ea7bea4f7d3c5ee5124e7c8de372226e436b330a55/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3935174fa4d9f70525a4367aaff3cb8bc0548129d114260c29d9dfa4a5b41692", size = 791691, upload-time = "2026-02-28T02:16:01.752Z" }, + { url = "https://files.pythonhosted.org/packages/f5/34/96631bcf446a56ba0b2a7f684358a76855dfe315b7c2f89b35388494ede0/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b2b23587b26496ff5fd40df4278becdf386813ec00dc3533fa43a4cf0e2ad3c", size = 783111, upload-time = "2026-02-28T02:16:03.651Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/f95cb7a85fe284d41cd2f3625e0f2ae30172b55dfd2af1d9b4eaef6259d7/regex-2026.2.28-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b24bd7e9d85dc7c6a8bd2aa14ecd234274a0248335a02adeb25448aecdd420d", size = 767512, upload-time = "2026-02-28T02:16:05.616Z" }, + { url = "https://files.pythonhosted.org/packages/3d/af/a650f64a79c02a97f73f64d4e7fc4cc1984e64affab14075e7c1f9a2db34/regex-2026.2.28-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd477d5f79920338107f04aa645f094032d9e3030cc55be581df3d1ef61aa318", size = 773920, upload-time = "2026-02-28T02:16:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/72/f8/3f9c2c2af37aedb3f5a1e7227f81bea065028785260d9cacc488e43e6997/regex-2026.2.28-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b49eb78048c6354f49e91e4b77da21257fecb92256b6d599ae44403cab30b05b", size = 846681, upload-time = "2026-02-28T02:16:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/8db04a334571359f4d127d8f89550917ec6561a2fddfd69cd91402b47482/regex-2026.2.28-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a25c7701e4f7a70021db9aaf4a4a0a67033c6318752146e03d1b94d32006217e", size = 755565, upload-time = "2026-02-28T02:16:11.972Z" }, + { url = "https://files.pythonhosted.org/packages/da/bc/91c22f384d79324121b134c267a86ca90d11f8016aafb1dc5bee05890ee3/regex-2026.2.28-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9dd450db6458387167e033cfa80887a34c99c81d26da1bf8b0b41bf8c9cac88e", size = 835789, upload-time = "2026-02-28T02:16:14.036Z" }, + { url = "https://files.pythonhosted.org/packages/46/a7/4cc94fd3af01dcfdf5a9ed75c8e15fd80fcd62cc46da7592b1749e9c35db/regex-2026.2.28-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2954379dd20752e82d22accf3ff465311cbb2bac6c1f92c4afd400e1757f7451", size = 780094, upload-time = "2026-02-28T02:16:15.468Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/e5a38f420af3c77cab4a65f0c3a55ec02ac9babf04479cfd282d356988a6/regex-2026.2.28-cp310-cp310-win32.whl", hash = "sha256:1f8b17be5c27a684ea6759983c13506bd77bfc7c0347dff41b18ce5ddd2ee09a", size = 266025, upload-time = "2026-02-28T02:16:16.828Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0a/205c4c1466a36e04d90afcd01d8908bac327673050c7fe316b2416d99d3d/regex-2026.2.28-cp310-cp310-win_amd64.whl", hash = "sha256:dd8847c4978bc3c7e6c826fb745f5570e518b8459ac2892151ce6627c7bc00d5", size = 277965, upload-time = "2026-02-28T02:16:18.752Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4d/29b58172f954b6ec2c5ed28529a65e9026ab96b4b7016bcd3858f1c31d3c/regex-2026.2.28-cp310-cp310-win_arm64.whl", hash = "sha256:73cdcdbba8028167ea81490c7f45280113e41db2c7afb65a276f4711fa3bcbff", size = 270336, upload-time = "2026-02-28T02:16:20.735Z" }, + { url = "https://files.pythonhosted.org/packages/04/db/8cbfd0ba3f302f2d09dd0019a9fcab74b63fee77a76c937d0e33161fb8c1/regex-2026.2.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e621fb7c8dc147419b28e1702f58a0177ff8308a76fa295c71f3e7827849f5d9", size = 488462, upload-time = "2026-02-28T02:16:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/5d/10/ccc22c52802223f2368731964ddd117799e1390ffc39dbb31634a83022ee/regex-2026.2.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d5bef2031cbf38757a0b0bc4298bb4824b6332d28edc16b39247228fbdbad97", size = 290774, upload-time = "2026-02-28T02:16:23.993Z" }, + { url = "https://files.pythonhosted.org/packages/62/b9/6796b3bf3101e64117201aaa3a5a030ec677ecf34b3cd6141b5d5c6c67d5/regex-2026.2.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb399ed84eabf4282587ba151f2732ad8168e66f1d3f85b1d038868fe547703", size = 288724, upload-time = "2026-02-28T02:16:25.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/02/291c0ae3f3a10cea941d0f5366da1843d8d1fa8a25b0671e20a0e454bb38/regex-2026.2.28-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1b34dfa72f826f535b20712afa9bb3ba580020e834f3c69866c5bddbf10098", size = 791924, upload-time = "2026-02-28T02:16:26.863Z" }, + { url = "https://files.pythonhosted.org/packages/0f/57/f0235cc520d9672742196c5c15098f8f703f2758d48d5a7465a56333e496/regex-2026.2.28-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:851fa70df44325e1e4cdb79c5e676e91a78147b1b543db2aec8734d2add30ec2", size = 860095, upload-time = "2026-02-28T02:16:28.772Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/393c94cbedda79a0f5f2435ebd01644aba0b338d327eb24b4aa5b8d6c07f/regex-2026.2.28-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:516604edd17b1c2c3e579cf4e9b25a53bf8fa6e7cedddf1127804d3e0140ca64", size = 906583, upload-time = "2026-02-28T02:16:30.977Z" }, + { url = "https://files.pythonhosted.org/packages/2c/73/a72820f47ca5abf2b5d911d0407ba5178fc52cf9780191ed3a54f5f419a2/regex-2026.2.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ce83654d1ab701cb619285a18a8e5a889c1216d746ddc710c914ca5fd71022", size = 800234, upload-time = "2026-02-28T02:16:32.55Z" }, + { url = "https://files.pythonhosted.org/packages/34/b3/6e6a4b7b31fa998c4cf159a12cbeaf356386fbd1a8be743b1e80a3da51e4/regex-2026.2.28-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2791948f7c70bb9335a9102df45e93d428f4b8128020d85920223925d73b9e1", size = 772803, upload-time = "2026-02-28T02:16:34.029Z" }, + { url = "https://files.pythonhosted.org/packages/10/e7/5da0280c765d5a92af5e1cd324b3fe8464303189cbaa449de9a71910e273/regex-2026.2.28-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03a83cc26aa2acda6b8b9dfe748cf9e84cbd390c424a1de34fdcef58961a297a", size = 781117, upload-time = "2026-02-28T02:16:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/76/39/0b8d7efb256ae34e1b8157acc1afd8758048a1cf0196e1aec2e71fd99f4b/regex-2026.2.28-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ec6f5674c5dc836994f50f1186dd1fafde4be0666aae201ae2fcc3d29d8adf27", size = 854224, upload-time = "2026-02-28T02:16:38.119Z" }, + { url = "https://files.pythonhosted.org/packages/21/ff/a96d483ebe8fe6d1c67907729202313895d8de8495569ec319c6f29d0438/regex-2026.2.28-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:50c2fc924749543e0eacc93ada6aeeb3ea5f6715825624baa0dccaec771668ae", size = 761898, upload-time = "2026-02-28T02:16:40.333Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/d4f2e75cb4a54b484e796017e37c0d09d8a0a837de43d17e238adf163f4e/regex-2026.2.28-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba55c50f408fb5c346a3a02d2ce0ebc839784e24f7c9684fde328ff063c3cdea", size = 844832, upload-time = "2026-02-28T02:16:41.875Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/428a135cf5e15e4e11d1e696eb2bf968362f8ea8a5f237122e96bc2ae950/regex-2026.2.28-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:edb1b1b3a5576c56f08ac46f108c40333f222ebfd5cf63afdfa3aab0791ebe5b", size = 788347, upload-time = "2026-02-28T02:16:43.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/68691428851cf9c9c3707217ab1d9b47cfeec9d153a49919e6c368b9e926/regex-2026.2.28-cp311-cp311-win32.whl", hash = "sha256:948c12ef30ecedb128903c2c2678b339746eb7c689c5c21957c4a23950c96d15", size = 266033, upload-time = "2026-02-28T02:16:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/1483de1c57024e89296cbcceb9cccb3f625d416ddb46e570be185c9b05a9/regex-2026.2.28-cp311-cp311-win_amd64.whl", hash = "sha256:fd63453f10d29097cc3dc62d070746523973fb5aa1c66d25f8558bebd47fed61", size = 277978, upload-time = "2026-02-28T02:16:46.75Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/abec45dc6e7252e3dbc797120496e43bb5730a7abf0d9cb69340696a2f2d/regex-2026.2.28-cp311-cp311-win_arm64.whl", hash = "sha256:00f2b8d9615aa165fdff0a13f1a92049bfad555ee91e20d246a51aa0b556c60a", size = 270340, upload-time = "2026-02-28T02:16:48.626Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, + { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, + { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, + { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, + { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, + { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311, upload-time = "2026-02-28T02:17:22.591Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285, upload-time = "2026-02-28T02:17:24.355Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051, upload-time = "2026-02-28T02:17:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842, upload-time = "2026-02-28T02:17:29.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083, upload-time = "2026-02-28T02:17:31.363Z" }, + { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412, upload-time = "2026-02-28T02:17:33.248Z" }, + { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101, upload-time = "2026-02-28T02:17:35.053Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260, upload-time = "2026-02-28T02:17:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311, upload-time = "2026-02-28T02:17:39.855Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876, upload-time = "2026-02-28T02:17:42.317Z" }, + { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632, upload-time = "2026-02-28T02:17:45.073Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320, upload-time = "2026-02-28T02:17:47.192Z" }, + { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152, upload-time = "2026-02-28T02:17:49.067Z" }, + { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398, upload-time = "2026-02-28T02:17:50.744Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282, upload-time = "2026-02-28T02:17:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382, upload-time = "2026-02-28T02:17:54.888Z" }, + { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541, upload-time = "2026-02-28T02:17:56.813Z" }, + { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984, upload-time = "2026-02-28T02:17:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509, upload-time = "2026-02-28T02:18:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429, upload-time = "2026-02-28T02:18:02.328Z" }, + { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422, upload-time = "2026-02-28T02:18:04.23Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175, upload-time = "2026-02-28T02:18:06.791Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044, upload-time = "2026-02-28T02:18:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056, upload-time = "2026-02-28T02:18:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743, upload-time = "2026-02-28T02:18:13.025Z" }, + { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633, upload-time = "2026-02-28T02:18:16.84Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862, upload-time = "2026-02-28T02:18:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788, upload-time = "2026-02-28T02:18:21.475Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184, upload-time = "2026-02-28T02:18:23.492Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137, upload-time = "2026-02-28T02:18:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682, upload-time = "2026-02-28T02:18:27.205Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735, upload-time = "2026-02-28T02:18:29.015Z" }, + { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" }, + { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" }, + { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" }, + { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" }, + { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825, upload-time = "2026-02-28T02:18:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548, upload-time = "2026-02-28T02:19:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444, upload-time = "2026-02-28T02:19:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" }, + { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" }, + { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" }, + { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" }, + { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936, upload-time = "2026-02-28T02:19:36.313Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779, upload-time = "2026-02-28T02:19:38.625Z" }, + { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010, upload-time = "2026-02-28T02:19:40.65Z" }, ] [[package]] @@ -5218,316 +6032,325 @@ version = "0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numba" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/f1/34be702a69a5d272e844c98cee82351f880985cfbca0cc86378011078497/resampy-0.4.3.tar.gz", hash = "sha256:a0d1c28398f0e55994b739650afef4e3974115edbe96cd4bb81968425e916e47", size = 3080604, upload-time = "2024-03-05T20:36:08.119Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/b9/3b00ac340a1aab3389ebcc52c779914a44aadf7b0cb7a3bf053195735607/resampy-0.4.3-py3-none-any.whl", hash = "sha256:ad2ed64516b140a122d96704e32bc0f92b23f45419e8b8f478e5a05f83edcebd", size = 3076529, upload-time = "2024-03-05T20:36:02.439Z" }, ] +[[package]] +name = "rfc3986" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/30/5b1b6c28c105629cc12b33bdcbb0b11b5bb1880c6cfbd955f9e792921aa8/rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", size = 49378, upload-time = "2021-05-07T23:29:27.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/e5/63ca2c4edf4e00657584608bee1001302bbf8c5f569340b78304f2f446cb/rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97", size = 31976, upload-time = "2021-05-07T23:29:25.611Z" }, +] + [[package]] name = "rich" -version = "14.1.0" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +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/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, + { 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.15.1" +version = "0.19.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/33/1a18839aaa8feef7983590c05c22c9c09d245ada6017d118325bbfcc7651/rich_toolkit-0.15.1.tar.gz", hash = "sha256:6f9630eb29f3843d19d48c3bd5706a086d36d62016687f9d0efa027ddc2dd08a", size = 115322, upload-time = "2025-09-04T09:28:11.789Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/ba/dae9e3096651042754da419a4042bc1c75e07d615f9b15066d738838e4df/rich_toolkit-0.19.7.tar.gz", hash = "sha256:133c0915872da91d4c25d85342d5ec1dfacc69b63448af1a08a0d4b4f23ef46e", size = 195877, upload-time = "2026-02-24T16:06:20.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/49/42821d55ead7b5a87c8d121edf323cb393d8579f63e933002ade900b784f/rich_toolkit-0.15.1-py3-none-any.whl", hash = "sha256:36a0b1d9a135d26776e4b78f1d5c2655da6e0ef432380b5c6b523c8d8ab97478", size = 29412, upload-time = "2025-09-04T09:28:10.587Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3c/c923619f6d2f5fafcc96fec0aaf9550a46cd5b6481f06e0c6b66a2a4fed0/rich_toolkit-0.19.7-py3-none-any.whl", hash = "sha256:0288e9203728c47c5a4eb60fd2f0692d9df7455a65901ab6f898437a2ba5989d", size = 32963, upload-time = "2026-02-24T16:06:22.066Z" }, ] [[package]] name = "rignore" -version = "0.7.0" +version = "0.7.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/46/e5ef3423a3746f91d3a3d9a68c499fde983be7dbab7d874efa8d3bb139ba/rignore-0.7.0.tar.gz", hash = "sha256:cfe6a2cbec855b440d7550d53e670246fce43ca5847e46557b6d4577c9cdb540", size = 12796, upload-time = "2025-10-02T13:26:22.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/62/ffdf1df1414f97b938926ddcd5914844c266ecb33131145d12be566cfd1f/rignore-0.7.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f9a456e1620aefb016fe0af51b09acd06736fddc8ce3417adfdd9191031b4c48", size = 884113, upload-time = "2025-10-02T13:25:03.336Z" }, - { url = "https://files.pythonhosted.org/packages/7d/6a/4e7fa97d378bd55df4f1ad0fbe8b2deb79bc73c3f2081f584f59d7a232b2/rignore-0.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4a96b9b30e3567ecec1fd37535f3c83093d0552af0891765a314650f35a22ad", size = 815695, upload-time = "2025-10-02T13:24:51.698Z" }, - { url = "https://files.pythonhosted.org/packages/b3/19/04831e4d3db0d828f9cf497b53c944b9c56c26fba764c98747013aae0585/rignore-0.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e011b6412690e34113d5cb133844bfe087fe9a57b37c63cb68671dfbf6080ed", size = 890938, upload-time = "2025-10-02T13:23:16.526Z" }, - { url = "https://files.pythonhosted.org/packages/0f/3a/da748c8ec25fa15a855fdb6f66c86fc1b1756f5cbe354389d4311d84022e/rignore-0.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c177b8267aa361bf04f9e28fa948881ff01e98e2556bf9d39b088e42de23b190", size = 865825, upload-time = "2025-10-02T13:23:35.145Z" }, - { url = "https://files.pythonhosted.org/packages/58/8a/8cd9415da272e94a5b306e37f4cc3c0631f08b884836d5c92cf90cecc7a8/rignore-0.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cfe5873ac415f62d221d8bd04d88f9a70e73fe7aea0c094b9974e530628d8ea", size = 1168074, upload-time = "2025-10-02T13:23:52.542Z" }, - { url = "https://files.pythonhosted.org/packages/f5/98/008476632a518463875d44dba429a03d59333194ed3a0d08b29c0348f7c3/rignore-0.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20d62988d4f565ab101ec33c501527fde52693eced4ea34d5da61b88db602616", size = 936248, upload-time = "2025-10-02T13:24:08.962Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ed/6d5c345ba0de67ff4096f0ebcfd2bfdad72335ab9dabe1c665a9579ba687/rignore-0.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:427ba9cfc424aedb0b569d659d2f2ea88ed308d8eb245446db10733a73db0fb1", size = 951260, upload-time = "2025-10-02T13:24:38.712Z" }, - { url = "https://files.pythonhosted.org/packages/44/ca/c56e097b091313b723416de9e28826ac92d731af5c4b51c4892e04286efc/rignore-0.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:86280aae7d0980debe5ed6785e3fc9a68ca627ac84e7c84048d7d3fe6d80ef7a", size = 975261, upload-time = "2025-10-02T13:24:25.553Z" }, - { url = "https://files.pythonhosted.org/packages/e0/dc/2e6987f8f6c8c96c29074901d7d5624de9d1741b4cab6c15975ad159f959/rignore-0.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:389c5fe844ad1fd5fba46c0cba0d9e68bfdcaae12e943b135395730efe45bcfe", size = 1071689, upload-time = "2025-10-02T13:25:15.11Z" }, - { url = "https://files.pythonhosted.org/packages/61/83/fda08b5e11e98f9e96e8c94b7cd5c21ec50193e2861784eb6e21a61f6ccf/rignore-0.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:34a34d5e86fda5355b55d927f43ff515c7371e8880b8f16cb92c3278c21327ee", size = 1129324, upload-time = "2025-10-02T13:25:31.669Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/e5ee481d4f7ccc373a1ebeec2d03415d1cf2b45522ac7424fe773b454f17/rignore-0.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f7aacce87c710a3f254eb8b28a0cec1801362e7cf4f8258cceb8f36fc9cc695a", size = 1108242, upload-time = "2025-10-02T13:25:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/5a/89/195c5a909303c841ad5e1de300f1d3dd2177768cd3369318654f92b95d8a/rignore-0.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3b88dfeb0d08902fe28eb5d75cbfac1d130ccdaeaca742eab1628bab7960294", size = 1116370, upload-time = "2025-10-02T13:26:06.084Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d3/c582c4751266f7293346caefddfcd9e7aa2b83085e8c57db6df47b51538f/rignore-0.7.0-cp310-cp310-win32.whl", hash = "sha256:bf43125e8b34828ba91fe37a5cfadd677ff46152d539cdab19bb1390f85d21a5", size = 637209, upload-time = "2025-10-02T13:26:34.427Z" }, - { url = "https://files.pythonhosted.org/packages/89/38/da8013a7b5876e7ed54168e8c297fd8345c2d40e726ef122e2b374df72b3/rignore-0.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:39a6cf0d81ffbbbd1c353b6a9a5634722714a6caafdcdc056f361e62049aa93b", size = 716785, upload-time = "2025-10-02T13:26:23.691Z" }, - { url = "https://files.pythonhosted.org/packages/21/c4/c6fe75a64c9499b1d01c6e00054a9564900aaee3cb8d99cce7b9d853aba3/rignore-0.7.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a83923fd4adff85737c54aecbdb8b7c8f1bba913af019ffebcf6d65d3903cefd", size = 883839, upload-time = "2025-10-02T13:25:04.814Z" }, - { url = "https://files.pythonhosted.org/packages/95/cf/90db9c137bebce283f6fad00b032b9953ee4239f4f67e53e993550e0740b/rignore-0.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f029f6b8f66310659d4e8616a0adaf0de79b7b076b1e37261d532b24e000eff2", size = 815865, upload-time = "2025-10-02T13:24:53.482Z" }, - { url = "https://files.pythonhosted.org/packages/31/08/d64298cec32d5df121968b3ab75d17d2a30ff02f080a3457893e57689809/rignore-0.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:686c162f945ede315b7b63958d83531b18226cad4fae9170a5787dd8b8b4be89", size = 891607, upload-time = "2025-10-02T13:23:18.739Z" }, - { url = "https://files.pythonhosted.org/packages/d7/b3/602bb25ba0c862dd3f7f52af0f5e3fce4321207a1b76c0b3b7f17aed0146/rignore-0.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3c8b62a00c1b6e0ed73412ba8d37d05e214e6a8757f2779d313078d2bdec209", size = 865644, upload-time = "2025-10-02T13:23:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/d7/fc/18f5ac22714bdd0437aaa59ff2ded2ba3caff2745c89e671bc9c91c52947/rignore-0.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f115666738614cdb0ef122c2b48806043b9b6c603dc03a4708b2eb1df5a44514", size = 1167949, upload-time = "2025-10-02T13:23:54.257Z" }, - { url = "https://files.pythonhosted.org/packages/b6/1b/6409b434420995b8897c3d6b5a2701343857d2d36d159bd9305287c33634/rignore-0.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ffaf2047304b97bc648592f82c0aeba3468f43546a918994411b8f1d79d42d6", size = 935950, upload-time = "2025-10-02T13:24:10.463Z" }, - { url = "https://files.pythonhosted.org/packages/b9/56/c0a03cb643ca41091f0377ffea3a35ae3f3cff39b075ca94eec35fae6ed0/rignore-0.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04678c2f1787eb07378754d6aa50e66ce712e0b75e8b843fd9e5e4da35130617", size = 951418, upload-time = "2025-10-02T13:24:40.222Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3b/33783bc1681662789f71614dee496fb0dd96de4887eb8d5d2cb9f365d1ff/rignore-0.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53a4c4a43558f34b32732efcee9c79c7948ff26673bb764aa0e9bbe951e435fa", size = 975421, upload-time = "2025-10-02T13:24:27.049Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e2/af19c05288c2afb5b79f73c68e88a34b88245b66e5cf358417461a72c8c5/rignore-0.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:794f72ce7195cad1fb41c03b3e3484396c404498b73855004ebea965a697edd9", size = 1071989, upload-time = "2025-10-02T13:25:17.248Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ea/6ab6d1afafcd3f6e5ba898646bcfe3a6f69eb8f4ac264dd82848ab7f2c5b/rignore-0.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:989f35a152bc508c52d63d7d4527215c5dabe7981e5744bcf35f96c99f3758f7", size = 1129150, upload-time = "2025-10-02T13:25:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/0d/49/a327d54cbd5f9f34ed383057ee1c9a044571878045cbd37a129f27f13ab0/rignore-0.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b945b29a995fdcf669dc098ec40237131742de2cf49484011ba3f81d0fff23a3", size = 1107917, upload-time = "2025-10-02T13:25:49.702Z" }, - { url = "https://files.pythonhosted.org/packages/86/f8/89a1269911e7895e3c4a5c1fb1abb3b9b255362035fa54c593287cf38b15/rignore-0.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e4deda4c3e5cec1ebfb714094cd9af79e8840680187537d13a216377d6aa2ed6", size = 1116013, upload-time = "2025-10-02T13:26:07.597Z" }, - { url = "https://files.pythonhosted.org/packages/96/8c/6e85f0437451777649a582b558252f671571ad044d3d14a70978d5f9070c/rignore-0.7.0-cp311-cp311-win32.whl", hash = "sha256:d0fa18c39a4f25275abeb05a7889d11b4dfed9966d5eb1d41fd13da1394863b0", size = 637212, upload-time = "2025-10-02T13:26:36.34Z" }, - { url = "https://files.pythonhosted.org/packages/e7/10/d2ac60b125b19c0ed976ce66cae4d3061c390e650d2806ac2b9e6fe17634/rignore-0.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac18b6fe469a3c57a92c5fc82f94f260922177b003189104eb758316b7b54d6e", size = 716632, upload-time = "2025-10-02T13:26:25.224Z" }, - { url = "https://files.pythonhosted.org/packages/ca/0e/be002ba0cb4752b518de8487968a82c47ad2cc956af354e09f055474754b/rignore-0.7.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:df6d38f3c3903bfeec94e8a927a3656e0b95c27d3b5c29e63797dd359978aff8", size = 880602, upload-time = "2025-10-02T13:25:06.365Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7f/8a16c5d6200952a219ad8866be430ed42f488b1888449aab0eba20e8123c/rignore-0.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:da1b9ccc2cf6df196fe3187287e7ed858e967ae56974901414031f5524ea33b8", size = 811654, upload-time = "2025-10-02T13:24:55.118Z" }, - { url = "https://files.pythonhosted.org/packages/4e/e6/fd2cbc71f725ea10892c85ea56bd8f54426557cf5ac2924f9c27b771ee45/rignore-0.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0525ccf3e8b9ccd6f1dfc87ecc78218a83605070b247633636d144acdf6b73be", size = 892031, upload-time = "2025-10-02T13:23:20.558Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c8/0dfd755f57515d34ca26de011e016f62db86f7bef0586f2ab0d9f6e18136/rignore-0.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:570bcf51fd9f78ec79ec33f2f852e6665027fae80cc3e5e2523c97d3f4220369", size = 865496, upload-time = "2025-10-02T13:23:37.965Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b9/f73af8509842d74788fc26feca25db1eade9291fae79540872c130407340/rignore-0.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:32f5d3d90a520d61e43c2a23724852c689c3ed36b38264c77b613f967e2d1f68", size = 1165555, upload-time = "2025-10-02T13:23:56.009Z" }, - { url = "https://files.pythonhosted.org/packages/44/22/67d2fb589cedd7bf3a01e16617f2da10f172165b3ecdaa8fa0707043e9ed/rignore-0.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7d189cfb9059dfa497e5480c411bd2aba838124b50b93abf7e92556221b7956", size = 936631, upload-time = "2025-10-02T13:24:11.97Z" }, - { url = "https://files.pythonhosted.org/packages/4e/6b/e0f969a1cb3ff2caa0dd342e512d7a0a6f1b737b6f5373c04606aa946e80/rignore-0.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c871a31596476ac4343f6b803ee8ddca068425e1837cf6849ebe46c498c73c5", size = 951058, upload-time = "2025-10-02T13:24:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/45/cf/ccf053fb87601332e8b2e2da707f2801bee66ee5fe843687183f45c2e768/rignore-0.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5b7d8ce1efbd8fa865712d34753ce4eb8e0732874df95351244e14308fb87d0a", size = 974638, upload-time = "2025-10-02T13:24:29Z" }, - { url = "https://files.pythonhosted.org/packages/de/ae/a00181c0d2dc437a3729dbebcfffd67bb849d1c53e45850c7b4428f5fba4/rignore-0.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d261aea1a51ef93c262b52ad195a1092a8bae17577e8192473d1b5fd30379346", size = 1072970, upload-time = "2025-10-02T13:25:18.888Z" }, - { url = "https://files.pythonhosted.org/packages/81/30/3011207fc9f26f9eb21d2282dfedd8f2d66cf7a9a3053370c9b4b87601e1/rignore-0.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:034bef935e3734b4ad2dada59c96717f3e3d0b48551a0c79379c4d3280b4a397", size = 1128833, upload-time = "2025-10-02T13:25:34.987Z" }, - { url = "https://files.pythonhosted.org/packages/4b/be/4c6a860f851db6cb0b96a3ec62dd4fe95290ee36e67b845ffab58908c6cc/rignore-0.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f816b65c9bf97093d792c9b50369d5a81a5f95b4ed5f003d4091bd1db3b70d8", size = 1106909, upload-time = "2025-10-02T13:25:51.266Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8a/691d79e72f000968e1e3457ff53634760dac24fa6c6b5663d994362b8a99/rignore-0.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b88479f0a89828781d25a9acd485be88abf4f1f1c14e455b6530da265adb593c", size = 1115733, upload-time = "2025-10-02T13:26:09.256Z" }, - { url = "https://files.pythonhosted.org/packages/30/5b/4566f88a4ad452f94995cfca55c2509238ab94c4e191497edd1fd21dac4c/rignore-0.7.0-cp312-cp312-win32.whl", hash = "sha256:89324cffc3312ad50e43f07f51966d421dc44d7c0d219747259270ee5fbc59e3", size = 637030, upload-time = "2025-10-02T13:26:38.533Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6a/169ced0141a9f102a97b9de2b20d3d77043a9a0ced4ef94148f31ba02628/rignore-0.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:bbbbc7582d3926a250a14acf7c6b1d60b6d610275ac026856555fd12492e716e", size = 716355, upload-time = "2025-10-02T13:26:27.022Z" }, - { url = "https://files.pythonhosted.org/packages/5e/85/cd1441043c5ed13e671153af260c5f328042ebfb87aa28849367602206f2/rignore-0.7.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:190e469db68112c4027a7a126facfd80ce353374ff208c585ca7dacc75de0472", size = 880474, upload-time = "2025-10-02T13:25:08.111Z" }, - { url = "https://files.pythonhosted.org/packages/f4/07/d5b9593cb05593718508308543a8fbee75998a7489cf4f4b489d2632bd4a/rignore-0.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0a43f6fabf46ed8e96fbf2861187362e513960c2a8200c35242981bd36ef8b96", size = 811882, upload-time = "2025-10-02T13:24:56.599Z" }, - { url = "https://files.pythonhosted.org/packages/aa/67/b82b2704660c280061d8bc90bc91092622309f78e20c9e3321f45f88cd4e/rignore-0.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b89a59e5291805eca3c3317a55fcd2a579e9ee1184511660078a398182463deb", size = 892043, upload-time = "2025-10-02T13:23:22.326Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7e/e91a1899a06882cd8a7acc3025c51b9f830971b193bd6b72e34254ed7733/rignore-0.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a155f36be847c05c800e0218e9ac04946ba44bf077e1f11dc024ca9e1f7a727", size = 865404, upload-time = "2025-10-02T13:23:40.085Z" }, - { url = "https://files.pythonhosted.org/packages/91/2c/68487538a2d2d7e0e1ca1051d143af690211314e22cbed58a245e816ebaf/rignore-0.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dba075135ac3cda5f3236b4f03f82bbcd97454a908631ad3da93aae1e7390b17", size = 1167661, upload-time = "2025-10-02T13:23:57.578Z" }, - { url = "https://files.pythonhosted.org/packages/b4/39/8498ac13fb710a1920526480f9476aaeaaaa20c522a027d07513929ba9d9/rignore-0.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8525b8c31f36dc9fbcb474ef58d654f6404b19b6110b7f5df332e58e657a4aa8", size = 936272, upload-time = "2025-10-02T13:24:13.414Z" }, - { url = "https://files.pythonhosted.org/packages/55/1a/38b92fde209931611dcff0db59bd5656a325ba58d368d4e50f1e711fdd16/rignore-0.7.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0428b64d8b02ad83fc0a2505ded0e9064cac97df7aa1dffc9c7558b56429912", size = 950552, upload-time = "2025-10-02T13:24:43.263Z" }, - { url = "https://files.pythonhosted.org/packages/e3/01/f59f38ae1b879309b0151b1ed0dd82880e1d3759f91bfdaa570730672308/rignore-0.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0ab1db960a64835ec3ed541951821bfc38f30dfbd6ebd990f7d039d0c54ff957", size = 974407, upload-time = "2025-10-02T13:24:30.618Z" }, - { url = "https://files.pythonhosted.org/packages/6e/67/de92fdc09dc1a622abb6d1b2678e940d24de2a07c60d193126eb52a7e8ea/rignore-0.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3749711b1e50fb5b28b55784e159a3b8209ecc72d01cc1511c05bc3a23b4a063", size = 1072865, upload-time = "2025-10-02T13:25:20.451Z" }, - { url = "https://files.pythonhosted.org/packages/65/bb/75fbef03cf56b0918880cb3b922da83d6546309566be60f6c6b451f7221b/rignore-0.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:57240739c786f897f89e29c05e529291ee1b477df9f6b29b774403a23a169fe2", size = 1129007, upload-time = "2025-10-02T13:25:36.837Z" }, - { url = "https://files.pythonhosted.org/packages/ec/24/4d591d45a8994fb4afaefa22e356d69948726c9ccba0cfd76c82509aedc2/rignore-0.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6b70581286acd5f96ce11efd209bfe9261108586e1a948cc558fc3f58ba5bf5f", size = 1106827, upload-time = "2025-10-02T13:25:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b3/b614d54fa1f1c7621aeb20b2841cd980288ad9d7d61407fc4595d5c5f132/rignore-0.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33fb6e4cba1b798f1328e889b4bf2341894d82e3be42bb3513b4e0fe38788538", size = 1115328, upload-time = "2025-10-02T13:26:10.947Z" }, - { url = "https://files.pythonhosted.org/packages/83/22/ea0b3e30e230b2d2222e1ee18e20316c8297088f4cc6a6ea2ee6cb34f595/rignore-0.7.0-cp313-cp313-win32.whl", hash = "sha256:119f0497fb4776cddc663ee8f35085ce00758bd423221ba1e8222a816e10cf5e", size = 636896, upload-time = "2025-10-02T13:26:40.3Z" }, - { url = "https://files.pythonhosted.org/packages/79/16/f55b3db13f6fff408fde348d2a726d3b4ba06ed55dce8ff119e374ce3005/rignore-0.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:fb06e11dda689be138909f53639f0baa8d7c6be4d76ca9ec316382ccf3517469", size = 716519, upload-time = "2025-10-02T13:26:28.51Z" }, - { url = "https://files.pythonhosted.org/packages/69/db/8c20a7b59abb21d3d20d387656b6759cd5890fa68185064fe8899f942a4b/rignore-0.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f2255821ab4bc34fa129a94535f5d0d88b164940b25d0a3b26ebd41d99f1a9f", size = 890684, upload-time = "2025-10-02T13:23:23.761Z" }, - { url = "https://files.pythonhosted.org/packages/45/a0/ae5ca63aed23f64dcd740f55ee6432037af5c09d25efaf79dc052a4a51ff/rignore-0.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b57efcbbc1510f8ce831a5e19fb1fe9dd329bb246c4e4f8a09bf1c06687b0331", size = 865174, upload-time = "2025-10-02T13:23:41.948Z" }, - { url = "https://files.pythonhosted.org/packages/ae/27/5aff661e792efbffda689f0d3fa91ea36f2e0d4bcca3b02f70ae95ea96da/rignore-0.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ead4bc2baceeccdfeb82cb70ba8f70fdb6dc1e58976f805f9d0d19b9ee915f0", size = 1165293, upload-time = "2025-10-02T13:23:59.238Z" }, - { url = "https://files.pythonhosted.org/packages/cb/df/13de7ce5ba2a58c724ef202310408729941c262179389df5e90cb9a41381/rignore-0.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f0a8996437a22df0faf2844d65ec91d41176b9d4e7357abee42baa39dc996ae", size = 936093, upload-time = "2025-10-02T13:24:15.057Z" }, - { url = "https://files.pythonhosted.org/packages/c3/63/4ea42bc454db8499906c8d075a7a0053b7fd381b85f3bcc857e68a8b8b23/rignore-0.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:cb17ef4a413444fccbd57e1b4a3870f1320951b81f1b7007af9c70e1a5bc2897", size = 1071518, upload-time = "2025-10-02T13:25:22.076Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a7/7400a4343d1b5a1345a98846c6fd7768ff13890d207fce79d690c7fd7798/rignore-0.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:b12b316adf6cf64f9d22bd690b2aa019a37335a1f632a0da7fb15a423cb64080", size = 1128403, upload-time = "2025-10-02T13:25:38.394Z" }, - { url = "https://files.pythonhosted.org/packages/45/8b/ce8ff27336a86bad47bbf011f8f7fb0b82b559ee4a0d6a4815ee3555ef56/rignore-0.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:dba8181d999387c17dd6cce5fd7f0009376ca8623d2d86842d034b18d83dc768", size = 1105552, upload-time = "2025-10-02T13:25:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e2/7925b564d853c7057f150a7f2f384400422ed30f7b7baf2fde5849562381/rignore-0.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:04a3d4513cdd184f4f849ae8d6407a169cca543a2c4dd69bfc42e67cb0155504", size = 1114826, upload-time = "2025-10-02T13:26:12.56Z" }, - { url = "https://files.pythonhosted.org/packages/c4/34/c42ccdd81143d38d99e45b965e4040a1ef6c07a365ad205dd94b6d16c794/rignore-0.7.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:a296bc26b713aacd0f31702e7d89426ba6240abdbf01b2b18daeeaeaa782f475", size = 879718, upload-time = "2025-10-02T13:25:09.62Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ba/f522adf949d2b581a0a1e488a79577631ed6661fdc12e80d4182ed655036/rignore-0.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7f71807ed0bc1542860a8fa1615a0d93f3d5a22dde1066e9f50d7270bc60686", size = 810391, upload-time = "2025-10-02T13:24:58.144Z" }, - { url = "https://files.pythonhosted.org/packages/f2/82/935bffa4ad7d9560541daaca7ba0e4ee9b0b9a6370ab9518cf9c991087bb/rignore-0.7.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7e6ff54399ddb650f4e4dc74b325766e7607967a49b868326e9687fc3642620", size = 950261, upload-time = "2025-10-02T13:24:45.121Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0e/22abda23cc6d20901262fcfea50c25ed66ca6e1a5dc610d338df4ca10407/rignore-0.7.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:09dfad3ca450b3967533c6b1a2c7c0228c63c518f619ff342df5f9c3ed978b66", size = 974258, upload-time = "2025-10-02T13:24:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/ed/8d/0ba2c712723fdda62125087d00dcdad93102876d4e3fa5adbb99f0b859c3/rignore-0.7.0-cp314-cp314-win32.whl", hash = "sha256:2850718cfb1caece6b7ac19a524c7905a8d0c6627b0d0f4e81798e20b6c75078", size = 637403, upload-time = "2025-10-02T13:26:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/1c/63/0d7df1237c6353d1a85d8a0bc1797ac766c68e8bc6fbca241db74124eb61/rignore-0.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2401637dc8ab074f5e642295f8225d2572db395ae504ffc272a8d21e9fe77b2c", size = 717404, upload-time = "2025-10-02T13:26:29.936Z" }, - { url = "https://files.pythonhosted.org/packages/eb/28/59aa850097283f3ae651e13ced4a8beadab8bab2f193a6e6d32d4235fc79/rignore-0.7.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11831ac0c3bc656667848abd0cb869d288b13ad69a976cac307b447a4f79c9d3", size = 893706, upload-time = "2025-10-02T13:23:29.65Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5e/0bf7c2101cd557374805372ae8a230ba83b4aa460c2f2f327580f79774f9/rignore-0.7.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1f18bdc1bfd73da6a43bcf2c08f94e1ecddabf234e47e0f95daf6107cf937fb3", size = 867651, upload-time = "2025-10-02T13:23:47.193Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ac/33080dba026b863a43e43a4c861278e51eb1b03c90f1a108f35a74b85d2d/rignore-0.7.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6268a12ecb6d25caf0adb166732940bd9113e7dddd46018e457f1fb9408a707", size = 1168395, upload-time = "2025-10-02T13:24:03.882Z" }, - { url = "https://files.pythonhosted.org/packages/83/b1/0c62eb8df324c00ef65c02c24dee30cc5a491ba224728916703761ee7c80/rignore-0.7.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c1402445b24c8904b6cef124f2798d55a2ba84b41397d7fdab6fe316b1f20e6", size = 938744, upload-time = "2025-10-02T13:24:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/5d/c3/965ab1d3674e790429a63d8486e2432fe120b29d4f4682a4171b3b3efac2/rignore-0.7.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8aa1c3215cc9587e55b3326357637e17a2ed713ddb2c59339f908aae98ae01d6", size = 1074476, upload-time = "2025-10-02T13:25:26.864Z" }, - { url = "https://files.pythonhosted.org/packages/36/2d/2673af40f46c2182c34d06e0662c035130008b47f74e4b5c72cddbbb50b6/rignore-0.7.0-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:78c8f56aaae18406699026e26f9c3b4721adc95f08ac4e76972aed0efb5eb91d", size = 1131270, upload-time = "2025-10-02T13:25:43.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/6c/7fcd680db36c2e34c0d5cceefd7a423772a9fbf25bb468b5748fedacda94/rignore-0.7.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:89ab6d73ffd48be27032def1decef83faebee891519e7f2006df5657b8ba2f4a", size = 1110257, upload-time = "2025-10-02T13:26:00.717Z" }, - { url = "https://files.pythonhosted.org/packages/22/95/8ce27268d27fd12bc1b80d3e1840402f3ef3c205e788975d61c7a4077ef8/rignore-0.7.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:cf4eebeebeabd27467b51d5dbc92adc7177fcfd73a29c86f649853ba476d98fa", size = 1118831, upload-time = "2025-10-02T13:26:17.187Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/b02edbf5059f7947e375dc46583283aad579505e9e07775277e7fd6e04db/rignore-0.7.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f40142a34a7f08cd90fb4e74e43deffe3381fa3b164fb59857fa4e3996d4716d", size = 892600, upload-time = "2025-10-02T13:23:31.158Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c5/3caa7732a91623110bc80c30f592efc6571a1c610b94f36083601ebf2392/rignore-0.7.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ccbc0b6285bb981316e5646ac96be7bca9665ee2444427d8d170fda5eda6f022", size = 866500, upload-time = "2025-10-02T13:23:49.099Z" }, - { url = "https://files.pythonhosted.org/packages/8b/66/943300886972b2dded2e0e851c1da1ad36565d40b5e55833b049cbf9285b/rignore-0.7.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77cdf15a8b0ab80cd1d05a754b3237330e60e8731c255b7eb2a5d240a68df9f8", size = 1167255, upload-time = "2025-10-02T13:24:05.583Z" }, - { url = "https://files.pythonhosted.org/packages/1e/26/2f8cb5a546ce7056fe0fb8afbfc887431f9ba986cd7b4c65821dac13afa8/rignore-0.7.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:14e7e5ac99d60dd1993032205de7e79c36687825c45a7caa704620a0e9fde03f", size = 937991, upload-time = "2025-10-02T13:24:21.694Z" }, - { url = "https://files.pythonhosted.org/packages/2d/29/f97d581fc4d1013a42fe51154f820a7ccb97c679a2c2ea0c73072aa8935e/rignore-0.7.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fae67456f053942ccda2cb2677a55fd34397e6674eaa403ab7c1c4930dcb12", size = 951972, upload-time = "2025-10-02T13:24:50.199Z" }, - { url = "https://files.pythonhosted.org/packages/6a/06/18da8ea8fc217fce872f81de23217c7ae011dd6e396dff026a262b499a4b/rignore-0.7.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b55d2dcee6808f677ef25219ec0bb4852fbf2edb0b5010a5f18fe5feee276d6", size = 976002, upload-time = "2025-10-02T13:24:36.851Z" }, - { url = "https://files.pythonhosted.org/packages/ea/11/2f998fccb85a31f8dbd94b31123b48645067d4ca55b49c033987286475e7/rignore-0.7.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:7ff87634a648f17a9992ac4ce2fb48397696e3ab4a80154a895b9d1f6fc606cf", size = 1073180, upload-time = "2025-10-02T13:25:28.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/bf/ee6927f8dd8644f4c9c44d364380ab49629d259cc9611224512b161d7bef/rignore-0.7.0-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:c5721daa569fae74f5bf060165f96c6fec0a963ed008213e778259945406ec53", size = 1130056, upload-time = "2025-10-02T13:25:45.019Z" }, - { url = "https://files.pythonhosted.org/packages/33/89/b231f432caced14303055c8611b34c5e2910c48b882de1c79eff4ce177d0/rignore-0.7.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:5770e783e08403b02c052b8b74a3e9431142aca93c78ccd1cc389b4dc60c2846", size = 1108603, upload-time = "2025-10-02T13:26:02.539Z" }, - { url = "https://files.pythonhosted.org/packages/a1/33/d331a0aea9e4a00ff530ad18421c46e213da1a608ad05463a2e5ae6cc572/rignore-0.7.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:504f66805fcc2a684cd1cda460d9f15b8b08997f06d9281efa221007072c53f5", size = 1117330, upload-time = "2025-10-02T13:26:18.741Z" }, + { url = "https://files.pythonhosted.org/packages/86/7a/b970cd0138b0ece72eb28f086e933f9ed75b795716ad3de5ab22994b3b54/rignore-0.7.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f3c74a7e5ee77aea669c95fdb3933f2a6c7549893700082e759128a29cf67e45", size = 884999, upload-time = "2025-11-05T20:42:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/23faca29616d8966ada63fb0e13c214107811fa9a0aba2275e4c7ca63bd5/rignore-0.7.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b7202404958f5fe3474bac91f65350f0b1dde1a5e05089f2946549b7e91e79ec", size = 824824, upload-time = "2025-11-05T20:42:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/05a1e61f04cf2548524224f0b5f21ca19ea58f7273a863bac10846b8ff69/rignore-0.7.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bde7c5835fa3905bfb7e329a4f1d7eccb676de63da7a3f934ddd5c06df20597", size = 899121, upload-time = "2025-11-05T20:40:48.94Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/71518847e10bdbf359badad8800e4681757a01f4777b3c5e03dbde8a42d8/rignore-0.7.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:626c3d4ba03af266694d25101bc1d8d16eda49c5feb86cedfec31c614fceca7d", size = 873813, upload-time = "2025-11-05T20:41:04.71Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c8/32ae405d3e7fd4d9f9b7838f2fcca0a5005bb87fa514b83f83fd81c0df22/rignore-0.7.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a43841e651e7a05a4274b9026cc408d1912e64016ede8cd4c145dae5d0635be", size = 1168019, upload-time = "2025-11-05T20:41:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/013c955982bc5b4719bf9a5bea58be317eea28aa12bfd004025e3cd7c000/rignore-0.7.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7978c498dbf7f74d30cdb8859fe612167d8247f0acd377ae85180e34490725da", size = 942822, upload-time = "2025-11-05T20:41:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/90/fb/9a3f3156c6ed30bcd597e63690353edac1fcffe9d382ad517722b56ac195/rignore-0.7.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d22f72ab695c07d2d96d2a645208daff17084441b5d58c07378c9dd6f9c4c87", size = 959820, upload-time = "2025-11-05T20:42:06.364Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b2/93bf609633021e9658acaff24cfb055d8cdaf7f5855d10ebb35307900dda/rignore-0.7.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d5bd8e1a91ed1a789b2cbe39eeea9204a6719d4f2cf443a9544b521a285a295f", size = 985050, upload-time = "2025-11-05T20:41:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/ec2d040469bdfd7b743df10f2201c5d285009a4263d506edbf7a06a090bb/rignore-0.7.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fc03efad5789365018e94ac4079f851a999bc154d1551c45179f7fcf45322", size = 1079164, upload-time = "2025-11-05T21:40:10.368Z" }, + { url = "https://files.pythonhosted.org/packages/df/26/4b635f4ea5baf4baa8ba8eee06163f6af6e76dfbe72deb57da34bb24b19d/rignore-0.7.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ce2617fe28c51367fd8abfd4eeea9e61664af63c17d4ea00353d8ef56dfb95fa", size = 1139028, upload-time = "2025-11-05T21:40:27.977Z" }, + { url = "https://files.pythonhosted.org/packages/6a/54/a3147ebd1e477b06eb24e2c2c56d951ae5faa9045b7b36d7892fec5080d9/rignore-0.7.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7c4ad2cee85068408e7819a38243043214e2c3047e9bd4c506f8de01c302709e", size = 1119024, upload-time = "2025-11-05T21:40:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f4/27475db769a57cff18fe7e7267b36e6cdb5b1281caa185ba544171106cba/rignore-0.7.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:02cd240bfd59ecc3907766f4839cbba20530a2e470abca09eaa82225e4d946fb", size = 1128531, upload-time = "2025-11-05T21:41:02.734Z" }, + { url = "https://files.pythonhosted.org/packages/97/32/6e782d3b352e4349fa0e90bf75b13cb7f11d8908b36d9e2b262224b65d9a/rignore-0.7.6-cp310-cp310-win32.whl", hash = "sha256:fe2bd8fa1ff555259df54c376abc73855cb02628a474a40d51b358c3a1ddc55b", size = 646817, upload-time = "2025-11-05T21:41:47.51Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8a/53185c69abb3bb362e8a46b8089999f820bf15655629ff8395107633c8ab/rignore-0.7.6-cp310-cp310-win_amd64.whl", hash = "sha256:d80afd6071c78baf3765ec698841071b19e41c326f994cfa69b5a1df676f5d39", size = 727001, upload-time = "2025-11-05T21:41:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/25/41/b6e2be3069ef3b7f24e35d2911bd6deb83d20ed5642ad81d5a6d1c015473/rignore-0.7.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:40be8226e12d6653abbebaffaea2885f80374c1c8f76fe5ca9e0cadd120a272c", size = 885285, upload-time = "2025-11-05T20:42:39.763Z" }, + { url = "https://files.pythonhosted.org/packages/52/66/ba7f561b6062402022887706a7f2b2c2e2e2a28f1e3839202b0a2f77e36d/rignore-0.7.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:182f4e5e4064d947c756819446a7d4cdede8e756b8c81cf9e509683fe38778d7", size = 823882, upload-time = "2025-11-05T20:42:23.488Z" }, + { url = "https://files.pythonhosted.org/packages/f5/81/4087453df35a90b07370647b19017029324950c1b9137d54bf1f33843f17/rignore-0.7.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16b63047648a916a87be1e51bb5c009063f1b8b6f5afe4f04f875525507e63dc", size = 899362, upload-time = "2025-11-05T20:40:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c9/390a8fdfabb76d71416be773bd9f162977bd483084f68daf19da1dec88a6/rignore-0.7.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba5524f5178deca4d7695e936604ebc742acb8958f9395776e1fcb8133f8257a", size = 873633, upload-time = "2025-11-05T20:41:06.193Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/79404fcb0faa76edfbc9df0901f8ef18568d1104919ebbbad6d608c888d1/rignore-0.7.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62020dbb89a1dd4b84ab3d60547b3b2eb2723641d5fb198463643f71eaaed57d", size = 1167633, upload-time = "2025-11-05T20:41:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/b3466d32d445d158a0aceb80919085baaae495b1f540fb942f91d93b5e5b/rignore-0.7.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b34acd532769d5a6f153a52a98dcb81615c949ab11697ce26b2eb776af2e174d", size = 941434, upload-time = "2025-11-05T20:41:38.151Z" }, + { url = "https://files.pythonhosted.org/packages/e8/40/9cd949761a7af5bc27022a939c91ff622d29c7a0b66d0c13a863097dde2d/rignore-0.7.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c5e53b752f9de44dff7b3be3c98455ce3bf88e69d6dc0cf4f213346c5e3416c", size = 959461, upload-time = "2025-11-05T20:42:08.476Z" }, + { url = "https://files.pythonhosted.org/packages/b5/87/1e1a145731f73bdb7835e11f80da06f79a00d68b370d9a847de979575e6d/rignore-0.7.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25b3536d13a5d6409ce85f23936f044576eeebf7b6db1d078051b288410fc049", size = 985323, upload-time = "2025-11-05T20:41:52.735Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/1ecff992fc3f59c4fcdcb6c07d5f6c1e6dfb55ccda19c083aca9d86fa1c6/rignore-0.7.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6e01cad2b0b92f6b1993f29fc01f23f2d78caf4bf93b11096d28e9d578eb08ce", size = 1079173, upload-time = "2025-11-05T21:40:12.007Z" }, + { url = "https://files.pythonhosted.org/packages/17/18/162eedadb4c2282fa4c521700dbf93c9b14b8842e8354f7d72b445b8d593/rignore-0.7.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5991e46ab9b4868334c9e372ab0892b0150f3f586ff2b1e314272caeb38aaedb", size = 1139012, upload-time = "2025-11-05T21:40:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/78/96/a9ca398a8af74bb143ad66c2a31303c894111977e28b0d0eab03867f1b43/rignore-0.7.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c8ae562e5d1246cba5eaeb92a47b2a279e7637102828dde41dcbe291f529a3e", size = 1118827, upload-time = "2025-11-05T21:40:46.6Z" }, + { url = "https://files.pythonhosted.org/packages/9f/22/1c1a65047df864def9a047dbb40bc0b580b8289a4280e62779cd61ae21f2/rignore-0.7.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aaf938530dcc0b47c4cfa52807aa2e5bfd5ca6d57a621125fe293098692f6345", size = 1128182, upload-time = "2025-11-05T21:41:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f4/1526eb01fdc2235aca1fd9d0189bee4021d009a8dcb0161540238c24166e/rignore-0.7.6-cp311-cp311-win32.whl", hash = "sha256:166ebce373105dd485ec213a6a2695986346e60c94ff3d84eb532a237b24a4d5", size = 646547, upload-time = "2025-11-05T21:41:49.439Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c8/dda0983e1845706beb5826459781549a840fe5a7eb934abc523e8cd17814/rignore-0.7.6-cp311-cp311-win_amd64.whl", hash = "sha256:44f35ee844b1a8cea50d056e6a595190ce9d42d3cccf9f19d280ae5f3058973a", size = 727139, upload-time = "2025-11-05T21:41:34.367Z" }, + { url = "https://files.pythonhosted.org/packages/e3/47/eb1206b7bf65970d41190b879e1723fc6bbdb2d45e53565f28991a8d9d96/rignore-0.7.6-cp311-cp311-win_arm64.whl", hash = "sha256:14b58f3da4fa3d5c3fa865cab49821675371f5e979281c683e131ae29159a581", size = 657598, upload-time = "2025-11-05T21:41:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" }, + { url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" }, + { url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" }, + { url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" }, + { url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, + { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, + { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, + { url = "https://files.pythonhosted.org/packages/85/12/62d690b4644c330d7ac0f739b7f078190ab4308faa909a60842d0e4af5b2/rignore-0.7.6-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3d3a523af1cd4ed2c0cba8d277a32d329b0c96ef9901fb7ca45c8cfaccf31a5", size = 887462, upload-time = "2025-11-05T20:42:50.804Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/6528a0e97ed2bd7a7c329183367d1ffbc5b9762ae8348d88dae72cc9d1f5/rignore-0.7.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:990853566e65184a506e1e2af2d15045afad3ebaebb8859cb85b882081915110", size = 826918, upload-time = "2025-11-05T20:42:33.689Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2c/7d7bad116e09a04e9e1688c6f891fa2d4fd33f11b69ac0bd92419ddebeae/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cab9ff2e436ce7240d7ee301c8ef806ed77c1fd6b8a8239ff65f9bbbcb5b8a3", size = 900922, upload-time = "2025-11-05T20:41:00.361Z" }, + { url = "https://files.pythonhosted.org/packages/09/ba/e5ea89fbde8e37a90ce456e31c5e9d85512cef5ae38e0f4d2426eb776a19/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1a6671b2082c13bfd9a5cf4ce64670f832a6d41470556112c4ab0b6519b2fc4", size = 876987, upload-time = "2025-11-05T20:41:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fb/93d14193f0ec0c3d35b763f0a000e9780f63b2031f3d3756442c2152622d/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2468729b4c5295c199d084ab88a40afcb7c8b974276805105239c07855bbacee", size = 1171110, upload-time = "2025-11-05T20:41:32.631Z" }, + { url = "https://files.pythonhosted.org/packages/9e/46/08436312ff96ffa29cfa4e1a987efc37e094531db46ba5e9fda9bb792afd/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:775710777fd71e5fdf54df69cdc249996a1d6f447a2b5bfb86dbf033fddd9cf9", size = 943339, upload-time = "2025-11-05T20:41:47.128Z" }, + { url = "https://files.pythonhosted.org/packages/34/28/3b3c51328f505cfaf7e53f408f78a1e955d561135d02f9cb0341ea99f69a/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4565407f4a77f72cf9d91469e75d15d375f755f0a01236bb8aaa176278cc7085", size = 961680, upload-time = "2025-11-05T20:42:18.061Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9e/cbff75c8676d4f4a90bd58a1581249d255c7305141b0868f0abc0324836b/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc44c33f8fb2d5c9da748de7a6e6653a78aa740655e7409895e94a247ffa97c8", size = 987045, upload-time = "2025-11-05T20:42:02.315Z" }, + { url = "https://files.pythonhosted.org/packages/8c/25/d802d1d369502a7ddb8816059e7c79d2d913e17df975b863418e0aca4d8a/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8f32478f05540513c11923e8838afab9efef0131d66dca7f67f0e1bbd118af6a", size = 1080310, upload-time = "2025-11-05T21:40:23.184Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/250b785c2e473b1ab763eaf2be820934c2a5409a722e94b279dddac21c7d/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:1b63a3dd76225ea35b01dd6596aa90b275b5d0f71d6dc28fce6dd295d98614aa", size = 1140998, upload-time = "2025-11-05T21:40:40.603Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d6/bb42fd2a8bba6aea327962656e20621fd495523259db40cfb4c5f760f05c/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:fe6c41175c36554a4ef0994cd1b4dbd6d73156fca779066456b781707402048e", size = 1121178, upload-time = "2025-11-05T21:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/97/f4/aeb548374129dce3dc191a4bb598c944d9ed663f467b9af830315d86059c/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a0c6792406ae36f4e7664dc772da909451d46432ff8485774526232d4885063", size = 1130190, upload-time = "2025-11-05T21:41:16.403Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/a6250ff0c49a3cdb943910ada4116e708118e9b901c878cfae616c80a904/rignore-0.7.6-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a20b6fb61bcced9a83dfcca6599ad45182b06ba720cff7c8d891e5b78db5b65f", size = 886470, upload-time = "2025-11-05T20:42:52.314Z" }, + { url = "https://files.pythonhosted.org/packages/35/af/c69c0c51b8f9f7914d95c4ea91c29a2ac067572048cae95dd6d2efdbe05d/rignore-0.7.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:392dcabfecbe176c9ebbcb40d85a5e86a5989559c4f988c2741da7daf1b5be25", size = 825976, upload-time = "2025-11-05T20:42:35.118Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d2/1b264f56132264ea609d3213ab603d6a27016b19559a1a1ede1a66a03dcd/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22baa462abdc36fdd5a5e2dae423107723351b85ff093762f9261148b9d0a04a", size = 899739, upload-time = "2025-11-05T20:41:01.518Z" }, + { url = "https://files.pythonhosted.org/packages/55/e4/b3c5dfdd8d8a10741dfe7199ef45d19a0e42d0c13aa377c83bd6caf65d90/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53fb28882d2538cb2d231972146c4927a9d9455e62b209f85d634408c4103538", size = 874843, upload-time = "2025-11-05T20:41:17.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/10/d6f3750233881a2a154cefc9a6a0a9b19da526b19f7f08221b552c6f827d/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87409f7eeb1103d6b77f3472a3a0d9a5953e3ae804a55080bdcb0120ee43995b", size = 1170348, upload-time = "2025-11-05T20:41:34.21Z" }, + { url = "https://files.pythonhosted.org/packages/6e/10/ad98ca05c9771c15af734cee18114a3c280914b6e34fde9ffea2e61e88aa/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684014e42e4341ab3ea23a203551857fcc03a7f8ae96ca3aefb824663f55db32", size = 942315, upload-time = "2025-11-05T20:41:48.508Z" }, + { url = "https://files.pythonhosted.org/packages/de/00/ab5c0f872acb60d534e687e629c17e0896c62da9b389c66d3aa16b817aa8/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77356ebb01ba13f8a425c3d30fcad40e57719c0e37670d022d560884a30e4767", size = 961047, upload-time = "2025-11-05T20:42:19.403Z" }, + { url = "https://files.pythonhosted.org/packages/b8/86/3030fdc363a8f0d1cd155b4c453d6db9bab47a24fcc64d03f61d9d78fe6a/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6cbd8a48abbd3747a6c830393cd578782fab5d43f4deea48c5f5e344b8fed2b0", size = 986090, upload-time = "2025-11-05T20:42:03.581Z" }, + { url = "https://files.pythonhosted.org/packages/33/b8/133aa4002cee0ebbb39362f94e4898eec7fbd09cec9fcbce1cd65b355b7f/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2673225dcec7f90497e79438c35e34638d0d0391ccea3cbb79bfb9adc0dc5bd7", size = 1079656, upload-time = "2025-11-05T21:40:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/67/56/36d5d34210e5e7dfcd134eed8335b19e80ae940ee758f493e4f2b344dd70/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:c081f17290d8a2b96052b79207622aa635686ea39d502b976836384ede3d303c", size = 1139789, upload-time = "2025-11-05T21:40:42.119Z" }, + { url = "https://files.pythonhosted.org/packages/6b/5b/bb4f9420802bf73678033a4a55ab1bede36ce2e9b41fec5f966d83d932b3/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:57e8327aacc27f921968cb2a174f9e47b084ce9a7dd0122c8132d22358f6bd79", size = 1120308, upload-time = "2025-11-05T21:40:59.402Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8b/a1299085b28a2f6135e30370b126e3c5055b61908622f2488ade67641479/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:d8955b57e42f2a5434670d5aa7b75eaf6e74602ccd8955dddf7045379cd762fb", size = 1129444, upload-time = "2025-11-05T21:41:17.906Z" }, ] [[package]] -name = "roman-numerals-py" -version = "3.1.0" +name = "roman-numerals" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] [[package]] name = "rpds-py" -version = "0.27.1" +version = "0.30.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/ed/3aef893e2dd30e77e35d20d4ddb45ca459db59cead748cad9796ad479411/rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef", size = 371606, upload-time = "2025-08-27T12:12:25.189Z" }, - { url = "https://files.pythonhosted.org/packages/6d/82/9818b443e5d3eb4c83c3994561387f116aae9833b35c484474769c4a8faf/rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be", size = 353452, upload-time = "2025-08-27T12:12:27.433Z" }, - { url = "https://files.pythonhosted.org/packages/99/c7/d2a110ffaaa397fc6793a83c7bd3545d9ab22658b7cdff05a24a4535cc45/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61", size = 381519, upload-time = "2025-08-27T12:12:28.719Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bc/e89581d1f9d1be7d0247eaef602566869fdc0d084008ba139e27e775366c/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb", size = 394424, upload-time = "2025-08-27T12:12:30.207Z" }, - { url = "https://files.pythonhosted.org/packages/ac/2e/36a6861f797530e74bb6ed53495f8741f1ef95939eed01d761e73d559067/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657", size = 523467, upload-time = "2025-08-27T12:12:31.808Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/c1bc2be32564fa499f988f0a5c6505c2f4746ef96e58e4d7de5cf923d77e/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013", size = 402660, upload-time = "2025-08-27T12:12:33.444Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ec/ef8bf895f0628dd0a59e54d81caed6891663cb9c54a0f4bb7da918cb88cf/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a", size = 384062, upload-time = "2025-08-27T12:12:34.857Z" }, - { url = "https://files.pythonhosted.org/packages/69/f7/f47ff154be8d9a5e691c083a920bba89cef88d5247c241c10b9898f595a1/rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1", size = 401289, upload-time = "2025-08-27T12:12:36.085Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d9/ca410363efd0615814ae579f6829cafb39225cd63e5ea5ed1404cb345293/rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10", size = 417718, upload-time = "2025-08-27T12:12:37.401Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a0/8cb5c2ff38340f221cc067cc093d1270e10658ba4e8d263df923daa18e86/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808", size = 558333, upload-time = "2025-08-27T12:12:38.672Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8c/1b0de79177c5d5103843774ce12b84caa7164dfc6cd66378768d37db11bf/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8", size = 589127, upload-time = "2025-08-27T12:12:41.48Z" }, - { url = "https://files.pythonhosted.org/packages/c8/5e/26abb098d5e01266b0f3a2488d299d19ccc26849735d9d2b95c39397e945/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9", size = 554899, upload-time = "2025-08-27T12:12:42.925Z" }, - { url = "https://files.pythonhosted.org/packages/de/41/905cc90ced13550db017f8f20c6d8e8470066c5738ba480d7ba63e3d136b/rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4", size = 217450, upload-time = "2025-08-27T12:12:44.813Z" }, - { url = "https://files.pythonhosted.org/packages/75/3d/6bef47b0e253616ccdf67c283e25f2d16e18ccddd38f92af81d5a3420206/rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1", size = 228447, upload-time = "2025-08-27T12:12:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c1/7907329fbef97cbd49db6f7303893bd1dd5a4a3eae415839ffdfb0762cae/rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881", size = 371063, upload-time = "2025-08-27T12:12:47.856Z" }, - { url = "https://files.pythonhosted.org/packages/11/94/2aab4bc86228bcf7c48760990273653a4900de89c7537ffe1b0d6097ed39/rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5", size = 353210, upload-time = "2025-08-27T12:12:49.187Z" }, - { url = "https://files.pythonhosted.org/packages/3a/57/f5eb3ecf434342f4f1a46009530e93fd201a0b5b83379034ebdb1d7c1a58/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e", size = 381636, upload-time = "2025-08-27T12:12:50.492Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f4/ef95c5945e2ceb5119571b184dd5a1cc4b8541bbdf67461998cfeac9cb1e/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c", size = 394341, upload-time = "2025-08-27T12:12:52.024Z" }, - { url = "https://files.pythonhosted.org/packages/5a/7e/4bd610754bf492d398b61725eb9598ddd5eb86b07d7d9483dbcd810e20bc/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195", size = 523428, upload-time = "2025-08-27T12:12:53.779Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e5/059b9f65a8c9149361a8b75094864ab83b94718344db511fd6117936ed2a/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52", size = 402923, upload-time = "2025-08-27T12:12:55.15Z" }, - { url = "https://files.pythonhosted.org/packages/f5/48/64cabb7daced2968dd08e8a1b7988bf358d7bd5bcd5dc89a652f4668543c/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed", size = 384094, upload-time = "2025-08-27T12:12:57.194Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e1/dc9094d6ff566bff87add8a510c89b9e158ad2ecd97ee26e677da29a9e1b/rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a", size = 401093, upload-time = "2025-08-27T12:12:58.985Z" }, - { url = "https://files.pythonhosted.org/packages/37/8e/ac8577e3ecdd5593e283d46907d7011618994e1d7ab992711ae0f78b9937/rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde", size = 417969, upload-time = "2025-08-27T12:13:00.367Z" }, - { url = "https://files.pythonhosted.org/packages/66/6d/87507430a8f74a93556fe55c6485ba9c259949a853ce407b1e23fea5ba31/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21", size = 558302, upload-time = "2025-08-27T12:13:01.737Z" }, - { url = "https://files.pythonhosted.org/packages/3a/bb/1db4781ce1dda3eecc735e3152659a27b90a02ca62bfeea17aee45cc0fbc/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9", size = 589259, upload-time = "2025-08-27T12:13:03.127Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/ae1c8943d11a814d01b482e1f8da903f88047a962dff9bbdadf3bd6e6fd1/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948", size = 554983, upload-time = "2025-08-27T12:13:04.516Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d5/0b2a55415931db4f112bdab072443ff76131b5ac4f4dc98d10d2d357eb03/rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39", size = 217154, upload-time = "2025-08-27T12:13:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/24/75/3b7ffe0d50dc86a6a964af0d1cc3a4a2cdf437cb7b099a4747bbb96d1819/rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15", size = 228627, upload-time = "2025-08-27T12:13:07.625Z" }, - { url = "https://files.pythonhosted.org/packages/8d/3f/4fd04c32abc02c710f09a72a30c9a55ea3cc154ef8099078fd50a0596f8e/rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746", size = 220998, upload-time = "2025-08-27T12:13:08.972Z" }, - { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, - { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, - { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, - { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, - { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, - { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, - { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, - { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, - { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, - { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, - { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, - { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, - { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, - { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, - { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, - { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, - { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, - { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, - { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, - { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, - { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, - { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, - { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, - { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, - { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, - { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, - { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, - { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, - { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, - { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, - { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, - { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, - { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, - { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, - { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, - { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, - { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, - { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, - { url = "https://files.pythonhosted.org/packages/d5/63/b7cc415c345625d5e62f694ea356c58fb964861409008118f1245f8c3347/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf", size = 371360, upload-time = "2025-08-27T12:15:29.218Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8c/12e1b24b560cf378b8ffbdb9dc73abd529e1adcfcf82727dfd29c4a7b88d/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3", size = 353933, upload-time = "2025-08-27T12:15:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/9b/85/1bb2210c1f7a1b99e91fea486b9f0f894aa5da3a5ec7097cbad7dec6d40f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636", size = 382962, upload-time = "2025-08-27T12:15:32.348Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c9/a839b9f219cf80ed65f27a7f5ddbb2809c1b85c966020ae2dff490e0b18e/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8", size = 394412, upload-time = "2025-08-27T12:15:33.839Z" }, - { url = "https://files.pythonhosted.org/packages/02/2d/b1d7f928b0b1f4fc2e0133e8051d199b01d7384875adc63b6ddadf3de7e5/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc", size = 523972, upload-time = "2025-08-27T12:15:35.377Z" }, - { url = "https://files.pythonhosted.org/packages/a9/af/2cbf56edd2d07716df1aec8a726b3159deb47cb5c27e1e42b71d705a7c2f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8", size = 403273, upload-time = "2025-08-27T12:15:37.051Z" }, - { url = "https://files.pythonhosted.org/packages/c0/93/425e32200158d44ff01da5d9612c3b6711fe69f606f06e3895511f17473b/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc", size = 385278, upload-time = "2025-08-27T12:15:38.571Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1a/1a04a915ecd0551bfa9e77b7672d1937b4b72a0fc204a17deef76001cfb2/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71", size = 402084, upload-time = "2025-08-27T12:15:40.529Z" }, - { url = "https://files.pythonhosted.org/packages/51/f7/66585c0fe5714368b62951d2513b684e5215beaceab2c6629549ddb15036/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad", size = 419041, upload-time = "2025-08-27T12:15:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7e/83a508f6b8e219bba2d4af077c35ba0e0cdd35a751a3be6a7cba5a55ad71/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab", size = 560084, upload-time = "2025-08-27T12:15:43.839Z" }, - { url = "https://files.pythonhosted.org/packages/66/66/bb945683b958a1b19eb0fe715594630d0f36396ebdef4d9b89c2fa09aa56/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059", size = 590115, upload-time = "2025-08-27T12:15:46.647Z" }, - { url = "https://files.pythonhosted.org/packages/12/00/ccfaafaf7db7e7adace915e5c2f2c2410e16402561801e9c7f96683002d3/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b", size = 556561, upload-time = "2025-08-27T12:15:48.219Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b7/92b6ed9aad103bfe1c45df98453dfae40969eef2cb6c6239c58d7e96f1b3/rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819", size = 229125, upload-time = "2025-08-27T12:15:49.956Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ed/e1fba02de17f4f76318b834425257c8ea297e415e12c68b4361f63e8ae92/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df", size = 371402, upload-time = "2025-08-27T12:15:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/af/7c/e16b959b316048b55585a697e94add55a4ae0d984434d279ea83442e460d/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3", size = 354084, upload-time = "2025-08-27T12:15:53.219Z" }, - { url = "https://files.pythonhosted.org/packages/de/c1/ade645f55de76799fdd08682d51ae6724cb46f318573f18be49b1e040428/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9", size = 383090, upload-time = "2025-08-27T12:15:55.158Z" }, - { url = "https://files.pythonhosted.org/packages/1f/27/89070ca9b856e52960da1472efcb6c20ba27cfe902f4f23ed095b9cfc61d/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc", size = 394519, upload-time = "2025-08-27T12:15:57.238Z" }, - { url = "https://files.pythonhosted.org/packages/b3/28/be120586874ef906aa5aeeae95ae8df4184bc757e5b6bd1c729ccff45ed5/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4", size = 523817, upload-time = "2025-08-27T12:15:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/70cc197bc11cfcde02a86f36ac1eed15c56667c2ebddbdb76a47e90306da/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66", size = 403240, upload-time = "2025-08-27T12:16:00.923Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/46936cca449f7f518f2f4996e0e8344db4b57e2081e752441154089d2a5f/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e", size = 385194, upload-time = "2025-08-27T12:16:02.802Z" }, - { url = "https://files.pythonhosted.org/packages/e1/62/29c0d3e5125c3270b51415af7cbff1ec587379c84f55a5761cc9efa8cd06/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c", size = 402086, upload-time = "2025-08-27T12:16:04.806Z" }, - { url = "https://files.pythonhosted.org/packages/8f/66/03e1087679227785474466fdd04157fb793b3b76e3fcf01cbf4c693c1949/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf", size = 419272, upload-time = "2025-08-27T12:16:06.471Z" }, - { url = "https://files.pythonhosted.org/packages/6a/24/e3e72d265121e00b063aef3e3501e5b2473cf1b23511d56e529531acf01e/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf", size = 560003, upload-time = "2025-08-27T12:16:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/26/ca/f5a344c534214cc2d41118c0699fffbdc2c1bc7046f2a2b9609765ab9c92/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6", size = 590482, upload-time = "2025-08-27T12:16:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" }, -] - -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] [[package]] name = "ruff" -version = "0.13.3" +version = "0.15.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" }, - { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" }, - { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" }, - { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" }, - { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" }, - { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" }, - { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" }, - { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" }, - { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" }, - { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" }, - { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" }, - { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" }, - { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, ] [[package]] @@ -5544,29 +6367,33 @@ wheels = [ [[package]] name = "safetensors" -version = "0.6.2" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/cc/738f3011628920e027a11754d9cae9abec1aed00f7ae860abbf843755233/safetensors-0.6.2.tar.gz", hash = "sha256:43ff2aa0e6fa2dc3ea5524ac7ad93a9839256b8703761e76e2d0b2a3fa4f15d9", size = 197968, upload-time = "2025-08-08T13:13:58.654Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/b1/3f5fd73c039fc87dba3ff8b5d528bfc5a32b597fea8e7a6a4800343a17c7/safetensors-0.6.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9c85ede8ec58f120bad982ec47746981e210492a6db876882aa021446af8ffba", size = 454797, upload-time = "2025-08-08T13:13:52.066Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c9/bb114c158540ee17907ec470d01980957fdaf87b4aa07914c24eba87b9c6/safetensors-0.6.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d6675cf4b39c98dbd7d940598028f3742e0375a6b4d4277e76beb0c35f4b843b", size = 432206, upload-time = "2025-08-08T13:13:50.931Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/f70c34e47df3110e8e0bb268d90db8d4be8958a54ab0336c9be4fe86dac8/safetensors-0.6.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d2d2b3ce1e2509c68932ca03ab8f20570920cd9754b05063d4368ee52833ecd", size = 473261, upload-time = "2025-08-08T13:13:41.259Z" }, - { url = "https://files.pythonhosted.org/packages/2a/f5/be9c6a7c7ef773e1996dc214e73485286df1836dbd063e8085ee1976f9cb/safetensors-0.6.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:93de35a18f46b0f5a6a1f9e26d91b442094f2df02e9fd7acf224cfec4238821a", size = 485117, upload-time = "2025-08-08T13:13:43.506Z" }, - { url = "https://files.pythonhosted.org/packages/c9/55/23f2d0a2c96ed8665bf17a30ab4ce5270413f4d74b6d87dd663258b9af31/safetensors-0.6.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89a89b505f335640f9120fac65ddeb83e40f1fd081cb8ed88b505bdccec8d0a1", size = 616154, upload-time = "2025-08-08T13:13:45.096Z" }, - { url = "https://files.pythonhosted.org/packages/98/c6/affb0bd9ce02aa46e7acddbe087912a04d953d7a4d74b708c91b5806ef3f/safetensors-0.6.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4d0d0b937e04bdf2ae6f70cd3ad51328635fe0e6214aa1fc811f3b576b3bda", size = 520713, upload-time = "2025-08-08T13:13:46.25Z" }, - { url = "https://files.pythonhosted.org/packages/fe/5d/5a514d7b88e310c8b146e2404e0dc161282e78634d9358975fd56dfd14be/safetensors-0.6.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8045db2c872db8f4cbe3faa0495932d89c38c899c603f21e9b6486951a5ecb8f", size = 485835, upload-time = "2025-08-08T13:13:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/7a/7b/4fc3b2ba62c352b2071bea9cfbad330fadda70579f617506ae1a2f129cab/safetensors-0.6.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:81e67e8bab9878bb568cffbc5f5e655adb38d2418351dc0859ccac158f753e19", size = 521503, upload-time = "2025-08-08T13:13:47.651Z" }, - { url = "https://files.pythonhosted.org/packages/5a/50/0057e11fe1f3cead9254315a6c106a16dd4b1a19cd247f7cc6414f6b7866/safetensors-0.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0e4d029ab0a0e0e4fdf142b194514695b1d7d3735503ba700cf36d0fc7136ce", size = 652256, upload-time = "2025-08-08T13:13:53.167Z" }, - { url = "https://files.pythonhosted.org/packages/e9/29/473f789e4ac242593ac1656fbece6e1ecd860bb289e635e963667807afe3/safetensors-0.6.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fa48268185c52bfe8771e46325a1e21d317207bcabcb72e65c6e28e9ffeb29c7", size = 747281, upload-time = "2025-08-08T13:13:54.656Z" }, - { url = "https://files.pythonhosted.org/packages/68/52/f7324aad7f2df99e05525c84d352dc217e0fa637a4f603e9f2eedfbe2c67/safetensors-0.6.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:d83c20c12c2d2f465997c51b7ecb00e407e5f94d7dec3ea0cc11d86f60d3fde5", size = 692286, upload-time = "2025-08-08T13:13:55.884Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fe/cad1d9762868c7c5dc70c8620074df28ebb1a8e4c17d4c0cb031889c457e/safetensors-0.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d944cea65fad0ead848b6ec2c37cc0b197194bec228f8020054742190e9312ac", size = 655957, upload-time = "2025-08-08T13:13:57.029Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/e2158e17bbe57d104f0abbd95dff60dda916cf277c9f9663b4bf9bad8b6e/safetensors-0.6.2-cp38-abi3-win32.whl", hash = "sha256:cab75ca7c064d3911411461151cb69380c9225798a20e712b102edda2542ddb1", size = 308926, upload-time = "2025-08-08T13:14:01.095Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c3/c0be1135726618dc1e28d181b8c442403d8dbb9e273fd791de2d4384bcdd/safetensors-0.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:c7b214870df923cbc1593c3faee16bec59ea462758699bd3fee399d00aac072c", size = 320192, upload-time = "2025-08-08T13:13:59.467Z" }, + { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6a/4d08d89a6fcbe905c5ae68b8b34f0791850882fc19782d0d02c65abbdf3b/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4729811a6640d019a4b7ba8638ee2fd21fa5ca8c7e7bdf0fed62068fcaac737", size = 492430, upload-time = "2025-11-19T15:18:11.884Z" }, + { url = "https://files.pythonhosted.org/packages/dd/29/59ed8152b30f72c42d00d241e58eaca558ae9dbfa5695206e2e0f54c7063/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12f49080303fa6bb424b362149a12949dfbbf1e06811a88f2307276b0c131afd", size = 503977, upload-time = "2025-11-19T15:18:17.523Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/4811bfec67fa260e791369b16dab105e4bae82686120554cc484064e22b4/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0071bffba4150c2f46cae1432d31995d77acfd9f8db598b5d1a2ce67e8440ad2", size = 623890, upload-time = "2025-11-19T15:18:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/632a58724221ef03d78ab65062e82a1010e1bef8e8e0b9d7c6d7b8044841/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:473b32699f4200e69801bf5abf93f1a4ecd432a70984df164fc22ccf39c4a6f3", size = 531885, upload-time = "2025-11-19T15:18:27.146Z" }, ] [[package]] name = "sarvamai" -version = "0.1.21" +version = "0.1.26" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -5575,9 +6402,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/d7/31/13f65e8533b667514e1cfe838d12a14494cbc5943fd8f0c101305127459b/sarvamai-0.1.26.tar.gz", hash = "sha256:d51a213c27feb33d65f5b71e4882dcdb873dc5e0d720390b7ba18d1bdeec2471", size = 113050, upload-time = "2026-03-06T16:40:36.647Z" } 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/76/c9/c03a807ace9cafbfe26418be995e4959142a55313c9f26564586e111f31d/sarvamai-0.1.26-py3-none-any.whl", hash = "sha256:39e79ba0932f4501a2aa28f84fd2de64d34fc9a7af2b0d4ead1efa617517b3bd", size = 229057, upload-time = "2026-03-06T16:40:35.584Z" }, ] [[package]] @@ -5588,7 +6415,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -5641,91 +6468,105 @@ wheels = [ [[package]] name = "scipy" -version = "1.16.2" +version = "1.17.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/3b/546a6f0bfe791bbb7f8d591613454d15097e53f906308ec6f7c1ce588e8e/scipy-1.16.2.tar.gz", hash = "sha256:af029b153d243a80afb6eabe40b0a07f8e35c9adc269c019f364ad747f826a6b", size = 30580599, upload-time = "2025-09-11T17:48:08.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/ef/37ed4b213d64b48422df92560af7300e10fe30b5d665dd79932baebee0c6/scipy-1.16.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:6ab88ea43a57da1af33292ebd04b417e8e2eaf9d5aa05700be8d6e1b6501cd92", size = 36619956, upload-time = "2025-09-11T17:39:20.5Z" }, - { url = "https://files.pythonhosted.org/packages/85/ab/5c2eba89b9416961a982346a4d6a647d78c91ec96ab94ed522b3b6baf444/scipy-1.16.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c95e96c7305c96ede73a7389f46ccd6c659c4da5ef1b2789466baeaed3622b6e", size = 28931117, upload-time = "2025-09-11T17:39:29.06Z" }, - { url = "https://files.pythonhosted.org/packages/80/d1/eed51ab64d227fe60229a2d57fb60ca5898cfa50ba27d4f573e9e5f0b430/scipy-1.16.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:87eb178db04ece7c698220d523c170125dbffebb7af0345e66c3554f6f60c173", size = 20921997, upload-time = "2025-09-11T17:39:34.892Z" }, - { url = "https://files.pythonhosted.org/packages/be/7c/33ea3e23bbadde96726edba6bf9111fb1969d14d9d477ffa202c67bec9da/scipy-1.16.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:4e409eac067dcee96a57fbcf424c13f428037827ec7ee3cb671ff525ca4fc34d", size = 23523374, upload-time = "2025-09-11T17:39:40.846Z" }, - { url = "https://files.pythonhosted.org/packages/96/0b/7399dc96e1e3f9a05e258c98d716196a34f528eef2ec55aad651ed136d03/scipy-1.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e574be127bb760f0dad24ff6e217c80213d153058372362ccb9555a10fc5e8d2", size = 33583702, upload-time = "2025-09-11T17:39:49.011Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bc/a5c75095089b96ea72c1bd37a4497c24b581ec73db4ef58ebee142ad2d14/scipy-1.16.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5db5ba6188d698ba7abab982ad6973265b74bb40a1efe1821b58c87f73892b9", size = 35883427, upload-time = "2025-09-11T17:39:57.406Z" }, - { url = "https://files.pythonhosted.org/packages/ab/66/e25705ca3d2b87b97fe0a278a24b7f477b4023a926847935a1a71488a6a6/scipy-1.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec6e74c4e884104ae006d34110677bfe0098203a3fec2f3faf349f4cb05165e3", size = 36212940, upload-time = "2025-09-11T17:40:06.013Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fd/0bb911585e12f3abdd603d721d83fc1c7492835e1401a0e6d498d7822b4b/scipy-1.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:912f46667d2d3834bc3d57361f854226475f695eb08c08a904aadb1c936b6a88", size = 38865092, upload-time = "2025-09-11T17:40:15.143Z" }, - { url = "https://files.pythonhosted.org/packages/d6/73/c449a7d56ba6e6f874183759f8483cde21f900a8be117d67ffbb670c2958/scipy-1.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:91e9e8a37befa5a69e9cacbe0bcb79ae5afb4a0b130fd6db6ee6cc0d491695fa", size = 38687626, upload-time = "2025-09-11T17:40:24.041Z" }, - { url = "https://files.pythonhosted.org/packages/68/72/02f37316adf95307f5d9e579023c6899f89ff3a051fa079dbd6faafc48e5/scipy-1.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:f3bf75a6dcecab62afde4d1f973f1692be013110cad5338007927db8da73249c", size = 25503506, upload-time = "2025-09-11T17:40:30.703Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8d/6396e00db1282279a4ddd507c5f5e11f606812b608ee58517ce8abbf883f/scipy-1.16.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:89d6c100fa5c48472047632e06f0876b3c4931aac1f4291afc81a3644316bb0d", size = 36646259, upload-time = "2025-09-11T17:40:39.329Z" }, - { url = "https://files.pythonhosted.org/packages/3b/93/ea9edd7e193fceb8eef149804491890bde73fb169c896b61aa3e2d1e4e77/scipy-1.16.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ca748936cd579d3f01928b30a17dc474550b01272d8046e3e1ee593f23620371", size = 28888976, upload-time = "2025-09-11T17:40:46.82Z" }, - { url = "https://files.pythonhosted.org/packages/91/4d/281fddc3d80fd738ba86fd3aed9202331180b01e2c78eaae0642f22f7e83/scipy-1.16.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:fac4f8ce2ddb40e2e3d0f7ec36d2a1e7f92559a2471e59aec37bd8d9de01fec0", size = 20879905, upload-time = "2025-09-11T17:40:52.545Z" }, - { url = "https://files.pythonhosted.org/packages/69/40/b33b74c84606fd301b2915f0062e45733c6ff5708d121dd0deaa8871e2d0/scipy-1.16.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:033570f1dcefd79547a88e18bccacff025c8c647a330381064f561d43b821232", size = 23553066, upload-time = "2025-09-11T17:40:59.014Z" }, - { url = "https://files.pythonhosted.org/packages/55/a7/22c739e2f21a42cc8f16bc76b47cff4ed54fbe0962832c589591c2abec34/scipy-1.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ea3421209bf00c8a5ef2227de496601087d8f638a2363ee09af059bd70976dc1", size = 33336407, upload-time = "2025-09-11T17:41:06.796Z" }, - { url = "https://files.pythonhosted.org/packages/53/11/a0160990b82999b45874dc60c0c183d3a3a969a563fffc476d5a9995c407/scipy-1.16.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f66bd07ba6f84cd4a380b41d1bf3c59ea488b590a2ff96744845163309ee8e2f", size = 35673281, upload-time = "2025-09-11T17:41:15.055Z" }, - { url = "https://files.pythonhosted.org/packages/96/53/7ef48a4cfcf243c3d0f1643f5887c81f29fdf76911c4e49331828e19fc0a/scipy-1.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e9feab931bd2aea4a23388c962df6468af3d808ddf2d40f94a81c5dc38f32ef", size = 36004222, upload-time = "2025-09-11T17:41:23.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7f/71a69e0afd460049d41c65c630c919c537815277dfea214031005f474d78/scipy-1.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03dfc75e52f72cf23ec2ced468645321407faad8f0fe7b1f5b49264adbc29cb1", size = 38664586, upload-time = "2025-09-11T17:41:31.021Z" }, - { url = "https://files.pythonhosted.org/packages/34/95/20e02ca66fb495a95fba0642fd48e0c390d0ece9b9b14c6e931a60a12dea/scipy-1.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:0ce54e07bbb394b417457409a64fd015be623f36e330ac49306433ffe04bc97e", size = 38550641, upload-time = "2025-09-11T17:41:36.61Z" }, - { url = "https://files.pythonhosted.org/packages/92/ad/13646b9beb0a95528ca46d52b7babafbe115017814a611f2065ee4e61d20/scipy-1.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a8ffaa4ac0df81a0b94577b18ee079f13fecdb924df3328fc44a7dc5ac46851", size = 25456070, upload-time = "2025-09-11T17:41:41.3Z" }, - { url = "https://files.pythonhosted.org/packages/c1/27/c5b52f1ee81727a9fc457f5ac1e9bf3d6eab311805ea615c83c27ba06400/scipy-1.16.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:84f7bf944b43e20b8a894f5fe593976926744f6c185bacfcbdfbb62736b5cc70", size = 36604856, upload-time = "2025-09-11T17:41:47.695Z" }, - { url = "https://files.pythonhosted.org/packages/32/a9/15c20d08e950b540184caa8ced675ba1128accb0e09c653780ba023a4110/scipy-1.16.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5c39026d12edc826a1ef2ad35ad1e6d7f087f934bb868fc43fa3049c8b8508f9", size = 28864626, upload-time = "2025-09-11T17:41:52.642Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fc/ea36098df653cca26062a627c1a94b0de659e97127c8491e18713ca0e3b9/scipy-1.16.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e52729ffd45b68777c5319560014d6fd251294200625d9d70fd8626516fc49f5", size = 20855689, upload-time = "2025-09-11T17:41:57.886Z" }, - { url = "https://files.pythonhosted.org/packages/dc/6f/d0b53be55727f3e6d7c72687ec18ea6d0047cf95f1f77488b99a2bafaee1/scipy-1.16.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:024dd4a118cccec09ca3209b7e8e614931a6ffb804b2a601839499cb88bdf925", size = 23512151, upload-time = "2025-09-11T17:42:02.303Z" }, - { url = "https://files.pythonhosted.org/packages/11/85/bf7dab56e5c4b1d3d8eef92ca8ede788418ad38a7dc3ff50262f00808760/scipy-1.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a5dc7ee9c33019973a470556081b0fd3c9f4c44019191039f9769183141a4d9", size = 33329824, upload-time = "2025-09-11T17:42:07.549Z" }, - { url = "https://files.pythonhosted.org/packages/da/6a/1a927b14ddc7714111ea51f4e568203b2bb6ed59bdd036d62127c1a360c8/scipy-1.16.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c2275ff105e508942f99d4e3bc56b6ef5e4b3c0af970386ca56b777608ce95b7", size = 35681881, upload-time = "2025-09-11T17:42:13.255Z" }, - { url = "https://files.pythonhosted.org/packages/c1/5f/331148ea5780b4fcc7007a4a6a6ee0a0c1507a796365cc642d4d226e1c3a/scipy-1.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af80196eaa84f033e48444d2e0786ec47d328ba00c71e4299b602235ffef9acb", size = 36006219, upload-time = "2025-09-11T17:42:18.765Z" }, - { url = "https://files.pythonhosted.org/packages/46/3a/e991aa9d2aec723b4a8dcfbfc8365edec5d5e5f9f133888067f1cbb7dfc1/scipy-1.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9fb1eb735fe3d6ed1f89918224e3385fbf6f9e23757cacc35f9c78d3b712dd6e", size = 38682147, upload-time = "2025-09-11T17:42:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/a1/57/0f38e396ad19e41b4c5db66130167eef8ee620a49bc7d0512e3bb67e0cab/scipy-1.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:fda714cf45ba43c9d3bae8f2585c777f64e3f89a2e073b668b32ede412d8f52c", size = 38520766, upload-time = "2025-09-11T17:43:25.342Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a5/85d3e867b6822d331e26c862a91375bb7746a0b458db5effa093d34cdb89/scipy-1.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:2f5350da923ccfd0b00e07c3e5cfb316c1c0d6c1d864c07a72d092e9f20db104", size = 25451169, upload-time = "2025-09-11T17:43:30.198Z" }, - { url = "https://files.pythonhosted.org/packages/09/d9/60679189bcebda55992d1a45498de6d080dcaf21ce0c8f24f888117e0c2d/scipy-1.16.2-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:53d8d2ee29b925344c13bda64ab51785f016b1b9617849dac10897f0701b20c1", size = 37012682, upload-time = "2025-09-11T17:42:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/83/be/a99d13ee4d3b7887a96f8c71361b9659ba4ef34da0338f14891e102a127f/scipy-1.16.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:9e05e33657efb4c6a9d23bd8300101536abd99c85cca82da0bffff8d8764d08a", size = 29389926, upload-time = "2025-09-11T17:42:35.845Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0a/130164a4881cec6ca8c00faf3b57926f28ed429cd6001a673f83c7c2a579/scipy-1.16.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:7fe65b36036357003b3ef9d37547abeefaa353b237e989c21027b8ed62b12d4f", size = 21381152, upload-time = "2025-09-11T17:42:40.07Z" }, - { url = "https://files.pythonhosted.org/packages/47/a6/503ffb0310ae77fba874e10cddfc4a1280bdcca1d13c3751b8c3c2996cf8/scipy-1.16.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6406d2ac6d40b861cccf57f49592f9779071655e9f75cd4f977fa0bdd09cb2e4", size = 23914410, upload-time = "2025-09-11T17:42:44.313Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c7/1147774bcea50d00c02600aadaa919facbd8537997a62496270133536ed6/scipy-1.16.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff4dc42bd321991fbf611c23fc35912d690f731c9914bf3af8f417e64aca0f21", size = 33481880, upload-time = "2025-09-11T17:42:49.325Z" }, - { url = "https://files.pythonhosted.org/packages/6a/74/99d5415e4c3e46b2586f30cdbecb95e101c7192628a484a40dd0d163811a/scipy-1.16.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:654324826654d4d9133e10675325708fb954bc84dae6e9ad0a52e75c6b1a01d7", size = 35791425, upload-time = "2025-09-11T17:42:54.711Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ee/a6559de7c1cc710e938c0355d9d4fbcd732dac4d0d131959d1f3b63eb29c/scipy-1.16.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63870a84cd15c44e65220eaed2dac0e8f8b26bbb991456a033c1d9abfe8a94f8", size = 36178622, upload-time = "2025-09-11T17:43:00.375Z" }, - { url = "https://files.pythonhosted.org/packages/4e/7b/f127a5795d5ba8ece4e0dce7d4a9fb7cb9e4f4757137757d7a69ab7d4f1a/scipy-1.16.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fa01f0f6a3050fa6a9771a95d5faccc8e2f5a92b4a2e5440a0fa7264a2398472", size = 38783985, upload-time = "2025-09-11T17:43:06.661Z" }, - { url = "https://files.pythonhosted.org/packages/3e/9f/bc81c1d1e033951eb5912cd3750cc005943afa3e65a725d2443a3b3c4347/scipy-1.16.2-cp313-cp313t-win_amd64.whl", hash = "sha256:116296e89fba96f76353a8579820c2512f6e55835d3fad7780fece04367de351", size = 38631367, upload-time = "2025-09-11T17:43:14.44Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5e/2cc7555fd81d01814271412a1d59a289d25f8b63208a0a16c21069d55d3e/scipy-1.16.2-cp313-cp313t-win_arm64.whl", hash = "sha256:98e22834650be81d42982360382b43b17f7ba95e0e6993e2a4f5b9ad9283a94d", size = 25787992, upload-time = "2025-09-11T17:43:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ac/ad8951250516db71619f0bd3b2eb2448db04b720a003dd98619b78b692c0/scipy-1.16.2-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:567e77755019bb7461513c87f02bb73fb65b11f049aaaa8ca17cfaa5a5c45d77", size = 36595109, upload-time = "2025-09-11T17:43:35.713Z" }, - { url = "https://files.pythonhosted.org/packages/ff/f6/5779049ed119c5b503b0f3dc6d6f3f68eefc3a9190d4ad4c276f854f051b/scipy-1.16.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:17d9bb346194e8967296621208fcdfd39b55498ef7d2f376884d5ac47cec1a70", size = 28859110, upload-time = "2025-09-11T17:43:40.814Z" }, - { url = "https://files.pythonhosted.org/packages/82/09/9986e410ae38bf0a0c737ff8189ac81a93b8e42349aac009891c054403d7/scipy-1.16.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:0a17541827a9b78b777d33b623a6dcfe2ef4a25806204d08ead0768f4e529a88", size = 20850110, upload-time = "2025-09-11T17:43:44.981Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ad/485cdef2d9215e2a7df6d61b81d2ac073dfacf6ae24b9ae87274c4e936ae/scipy-1.16.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:d7d4c6ba016ffc0f9568d012f5f1eb77ddd99412aea121e6fa8b4c3b7cbad91f", size = 23497014, upload-time = "2025-09-11T17:43:49.074Z" }, - { url = "https://files.pythonhosted.org/packages/a7/74/f6a852e5d581122b8f0f831f1d1e32fb8987776ed3658e95c377d308ed86/scipy-1.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9702c4c023227785c779cba2e1d6f7635dbb5b2e0936cdd3a4ecb98d78fd41eb", size = 33401155, upload-time = "2025-09-11T17:43:54.661Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f5/61d243bbc7c6e5e4e13dde9887e84a5cbe9e0f75fd09843044af1590844e/scipy-1.16.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1cdf0ac28948d225decdefcc45ad7dd91716c29ab56ef32f8e0d50657dffcc7", size = 35691174, upload-time = "2025-09-11T17:44:00.101Z" }, - { url = "https://files.pythonhosted.org/packages/03/99/59933956331f8cc57e406cdb7a483906c74706b156998f322913e789c7e1/scipy-1.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:70327d6aa572a17c2941cdfb20673f82e536e91850a2e4cb0c5b858b690e1548", size = 36070752, upload-time = "2025-09-11T17:44:05.619Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7d/00f825cfb47ee19ef74ecf01244b43e95eae74e7e0ff796026ea7cd98456/scipy-1.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5221c0b2a4b58aa7c4ed0387d360fd90ee9086d383bb34d9f2789fafddc8a936", size = 38701010, upload-time = "2025-09-11T17:44:11.322Z" }, - { url = "https://files.pythonhosted.org/packages/e4/9f/b62587029980378304ba5a8563d376c96f40b1e133daacee76efdcae32de/scipy-1.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:f5a85d7b2b708025af08f060a496dd261055b617d776fc05a1a1cc69e09fe9ff", size = 39360061, upload-time = "2025-09-11T17:45:09.814Z" }, - { url = "https://files.pythonhosted.org/packages/82/04/7a2f1609921352c7fbee0815811b5050582f67f19983096c4769867ca45f/scipy-1.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:2cc73a33305b4b24556957d5857d6253ce1e2dcd67fa0ff46d87d1670b3e1e1d", size = 26126914, upload-time = "2025-09-11T17:45:14.73Z" }, - { url = "https://files.pythonhosted.org/packages/51/b9/60929ce350c16b221928725d2d1d7f86cf96b8bc07415547057d1196dc92/scipy-1.16.2-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:9ea2a3fed83065d77367775d689401a703d0f697420719ee10c0780bcab594d8", size = 37013193, upload-time = "2025-09-11T17:44:16.757Z" }, - { url = "https://files.pythonhosted.org/packages/2a/41/ed80e67782d4bc5fc85a966bc356c601afddd175856ba7c7bb6d9490607e/scipy-1.16.2-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7280d926f11ca945c3ef92ba960fa924e1465f8d07ce3a9923080363390624c4", size = 29390172, upload-time = "2025-09-11T17:44:21.783Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a3/2f673ace4090452696ccded5f5f8efffb353b8f3628f823a110e0170b605/scipy-1.16.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:8afae1756f6a1fe04636407ef7dbece33d826a5d462b74f3d0eb82deabefd831", size = 21381326, upload-time = "2025-09-11T17:44:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/42/bf/59df61c5d51395066c35836b78136accf506197617c8662e60ea209881e1/scipy-1.16.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:5c66511f29aa8d233388e7416a3f20d5cae7a2744d5cee2ecd38c081f4e861b3", size = 23915036, upload-time = "2025-09-11T17:44:30.527Z" }, - { url = "https://files.pythonhosted.org/packages/91/c3/edc7b300dc16847ad3672f1a6f3f7c5d13522b21b84b81c265f4f2760d4a/scipy-1.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efe6305aeaa0e96b0ccca5ff647a43737d9a092064a3894e46c414db84bc54ac", size = 33484341, upload-time = "2025-09-11T17:44:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/26/c7/24d1524e72f06ff141e8d04b833c20db3021020563272ccb1b83860082a9/scipy-1.16.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f3a337d9ae06a1e8d655ee9d8ecb835ea5ddcdcbd8d23012afa055ab014f374", size = 35790840, upload-time = "2025-09-11T17:44:41.76Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b7/5aaad984eeedd56858dc33d75efa59e8ce798d918e1033ef62d2708f2c3d/scipy-1.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bab3605795d269067d8ce78a910220262711b753de8913d3deeaedb5dded3bb6", size = 36174716, upload-time = "2025-09-11T17:44:47.316Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c2/e276a237acb09824822b0ada11b028ed4067fdc367a946730979feacb870/scipy-1.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b0348d8ddb55be2a844c518cd8cc8deeeb8aeba707cf834db5758fc89b476a2c", size = 38790088, upload-time = "2025-09-11T17:44:53.011Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b4/5c18a766e8353015439f3780f5fc473f36f9762edc1a2e45da3ff5a31b21/scipy-1.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:26284797e38b8a75e14ea6631d29bda11e76ceaa6ddb6fdebbfe4c4d90faf2f9", size = 39457455, upload-time = "2025-09-11T17:44:58.899Z" }, - { url = "https://files.pythonhosted.org/packages/97/30/2f9a5243008f76dfc5dee9a53dfb939d9b31e16ce4bd4f2e628bfc5d89d2/scipy-1.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d2a4472c231328d4de38d5f1f68fdd6d28a615138f842580a8a321b5845cf779", size = 26448374, upload-time = "2025-09-11T17:45:03.45Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "segments" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "csvw" }, + { name = "regex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/57/85cac3a8e32370e88fa5fa92812edb6025db7fcbed51452bd56ee1524957/segments-2.4.0.tar.gz", hash = "sha256:bba71f5520ddd54c8aa2f4d765a60618c6862162d6e7356a4a097f2223166f5b", size = 18662, upload-time = "2026-03-07T10:01:28.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/60/eef9acce946177f92c9aabf432224d87ab908bafafac516a36ab924199f3/segments-2.4.0-py2.py3-none-any.whl", hash = "sha256:4021dc67f201cc03c864c74c618bdb163b1af629da3040babbaa37d8813f3db0", size = 16321, upload-time = "2026-03-07T10:01:27.885Z" }, ] [[package]] name = "sentry-sdk" -version = "2.39.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/72/43294fa4bdd75c51610b5104a3ff834459ba653abb415150aa7826a249dd/sentry_sdk-2.39.0.tar.gz", hash = "sha256:8c185854d111f47f329ab6bc35993f28f7a6b7114db64aa426b326998cfa14e9", size = 348556, upload-time = "2025-09-25T09:15:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/44/4356cc64246ba7b2b920f7c97a85c3c52748e213e250b512ee8152eb559d/sentry_sdk-2.39.0-py2.py3-none-any.whl", hash = "sha256:ba655ca5e57b41569b18e2a5552cb3375209760a5d332cdd87c6c3f28f729602", size = 370851, upload-time = "2025-09-25T09:15:36.35Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" }, ] [[package]] @@ -5762,19 +6603,19 @@ wheels = [ [[package]] name = "simli-ai" -version = "1.0.3" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiortc" }, { name = "av" }, { name = "httpx" }, - { name = "livekit" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { 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/aa/8c/fe0697cd371a0f203b915f59e376e1807e4ad79bd53e20ceea57a161f242/simli_ai-2.0.2.tar.gz", hash = "sha256:53b99901fe4c5eeb7637492f70dde34c131ee9e5589bf8781a75494c0469ca03", size = 16422, upload-time = "2026-02-25T11:13:16.854Z" } 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/5d/f0/fb6737a87069ed2830d421c7e45cc5c117c8bc7d2183bb37466c0bf6f6ab/simli_ai-2.0.2-py3-none-any.whl", hash = "sha256:023cb8ef37c74f7463810af4595c2e0c2850647e33f9ff9b2ef09d088c0d2403", size = 19914, upload-time = "2026-02-25T11:13:15.257Z" }, ] [[package]] @@ -5788,28 +6629,28 @@ wheels = [ [[package]] name = "smart-open" -version = "7.5.0" +version = "7.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/9a/0a7acb748b86e2922982366d780ca4b16c33f7246fa5860d26005c97e4f3/smart_open-7.5.0.tar.gz", hash = "sha256:f394b143851d8091011832ac8113ea4aba6b92e6c35f6e677ddaaccb169d7cb9", size = 53920, upload-time = "2025-11-08T21:38:40.698Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/be/a66598b305763861a9ab15ff0f2fbc44e47b1ce7a776797337a4eef37c66/smart_open-7.5.1.tar.gz", hash = "sha256:3f08e16827c4733699e6b2cc40328a3568f900cb12ad9a3ad233ba6c872d9fe7", size = 54034, upload-time = "2026-02-23T11:01:28.979Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/95/bc978be7ea0babf2fb48a414b6afaad414c6a9e8b1eafc5b8a53c030381a/smart_open-7.5.0-py3-none-any.whl", hash = "sha256:87e695c5148bbb988f15cec00971602765874163be85acb1c9fb8abc012e6599", size = 63940, upload-time = "2025-11-08T21:38:39.024Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ea/dcdecd68acebb49d3fd560473a43499b1635076f7f1ae8641c060fe7ce74/smart_open-7.5.1-py3-none-any.whl", hash = "sha256:3e07cbbd9c8a908bcb8e25d48becf1a5cbb4886fa975e9f34c672ed171df2318", size = 64108, upload-time = "2026-02-23T11:01:27.429Z" }, ] [[package]] name = "smithy-aws-core" -version = "0.2.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aws-sdk-signers", marker = "python_full_version >= '3.12'" }, { name = "smithy-core", marker = "python_full_version >= '3.12'" }, { name = "smithy-http", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/c8/5970c869527972b23a1733de3993d54283c84a2340e84acdd48a11aa0ff4/smithy_aws_core-0.2.0.tar.gz", hash = "sha256:dfa1ecd311d6f0a16f48c86d793085e2a0a33a46de897d129dd1f142a43bf7f6", size = 11344, upload-time = "2025-11-21T18:33:01.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/14/0f00836c3d6d2309f4090df98b918ea07980cea97f01c1254b6d4e5cc9f8/smithy_aws_core-0.4.0.tar.gz", hash = "sha256:579caef8b2519e2593006ded4e6a369f99387d69f06e857fffda8fc4ad1eb11d", size = 11431, upload-time = "2026-02-24T18:55:22.066Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/25/739c0005a6cb4effbc2d37fe23590660b508fe314200f4acf94410a8f315/smithy_aws_core-0.2.0-py3-none-any.whl", hash = "sha256:d112082ef77758e1977f8694cf690ac35c76570c12a6590fccd5da085a3ce507", size = 18966, upload-time = "2025-11-21T18:33:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/ae/87/4a8d4e14357abb8a207cc8d4bf47d11ebc9792ca2d20a940231576714287/smithy_aws_core-0.4.0-py3-none-any.whl", hash = "sha256:004164599a0a2911ea80889dd2c954fd7da17ff3e8d208fb732b1e778a1450f1", size = 18965, upload-time = "2026-02-24T18:55:21.181Z" }, ] [package.optional-dependencies] @@ -5822,35 +6663,35 @@ json = [ [[package]] name = "smithy-aws-event-stream" -version = "0.2.0" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smithy-core", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/90/78283c21484f8cf9862982e53bc2769b784910735fb5fb2400a17bfb5fdd/smithy_aws_event_stream-0.2.0.tar.gz", hash = "sha256:99700a11346e7ab1435ff2e53e6f6d60a1e857f2b2ee1941d40b54270adf3323", size = 12278, upload-time = "2025-11-21T18:33:03.79Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3f/c1544c3336122571b371eea2dd0dc2542112f172029f06bb43e4c09c128e/smithy_aws_event_stream-0.2.1.tar.gz", hash = "sha256:ae67ea3e582f2c2c556e302e57bc90a19b9b5847bcc8520e89396bcafc26235f", size = 12334, upload-time = "2025-12-30T23:23:27.493Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/f5/08b997eee81b55150496ce565f0e03c72d0c80e5b218170bdeae7c46a5a4/smithy_aws_event_stream-0.2.0-py3-none-any.whl", hash = "sha256:679a0c7d944e67d3a55d287541b3ca1e61f9d6a62e13401367dcc034e75aa55d", size = 15567, upload-time = "2025-11-21T18:33:02.711Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/b52689e3dc39d478f7ba69ca18be2e762b3866efe4ae5312d69059f9fe01/smithy_aws_event_stream-0.2.1-py3-none-any.whl", hash = "sha256:31d1089306732b4f35e1c6be2e8c2a3f7524c72c59aa2fd1dcba77b97bef4bc3", size = 15569, upload-time = "2025-12-30T23:23:26.411Z" }, ] [[package]] name = "smithy-core" -version = "0.2.0" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/f6/140f0be9331dd7cd8fa012b3ca4735df39a1a81d03eea89728f997249116/smithy_core-0.2.0.tar.gz", hash = "sha256:05c3e3309df5dcb9cf53e241bd57a96510e4575186443ea157db9dbb59b6c85e", size = 50334, upload-time = "2025-11-21T18:33:05.697Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f3/98e9a96ae2d7f9a5d92cf5b7a02791ae4c563530a5fbec7b4dce5797758e/smithy_core-0.3.0.tar.gz", hash = "sha256:7d3501d28aab379a3ab4ae33a8ff854779a3e4bd1f1e048960f350c717e1f38d", size = 50629, upload-time = "2026-01-02T17:41:49.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e3/d0defa2acf50b91625fe15e3ddb0c8e41ff64363a1f4cd9b8f19ae2ec0c6/smithy_core-0.2.0-py3-none-any.whl", hash = "sha256:db4620da3497abb60f79ac1d8a738d3eac46d7e820bfb50c777c36e932915239", size = 64777, upload-time = "2025-11-21T18:33:04.591Z" }, + { url = "https://files.pythonhosted.org/packages/88/0c/bdb2e66c477f5b3682d39152e299d0687750690837437cbbfee833e72975/smithy_core-0.3.0-py3-none-any.whl", hash = "sha256:1aa7d681d53c984c510068f25853d3c0a874cbedad7b753e20b25618b589490a", size = 64900, upload-time = "2026-01-02T17:41:48.624Z" }, ] [[package]] name = "smithy-http" -version = "0.3.0" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smithy-core", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/c7/4d8be56e897f99f3b6ffcdf52ba00a468febc939fca85b90f1c122450830/smithy_http-0.3.0.tar.gz", hash = "sha256:55dcc3af315eee6863d2f3f58ada1d9cb4bcc3a57faac10a1b21d4a93722f520", size = 28674, upload-time = "2025-11-21T18:33:07.387Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/bc/2e7f293932b08a3f546ebd7c1d9ab840c50cb0f9c95c7bc5d1c6cdf1a948/smithy_http-0.3.1.tar.gz", hash = "sha256:2a3faf27146e1a02bdab798dd6b1c57432918a483927ca87c3b8141e206107e9", size = 28771, upload-time = "2025-12-30T23:11:28.906Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/e5/59ae79ecdc9a935ad10512c581b3054ebb1afd90498ecc8afaf141dbc22b/smithy_http-0.3.0-py3-none-any.whl", hash = "sha256:972924304febd77c7134a7cffab83ce3b48423ff966dcc1f257e2c0d58fa9b18", size = 40520, upload-time = "2025-11-21T18:33:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ac/a83c919082c72958ac4ba235b57abab0cf6f6e64d9d4ee4d6fbc8b00c718/smithy_http-0.3.1-py3-none-any.whl", hash = "sha256:32f9dac12a3e0d548a0978f660ab470228468b7d6c0576f8cdee9e1aaa247af1", size = 40540, upload-time = "2025-12-30T23:11:27.751Z" }, ] [package.optional-dependencies] @@ -5860,15 +6701,15 @@ awscrt = [ [[package]] name = "smithy-json" -version = "0.2.0" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ijson", marker = "python_full_version >= '3.12'" }, { name = "smithy-core", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/cf/e319a2a299b27bc0addf46ee3d4b9c25ec0817e3a0507b2b7a33eddc19f1/smithy_json-0.2.0.tar.gz", hash = "sha256:0946066fdda15d6a579dfdd4b61a547ab915eb057bd176fc2bc17d01dc789499", size = 7157, upload-time = "2025-11-21T18:33:08.968Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/74/1489187f6ec3e645f8d986de23c0a74d5d9b5ec5796a5ca1d95cd7881ea8/smithy_json-0.2.1.tar.gz", hash = "sha256:a8889465cb04d1ef9ba5efae036115d37770ce65b3b6de2f95e66c0cf9e0dad8", size = 7210, upload-time = "2025-12-30T23:23:17.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/b1/33012ac5b2e5940a00b6e1ccc313330e6f8692152a151f72a398cd6be0e0/smithy_json-0.2.0-py3-none-any.whl", hash = "sha256:5018a4e61731afa3094a02d737d4f956dbf270c271410c089045a17d86fc3b3b", size = 9911, upload-time = "2025-11-21T18:33:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c1/b9ca91402b415e38566eb8b6c16c0136382bff2dcd39fd28cc58d4eda1a9/smithy_json-0.2.1-py3-none-any.whl", hash = "sha256:4222dcbe8a5187ba18caaf556d280804c45e6646fba92f0f6148c4f1d9bb721b", size = 9908, upload-time = "2025-12-30T23:23:16.194Z" }, ] [[package]] @@ -5895,7 +6736,8 @@ version = "0.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" } wheels = [ @@ -5910,28 +6752,34 @@ wheels = [ [[package]] name = "soxr" -version = "0.5.0.post1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/c0/4429bf9b3be10e749149e286aa5c53775399ec62891c6b970456c6dca325/soxr-0.5.0.post1.tar.gz", hash = "sha256:7092b9f3e8a416044e1fa138c8172520757179763b85dc53aa9504f4813cff73", size = 170853, upload-time = "2024-08-31T03:43:33.058Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/7e/f4b461944662ad75036df65277d6130f9411002bfb79e9df7dff40a31db9/soxr-1.0.0.tar.gz", hash = "sha256:e07ee6c1d659bc6957034f4800c60cb8b98de798823e34d2a2bba1caa85a4509", size = 171415, upload-time = "2025-09-07T13:22:21.317Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/96/bee1eb69d66fc28c3b219ba9b8674b49d3dcc6cd2f9b3e5114ff28cf88b5/soxr-0.5.0.post1-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:7406d782d85f8cf64e66b65e6b7721973de8a1dc50b9e88bc2288c343a987484", size = 203841, upload-time = "2024-08-31T03:42:59.186Z" }, - { url = "https://files.pythonhosted.org/packages/1f/5d/56ad3d181d30d103128f65cc44f4c4e24c199e6d5723e562704e47c89f78/soxr-0.5.0.post1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa0a382fb8d8e2afed2c1642723b2d2d1b9a6728ff89f77f3524034c8885b8c9", size = 160192, upload-time = "2024-08-31T03:43:01.128Z" }, - { url = "https://files.pythonhosted.org/packages/7f/09/e43c39390e26b4c1b8d46f8a1c252a5077fa9f81cc2326b03c3d2b85744e/soxr-0.5.0.post1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b01d3efb95a2851f78414bcd00738b0253eec3f5a1e5482838e965ffef84969", size = 221176, upload-time = "2024-08-31T03:43:02.663Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e6/059070b4cdb7fdd8ffbb67c5087c1da9716577127fb0540cd11dbf77923b/soxr-0.5.0.post1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc049b0a151a65aa75b92f0ac64bb2dba785d16b78c31c2b94e68c141751d6d", size = 252779, upload-time = "2024-08-31T03:43:04.582Z" }, - { url = "https://files.pythonhosted.org/packages/ad/64/86082b6372e5ff807dfa79b857da9f50e94e155706000daa43fdc3b59851/soxr-0.5.0.post1-cp310-cp310-win_amd64.whl", hash = "sha256:97f269bc26937c267a2ace43a77167d0c5c8bba5a2b45863bb6042b5b50c474e", size = 166881, upload-time = "2024-08-31T03:43:06.255Z" }, - { url = "https://files.pythonhosted.org/packages/29/28/dc62dae260a77603e8257e9b79078baa2ca4c0b4edc6f9f82c9113d6ef18/soxr-0.5.0.post1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:6fb77b626773a966e3d8f6cb24f6f74b5327fa5dc90f1ff492450e9cdc03a378", size = 203648, upload-time = "2024-08-31T03:43:08.339Z" }, - { url = "https://files.pythonhosted.org/packages/0e/48/3e88329a695f6e0e38a3b171fff819d75d7cc055dae1ec5d5074f34d61e3/soxr-0.5.0.post1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:39e0f791ba178d69cd676485dbee37e75a34f20daa478d90341ecb7f6d9d690f", size = 159933, upload-time = "2024-08-31T03:43:10.053Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a5/6b439164be6871520f3d199554568a7656e96a867adbbe5bac179caf5776/soxr-0.5.0.post1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f0b558f445ba4b64dbcb37b5f803052eee7d93b1dbbbb97b3ec1787cb5a28eb", size = 221010, upload-time = "2024-08-31T03:43:11.839Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e5/400e3bf7f29971abad85cb877e290060e5ec61fccd2fa319e3d85709c1be/soxr-0.5.0.post1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca6903671808e0a6078b0d146bb7a2952b118dfba44008b2aa60f221938ba829", size = 252471, upload-time = "2024-08-31T03:43:13.347Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/6a7e91bea7e6ca193ee429869b8f18548cd79759e064021ecb5756024c7c/soxr-0.5.0.post1-cp311-cp311-win_amd64.whl", hash = "sha256:c4d8d5283ed6f5efead0df2c05ae82c169cfdfcf5a82999c2d629c78b33775e8", size = 166723, upload-time = "2024-08-31T03:43:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e3/d422d279e51e6932e7b64f1170a4f61a7ee768e0f84c9233a5b62cd2c832/soxr-0.5.0.post1-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:fef509466c9c25f65eae0ce1e4b9ac9705d22c6038c914160ddaf459589c6e31", size = 199993, upload-time = "2024-08-31T03:43:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/20/f1/88adaca3c52e03bcb66b63d295df2e2d35bf355d19598c6ce84b20be7fca/soxr-0.5.0.post1-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:4704ba6b13a3f1e41d12acf192878384c1c31f71ce606829c64abdf64a8d7d32", size = 156373, upload-time = "2024-08-31T03:43:18.633Z" }, - { url = "https://files.pythonhosted.org/packages/b8/38/bad15a9e615215c8219652ca554b601663ac3b7ac82a284aca53ec2ff48c/soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd052a66471a7335b22a6208601a9d0df7b46b8d087dce4ff6e13eed6a33a2a1", size = 216564, upload-time = "2024-08-31T03:43:20.789Z" }, - { url = "https://files.pythonhosted.org/packages/e1/1a/569ea0420a0c4801c2c8dd40d8d544989522f6014d51def689125f3f2935/soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3f16810dd649ab1f433991d2a9661e9e6a116c2b4101039b53b3c3e90a094fc", size = 248455, upload-time = "2024-08-31T03:43:22.165Z" }, - { url = "https://files.pythonhosted.org/packages/bc/10/440f1ba3d4955e0dc740bbe4ce8968c254a3d644d013eb75eea729becdb8/soxr-0.5.0.post1-cp312-abi3-win_amd64.whl", hash = "sha256:b1be9fee90afb38546bdbd7bde714d1d9a8c5a45137f97478a83b65e7f3146f6", size = 164937, upload-time = "2024-08-31T03:43:23.671Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a7/11c36d71595b52fe84a220040ace679035953acf06b83bf2c7117c565d2c/soxr-1.0.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:b876a3156f67c76aef0cff1084eaf4088d9ca584bb569cb993f89a52ec5f399f", size = 206459, upload-time = "2025-09-07T13:21:46.904Z" }, + { url = "https://files.pythonhosted.org/packages/43/5e/8962f2aeea7777d2a6e65a24a2b83c6aea1a28badeda027fd328f7f03bb7/soxr-1.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d3b957a7b0cc19ae6aa45d40b2181474e53a8dd00efd7bce6bcf4e60e020892", size = 164808, upload-time = "2025-09-07T13:21:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/fc/91/00384166f110a3888ea8efd44523ba7168dd2dc39e3e43c931cc2d069fa9/soxr-1.0.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89685faedebc45af71f08f9957b61cc6143bc94ba43fe38e97067f81e272969", size = 208586, upload-time = "2025-09-07T13:21:50.341Z" }, + { url = "https://files.pythonhosted.org/packages/75/34/e18f1003e242aabed44ed8902534814d3e64209e4d1d874f5b9b67d73cde/soxr-1.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d255741b2f0084fd02d4a2ddd77cd495be9e7e7b6f9dba1c9494f86afefac65b", size = 242310, upload-time = "2025-09-07T13:21:51.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/9c/a1c5ed106b40cc1e2e12cd58831b7f1b61c5fbdb8eceeca4b3a0b0dbef6c/soxr-1.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:158a4a9055958c4b95ef91dbbe280cabb00946b5423b25a9b0ce31bd9e0a271e", size = 173561, upload-time = "2025-09-07T13:21:53.03Z" }, + { url = "https://files.pythonhosted.org/packages/65/ce/a3262bc8733d3a4ce5f660ed88c3d97f4b12658b0909e71334cba1721dcb/soxr-1.0.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:28e19d74a5ef45c0d7000f3c70ec1719e89077379df2a1215058914d9603d2d8", size = 206739, upload-time = "2025-09-07T13:21:54.572Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/e8cbd100b652697cc9865dbed08832e7e135ff533f453eb6db9e6168d153/soxr-1.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8dc69fc18884e53b72f6141fdf9d80997edbb4fec9dc2942edcb63abbe0d023", size = 165233, upload-time = "2025-09-07T13:21:55.887Z" }, + { url = "https://files.pythonhosted.org/packages/75/12/4b49611c9ba5e9fe6f807d0a83352516808e8e573f8b4e712fc0c17f3363/soxr-1.0.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f15450e6f65f22f02fcd4c5a9219c873b1e583a73e232805ff160c759a6b586", size = 208867, upload-time = "2025-09-07T13:21:57.076Z" }, + { url = "https://files.pythonhosted.org/packages/cc/70/92146ab970a3ef8c43ac160035b1e52fde5417f89adb10572f7e788d9596/soxr-1.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f73f57452f9df37b4de7a4052789fcbd474a5b28f38bba43278ae4b489d4384", size = 242633, upload-time = "2025-09-07T13:21:58.621Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a7/628479336206959463d08260bffed87905e7ba9e3bd83ca6b405a0736e94/soxr-1.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:9f417c3d69236051cf5a1a7bad7c4bff04eb3d8fcaa24ac1cb06e26c8d48d8dc", size = 173814, upload-time = "2025-09-07T13:21:59.798Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c7/f92b81f1a151c13afb114f57799b86da9330bec844ea5a0d3fe6a8732678/soxr-1.0.0-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:abecf4e39017f3fadb5e051637c272ae5778d838e5c3926a35db36a53e3a607f", size = 205508, upload-time = "2025-09-07T13:22:01.252Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1d/c945fea9d83ea1f2be9d116b3674dbaef26ed090374a77c394b31e3b083b/soxr-1.0.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:e973d487ee46aa8023ca00a139db6e09af053a37a032fe22f9ff0cc2e19c94b4", size = 163568, upload-time = "2025-09-07T13:22:03.558Z" }, + { url = "https://files.pythonhosted.org/packages/b5/80/10640970998a1d2199bef6c4d92205f36968cddaf3e4d0e9fe35ddd405bd/soxr-1.0.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ce273cca101aff3d8c387db5a5a41001ba76ef1837883438d3c652507a9ccc", size = 204707, upload-time = "2025-09-07T13:22:05.125Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/2726603c13c2126cb8ded9e57381b7377f4f0df6ba4408e1af5ddbfdc3dd/soxr-1.0.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f2a69686f2856d37823bbb7b78c3d44904f311fe70ba49b893af11d6b6047b", size = 238032, upload-time = "2025-09-07T13:22:06.428Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/530252227f4d0721a5524a936336485dfb429bb206a66baf8e470384f4a2/soxr-1.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:2a3b77b115ae7c478eecdbd060ed4f61beda542dfb70639177ac263aceda42a2", size = 172070, upload-time = "2025-09-07T13:22:07.62Z" }, + { url = "https://files.pythonhosted.org/packages/99/77/d3b3c25b4f1b1aa4a73f669355edcaee7a52179d0c50407697200a0e55b9/soxr-1.0.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:392a5c70c04eb939c9c176bd6f654dec9a0eaa9ba33d8f1024ed63cf68cdba0a", size = 209509, upload-time = "2025-09-07T13:22:08.773Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ee/3ca73e18781bb2aff92b809f1c17c356dfb9a1870652004bd432e79afbfa/soxr-1.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fdc41a1027ba46777186f26a8fba7893be913383414135577522da2fcc684490", size = 167690, upload-time = "2025-09-07T13:22:10.259Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f0/eea8b5f587a2531657dc5081d2543a5a845f271a3bea1c0fdee5cebde021/soxr-1.0.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:449acd1dfaf10f0ce6dfd75c7e2ef984890df94008765a6742dafb42061c1a24", size = 209541, upload-time = "2025-09-07T13:22:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/64/59/2430a48c705565eb09e78346950b586f253a11bd5313426ced3ecd9b0feb/soxr-1.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38b35c99e408b8f440c9376a5e1dd48014857cd977c117bdaa4304865ae0edd0", size = 243025, upload-time = "2025-09-07T13:22:12.877Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1b/f84a2570a74094e921bbad5450b2a22a85d58585916e131d9b98029c3e69/soxr-1.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a39b519acca2364aa726b24a6fd55acf29e4c8909102e0b858c23013c38328e5", size = 184850, upload-time = "2025-09-07T13:22:14.068Z" }, ] [[package]] @@ -5948,16 +6796,17 @@ wheels = [ [[package]] name = "speechmatics-voice" -version = "0.2.6" +version = "0.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pydantic" }, { name = "speechmatics-rt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d2/18/7790718c826be18eadaa7cfc0cc9f229d157f5f3aff628b9fc0f180a7878/speechmatics_voice-0.2.6.tar.gz", hash = "sha256:ae384e8f97862fc6adf38937e1d1d63cd16b64bc49aded8ccad273155634a636", size = 60881, upload-time = "2026-01-08T00:54:41.405Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/b2/72b5b2203bbefbd22e7692adaca0dd7c2feebed1aaea5599ec579f74fbbf/speechmatics_voice-0.2.8.tar.gz", hash = "sha256:b2d9cbf773fd94400c744734662e2b16b5bdc4271d0dafde46ac032c438fe000", size = 61419, upload-time = "2026-01-26T16:26:09.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/cc/ae6dc3d5638a3fc86c4537af1fb394dee3c4a2a5e9dbebf9fb83a8052939/speechmatics_voice-0.2.6-py3-none-any.whl", hash = "sha256:15d61cb02d7fe492f966cc28ddb0ada199fdd12543b9a61cb8757c7bf25b7a94", size = 57103, upload-time = "2026-01-08T00:54:39.92Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/a2ab215a7a31fad5ef9267420dc9ced96d6d52e5b80b131ef41424607849/speechmatics_voice-0.2.8-py3-none-any.whl", hash = "sha256:423ac7620ae8c98f175faace2184ac4ab1fe448ffb41af57aae05ec655326f79", size = 57629, upload-time = "2026-01-26T16:26:07.59Z" }, ] [package.optional-dependencies] @@ -5978,7 +6827,7 @@ dependencies = [ { name = "alabaster", marker = "python_full_version < '3.11'" }, { name = "babel", marker = "python_full_version < '3.11'" }, { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "imagesize", marker = "python_full_version < '3.11'" }, { name = "jinja2", marker = "python_full_version < '3.11'" }, { name = "packaging", marker = "python_full_version < '3.11'" }, @@ -6000,35 +6849,66 @@ wheels = [ [[package]] name = "sphinx" -version = "8.2.3" +version = "9.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.11'" }, - { name = "babel", marker = "python_full_version >= '3.11'" }, - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.11'" }, - { name = "imagesize", marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "requests", marker = "python_full_version >= '3.11'" }, - { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, ] [[package]] @@ -6048,49 +6928,68 @@ wheels = [ [[package]] name = "sphinx-autodoc-typehints" -version = "3.2.0" +version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/68/a388a9b8f066cd865d9daa65af589d097efbfab9a8c302d2cb2daa43b52e/sphinx_autodoc_typehints-3.2.0.tar.gz", hash = "sha256:107ac98bc8b4837202c88c0736d59d6da44076e65a0d7d7d543a78631f662a9b", size = 36724, upload-time = "2025-04-25T16:53:25.872Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/f6/bdd93582b2aaad2cfe9eb5695a44883c8bc44572dd3c351a947acbb13789/sphinx_autodoc_typehints-3.6.1.tar.gz", hash = "sha256:fa0b686ae1b85965116c88260e5e4b82faec3687c2e94d6a10f9b36c3743e2fe", size = 37563, upload-time = "2026-01-02T15:23:46.543Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/c7/8aab362e86cbf887e58be749a78d20ad743e1eb2c73c2b13d4761f39a104/sphinx_autodoc_typehints-3.2.0-py3-none-any.whl", hash = "sha256:884b39be23b1d884dcc825d4680c9c6357a476936e3b381a67ae80091984eb49", size = 20563, upload-time = "2025-04-25T16:53:24.492Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6a/c0360b115c81d449b3b73bf74b64ca773464d5c7b1b77bda87c5e874853b/sphinx_autodoc_typehints-3.6.1-py3-none-any.whl", hash = "sha256:dd818ba31d4c97f219a8c0fcacef280424f84a3589cedcb73003ad99c7da41ca", size = 20869, upload-time = "2026-01-02T15:23:45.194Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.9.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", +] +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/74/4d/02216afa475c838c123b41f30e3a2875ad27c14fae3f5bbed4ba4e7fd894/sphinx_autodoc_typehints-3.9.8.tar.gz", hash = "sha256:1e36b31ee593b7e838988045918b7fa965f5062abbd6800af96d5e2c3f17130e", size = 68763, upload-time = "2026-03-09T15:40:01.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/6c/f275f59095b2fec6627c3ce2caba4e18f55a3925718cf0547cde04821a37/sphinx_autodoc_typehints-3.9.8-py3-none-any.whl", hash = "sha256:df123ec82479934fed27e31d4ccdcf382901c5d9481450fc224054496e574466", size = 36685, upload-time = "2026-03-09T15:39:59.567Z" }, ] [[package]] name = "sphinx-markdown-builder" -version = "0.6.8" +version = "0.6.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "tabulate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/36/f4a2efb804e2b89a6a29338bd1e9895af806e465c4a13ca59271f9d40dfd/sphinx_markdown_builder-0.6.8.tar.gz", hash = "sha256:6141b566bf18dd1cd515a0a90efd91c6c4d10fc638554fab2fd19cba66543dd7", size = 22007, upload-time = "2025-01-19T01:58:20.497Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/58/0b7b9a7d071140b3705885d51932e8b62f520388c2772e4952189971727b/sphinx_markdown_builder-0.6.10.tar.gz", hash = "sha256:cd5acf88d52ea0146a712fd557404f10326dff3428a78ba928e59b1727fd4a86", size = 22688, upload-time = "2026-03-11T10:56:57.639Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/98/7e8e11d4edce0947d89c5d00ed43d925a5254dc9733579382b04f77e5ff2/sphinx_markdown_builder-0.6.8-py3-none-any.whl", hash = "sha256:f04ab42d52449363228b9104569c56b778534f9c41a168af8cfc721a1e0e3edc", size = 17270, upload-time = "2025-01-19T01:58:19.296Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8f/9fecf3d081d5cd49eff83a17b9fef50ed741e6223ab3bb906de4ab0068f9/sphinx_markdown_builder-0.6.10-py3-none-any.whl", hash = "sha256:16d86738b9ac69fcbc86e373c31c6402c30af1fa8d98d0f62cc5f38bfe5fc26e", size = 16700, upload-time = "2026-03-11T10:56:56.135Z" }, ] [[package]] name = "sphinx-rtd-theme" -version = "3.0.2" +version = "3.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinxcontrib-jquery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/44/c97faec644d29a5ceddd3020ae2edffa69e7d00054a8c7a6021e82f20335/sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85", size = 7620463, upload-time = "2024-11-13T11:06:04.545Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/77/46e3bac77b82b4df5bb5b61f2de98637724f246b4966cfc34bc5895d852a/sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13", size = 7655561, upload-time = "2024-11-13T11:06:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, ] [[package]] @@ -6126,7 +7025,8 @@ version = "4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { 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/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } wheels = [ @@ -6162,93 +7062,111 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.43" +version = "2.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/4e/985f7da36f09592c5ade99321c72c15101d23c0bb7eecfd1daaca5714422/sqlalchemy-2.0.43-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069", size = 2133162, upload-time = "2025-08-11T15:52:17.854Z" }, - { url = "https://files.pythonhosted.org/packages/37/34/798af8db3cae069461e3bc0898a1610dc469386a97048471d364dc8aae1c/sqlalchemy-2.0.43-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154", size = 2123082, upload-time = "2025-08-11T15:52:19.181Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0f/79cf4d9dad42f61ec5af1e022c92f66c2d110b93bb1dc9b033892971abfa/sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612", size = 3208871, upload-time = "2025-08-11T15:50:30.656Z" }, - { url = "https://files.pythonhosted.org/packages/56/b3/59befa58fb0e1a9802c87df02344548e6d007e77e87e6084e2131c29e033/sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019", size = 3209583, upload-time = "2025-08-11T15:57:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/29/d2/124b50c0eb8146e8f0fe16d01026c1a073844f0b454436d8544fe9b33bd7/sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20", size = 3148177, upload-time = "2025-08-11T15:50:32.078Z" }, - { url = "https://files.pythonhosted.org/packages/83/f5/e369cd46aa84278107624617034a5825fedfc5c958b2836310ced4d2eadf/sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18", size = 3172276, upload-time = "2025-08-11T15:57:49.477Z" }, - { url = "https://files.pythonhosted.org/packages/de/2b/4602bf4c3477fa4c837c9774e6dd22e0389fc52310c4c4dfb7e7ba05e90d/sqlalchemy-2.0.43-cp310-cp310-win32.whl", hash = "sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00", size = 2101491, upload-time = "2025-08-11T15:54:59.191Z" }, - { url = "https://files.pythonhosted.org/packages/38/2d/bfc6b6143adef553a08295490ddc52607ee435b9c751c714620c1b3dd44d/sqlalchemy-2.0.43-cp310-cp310-win_amd64.whl", hash = "sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b", size = 2125148, upload-time = "2025-08-11T15:55:00.593Z" }, - { url = "https://files.pythonhosted.org/packages/9d/77/fa7189fe44114658002566c6fe443d3ed0ec1fa782feb72af6ef7fbe98e7/sqlalchemy-2.0.43-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29", size = 2136472, upload-time = "2025-08-11T15:52:21.789Z" }, - { url = "https://files.pythonhosted.org/packages/99/ea/92ac27f2fbc2e6c1766bb807084ca455265707e041ba027c09c17d697867/sqlalchemy-2.0.43-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631", size = 2126535, upload-time = "2025-08-11T15:52:23.109Z" }, - { url = "https://files.pythonhosted.org/packages/94/12/536ede80163e295dc57fff69724caf68f91bb40578b6ac6583a293534849/sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685", size = 3297521, upload-time = "2025-08-11T15:50:33.536Z" }, - { url = "https://files.pythonhosted.org/packages/03/b5/cacf432e6f1fc9d156eca0560ac61d4355d2181e751ba8c0cd9cb232c8c1/sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca", size = 3297343, upload-time = "2025-08-11T15:57:51.186Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ba/d4c9b526f18457667de4c024ffbc3a0920c34237b9e9dd298e44c7c00ee5/sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d", size = 3232113, upload-time = "2025-08-11T15:50:34.949Z" }, - { url = "https://files.pythonhosted.org/packages/aa/79/c0121b12b1b114e2c8a10ea297a8a6d5367bc59081b2be896815154b1163/sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3", size = 3258240, upload-time = "2025-08-11T15:57:52.983Z" }, - { url = "https://files.pythonhosted.org/packages/79/99/a2f9be96fb382f3ba027ad42f00dbe30fdb6ba28cda5f11412eee346bec5/sqlalchemy-2.0.43-cp311-cp311-win32.whl", hash = "sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921", size = 2101248, upload-time = "2025-08-11T15:55:01.855Z" }, - { url = "https://files.pythonhosted.org/packages/ee/13/744a32ebe3b4a7a9c7ea4e57babae7aa22070d47acf330d8e5a1359607f1/sqlalchemy-2.0.43-cp311-cp311-win_amd64.whl", hash = "sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8", size = 2126109, upload-time = "2025-08-11T15:55:04.092Z" }, - { url = "https://files.pythonhosted.org/packages/61/db/20c78f1081446095450bdc6ee6cc10045fce67a8e003a5876b6eaafc5cc4/sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24", size = 2134891, upload-time = "2025-08-11T15:51:13.019Z" }, - { url = "https://files.pythonhosted.org/packages/45/0a/3d89034ae62b200b4396f0f95319f7d86e9945ee64d2343dcad857150fa2/sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83", size = 2123061, upload-time = "2025-08-11T15:51:14.319Z" }, - { url = "https://files.pythonhosted.org/packages/cb/10/2711f7ff1805919221ad5bee205971254845c069ee2e7036847103ca1e4c/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9", size = 3320384, upload-time = "2025-08-11T15:52:35.088Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0e/3d155e264d2ed2778484006ef04647bc63f55b3e2d12e6a4f787747b5900/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48", size = 3329648, upload-time = "2025-08-11T15:56:34.153Z" }, - { url = "https://files.pythonhosted.org/packages/5b/81/635100fb19725c931622c673900da5efb1595c96ff5b441e07e3dd61f2be/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687", size = 3258030, upload-time = "2025-08-11T15:52:36.933Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ed/a99302716d62b4965fded12520c1cbb189f99b17a6d8cf77611d21442e47/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe", size = 3294469, upload-time = "2025-08-11T15:56:35.553Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a2/3a11b06715149bf3310b55a98b5c1e84a42cfb949a7b800bc75cb4e33abc/sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d", size = 2098906, upload-time = "2025-08-11T15:55:00.645Z" }, - { url = "https://files.pythonhosted.org/packages/bc/09/405c915a974814b90aa591280623adc6ad6b322f61fd5cff80aeaef216c9/sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a", size = 2126260, upload-time = "2025-08-11T15:55:02.965Z" }, - { url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598, upload-time = "2025-08-11T15:51:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415, upload-time = "2025-08-11T15:51:17.256Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707, upload-time = "2025-08-11T15:52:38.444Z" }, - { url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602, upload-time = "2025-08-11T15:56:37.348Z" }, - { url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248, upload-time = "2025-08-11T15:52:39.865Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363, upload-time = "2025-08-11T15:56:39.11Z" }, - { url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718, upload-time = "2025-08-11T15:55:05.349Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200, upload-time = "2025-08-11T15:55:07.932Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/1235676e93dd3b742a4a8eddfae49eea46c85e3eed29f0da446a8dd57500/sqlalchemy-2.0.48-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7001dc9d5f6bb4deb756d5928eaefe1930f6f4179da3924cbd95ee0e9f4dce89", size = 2157384, upload-time = "2026-03-02T15:38:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d7/fa728b856daa18c10e1390e76f26f64ac890c947008284387451d56ca3d0/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a89ce07ad2d4b8cfc30bd5889ec40613e028ed80ef47da7d9dd2ce969ad30e0", size = 3236981, upload-time = "2026-03-02T15:58:53.53Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ad/6c4395649a212a6c603a72c5b9ab5dce3135a1546cfdffa3c427e71fd535/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10853a53a4a00417a00913d270dddda75815fcb80675874285f41051c094d7dd", size = 3235232, upload-time = "2026-03-02T15:52:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/58f845e511ac0509765a6f85eb24924c1ef0d54fb50de9d15b28c3601458/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fac0fa4e4f55f118fd87177dacb1c6522fe39c28d498d259014020fec9164c29", size = 3188106, upload-time = "2026-03-02T15:58:55.193Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/6dcc7bfa5f5794c3a095e78cd1de8269dfb5584dfd4c2c00a50d3c1ade44/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3713e21ea67bca727eecd4a24bf68bcd414c403faae4989442be60994301ded0", size = 3209522, upload-time = "2026-03-02T15:52:27.407Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/b632875ab35874d42657f079529f0745410604645c269a8c21fb4272ff7a/sqlalchemy-2.0.48-cp310-cp310-win32.whl", hash = "sha256:d404dc897ce10e565d647795861762aa2d06ca3f4a728c5e9a835096c7059018", size = 2117695, upload-time = "2026-03-02T15:46:51.389Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/9752eb2a41afdd8568e41ac3c3128e32a0a73eada5ab80483083604a56d1/sqlalchemy-2.0.48-cp310-cp310-win_amd64.whl", hash = "sha256:841a94c66577661c1f088ac958cd767d7c9bf507698f45afffe7a4017049de76", size = 2140928, upload-time = "2026-03-02T15:46:52.992Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" }, + { url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" }, + { url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cc/3e600a90ae64047f33313d7d32e5ad025417f09d2ded487e8284b5e21a15/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d", size = 3265431, upload-time = "2026-03-02T15:58:59.096Z" }, + { url = "https://files.pythonhosted.org/packages/8b/19/780138dacfe3f5024f4cf96e4005e91edf6653d53d3673be4844578faf1d/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571", size = 3287646, upload-time = "2026-03-02T15:52:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/40/fd/f32ced124f01a23151f4777e4c705f3a470adc7bd241d9f36a7c941a33bf/sqlalchemy-2.0.48-cp311-cp311-win32.whl", hash = "sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617", size = 2116956, upload-time = "2026-03-02T15:46:54.535Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/dd767277f6feef12d05651538f280277e661698f617fa4d086cce6055416/sqlalchemy-2.0.48-cp311-cp311-win_amd64.whl", hash = "sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c", size = 2141627, upload-time = "2026-03-02T15:46:55.849Z" }, + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, + { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, ] [[package]] name = "sse-starlette" -version = "3.0.2" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" }, ] [[package]] name = "starlette" -version = "0.48.0" +version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] [[package]] name = "strands-agents" -version = "1.10.0" +version = "1.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, { name = "botocore" }, { name = "docstring-parser" }, + { name = "jsonschema" }, { name = "mcp" }, { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation-threading" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, + { name = "pyyaml" }, { name = "typing-extensions" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/24/5ea42c4057332d7d8307e4b04886aaebe22a3776cae322517b5903378ea8/strands_agents-1.10.0.tar.gz", hash = "sha256:6e92e27e4fe70c04879d553f3fa64b9a5056aae1b79f6a916dd32adaf1723109", size = 407496, upload-time = "2025-09-29T14:29:26.39Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/82/6c193a8ea19ed91a368a4cf7d20c87457793e1286dac5811a5c2a60a5cc2/strands_agents-1.30.0.tar.gz", hash = "sha256:358db9d78304fc1fe324763be545243e3f9cb030ed0f6f51d0c91d37caff7746", size = 773031, upload-time = "2026-03-11T18:38:32.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/f7/321549660d10e4e1d8ca558f4b8fb450e9d77c23cf744a9dc121b3e356dd/strands_agents-1.10.0-py3-none-any.whl", hash = "sha256:56b775f1ada565b321de41bb92e5d33c240fb0582c66d45c241e42af0a200b10", size = 211680, upload-time = "2025-09-29T14:29:24.478Z" }, + { url = "https://files.pythonhosted.org/packages/6e/94/ecc2df8100fdf745d41d10ac2de4c9cb0325384d0e28b4bb90c82a6ec63b/strands_agents-1.30.0-py3-none-any.whl", hash = "sha256:457ba7b063df61d00f122c913b6b85ba6431d17741b9e34484a7e16fb7e00430", size = 386493, upload-time = "2026-03-11T18:38:30.503Z" }, ] [[package]] @@ -6265,61 +7183,95 @@ wheels = [ [[package]] name = "tabulate" -version = "0.9.0" +version = "0.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] [[package]] name = "tenacity" -version = "8.5.0" +version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309, upload-time = "2024-07-05T07:25:31.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] [[package]] name = "tiktoken" -version = "0.11.0" +version = "0.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/86/ad0155a37c4f310935d5ac0b1ccf9bdb635dcb906e0a9a26b616dd55825a/tiktoken-0.11.0.tar.gz", hash = "sha256:3c518641aee1c52247c2b97e74d8d07d780092af79d5911a6ab5e79359d9b06a", size = 37648, upload-time = "2025-08-08T23:58:08.495Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/4d/c6a2e7dca2b4f2e9e0bfd62b3fe4f114322e2c028cfba905a72bc76ce479/tiktoken-0.11.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:8a9b517d6331d7103f8bef29ef93b3cca95fa766e293147fe7bacddf310d5917", size = 1059937, upload-time = "2025-08-08T23:57:28.57Z" }, - { url = "https://files.pythonhosted.org/packages/41/54/3739d35b9f94cb8dc7b0db2edca7192d5571606aa2369a664fa27e811804/tiktoken-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b4ddb1849e6bf0afa6cc1c5d809fb980ca240a5fffe585a04e119519758788c0", size = 999230, upload-time = "2025-08-08T23:57:30.241Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f4/ec8d43338d28d53513004ebf4cd83732a135d11011433c58bf045890cc10/tiktoken-0.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10331d08b5ecf7a780b4fe4d0281328b23ab22cdb4ff65e68d56caeda9940ecc", size = 1130076, upload-time = "2025-08-08T23:57:31.706Z" }, - { url = "https://files.pythonhosted.org/packages/94/80/fb0ada0a882cb453caf519a4bf0d117c2a3ee2e852c88775abff5413c176/tiktoken-0.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b062c82300341dc87e0258c69f79bed725f87e753c21887aea90d272816be882", size = 1183942, upload-time = "2025-08-08T23:57:33.142Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e9/6c104355b463601719582823f3ea658bc3aa7c73d1b3b7553ebdc48468ce/tiktoken-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:195d84bec46169af3b1349a1495c151d37a0ff4cba73fd08282736be7f92cc6c", size = 1244705, upload-time = "2025-08-08T23:57:34.594Z" }, - { url = "https://files.pythonhosted.org/packages/94/75/eaa6068f47e8b3f0aab9e05177cce2cf5aa2cc0ca93981792e620d4d4117/tiktoken-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe91581b0ecdd8783ce8cb6e3178f2260a3912e8724d2f2d49552b98714641a1", size = 884152, upload-time = "2025-08-08T23:57:36.18Z" }, - { url = "https://files.pythonhosted.org/packages/8a/91/912b459799a025d2842566fe1e902f7f50d54a1ce8a0f236ab36b5bd5846/tiktoken-0.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4ae374c46afadad0f501046db3da1b36cd4dfbfa52af23c998773682446097cf", size = 1059743, upload-time = "2025-08-08T23:57:37.516Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e9/6faa6870489ce64f5f75dcf91512bf35af5864583aee8fcb0dcb593121f5/tiktoken-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:25a512ff25dc6c85b58f5dd4f3d8c674dc05f96b02d66cdacf628d26a4e4866b", size = 999334, upload-time = "2025-08-08T23:57:38.595Z" }, - { url = "https://files.pythonhosted.org/packages/a1/3e/a05d1547cf7db9dc75d1461cfa7b556a3b48e0516ec29dfc81d984a145f6/tiktoken-0.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2130127471e293d385179c1f3f9cd445070c0772be73cdafb7cec9a3684c0458", size = 1129402, upload-time = "2025-08-08T23:57:39.627Z" }, - { url = "https://files.pythonhosted.org/packages/34/9a/db7a86b829e05a01fd4daa492086f708e0a8b53952e1dbc9d380d2b03677/tiktoken-0.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21e43022bf2c33f733ea9b54f6a3f6b4354b909f5a73388fb1b9347ca54a069c", size = 1184046, upload-time = "2025-08-08T23:57:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/9d/bb/52edc8e078cf062ed749248f1454e9e5cfd09979baadb830b3940e522015/tiktoken-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:adb4e308eb64380dc70fa30493e21c93475eaa11669dea313b6bbf8210bfd013", size = 1244691, upload-time = "2025-08-08T23:57:42.251Z" }, - { url = "https://files.pythonhosted.org/packages/60/d9/884b6cd7ae2570ecdcaffa02b528522b18fef1cbbfdbcaa73799807d0d3b/tiktoken-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:ece6b76bfeeb61a125c44bbefdfccc279b5288e6007fbedc0d32bfec602df2f2", size = 884392, upload-time = "2025-08-08T23:57:43.628Z" }, - { url = "https://files.pythonhosted.org/packages/e7/9e/eceddeffc169fc75fe0fd4f38471309f11cb1906f9b8aa39be4f5817df65/tiktoken-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fd9e6b23e860973cf9526544e220b223c60badf5b62e80a33509d6d40e6c8f5d", size = 1055199, upload-time = "2025-08-08T23:57:45.076Z" }, - { url = "https://files.pythonhosted.org/packages/4f/cf/5f02bfefffdc6b54e5094d2897bc80efd43050e5b09b576fd85936ee54bf/tiktoken-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a76d53cee2da71ee2731c9caa747398762bda19d7f92665e882fef229cb0b5b", size = 996655, upload-time = "2025-08-08T23:57:46.304Z" }, - { url = "https://files.pythonhosted.org/packages/65/8e/c769b45ef379bc360c9978c4f6914c79fd432400a6733a8afc7ed7b0726a/tiktoken-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef72aab3ea240646e642413cb363b73869fed4e604dcfd69eec63dc54d603e8", size = 1128867, upload-time = "2025-08-08T23:57:47.438Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2d/4d77f6feb9292bfdd23d5813e442b3bba883f42d0ac78ef5fdc56873f756/tiktoken-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f929255c705efec7a28bf515e29dc74220b2f07544a8c81b8d69e8efc4578bd", size = 1183308, upload-time = "2025-08-08T23:57:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/7a/65/7ff0a65d3bb0fc5a1fb6cc71b03e0f6e71a68c5eea230d1ff1ba3fd6df49/tiktoken-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:61f1d15822e4404953d499fd1dcc62817a12ae9fb1e4898033ec8fe3915fdf8e", size = 1244301, upload-time = "2025-08-08T23:57:49.642Z" }, - { url = "https://files.pythonhosted.org/packages/f5/6e/5b71578799b72e5bdcef206a214c3ce860d999d579a3b56e74a6c8989ee2/tiktoken-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:45927a71ab6643dfd3ef57d515a5db3d199137adf551f66453be098502838b0f", size = 884282, upload-time = "2025-08-08T23:57:50.759Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cd/a9034bcee638716d9310443818d73c6387a6a96db93cbcb0819b77f5b206/tiktoken-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a5f3f25ffb152ee7fec78e90a5e5ea5b03b4ea240beed03305615847f7a6ace2", size = 1055339, upload-time = "2025-08-08T23:57:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/f1/91/9922b345f611b4e92581f234e64e9661e1c524875c8eadd513c4b2088472/tiktoken-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dc6e9ad16a2a75b4c4be7208055a1f707c9510541d94d9cc31f7fbdc8db41d8", size = 997080, upload-time = "2025-08-08T23:57:53.442Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9d/49cd047c71336bc4b4af460ac213ec1c457da67712bde59b892e84f1859f/tiktoken-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a0517634d67a8a48fd4a4ad73930c3022629a85a217d256a6e9b8b47439d1e4", size = 1128501, upload-time = "2025-08-08T23:57:54.808Z" }, - { url = "https://files.pythonhosted.org/packages/52/d5/a0dcdb40dd2ea357e83cb36258967f0ae96f5dd40c722d6e382ceee6bba9/tiktoken-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fb4effe60574675118b73c6fbfd3b5868e5d7a1f570d6cc0d18724b09ecf318", size = 1182743, upload-time = "2025-08-08T23:57:56.307Z" }, - { url = "https://files.pythonhosted.org/packages/3b/17/a0fc51aefb66b7b5261ca1314afa83df0106b033f783f9a7bcbe8e741494/tiktoken-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94f984c9831fd32688aef4348803b0905d4ae9c432303087bae370dc1381a2b8", size = 1244057, upload-time = "2025-08-08T23:57:57.628Z" }, - { url = "https://files.pythonhosted.org/packages/50/79/bcf350609f3a10f09fe4fc207f132085e497fdd3612f3925ab24d86a0ca0/tiktoken-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2177ffda31dec4023356a441793fed82f7af5291120751dee4d696414f54db0c", size = 883901, upload-time = "2025-08-08T23:57:59.359Z" }, + { url = "https://files.pythonhosted.org/packages/89/b3/2cb7c17b6c4cf8ca983204255d3f1d95eda7213e247e6947a0ee2c747a2c/tiktoken-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970", size = 1051991, upload-time = "2025-10-06T20:21:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/27/0f/df139f1df5f6167194ee5ab24634582ba9a1b62c6b996472b0277ec80f66/tiktoken-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16", size = 995798, upload-time = "2025-10-06T20:21:35.579Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5d/26a691f28ab220d5edc09b9b787399b130f24327ef824de15e5d85ef21aa/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:cde24cdb1b8a08368f709124f15b36ab5524aac5fa830cc3fdce9c03d4fb8030", size = 1129865, upload-time = "2025-10-06T20:21:36.675Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/443fab3d4e5ebecac895712abd3849b8da93b7b7dec61c7db5c9c7ebe40c/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6de0da39f605992649b9cfa6f84071e3f9ef2cec458d08c5feb1b6f0ff62e134", size = 1152856, upload-time = "2025-10-06T20:21:37.873Z" }, + { url = "https://files.pythonhosted.org/packages/54/35/388f941251b2521c70dd4c5958e598ea6d2c88e28445d2fb8189eecc1dfc/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6faa0534e0eefbcafaccb75927a4a380463a2eaa7e26000f0173b920e98b720a", size = 1195308, upload-time = "2025-10-06T20:21:39.577Z" }, + { url = "https://files.pythonhosted.org/packages/f8/00/c6681c7f833dd410576183715a530437a9873fa910265817081f65f9105f/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82991e04fc860afb933efb63957affc7ad54f83e2216fe7d319007dab1ba5892", size = 1255697, upload-time = "2025-10-06T20:21:41.154Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d2/82e795a6a9bafa034bf26a58e68fe9a89eeaaa610d51dbeb22106ba04f0a/tiktoken-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:6fb2995b487c2e31acf0a9e17647e3b242235a20832642bb7a9d1a181c0c1bb1", size = 879375, upload-time = "2025-10-06T20:21:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, + { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] [[package]] name = "timm" -version = "1.0.20" +version = "1.0.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, @@ -6328,34 +7280,39 @@ dependencies = [ { name = "torch" }, { name = "torchvision" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/ba/6f5d96622a4a9fc315da53f58b3ca224c66015efe40aa191df0d523ede7c/timm-1.0.20.tar.gz", hash = "sha256:7468d32a410c359181c1ef961f49c7e213286e0c342bfb898b99534a4221fc54", size = 2360052, upload-time = "2025-09-21T17:26:35.492Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/2c/593109822fe735e637382aca6640c1102c19797f7791f1fd1dab2d6c3cb1/timm-1.0.25.tar.gz", hash = "sha256:47f59fc2754725735cc81bb83bcbfce5bec4ebd5d4bb9e69da57daa92fcfa768", size = 2414743, upload-time = "2026-02-23T16:49:00.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/74/5573615570bf010f788e977ac57c4b49db0aaf6d634134f6a9212d8dcdfd/timm-1.0.20-py3-none-any.whl", hash = "sha256:f6e62f780358476691996c47aa49de87b95cc507edf923c3042f74a07e45b7fe", size = 2504047, upload-time = "2025-09-21T17:26:33.487Z" }, + { url = "https://files.pythonhosted.org/packages/ef/50/de09f69a74278a16f08f1d562047a2d6713783765ee3c6971881a2b21a3f/timm-1.0.25-py3-none-any.whl", hash = "sha256:bef7f61dd717cb2dbbb7e326f143e13d660a47ecbd84116e6fe33732bed5c484", size = 2565837, upload-time = "2026-02-23T16:48:58.324Z" }, ] [[package]] name = "tokenizers" -version = "0.22.1" +version = "0.22.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/46/fb6854cec3278fbfa4a75b50232c77622bc517ac886156e6afbfa4d8fc6e/tokenizers-0.22.1.tar.gz", hash = "sha256:61de6522785310a309b3407bac22d99c4db5dba349935e99e4d15ea2226af2d9", size = 363123, upload-time = "2025-09-19T09:49:23.424Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/33/f4b2d94ada7ab297328fc671fed209368ddb82f965ec2224eb1892674c3a/tokenizers-0.22.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:59fdb013df17455e5f950b4b834a7b3ee2e0271e6378ccb33aa74d178b513c73", size = 3069318, upload-time = "2025-09-19T09:49:11.848Z" }, - { url = "https://files.pythonhosted.org/packages/1c/58/2aa8c874d02b974990e89ff95826a4852a8b2a273c7d1b4411cdd45a4565/tokenizers-0.22.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8d4e484f7b0827021ac5f9f71d4794aaef62b979ab7608593da22b1d2e3c4edc", size = 2926478, upload-time = "2025-09-19T09:49:09.759Z" }, - { url = "https://files.pythonhosted.org/packages/1e/3b/55e64befa1e7bfea963cf4b787b2cea1011362c4193f5477047532ce127e/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19d2962dd28bc67c1f205ab180578a78eef89ac60ca7ef7cbe9635a46a56422a", size = 3256994, upload-time = "2025-09-19T09:48:56.701Z" }, - { url = "https://files.pythonhosted.org/packages/71/0b/fbfecf42f67d9b7b80fde4aabb2b3110a97fac6585c9470b5bff103a80cb/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38201f15cdb1f8a6843e6563e6e79f4abd053394992b9bbdf5213ea3469b4ae7", size = 3153141, upload-time = "2025-09-19T09:48:59.749Z" }, - { url = "https://files.pythonhosted.org/packages/17/a9/b38f4e74e0817af8f8ef925507c63c6ae8171e3c4cb2d5d4624bf58fca69/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1cbe5454c9a15df1b3443c726063d930c16f047a3cc724b9e6e1a91140e5a21", size = 3508049, upload-time = "2025-09-19T09:49:05.868Z" }, - { url = "https://files.pythonhosted.org/packages/d2/48/dd2b3dac46bb9134a88e35d72e1aa4869579eacc1a27238f1577270773ff/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e7d094ae6312d69cc2a872b54b91b309f4f6fbce871ef28eb27b52a98e4d0214", size = 3710730, upload-time = "2025-09-19T09:49:01.832Z" }, - { url = "https://files.pythonhosted.org/packages/93/0e/ccabc8d16ae4ba84a55d41345207c1e2ea88784651a5a487547d80851398/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afd7594a56656ace95cdd6df4cca2e4059d294c5cfb1679c57824b605556cb2f", size = 3412560, upload-time = "2025-09-19T09:49:03.867Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c6/dc3a0db5a6766416c32c034286d7c2d406da1f498e4de04ab1b8959edd00/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ef6063d7a84994129732b47e7915e8710f27f99f3a3260b8a38fc7ccd083f4", size = 3250221, upload-time = "2025-09-19T09:49:07.664Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a6/2c8486eef79671601ff57b093889a345dd3d576713ef047776015dc66de7/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ba0a64f450b9ef412c98f6bcd2a50c6df6e2443b560024a09fa6a03189726879", size = 9345569, upload-time = "2025-09-19T09:49:14.214Z" }, - { url = "https://files.pythonhosted.org/packages/6b/16/32ce667f14c35537f5f605fe9bea3e415ea1b0a646389d2295ec348d5657/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:331d6d149fa9c7d632cde4490fb8bbb12337fa3a0232e77892be656464f4b446", size = 9271599, upload-time = "2025-09-19T09:49:16.639Z" }, - { url = "https://files.pythonhosted.org/packages/51/7c/a5f7898a3f6baa3fc2685c705e04c98c1094c523051c805cdd9306b8f87e/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:607989f2ea68a46cb1dfbaf3e3aabdf3f21d8748312dbeb6263d1b3b66c5010a", size = 9533862, upload-time = "2025-09-19T09:49:19.146Z" }, - { url = "https://files.pythonhosted.org/packages/36/65/7e75caea90bc73c1dd8d40438adf1a7bc26af3b8d0a6705ea190462506e1/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a0f307d490295717726598ef6fa4f24af9d484809223bbc253b201c740a06390", size = 9681250, upload-time = "2025-09-19T09:49:21.501Z" }, - { url = "https://files.pythonhosted.org/packages/30/2c/959dddef581b46e6209da82df3b78471e96260e2bc463f89d23b1bf0e52a/tokenizers-0.22.1-cp39-abi3-win32.whl", hash = "sha256:b5120eed1442765cd90b903bb6cfef781fd8fe64e34ccaecbae4c619b7b12a82", size = 2472003, upload-time = "2025-09-19T09:49:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/b3/46/e33a8c93907b631a99377ef4c5f817ab453d0b34f93529421f42ff559671/tokenizers-0.22.1-cp39-abi3-win_amd64.whl", hash = "sha256:65fd6e3fb11ca1e78a6a93602490f134d1fdeb13bcef99389d5102ea318ed138", size = 2674684, upload-time = "2025-09-19T09:49:24.953Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, + { url = "https://files.pythonhosted.org/packages/84/04/655b79dbcc9b3ac5f1479f18e931a344af67e5b7d3b251d2dcdcd7558592/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4", size = 3282301, upload-time = "2026-01-05T10:40:34.858Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/e4851401f3d8f6f45d8480262ab6a5c8cb9c4302a790a35aa14eeed6d2fd/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c", size = 3161308, upload-time = "2026-01-05T10:40:40.737Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6e/55553992a89982cd12d4a66dddb5e02126c58677ea3931efcbe601d419db/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195", size = 3718964, upload-time = "2026-01-05T10:40:46.56Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" }, ] [[package]] @@ -6369,53 +7326,69 @@ wheels = [ [[package]] name = "tomli" -version = "2.2.1" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] name = "torch" -version = "2.7.0" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "cuda-bindings", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "filelock" }, { name = "fsspec" }, { name = "jinja2" }, { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "networkx", version = "3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, @@ -6429,6 +7402,7 @@ dependencies = [ { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "setuptools", marker = "python_full_version >= '3.12'" }, { name = "sympy" }, @@ -6436,88 +7410,124 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/46/c2/3fb87940fa160d956ee94d644d37b99a24b9c05a4222bf34f94c71880e28/torch-2.7.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c9afea41b11e1a1ab1b258a5c31afbd646d6319042bfe4f231b408034b51128b", size = 99158447, upload-time = "2025-04-23T14:35:10.557Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2c/91d1de65573fce563f5284e69d9c56b57289625cffbbb6d533d5d56c36a5/torch-2.7.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0b9960183b6e5b71239a3e6c883d8852c304e691c0b2955f7045e8a6d05b9183", size = 865164221, upload-time = "2025-04-23T14:33:27.864Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7e/1b1cc4e0e7cc2666cceb3d250eef47a205f0821c330392cf45eb08156ce5/torch-2.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:2ad79d0d8c2a20a37c5df6052ec67c2078a2c4e9a96dd3a8b55daaff6d28ea29", size = 212521189, upload-time = "2025-04-23T14:34:53.898Z" }, - { url = "https://files.pythonhosted.org/packages/dc/0b/b2b83f30b8e84a51bf4f96aa3f5f65fdf7c31c591cc519310942339977e2/torch-2.7.0-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:34e0168ed6de99121612d72224e59b2a58a83dae64999990eada7260c5dd582d", size = 68559462, upload-time = "2025-04-23T14:35:39.889Z" }, - { url = "https://files.pythonhosted.org/packages/40/da/7378d16cc636697f2a94f791cb496939b60fb8580ddbbef22367db2c2274/torch-2.7.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2b7813e904757b125faf1a9a3154e1d50381d539ced34da1992f52440567c156", size = 99159397, upload-time = "2025-04-23T14:35:35.304Z" }, - { url = "https://files.pythonhosted.org/packages/0e/6b/87fcddd34df9f53880fa1f0c23af7b6b96c935856473faf3914323588c40/torch-2.7.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fd5cfbb4c3bbadd57ad1b27d56a28008f8d8753733411a140fcfb84d7f933a25", size = 865183681, upload-time = "2025-04-23T14:34:21.802Z" }, - { url = "https://files.pythonhosted.org/packages/13/85/6c1092d4b06c3db1ed23d4106488750917156af0b24ab0a2d9951830b0e9/torch-2.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:58df8d5c2eeb81305760282b5069ea4442791a6bbf0c74d9069b7b3304ff8a37", size = 212520100, upload-time = "2025-04-23T14:35:27.473Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3f/85b56f7e2abcfa558c5fbf7b11eb02d78a4a63e6aeee2bbae3bb552abea5/torch-2.7.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:0a8d43caa342b9986101ec5feb5bbf1d86570b5caa01e9cb426378311258fdde", size = 68569377, upload-time = "2025-04-23T14:35:20.361Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5e/ac759f4c0ab7c01feffa777bd68b43d2ac61560a9770eeac074b450f81d4/torch-2.7.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:36a6368c7ace41ad1c0f69f18056020b6a5ca47bedaca9a2f3b578f5a104c26c", size = 99013250, upload-time = "2025-04-23T14:35:15.589Z" }, - { url = "https://files.pythonhosted.org/packages/9c/58/2d245b6f1ef61cf11dfc4aceeaacbb40fea706ccebac3f863890c720ab73/torch-2.7.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:15aab3e31c16feb12ae0a88dba3434a458874636f360c567caa6a91f6bfba481", size = 865042157, upload-time = "2025-04-23T14:32:56.011Z" }, - { url = "https://files.pythonhosted.org/packages/44/80/b353c024e6b624cd9ce1d66dcb9d24e0294680f95b369f19280e241a0159/torch-2.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:f56d4b2510934e072bab3ab8987e00e60e1262fb238176168f5e0c43a1320c6d", size = 212482262, upload-time = "2025-04-23T14:35:03.527Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8d/b2939e5254be932db1a34b2bd099070c509e8887e0c5a90c498a917e4032/torch-2.7.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:30b7688a87239a7de83f269333651d8e582afffce6f591fff08c046f7787296e", size = 68574294, upload-time = "2025-04-23T14:34:47.098Z" }, - { url = "https://files.pythonhosted.org/packages/14/24/720ea9a66c29151b315ea6ba6f404650834af57a26b2a04af23ec246b2d5/torch-2.7.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:868ccdc11798535b5727509480cd1d86d74220cfdc42842c4617338c1109a205", size = 99015553, upload-time = "2025-04-23T14:34:41.075Z" }, - { url = "https://files.pythonhosted.org/packages/4b/27/285a8cf12bd7cd71f9f211a968516b07dcffed3ef0be585c6e823675ab91/torch-2.7.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b52347118116cf3dff2ab5a3c3dd97c719eb924ac658ca2a7335652076df708", size = 865046389, upload-time = "2025-04-23T14:32:01.16Z" }, - { url = "https://files.pythonhosted.org/packages/74/c8/2ab2b6eadc45554af8768ae99668c5a8a8552e2012c7238ded7e9e4395e1/torch-2.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:434cf3b378340efc87c758f250e884f34460624c0523fe5c9b518d205c91dd1b", size = 212490304, upload-time = "2025-04-23T14:33:57.108Z" }, - { url = "https://files.pythonhosted.org/packages/28/fd/74ba6fde80e2b9eef4237fe668ffae302c76f0e4221759949a632ca13afa/torch-2.7.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:edad98dddd82220465b106506bb91ee5ce32bd075cddbcf2b443dfaa2cbd83bf", size = 68856166, upload-time = "2025-04-23T14:34:04.012Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b4/8df3f9fe6bdf59e56a0e538592c308d18638eb5f5dc4b08d02abb173c9f0/torch-2.7.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:2a885fc25afefb6e6eb18a7d1e8bfa01cc153e92271d980a49243b250d5ab6d9", size = 99091348, upload-time = "2025-04-23T14:33:48.975Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f5/0bd30e9da04c3036614aa1b935a9f7e505a9e4f1f731b15e165faf8a4c74/torch-2.7.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:176300ff5bc11a5f5b0784e40bde9e10a35c4ae9609beed96b4aeb46a27f5fae", size = 865104023, upload-time = "2025-04-23T14:30:40.537Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/2235d0c3012c596df1c8d39a3f4afc1ee1b6e318d469eda4c8bb68566448/torch-2.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d0ca446a93f474985d81dc866fcc8dccefb9460a29a456f79d99c29a78a66993", size = 212750916, upload-time = "2025-04-23T14:32:22.91Z" }, - { url = "https://files.pythonhosted.org/packages/90/48/7e6477cf40d48cc0a61fa0d41ee9582b9a316b12772fcac17bc1a40178e7/torch-2.7.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:27f5007bdf45f7bb7af7f11d1828d5c2487e030690afb3d89a651fd7036a390e", size = 68575074, upload-time = "2025-04-23T14:32:38.136Z" }, + { url = "https://files.pythonhosted.org/packages/5b/30/bfebdd8ec77db9a79775121789992d6b3b75ee5494971294d7b4b7c999bc/torch-2.10.0-2-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:2b980edd8d7c0a68c4e951ee1856334a43193f98730d97408fbd148c1a933313", size = 79411457, upload-time = "2026-02-10T21:44:59.189Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" }, + { url = "https://files.pythonhosted.org/packages/16/ee/efbd56687be60ef9af0c9c0ebe106964c07400eade5b0af8902a1d8cd58c/torch-2.10.0-3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a1ff626b884f8c4e897c4c33782bdacdff842a165fee79817b1dd549fdda1321", size = 915510070, upload-time = "2026-03-11T14:16:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/36/ab/7b562f1808d3f65414cd80a4f7d4bb00979d9355616c034c171249e1a303/torch-2.10.0-3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ac5bdcbb074384c66fa160c15b1ead77839e3fe7ed117d667249afce0acabfac", size = 915518691, upload-time = "2026-03-11T14:15:43.147Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1a/c61f36cfd446170ec27b3a4984f072fd06dab6b5d7ce27e11adb35d6c838/torch-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5276fa790a666ee8becaffff8acb711922252521b28fbce5db7db5cf9cb2026d", size = 145992962, upload-time = "2026-01-21T16:24:14.04Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/6662535354191e2d1555296045b63e4279e5a9dbad49acf55a5d38655a39/torch-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:aaf663927bcd490ae971469a624c322202a2a1e68936eb952535ca4cd3b90444", size = 915599237, upload-time = "2026-01-21T16:23:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/40/b8/66bbe96f0d79be2b5c697b2e0b187ed792a15c6c4b8904613454651db848/torch-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:a4be6a2a190b32ff5c8002a0977a25ea60e64f7ba46b1be37093c141d9c49aeb", size = 113720931, upload-time = "2026-01-21T16:24:23.743Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/d820f90e69cda6c8169b32a0c6a3ab7b17bf7990b8f2c680077c24a3c14c/torch-2.10.0-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:35e407430795c8d3edb07a1d711c41cc1f9eaddc8b2f1cc0a165a6767a8fb73d", size = 79411450, upload-time = "2026-01-21T16:25:30.692Z" }, + { url = "https://files.pythonhosted.org/packages/78/89/f5554b13ebd71e05c0b002f95148033e730d3f7067f67423026cc9c69410/torch-2.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3282d9febd1e4e476630a099692b44fdc214ee9bf8ee5377732d9d9dfe5712e4", size = 145992610, upload-time = "2026-01-21T16:25:26.327Z" }, + { url = "https://files.pythonhosted.org/packages/ae/30/a3a2120621bf9c17779b169fc17e3dc29b230c29d0f8222f499f5e159aa8/torch-2.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2f9edd8dbc99f62bc4dfb78af7bf89499bca3d753423ac1b4e06592e467b763", size = 915607863, upload-time = "2026-01-21T16:25:06.696Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3d/c87b33c5f260a2a8ad68da7147e105f05868c281c63d65ed85aa4da98c66/torch-2.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:29b7009dba4b7a1c960260fc8ac85022c784250af43af9fb0ebafc9883782ebd", size = 113723116, upload-time = "2026-01-21T16:25:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/61/d8/15b9d9d3a6b0c01b883787bd056acbe5cc321090d4b216d3ea89a8fcfdf3/torch-2.10.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:b7bd80f3477b830dd166c707c5b0b82a898e7b16f59a7d9d42778dd058272e8b", size = 79423461, upload-time = "2026-01-21T16:24:50.266Z" }, + { url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" }, + { url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5c/dee910b87c4d5c0fcb41b50839ae04df87c1cfc663cf1b5fca7ea565eeaa/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6d3707a61863d1c4d6ebba7be4ca320f42b869ee657e9b2c21c736bf17000294", size = 79498198, upload-time = "2026-01-21T16:24:34.704Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" }, + { url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" }, + { url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" }, + { url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" }, + { url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" }, + { url = "https://files.pythonhosted.org/packages/4f/93/716b5ac0155f1be70ed81bacc21269c3ece8dba0c249b9994094110bfc51/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:bf0d9ff448b0218e0433aeb198805192346c4fd659c852370d5cc245f602a06a", size = 79464992, upload-time = "2026-01-21T16:23:05.162Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/51e663ff190c9d16d4a8271203b71bc73a16aa7619b9f271a69b9d4a936b/torch-2.10.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:233aed0659a2503b831d8a67e9da66a62c996204c0bba4f4c442ccc0c68a3f60", size = 146018567, upload-time = "2026-01-21T16:22:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cd/4b95ef7f293b927c283db0b136c42be91c8ec6845c44de0238c8c23bdc80/torch-2.10.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:682497e16bdfa6efeec8cde66531bc8d1fbbbb4d8788ec6173c089ed3cc2bfe5", size = 915721646, upload-time = "2026-01-21T16:21:16.983Z" }, + { url = "https://files.pythonhosted.org/packages/56/97/078a007208f8056d88ae43198833469e61a0a355abc0b070edd2c085eb9a/torch-2.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:6528f13d2a8593a1a412ea07a99812495bec07e9224c28b2a25c0a30c7da025c", size = 113752373, upload-time = "2026-01-21T16:22:13.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/94/71994e7d0d5238393df9732fdab607e37e2b56d26a746cb59fdb415f8966/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f5ab4ba32383061be0fb74bda772d470140a12c1c3b58a0cfbf3dae94d164c28", size = 79850324, upload-time = "2026-01-21T16:22:09.494Z" }, + { url = "https://files.pythonhosted.org/packages/e2/65/1a05346b418ea8ccd10360eef4b3e0ce688fba544e76edec26913a8d0ee0/torch-2.10.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:716b01a176c2a5659c98f6b01bf868244abdd896526f1c692712ab36dbaf9b63", size = 146006482, upload-time = "2026-01-21T16:22:18.42Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b9/5f6f9d9e859fc3235f60578fa64f52c9c6e9b4327f0fe0defb6de5c0de31/torch-2.10.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d8f5912ba938233f86361e891789595ff35ca4b4e2ac8fe3670895e5976731d6", size = 915613050, upload-time = "2026-01-21T16:20:49.035Z" }, + { url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" }, ] [[package]] name = "torchaudio" -version = "2.7.0" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "torch" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/34/26/abc66c79092ad2eaaade546dc93e23d99ddf2513988261b943d274f5c01a/torchaudio-2.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c4a646c9e9347836c09e965eebc58dd028ec6ef34c46d3e7891bffd8dc645ea", size = 1842304, upload-time = "2025-04-23T14:47:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f7/17b8fbce19280424e612f254e1b89faf3c7640c022667a480307f2f3ca76/torchaudio-2.7.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:9e4073992f4f8e7113e4b505d95095361ceb2f21dd7b9310776160a24266f8f6", size = 1680682, upload-time = "2025-04-23T14:47:05.936Z" }, - { url = "https://files.pythonhosted.org/packages/f2/df/ee0097fc41f718152026541c4c6cdeea830bc09903cc36a53037942a6d3d/torchaudio-2.7.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:f7c99f7c062d6a56a3e281e3c2b779099e64cad1ce78891df61c4d19ce40742e", size = 3444849, upload-time = "2025-04-23T14:47:04.344Z" }, - { url = "https://files.pythonhosted.org/packages/65/a6/e1903c1b3787f0408d30624536d2ae30da9f749720f3cf272a4fb7abc490/torchaudio-2.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5443422640cbe532aaacd83ad2ee6911b0451f7f50e6b3755015e92df579d37", size = 2492239, upload-time = "2025-04-23T14:46:51.914Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d6/27deb8862ecc005c95a5c64bcc8cc27c74878eb8d4162ce4d39b35ea9e27/torchaudio-2.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:862d9c5cfe15688a7846962b5d3c9f959beffe82b1e5441935c7a37504c5c5e7", size = 1849075, upload-time = "2025-04-23T14:47:03.227Z" }, - { url = "https://files.pythonhosted.org/packages/04/95/29b4a4d87540779101cb60cb7f381fdb6bc6aea0af83f0f35aa8fc70cb0d/torchaudio-2.7.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:677bd32031310ee73a47d6eebc2e74e74c1cf467932945ee88082a3935b5c950", size = 1686165, upload-time = "2025-04-23T14:47:07.456Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/1873a49df9f1778c241543eaca14d613d657b9f9351c254952114251cb86/torchaudio-2.7.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c37b77dd528ad18a036466e856f53d8bd5912b757a775309354b4a977a069379", size = 3455781, upload-time = "2025-04-23T14:46:59.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1d/1fa4f69e4cd8c83831c3baad0ac9b56ece8ce0e75e5e5c0cdd3f591a458c/torchaudio-2.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:36b94819f5406b2599ac31542e2e7a7aaf4a5b5f466ce034f296b1ee1134c945", size = 2494793, upload-time = "2025-04-23T14:46:42.03Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b9/66dd7c4e16e8e6dcc52b4702ba7bbace589972b3597627d39d9dc3aa5fdd/torchaudio-2.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:65b4fc9b7f28367f918b02ae4db4290457bc4fdd160f22b7d684e93ab8dcb956", size = 1846733, upload-time = "2025-04-23T14:47:01.068Z" }, - { url = "https://files.pythonhosted.org/packages/47/48/850edf788c674494a7e148eee6f5563cae34c9a3e3e0962dcfce66c1dae7/torchaudio-2.7.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:33004ed47f18f00044c97ee8cd9e3f5e1c2e26ef23d4f72b5f1ae33e6182587b", size = 1686687, upload-time = "2025-04-23T14:47:02.136Z" }, - { url = "https://files.pythonhosted.org/packages/78/98/ec8c7aba67b44cdc59717d4b43d02023ded5da180d33c6469d20bf5bfa3c/torchaudio-2.7.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a6f03494075bcdd62e7fade7baf50a0ef107aa809d02b5e1786391adced451a3", size = 3454437, upload-time = "2025-04-23T14:46:57.557Z" }, - { url = "https://files.pythonhosted.org/packages/5e/23/b73163ac06e5a724375df61a5b6c853861a825fe98e64388f277514153dd/torchaudio-2.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:275931c8a38ff84b5692df990506b41f18d0a0706574d96bc8456ad9e5fa85c8", size = 2493451, upload-time = "2025-04-23T14:46:46.456Z" }, - { url = "https://files.pythonhosted.org/packages/c1/a5/bc4bb6b254d3d77e9fa4d219f29d3bff8db92acc9004c27e875f32d4724a/torchaudio-2.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:150fbde41da60296effed772b7a170f563cd44967555abb0603fc573f39ce245", size = 1847033, upload-time = "2025-04-23T14:46:58.774Z" }, - { url = "https://files.pythonhosted.org/packages/96/af/4c8d4e781ea5924590cccf8595a09081eb07a577c03fbf4bf04a2f5f7134/torchaudio-2.7.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9d921eeb036512a87efde007977b27bd326320cd7cd5f43195824173fe82e888", size = 1686308, upload-time = "2025-04-23T14:46:56.378Z" }, - { url = "https://files.pythonhosted.org/packages/12/02/ad1083f6ce534989c704c3efcd615bdd160934229882aa0a3ea95cd24a9a/torchaudio-2.7.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:30675a5f99551e036974a7476729eb5d31f453cf792ae6e0a0d449960f84f464", size = 3455266, upload-time = "2025-04-23T14:46:50.327Z" }, - { url = "https://files.pythonhosted.org/packages/88/49/923ebb2603156dd5c5ae6d845bf51a078e05f27432cd26f13ecdcc8713cd/torchaudio-2.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce8cfc07a4e59c835404583e7d3e171208b332b61bb92643f8723f6f192da8bf", size = 2493639, upload-time = "2025-04-23T14:46:40.909Z" }, - { url = "https://files.pythonhosted.org/packages/bf/85/dd4cd1202483e85c208e1ca3d31cc42c2972f1d955d11b742fa098a38a1b/torchaudio-2.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9e08138cac75cde2064c8b5bbd12f27bdeb3d36f4b8c2285fc9c42eaa97c0676", size = 1929989, upload-time = "2025-04-23T14:46:54.144Z" }, - { url = "https://files.pythonhosted.org/packages/ef/3a/8a1045f2b00c6300827c1e6a3e661e9d219b5406ef103dc2824604548b8c/torchaudio-2.7.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:1d928aeff495a0807b4da3b0dd46e15eae8070da5e7ed6d35c1dcfd9fdfe2b74", size = 1700439, upload-time = "2025-04-23T14:46:55.249Z" }, - { url = "https://files.pythonhosted.org/packages/72/53/21d589a5a41702b5d37bae224286986cb707500d5ecdbfdcfdbac9381a08/torchaudio-2.7.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ee4add33f24e9cb959bd9de89f36de5ebf844eda040d1d0b38f08617d67dedc3", size = 3466356, upload-time = "2025-04-23T14:46:49.131Z" }, - { url = "https://files.pythonhosted.org/packages/00/0b/5ef81aaacce5e9c316659ddc61a2b1e4f984a504d4a06fe61bab04cc75f1/torchaudio-2.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:725dbbcc9e744ca62de8856262c6f472ca26b1cd5db062b062a2d6b66a336cc0", size = 2544970, upload-time = "2025-04-23T14:46:44.837Z" }, + { url = "https://files.pythonhosted.org/packages/04/59/88ab8ebff9d91f1f1365088b30f1b9ccce07c5eeac666038a5dee5e2f9b1/torchaudio-2.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cde383582a6240c1315443df5c5638863e96b03acf1cb44a298aff07a72d373", size = 734944, upload-time = "2026-01-21T16:28:49.535Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d6/41f25f9ae9b37c191bed4cd474e403626685d2be8f7d20d011e6601fede1/torchaudio-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:cfb2ad4b7847d81931989127d803487263c8284f21156e9000daec1ac16c0831", size = 390449, upload-time = "2026-01-21T16:28:48.585Z" }, + { url = "https://files.pythonhosted.org/packages/43/ac/a14425fddd1cf56bb052a3bfd38880258008f8c3cd17f37bba55b3a88ce7/torchaudio-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:316cdb15fb37290fca89894b095d97b4dc14a90c4c61148ae5c96bb334d962cd", size = 1891070, upload-time = "2026-01-21T16:28:47.323Z" }, + { url = "https://files.pythonhosted.org/packages/6e/03/d1898db1bf7ecd47ca9b4e1b70927597d236cf721e3736d953d555901832/torchaudio-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:013079d1ba2a652184703e671b8339cbc7991f17e4ed927071fe7635f908a4a1", size = 474045, upload-time = "2026-01-21T16:28:46.191Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e7/401fe1d024bf9352371d854be6f339ad9928669e6bc8a5ba08e9dbce81cf/torchaudio-2.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcab0e39eb18da84cba1a0c87f600abb6ce97c882200cb46e841caea106f037f", size = 736373, upload-time = "2026-01-21T16:28:41.589Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b7/c66dc34a27441d78997e20d0ffe2f5ad73db9f7b1267511be255bb94ac9b/torchaudio-2.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:87c841a21e82703ebd4a29170c4e60c25a2b47312dc212930087ad58965ac0c8", size = 391843, upload-time = "2026-01-21T16:28:43.093Z" }, + { url = "https://files.pythonhosted.org/packages/13/ae/a2a34a64947c4fa4a61b4c86d8f36fbcb4ebfec30fdde140267db260f96c/torchaudio-2.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b2c77fb9114dd463dc805560bf55a1ac2a52e219794cc32b7b32cf2aeffd2826", size = 1894140, upload-time = "2026-01-21T16:28:35.892Z" }, + { url = "https://files.pythonhosted.org/packages/69/26/cd2aec609b4f8918e4e85e5c6a3f569bc7b5f72a7ecba3f784077102749c/torchaudio-2.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:4c6e9609046143b30a30183893d23ff1ce5de603dbe914b3cce5cc29f5aa5a9c", size = 474792, upload-time = "2026-01-21T16:28:45.254Z" }, + { url = "https://files.pythonhosted.org/packages/0f/36/28a6f3e857616cf7576bdbf8170e483b8c5d0a1f8d349ecb2b75921236aa/torchaudio-2.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d0fbdbfd2f621c51d28571050d6d0c7287791034e5c7303b31480af1258f33f", size = 737144, upload-time = "2026-01-21T16:28:44.189Z" }, + { url = "https://files.pythonhosted.org/packages/ea/3f/df620439a76ece170472d41438d11a1545d5db5dc9f1eaeab8c6e055a328/torchaudio-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:42b148a0921a3721abd1f6ae098b1ec9f89703e555c4f7a0d44da87b8decbcb9", size = 391973, upload-time = "2026-01-21T16:28:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/98/25/e55a30d7138f8fe56ed006df25b0a3c27681f0ec7bc9989e1778e6d559c3/torchaudio-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0e77b2956448d63790a99beed0b74ac8b8cd3a94dcdd9ad01974411078f46278", size = 1895234, upload-time = "2026-01-21T16:28:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/be/a0/da53c7d20fac15f66f8838653b91162de1bf21fb40fee88cf839e4ef5174/torchaudio-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f76a01ecebf1869e1f2c50a261f1cf07e5fccb24402b4e9bbb82d6725b9c7dd", size = 475470, upload-time = "2026-01-21T16:28:40.615Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/341e7bd588355f82c5180103cb2f8070a72ab1be920ab27553a1135d4aa6/torchaudio-2.10.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8fd38d28ee150c584d3ee3b05f39e021f0ad8a8ec8fec1f26dfe150c9db9b2f5", size = 737164, upload-time = "2026-01-21T16:28:38.354Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/831c2595c81b17141180ca11ab3c0836cc544ef13e15aa0e7b2cb619e582/torchaudio-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5bc39ff3ea341097ce1ab023dd88c9dd8ca5f96ebf48821e7d23766137bb55d7", size = 392757, upload-time = "2026-01-21T16:28:33.631Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d8/405c80c57dc68ca5855bddfaae57c3d84ea7397bf1eb2aa5d59c9fa1d3a9/torchaudio-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3057c4286db5673d266124a2a10ca54e19f516772e9057f44573a7da5b85e328", size = 1897099, upload-time = "2026-01-21T16:28:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/73/cf/0e48d67788c935e3b3d00e6f55a930a54a67f432e04c33ef80a38cb764fd/torchaudio-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:99e74d1901742bc10961d807fe75c0dd9496f4a4a4ff4bb317c5de4a0b6f24e6", size = 475476, upload-time = "2026-01-21T16:28:28.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/29/30bcce0f17a8279b051b09250993691a828f89a03278306b23571c18df04/torchaudio-2.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6cfe98ef0ea9bee6d6297493ce67ce0c54a38d80caf6535a3ae48900fd5f3769", size = 742449, upload-time = "2026-01-21T16:28:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/8c/653e7f67855424bf3b7cbb48335f8316f7fb02bb01a6cab38f6bf9555676/torchaudio-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:b41b254d958632dc00dc7768431cadda516c91641d798775cbb19bcd4f0d2be4", size = 393430, upload-time = "2026-01-21T16:28:34.855Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1f/f91fcb9dd47a19b720fb48042a2f6f023651948e73726e98fff60d5ed5c7/torchaudio-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:da1081d1018a1e95f5a13947402aeb037cf5ac8861219a6164df004898a96bb1", size = 1897271, upload-time = "2026-01-21T16:28:23.519Z" }, + { url = "https://files.pythonhosted.org/packages/57/27/270c26890f43838e8faa5d3e52f079bd9d9d09f9a535a11cf6b94e20ed21/torchaudio-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f1afa53146a5655258d3a86e689c6879dfe78581d9bee9ef611ace98722f86bb", size = 478966, upload-time = "2026-01-21T16:28:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5c/0e54b162bd0d1ec2f87b545553af839f906b940888d0122cdef04b965385/torchaudio-2.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f2897fbf776d55afcb5f6d9b7bdfaea850ca7a129c8f5e4b3a4b025c431130d", size = 739544, upload-time = "2026-01-21T16:28:26.947Z" }, + { url = "https://files.pythonhosted.org/packages/57/a1/ef5571406858f4ea89c18d6ad844d21cb9858708149e6bbd9a789ee30ea5/torchaudio-2.10.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:b2d5e11a2bec08f02a4f5fb7d1902ff82d48c533a27ceedc21e6ade650cf65b3", size = 393061, upload-time = "2026-01-21T16:28:25.802Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0f/a0cf0ebc6f71b1868ea056dd4cd4f1a2244b8da8bc38372a1adc984a7c1f/torchaudio-2.10.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:77f6cf11a3b61af1b0967cd642368ecd30a86d70f622b22410ae6cb42d980b72", size = 1897137, upload-time = "2026-01-21T16:28:15.366Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/98e6710a4601e190bc923c3683629c29d41fb18a818a9328515541f023ed/torchaudio-2.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:4711c2a86a005685ca3b5da135b2f370d81ac354e3dcb142ef45fe2c78b9c9c4", size = 475154, upload-time = "2026-01-21T16:28:22.438Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9b/cd02f8add38bd98761548b0821a5e54c564117a9bbeafaf95f665ab0fd72/torchaudio-2.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13bdc1bde0c88e999699d1503304a56fc9dea6401b76bc08a5f268368129d46c", size = 742453, upload-time = "2026-01-21T16:28:20.989Z" }, + { url = "https://files.pythonhosted.org/packages/53/8a/946aa07393845b918d318b5e34b3bd0359fd27fc9fac10a85fae2bb86382/torchaudio-2.10.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:ed912de8ec1b400e17a5172badcfcddc601a9cd4e02d200f3a9504fc8e54961c", size = 393434, upload-time = "2026-01-21T16:28:18.668Z" }, + { url = "https://files.pythonhosted.org/packages/e1/68/e37e8fbbae986afa80f8851e08fc017eb8ae5f7b398ee28ed92303da163e/torchaudio-2.10.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:f7aa33a8198e87949896e16ea245ea731906445becdf10130e8823c68494a94a", size = 1897289, upload-time = "2026-01-21T16:28:17.059Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/0e1f464463b85bc677036faffdfd23493aa17e8c3fc3a649abca8c019701/torchaudio-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e49f6a18a8552620c4394f8529b7551eda9312d46dfdd3500bd2be459c86aea4", size = 478968, upload-time = "2026-01-21T16:28:19.542Z" }, ] [[package]] name = "torchvision" -version = "0.22.0" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pillow" }, { name = "torch" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/03/a514766f068b088180f273913e539d08e830be3ae46ef8577ea62584a27c/torchvision-0.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:72256f1d7ff510b16c9fb4dd488584d0693f40c792f286a9620674438a81ccca", size = 1947829, upload-time = "2025-04-23T14:42:04.652Z" }, - { url = "https://files.pythonhosted.org/packages/a3/e5/ec4b52041cd8c440521b75864376605756bd2d112d6351ea6a1ab25008c1/torchvision-0.22.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:810ea4af3bc63cf39e834f91f4218ff5999271caaffe2456247df905002bd6c0", size = 2512604, upload-time = "2025-04-23T14:41:56.515Z" }, - { url = "https://files.pythonhosted.org/packages/e7/9e/e898a377e674da47e95227f3d7be2c49550ce381eebd8c7831c1f8bb7d39/torchvision-0.22.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6fbca169c690fa2b9b8c39c0ad76d5b8992296d0d03df01e11df97ce12b4e0ac", size = 7446399, upload-time = "2025-04-23T14:41:49.793Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ec/2cdb90c6d9d61410b3df9ca67c210b60bf9b07aac31f800380b20b90386c/torchvision-0.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:8c869df2e8e00f7b1d80a34439e6d4609b50fe3141032f50b38341ec2b59404e", size = 1716700, upload-time = "2025-04-23T14:42:03.562Z" }, - { url = "https://files.pythonhosted.org/packages/b1/43/28bc858b022f6337326d75f4027d2073aad5432328f01ee1236d847f1b82/torchvision-0.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:191ea28321fc262d8aa1a7fe79c41ff2848864bf382f9f6ea45c41dde8313792", size = 1947828, upload-time = "2025-04-23T14:42:00.439Z" }, - { url = "https://files.pythonhosted.org/packages/7e/71/ce9a303b94e64fe25d534593522ffc76848c4e64c11e4cbe9f6b8d537210/torchvision-0.22.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:6c5620e10ffe388eb6f4744962106ed7cf1508d26e6fdfa0c10522d3249aea24", size = 2514016, upload-time = "2025-04-23T14:41:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/09/42/6908bff012a1dcc4fc515e52339652d7f488e208986542765c02ea775c2f/torchvision-0.22.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ce292701c77c64dd3935e3e31c722c3b8b176a75f76dc09b804342efc1db5494", size = 7447546, upload-time = "2025-04-23T14:41:47.297Z" }, - { url = "https://files.pythonhosted.org/packages/e4/cf/8f9305cc0ea26badbbb3558ecae54c04a245429f03168f7fad502f8a5b25/torchvision-0.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:e4017b5685dbab4250df58084f07d95e677b2f3ed6c2e507a1afb8eb23b580ca", size = 1716472, upload-time = "2025-04-23T14:42:01.999Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ea/887d1d61cf4431a46280972de665f350af1898ce5006cd046326e5d0a2f2/torchvision-0.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31c3165418fe21c3d81fe3459e51077c2f948801b8933ed18169f54652796a0f", size = 1947826, upload-time = "2025-04-23T14:41:59.188Z" }, - { url = "https://files.pythonhosted.org/packages/72/ef/21f8b6122e13ae045b8e49658029c695fd774cd21083b3fa5c3f9c5d3e35/torchvision-0.22.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8f116bc82e0c076e70ba7776e611ed392b9666aa443662e687808b08993d26af", size = 2514571, upload-time = "2025-04-23T14:41:53.458Z" }, - { url = "https://files.pythonhosted.org/packages/7c/48/5f7617f6c60d135f86277c53f9d5682dfa4e66f4697f505f1530e8b69fb1/torchvision-0.22.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ce4dc334ebd508de2c534817c9388e928bc2500cf981906ae8d6e2ca3bf4727a", size = 7446522, upload-time = "2025-04-23T14:41:34.9Z" }, - { url = "https://files.pythonhosted.org/packages/99/94/a015e93955f5d3a68689cc7c385a3cfcd2d62b84655d18b61f32fb04eb67/torchvision-0.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:24b8c9255c209ca419cc7174906da2791c8b557b75c23496663ec7d73b55bebf", size = 1716664, upload-time = "2025-04-23T14:41:58.019Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2a/9b34685599dcb341d12fc2730055155623db7a619d2415a8d31f17050952/torchvision-0.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ece17995857dd328485c9c027c0b20ffc52db232e30c84ff6c95ab77201112c5", size = 1947823, upload-time = "2025-04-23T14:41:39.956Z" }, - { url = "https://files.pythonhosted.org/packages/77/77/88f64879483d66daf84f1d1c4d5c31ebb08e640411139042a258d5f7dbfe/torchvision-0.22.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:471c6dd75bb984c6ebe4f60322894a290bf3d4b195e769d80754f3689cd7f238", size = 2471592, upload-time = "2025-04-23T14:41:54.991Z" }, - { url = "https://files.pythonhosted.org/packages/f7/82/2f813eaae7c1fae1f9d9e7829578f5a91f39ef48d6c1c588a8900533dd3d/torchvision-0.22.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2b839ac0610a38f56bef115ee5b9eaca5f9c2da3c3569a68cc62dbcc179c157f", size = 7446333, upload-time = "2025-04-23T14:41:36.603Z" }, - { url = "https://files.pythonhosted.org/packages/58/19/ca7a4f8907a56351dfe6ae0a708f4e6b3569b5c61d282e3e7f61cf42a4ce/torchvision-0.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ada1c08b2f761443cd65b7c7b4aec9e2fc28f75b0d4e1b1ebc9d3953ebccc4d", size = 1716693, upload-time = "2025-04-23T14:41:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a7/f43e9c8d13118b4ffbaebea664c9338ab20fa115a908125afd2238ff16e7/torchvision-0.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdc96daa4658b47ce9384154c86ed1e70cba9d972a19f5de6e33f8f94a626790", size = 2137621, upload-time = "2025-04-23T14:41:51.427Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9a/2b59f5758ba7e3f23bc84e16947493bbce97392ec6d18efba7bdf0a3b10e/torchvision-0.22.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:753d3c84eeadd5979a33b3b73a25ecd0aa4af44d6b45ed2c70d44f5e0ac68312", size = 2476555, upload-time = "2025-04-23T14:41:38.357Z" }, - { url = "https://files.pythonhosted.org/packages/7d/40/a7bc2ab9b1e56d10a7fd9ae83191bb425fa308caa23d148f1c568006e02c/torchvision-0.22.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:b30e3ed29e4a61f7499bca50f57d8ebd23dfc52b14608efa17a534a55ee59a03", size = 7617924, upload-time = "2025-04-23T14:41:42.709Z" }, - { url = "https://files.pythonhosted.org/packages/c1/7b/30d423bdb2546250d719d7821aaf9058cc093d165565b245b159c788a9dd/torchvision-0.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e5d680162694fac4c8a374954e261ddfb4eb0ce103287b0f693e4e9c579ef957", size = 1638621, upload-time = "2025-04-23T14:41:46.06Z" }, + { url = "https://files.pythonhosted.org/packages/50/ae/cbf727421eb73f1cf907fbe5788326a08f111b3f6b6ddca15426b53fec9a/torchvision-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a95c47abb817d4e90ea1a8e57bd0d728e3e6b533b3495ae77d84d883c4d11f56", size = 1874919, upload-time = "2026-01-21T16:27:47.617Z" }, + { url = "https://files.pythonhosted.org/packages/64/68/dc7a224f606d53ea09f9a85196a3921ec3a801b0b1d17e84c73392f0c029/torchvision-0.25.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:acc339aba4a858192998c2b91f635827e40d9c469d9cf1455bafdda6e4c28ea4", size = 2343220, upload-time = "2026-01-21T16:27:44.26Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fa/8cce5ca7ffd4da95193232493703d20aa06303f37b119fd23a65df4f239a/torchvision-0.25.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0d9a3f925a081dd2ebb0b791249b687c2ef2c2717d027946654607494b9b64b6", size = 8068106, upload-time = "2026-01-21T16:27:37.805Z" }, + { url = "https://files.pythonhosted.org/packages/8b/b9/a53bcf8f78f2cd89215e9ded70041765d50ef13bf301f9884ec6041a9421/torchvision-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:b57430fbe9e9b697418a395041bb615124d9c007710a2712fda6e35fb310f264", size = 3697295, upload-time = "2026-01-21T16:27:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/3e/be/c704bceaf11c4f6b19d64337a34a877fcdfe3bd68160a8c9ae9bea4a35a3/torchvision-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db74a551946b75d19f9996c419a799ffdf6a223ecf17c656f90da011f1d75b20", size = 1874923, upload-time = "2026-01-21T16:27:46.574Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/f143cd71232430de1f547ceab840f68c55e127d72558b1061a71d0b193cd/torchvision-0.25.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f49964f96644dbac2506dffe1a0a7ec0f2bf8cf7a588c3319fed26e6329ffdf3", size = 2344808, upload-time = "2026-01-21T16:27:43.191Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/ad5d6165797de234c9658752acb4fce65b78a6a18d82efdf8367c940d8da/torchvision-0.25.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:153c0d2cbc34b7cf2da19d73450f24ba36d2b75ec9211b9962b5022fb9e4ecee", size = 8070752, upload-time = "2026-01-21T16:27:33.748Z" }, + { url = "https://files.pythonhosted.org/packages/23/19/55b28aecdc7f38df57b8eb55eb0b14a62b470ed8efeb22cdc74224df1d6a/torchvision-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:ea580ffd6094cc01914ad32f8c8118174f18974629af905cea08cb6d5d48c7b7", size = 4038722, upload-time = "2026-01-21T16:27:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/56/3a/6ea0d73f49a9bef38a1b3a92e8dd455cea58470985d25635beab93841748/torchvision-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2abe430c90b1d5e552680037d68da4eb80a5852ebb1c811b2b89d299b10573b", size = 1874920, upload-time = "2026-01-21T16:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/51/f8/c0e1ef27c66e15406fece94930e7d6feee4cb6374bbc02d945a630d6426e/torchvision-0.25.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b75deafa2dfea3e2c2a525559b04783515e3463f6e830cb71de0fb7ea36fe233", size = 2344556, upload-time = "2026-01-21T16:27:40.125Z" }, + { url = "https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f25aa9e380865b11ea6e9d99d84df86b9cc959f1a007cd966fc6f1ab2ed0e248", size = 8072351, upload-time = "2026-01-21T16:27:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/16/8f650c2e288977cf0f8f85184b90ee56ed170a4919347fc74ee99286ed6f/torchvision-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:f9c55ae8d673ab493325d1267cbd285bb94d56f99626c00ac4644de32a59ede3", size = 4303059, upload-time = "2026-01-21T16:27:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5b/1562a04a6a5a4cf8cf40016a0cdeda91ede75d6962cff7f809a85ae966a5/torchvision-0.25.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:24e11199e4d84ba9c5ee7825ebdf1cd37ce8deec225117f10243cae984ced3ec", size = 1874918, upload-time = "2026-01-21T16:27:39.02Z" }, + { url = "https://files.pythonhosted.org/packages/36/b1/3d6c42f62c272ce34fcce609bb8939bdf873dab5f1b798fd4e880255f129/torchvision-0.25.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f271136d2d2c0b7a24c5671795c6e4fd8da4e0ea98aeb1041f62bc04c4370ef", size = 2309106, upload-time = "2026-01-21T16:27:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/c7/60/59bb9c8b67cce356daeed4cb96a717caa4f69c9822f72e223a0eae7a9bd9/torchvision-0.25.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:855c0dc6d37f462482da7531c6788518baedca1e0847f3df42a911713acdfe52", size = 8071522, upload-time = "2026-01-21T16:27:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/9a9b1de0720f884ea50dbf9acb22cbe5312e51d7b8c4ac6ba9b51efd9bba/torchvision-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:cef0196be31be421f6f462d1e9da1101be7332d91984caa6f8022e6c78a5877f", size = 4321911, upload-time = "2026-01-21T16:27:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/52/99/dca81ed21ebaeff2b67cc9f815a20fdaa418b69f5f9ea4c6ed71721470db/torchvision-0.25.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a8f8061284395ce31bcd460f2169013382ccf411148ceb2ee38e718e9860f5a7", size = 1896209, upload-time = "2026-01-21T16:27:32.159Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/2103149761fdb4eaed58a53e8437b2d716d48f05174fab1d9fcf1e2a2244/torchvision-0.25.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:146d02c9876858420adf41f3189fe90e3d6a409cbfa65454c09f25fb33bf7266", size = 2310735, upload-time = "2026-01-21T16:27:22.327Z" }, + { url = "https://files.pythonhosted.org/packages/76/ad/f4c985ad52ddd3b22711c588501be1b330adaeaf6850317f66751711b78c/torchvision-0.25.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c4d395cb2c4a2712f6eb93a34476cdf7aae74bb6ea2ea1917f858e96344b00aa", size = 8089557, upload-time = "2026-01-21T16:27:27.666Z" }, + { url = "https://files.pythonhosted.org/packages/63/cc/0ea68b5802e5e3c31f44b307e74947bad5a38cc655231d845534ed50ddb8/torchvision-0.25.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5e6b449e9fa7d642142c0e27c41e5a43b508d57ed8e79b7c0a0c28652da8678c", size = 4344260, upload-time = "2026-01-21T16:27:17.018Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/fa839532660e2602b7e704d65010787c5bb296258b44fa8b9c1cd6175e7d/torchvision-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:620a236288d594dcec7634c754484542dc0a5c1b0e0b83a34bda5e91e9b7c3a1", size = 1896193, upload-time = "2026-01-21T16:27:24.785Z" }, + { url = "https://files.pythonhosted.org/packages/80/ed/d51889da7ceaf5ff7a0574fb28f9b6b223df19667265395891f81b364ab3/torchvision-0.25.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b5e7f50002a8145a98c5694a018e738c50e2972608310c7e88e1bd4c058f6ce", size = 2309331, upload-time = "2026-01-21T16:27:19.97Z" }, + { url = "https://files.pythonhosted.org/packages/90/a5/f93fcffaddd8f12f9e812256830ec9c9ca65abbf1bc369379f9c364d1ff4/torchvision-0.25.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:632db02300e83793812eee4f61ae6a2686dab10b4cfd628b620dc47747aa9d03", size = 8088713, upload-time = "2026-01-21T16:27:15.281Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/d0096eed5690d962853213f2ee00d91478dfcb586b62dbbb449fb8abc3a6/torchvision-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:d1abd5ed030c708f5dbf4812ad5f6fbe9384b63c40d6bd79f8df41a4a759a917", size = 4325058, upload-time = "2026-01-21T16:27:26.165Z" }, + { url = "https://files.pythonhosted.org/packages/97/36/96374a4c7ab50dea9787ce987815614ccfe988a42e10ac1a2e3e5b60319a/torchvision-0.25.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad9a8a5877782944d99186e4502a614770fe906626d76e9cd32446a0ac3075f2", size = 1896207, upload-time = "2026-01-21T16:27:23.383Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e2/7abb10a867db79b226b41da419b63b69c0bd5b82438c4a4ed50e084c552f/torchvision-0.25.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:40a122c3cf4d14b651f095e0f672b688dde78632783fc5cd3d4d5e4f6a828563", size = 2310741, upload-time = "2026-01-21T16:27:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/e6/0927784e6ffc340b6676befde1c60260bd51641c9c574b9298d791a9cda4/torchvision-0.25.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:846890161b825b38aa85fc37fb3ba5eea74e7091ff28bab378287111483b6443", size = 8089772, upload-time = "2026-01-21T16:27:14.048Z" }, + { url = "https://files.pythonhosted.org/packages/b6/37/e7ca4ec820d434c0f23f824eb29f0676a0c3e7a118f1514f5b949c3356da/torchvision-0.25.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f07f01d27375ad89d72aa2b3f2180f07da95dd9d2e4c758e015c0acb2da72977", size = 4425879, upload-time = "2026-01-21T16:27:12.579Z" }, ] [[package]] @@ -6536,24 +7546,25 @@ wheels = [ [[package]] name = "tqdm" -version = "4.67.1" +version = "4.67.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] name = "transformers" -version = "4.57.3" +version = "4.57.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "huggingface-hub" }, - { name = "numpy" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, { name = "pyyaml" }, { name = "regex" }, @@ -6562,48 +7573,47 @@ dependencies = [ { name = "tokenizers" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/70/d42a739e8dfde3d92bb2fff5819cbf331fe9657323221e79415cd5eb65ee/transformers-4.57.3.tar.gz", hash = "sha256:df4945029aaddd7c09eec5cad851f30662f8bd1746721b34cc031d70c65afebc", size = 10139680, upload-time = "2025-11-25T15:51:30.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/35/67252acc1b929dc88b6602e8c4a982e64f31e733b804c14bc24b47da35e6/transformers-4.57.6.tar.gz", hash = "sha256:55e44126ece9dc0a291521b7e5492b572e6ef2766338a610b9ab5afbb70689d3", size = 10134912, upload-time = "2026-01-16T10:38:39.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/6b/2f416568b3c4c91c96e5a365d164f8a4a4a88030aa8ab4644181fdadce97/transformers-4.57.3-py3-none-any.whl", hash = "sha256:c77d353a4851b1880191603d36acb313411d3577f6e2897814f333841f7003f4", size = 11993463, upload-time = "2025-11-25T15:51:26.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl", hash = "sha256:4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550", size = 11993498, upload-time = "2026-01-16T10:38:31.289Z" }, ] [[package]] name = "triton" -version = "3.3.0" +version = "3.6.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "setuptools" }, -] wheels = [ - { url = "https://files.pythonhosted.org/packages/76/04/d54d3a6d077c646624dc9461b0059e23fd5d30e0dbe67471e3654aec81f9/triton-3.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fad99beafc860501d7fcc1fb7045d9496cbe2c882b1674640304949165a916e7", size = 156441993, upload-time = "2025-04-09T20:27:25.107Z" }, - { url = "https://files.pythonhosted.org/packages/3c/c5/4874a81131cc9e934d88377fbc9d24319ae1fb540f3333b4e9c696ebc607/triton-3.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3161a2bf073d6b22c4e2f33f951f3e5e3001462b2570e6df9cd57565bdec2984", size = 156528461, upload-time = "2025-04-09T20:27:32.599Z" }, - { url = "https://files.pythonhosted.org/packages/11/53/ce18470914ab6cfbec9384ee565d23c4d1c55f0548160b1c7b33000b11fd/triton-3.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b68c778f6c4218403a6bd01be7484f6dc9e20fe2083d22dd8aef33e3b87a10a3", size = 156504509, upload-time = "2025-04-09T20:27:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/7d/74/4bf2702b65e93accaa20397b74da46fb7a0356452c1bb94dbabaf0582930/triton-3.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47bc87ad66fa4ef17968299acacecaab71ce40a238890acc6ad197c3abe2b8f1", size = 156516468, upload-time = "2025-04-09T20:27:48.196Z" }, - { url = "https://files.pythonhosted.org/packages/0a/93/f28a696fa750b9b608baa236f8225dd3290e5aff27433b06143adc025961/triton-3.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce4700fc14032af1e049005ae94ba908e71cd6c2df682239aed08e49bc71b742", size = 156580729, upload-time = "2025-04-09T20:27:55.424Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f7/f1c9d3424ab199ac53c2da567b859bcddbb9c9e7154805119f8bd95ec36f/triton-3.6.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6550fae429e0667e397e5de64b332d1e5695b73650ee75a6146e2e902770bea", size = 188105201, upload-time = "2026-01-20T16:00:29.272Z" }, + { url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, + { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" }, + { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, ] [[package]] name = "typer" -version = "0.19.2" +version = "0.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "click" }, { name = "rich" }, { name = "shellingham" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] [[package]] name = "types-protobuf" -version = "6.32.1.20250918" +version = "6.32.1.20260221" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/5a/bd06c2dbb77ebd4ea764473c9c4c014c7ba94432192cb965a274f8544b9d/types_protobuf-6.32.1.20250918.tar.gz", hash = "sha256:44ce0ae98475909ca72379946ab61a4435eec2a41090821e713c17e8faf5b88f", size = 63780, upload-time = "2025-09-18T02:50:39.391Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/5a/8d93d4f4af5dc3dd62aa4f020deae746b34b1d94fb5bee1f776c6b7e9d6c/types_protobuf-6.32.1.20250918-py3-none-any.whl", hash = "sha256:22ba6133d142d11cc34d3788ad6dead2732368ebb0406eaa7790ea6ae46c8d0b", size = 77885, upload-time = "2025-09-18T02:50:38.028Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" }, ] [[package]] @@ -6641,106 +7651,64 @@ wheels = [ ] [[package]] -name = "ujson" -version = "5.11.0" +name = "uritemplate" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583, upload-time = "2025-08-20T11:57:02.452Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/8bf7a4fabfd01c7eed92d9b290930ce6d14910dec708e73538baa38885d1/ujson-5.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:446e8c11c06048611c9d29ef1237065de0af07cabdd97e6b5b527b957692ec25", size = 55248, upload-time = "2025-08-20T11:55:02.368Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2e/eeab0b8b641817031ede4f790db4c4942df44a12f44d72b3954f39c6a115/ujson-5.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16ccb973b7ada0455201808ff11d48fe9c3f034a6ab5bd93b944443c88299f89", size = 53157, upload-time = "2025-08-20T11:55:04.012Z" }, - { url = "https://files.pythonhosted.org/packages/21/1b/a4e7a41870797633423ea79618526747353fd7be9191f3acfbdee0bf264b/ujson-5.11.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3134b783ab314d2298d58cda7e47e7a0f7f71fc6ade6ac86d5dbeaf4b9770fa6", size = 57657, upload-time = "2025-08-20T11:55:05.169Z" }, - { url = "https://files.pythonhosted.org/packages/94/ae/4e0d91b8f6db7c9b76423b3649612189506d5a06ddd3b6334b6d37f77a01/ujson-5.11.0-cp310-cp310-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:185f93ebccffebc8baf8302c869fac70dd5dd78694f3b875d03a31b03b062cdb", size = 59780, upload-time = "2025-08-20T11:55:06.325Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cc/46b124c2697ca2da7c65c4931ed3cb670646978157aa57a7a60f741c530f/ujson-5.11.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d06e87eded62ff0e5f5178c916337d2262fdbc03b31688142a3433eabb6511db", size = 57307, upload-time = "2025-08-20T11:55:07.493Z" }, - { url = "https://files.pythonhosted.org/packages/39/eb/20dd1282bc85dede2f1c62c45b4040bc4c389c80a05983515ab99771bca7/ujson-5.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:181fb5b15703a8b9370b25345d2a1fd1359f0f18776b3643d24e13ed9c036d4c", size = 1036369, upload-time = "2025-08-20T11:55:09.192Z" }, - { url = "https://files.pythonhosted.org/packages/64/a2/80072439065d493e3a4b1fbeec991724419a1b4c232e2d1147d257cac193/ujson-5.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a4df61a6df0a4a8eb5b9b1ffd673429811f50b235539dac586bb7e9e91994138", size = 1195738, upload-time = "2025-08-20T11:55:11.402Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7e/d77f9e9c039d58299c350c978e086a804d1fceae4fd4a1cc6e8d0133f838/ujson-5.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6eff24e1abd79e0ec6d7eae651dd675ddbc41f9e43e29ef81e16b421da896915", size = 1088718, upload-time = "2025-08-20T11:55:13.297Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f1/697559d45acc849cada6b3571d53522951b1a64027400507aabc6a710178/ujson-5.11.0-cp310-cp310-win32.whl", hash = "sha256:30f607c70091483550fbd669a0b37471e5165b317d6c16e75dba2aa967608723", size = 39653, upload-time = "2025-08-20T11:55:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/86/a2/70b73a0f55abe0e6b8046d365d74230c20c5691373e6902a599b2dc79ba1/ujson-5.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:3d2720e9785f84312b8e2cb0c2b87f1a0b1c53aaab3b2af3ab817d54409012e0", size = 43720, upload-time = "2025-08-20T11:55:15.897Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5f/b19104afa455630b43efcad3a24495b9c635d92aa8f2da4f30e375deb1a2/ujson-5.11.0-cp310-cp310-win_arm64.whl", hash = "sha256:85e6796631165f719084a9af00c79195d3ebf108151452fefdcb1c8bb50f0105", size = 38410, upload-time = "2025-08-20T11:55:17.556Z" }, - { url = "https://files.pythonhosted.org/packages/da/ea/80346b826349d60ca4d612a47cdf3533694e49b45e9d1c07071bb867a184/ujson-5.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d7c46cb0fe5e7056b9acb748a4c35aa1b428025853032540bb7e41f46767321f", size = 55248, upload-time = "2025-08-20T11:55:19.033Z" }, - { url = "https://files.pythonhosted.org/packages/57/df/b53e747562c89515e18156513cc7c8ced2e5e3fd6c654acaa8752ffd7cd9/ujson-5.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8951bb7a505ab2a700e26f691bdfacf395bc7e3111e3416d325b513eea03a58", size = 53156, upload-time = "2025-08-20T11:55:20.174Z" }, - { url = "https://files.pythonhosted.org/packages/41/b8/ab67ec8c01b8a3721fd13e5cb9d85ab2a6066a3a5e9148d661a6870d6293/ujson-5.11.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952c0be400229940248c0f5356514123d428cba1946af6fa2bbd7503395fef26", size = 57657, upload-time = "2025-08-20T11:55:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c7/fb84f27cd80a2c7e2d3c6012367aecade0da936790429801803fa8d4bffc/ujson-5.11.0-cp311-cp311-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:94fcae844f1e302f6f8095c5d1c45a2f0bfb928cccf9f1b99e3ace634b980a2a", size = 59779, upload-time = "2025-08-20T11:55:22.772Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/48706f7c1e917ecb97ddcfb7b1d756040b86ed38290e28579d63bd3fcc48/ujson-5.11.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e0ec1646db172beb8d3df4c32a9d78015e671d2000af548252769e33079d9a6", size = 57284, upload-time = "2025-08-20T11:55:24.01Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ce/48877c6eb4afddfd6bd1db6be34456538c07ca2d6ed233d3f6c6efc2efe8/ujson-5.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:da473b23e3a54448b008d33f742bcd6d5fb2a897e42d1fc6e7bf306ea5d18b1b", size = 1036395, upload-time = "2025-08-20T11:55:25.725Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7a/2c20dc97ad70cd7c31ad0596ba8e2cf8794d77191ba4d1e0bded69865477/ujson-5.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:aa6b3d4f1c0d3f82930f4cbd7fe46d905a4a9205a7c13279789c1263faf06dba", size = 1195731, upload-time = "2025-08-20T11:55:27.915Z" }, - { url = "https://files.pythonhosted.org/packages/15/f5/ca454f2f6a2c840394b6f162fff2801450803f4ff56c7af8ce37640b8a2a/ujson-5.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4843f3ab4fe1cc596bb7e02228ef4c25d35b4bb0809d6a260852a4bfcab37ba3", size = 1088710, upload-time = "2025-08-20T11:55:29.426Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d3/9ba310e07969bc9906eb7548731e33a0f448b122ad9705fed699c9b29345/ujson-5.11.0-cp311-cp311-win32.whl", hash = "sha256:e979fbc469a7f77f04ec2f4e853ba00c441bf2b06720aa259f0f720561335e34", size = 39648, upload-time = "2025-08-20T11:55:31.194Z" }, - { url = "https://files.pythonhosted.org/packages/57/f7/da05b4a8819f1360be9e71fb20182f0bb3ec611a36c3f213f4d20709e099/ujson-5.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:683f57f0dd3acdd7d9aff1de0528d603aafcb0e6d126e3dc7ce8b020a28f5d01", size = 43717, upload-time = "2025-08-20T11:55:32.241Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cc/f3f9ac0f24f00a623a48d97dc3814df5c2dc368cfb00031aa4141527a24b/ujson-5.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:7855ccea3f8dad5e66d8445d754fc1cf80265a4272b5f8059ebc7ec29b8d0835", size = 38402, upload-time = "2025-08-20T11:55:33.641Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ef/a9cb1fce38f699123ff012161599fb9f2ff3f8d482b4b18c43a2dc35073f/ujson-5.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7895f0d2d53bd6aea11743bd56e3cb82d729980636cd0ed9b89418bf66591702", size = 55434, upload-time = "2025-08-20T11:55:34.987Z" }, - { url = "https://files.pythonhosted.org/packages/b1/05/dba51a00eb30bd947791b173766cbed3492269c150a7771d2750000c965f/ujson-5.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12b5e7e22a1fe01058000d1b317d3b65cc3daf61bd2ea7a2b76721fe160fa74d", size = 53190, upload-time = "2025-08-20T11:55:36.384Z" }, - { url = "https://files.pythonhosted.org/packages/03/3c/fd11a224f73fbffa299fb9644e425f38b38b30231f7923a088dd513aabb4/ujson-5.11.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0180a480a7d099082501cad1fe85252e4d4bf926b40960fb3d9e87a3a6fbbc80", size = 57600, upload-time = "2025-08-20T11:55:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/55/b9/405103cae24899df688a3431c776e00528bd4799e7d68820e7ebcf824f92/ujson-5.11.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:fa79fdb47701942c2132a9dd2297a1a85941d966d8c87bfd9e29b0cf423f26cc", size = 59791, upload-time = "2025-08-20T11:55:38.877Z" }, - { url = "https://files.pythonhosted.org/packages/17/7b/2dcbc2bbfdbf68f2368fb21ab0f6735e872290bb604c75f6e06b81edcb3f/ujson-5.11.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8254e858437c00f17cb72e7a644fc42dad0ebb21ea981b71df6e84b1072aaa7c", size = 57356, upload-time = "2025-08-20T11:55:40.036Z" }, - { url = "https://files.pythonhosted.org/packages/d1/71/fea2ca18986a366c750767b694430d5ded6b20b6985fddca72f74af38a4c/ujson-5.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1aa8a2ab482f09f6c10fba37112af5f957689a79ea598399c85009f2f29898b5", size = 1036313, upload-time = "2025-08-20T11:55:41.408Z" }, - { url = "https://files.pythonhosted.org/packages/a3/bb/d4220bd7532eac6288d8115db51710fa2d7d271250797b0bfba9f1e755af/ujson-5.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a638425d3c6eed0318df663df44480f4a40dc87cc7c6da44d221418312f6413b", size = 1195782, upload-time = "2025-08-20T11:55:43.357Z" }, - { url = "https://files.pythonhosted.org/packages/80/47/226e540aa38878ce1194454385701d82df538ccb5ff8db2cf1641dde849a/ujson-5.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e3cff632c1d78023b15f7e3a81c3745cd3f94c044d1e8fa8efbd6b161997bbc", size = 1088817, upload-time = "2025-08-20T11:55:45.262Z" }, - { url = "https://files.pythonhosted.org/packages/7e/81/546042f0b23c9040d61d46ea5ca76f0cc5e0d399180ddfb2ae976ebff5b5/ujson-5.11.0-cp312-cp312-win32.whl", hash = "sha256:be6b0eaf92cae8cdee4d4c9e074bde43ef1c590ed5ba037ea26c9632fb479c88", size = 39757, upload-time = "2025-08-20T11:55:46.522Z" }, - { url = "https://files.pythonhosted.org/packages/44/1b/27c05dc8c9728f44875d74b5bfa948ce91f6c33349232619279f35c6e817/ujson-5.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:b7b136cc6abc7619124fd897ef75f8e63105298b5ca9bdf43ebd0e1fa0ee105f", size = 43859, upload-time = "2025-08-20T11:55:47.987Z" }, - { url = "https://files.pythonhosted.org/packages/22/2d/37b6557c97c3409c202c838aa9c960ca3896843b4295c4b7bb2bbd260664/ujson-5.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:6cd2df62f24c506a0ba322d5e4fe4466d47a9467b57e881ee15a31f7ecf68ff6", size = 38361, upload-time = "2025-08-20T11:55:49.122Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435, upload-time = "2025-08-20T11:55:50.243Z" }, - { url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193, upload-time = "2025-08-20T11:55:51.373Z" }, - { url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603, upload-time = "2025-08-20T11:55:52.583Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794, upload-time = "2025-08-20T11:55:53.69Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363, upload-time = "2025-08-20T11:55:54.843Z" }, - { url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311, upload-time = "2025-08-20T11:55:56.197Z" }, - { url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783, upload-time = "2025-08-20T11:55:58.081Z" }, - { url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822, upload-time = "2025-08-20T11:55:59.469Z" }, - { url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753, upload-time = "2025-08-20T11:56:01.345Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866, upload-time = "2025-08-20T11:56:02.552Z" }, - { url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363, upload-time = "2025-08-20T11:56:03.688Z" }, - { url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462, upload-time = "2025-08-20T11:56:04.873Z" }, - { url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246, upload-time = "2025-08-20T11:56:06.054Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631, upload-time = "2025-08-20T11:56:07.374Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877, upload-time = "2025-08-20T11:56:08.534Z" }, - { url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363, upload-time = "2025-08-20T11:56:09.758Z" }, - { url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394, upload-time = "2025-08-20T11:56:11.168Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837, upload-time = "2025-08-20T11:56:12.6Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837, upload-time = "2025-08-20T11:56:14.15Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022, upload-time = "2025-08-20T11:56:15.509Z" }, - { url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111, upload-time = "2025-08-20T11:56:16.597Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682, upload-time = "2025-08-20T11:56:17.763Z" }, - { url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759, upload-time = "2025-08-20T11:56:18.882Z" }, - { url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634, upload-time = "2025-08-20T11:56:20.012Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547, upload-time = "2025-08-20T11:56:21.175Z" }, - { url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489, upload-time = "2025-08-20T11:56:22.342Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035, upload-time = "2025-08-20T11:56:23.92Z" }, - { url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212, upload-time = "2025-08-20T11:56:25.274Z" }, - { url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500, upload-time = "2025-08-20T11:56:27.517Z" }, - { url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487, upload-time = "2025-08-20T11:56:29.07Z" }, - { url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859, upload-time = "2025-08-20T11:56:30.495Z" }, - { url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183, upload-time = "2025-08-20T11:56:31.574Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264, upload-time = "2025-08-20T11:56:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/50/17/30275aa2933430d8c0c4ead951cc4fdb922f575a349aa0b48a6f35449e97/ujson-5.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:abae0fb58cc820092a0e9e8ba0051ac4583958495bfa5262a12f628249e3b362", size = 51206, upload-time = "2025-08-20T11:56:48.797Z" }, - { url = "https://files.pythonhosted.org/packages/c3/15/42b3924258eac2551f8f33fa4e35da20a06a53857ccf3d4deb5e5d7c0b6c/ujson-5.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fac6c0649d6b7c3682a0a6e18d3de6857977378dce8d419f57a0b20e3d775b39", size = 48907, upload-time = "2025-08-20T11:56:50.136Z" }, - { url = "https://files.pythonhosted.org/packages/94/7e/0519ff7955aba581d1fe1fb1ca0e452471250455d182f686db5ac9e46119/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b42c115c7c6012506e8168315150d1e3f76e7ba0f4f95616f4ee599a1372bbc", size = 50319, upload-time = "2025-08-20T11:56:51.63Z" }, - { url = "https://files.pythonhosted.org/packages/74/cf/209d90506b7d6c5873f82c5a226d7aad1a1da153364e9ebf61eff0740c33/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:86baf341d90b566d61a394869ce77188cc8668f76d7bb2c311d77a00f4bdf844", size = 56584, upload-time = "2025-08-20T11:56:52.89Z" }, - { url = "https://files.pythonhosted.org/packages/e9/97/bd939bb76943cb0e1d2b692d7e68629f51c711ef60425fa5bb6968037ecd/ujson-5.11.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4598bf3965fc1a936bd84034312bcbe00ba87880ef1ee33e33c1e88f2c398b49", size = 51588, upload-time = "2025-08-20T11:56:54.054Z" }, - { url = "https://files.pythonhosted.org/packages/52/5b/8c5e33228f7f83f05719964db59f3f9f276d272dc43752fa3bbf0df53e7b/ujson-5.11.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:416389ec19ef5f2013592f791486bef712ebce0cd59299bf9df1ba40bb2f6e04", size = 43835, upload-time = "2025-08-20T11:56:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, ] [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" }, + { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" }, + { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" }, + { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" }, + { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" }, + { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/6c64bdbf71f58ccde7919e00491812556f446a5291573af92c49a5e9aaef/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b197cd5424cf89fb019ca7f53641d05bfe34b1879614bed111c9c313b5574cd8", size = 591617, upload-time = "2026-02-20T22:50:24.532Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f0/758c3b0fb0c4871c7704fef26a5bc861de4f8a68e4831669883bebe07b0f/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:12c65020ba6cb6abe1d57fcbfc2d0ea0506c67049ee031714057f5caf0f9bc9c", size = 303702, upload-time = "2026-02-20T22:50:40.687Z" }, + { url = "https://files.pythonhosted.org/packages/85/89/d91862b544c695cd58855efe3201f83894ed82fffe34500774238ab8eba7/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b5d2ad28063d422ccc2c28d46471d47b61a58de885d35113a8f18cb547e25bf", size = 337678, upload-time = "2026-02-20T22:50:39.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6b/cf342ba8a898f1de024be0243fac67c025cad530c79ea7f89c4ce718891a/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da2234387b45fde40b0fedfee64a0ba591caeea9c48c7698ab6e2d85c7991533", size = 343711, upload-time = "2026-02-20T22:50:43.965Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/049418d094d396dfa6606b30af925cc68a6670c3b9103b23e6990f84b589/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50fffc2827348c1e48972eed3d1c698959e63f9d030aa5dd82ba451113158a62", size = 476731, upload-time = "2026-02-20T22:50:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/0857f64d53a90321e6a46a3d4cc394f50e1366132dcd2ae147f9326ca98b/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dbe718765f70f5b7f9b7f66b6a937802941b1cc56bcf642ce0274169741e01", size = 338902, upload-time = "2026-02-20T22:50:33.927Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d0/5bf7cbf1ac138c92b9ac21066d18faf4d7e7f651047b700eb192ca4b9fdb/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:258186964039a8e36db10810c1ece879d229b01331e09e9030bc5dcabe231bd2", size = 364700, upload-time = "2026-02-20T22:50:21.732Z" }, ] [[package]] name = "uvicorn" -version = "0.37.0" +version = "0.42.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/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] [package.optional-dependencies] @@ -6756,49 +7724,62 @@ standard = [ [[package]] name = "uvloop" -version = "0.21.0" +version = "0.22.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019, upload-time = "2024-10-14T23:37:20.068Z" }, - { url = "https://files.pythonhosted.org/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d", size = 801898, upload-time = "2024-10-14T23:37:22.663Z" }, - { url = "https://files.pythonhosted.org/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26", size = 3827735, upload-time = "2024-10-14T23:37:25.129Z" }, - { url = "https://files.pythonhosted.org/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb", size = 3825126, upload-time = "2024-10-14T23:37:27.59Z" }, - { url = "https://files.pythonhosted.org/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f", size = 3705789, upload-time = "2024-10-14T23:37:29.385Z" }, - { url = "https://files.pythonhosted.org/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c", size = 3800523, upload-time = "2024-10-14T23:37:32.048Z" }, - { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload-time = "2024-10-14T23:37:33.612Z" }, - { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload-time = "2024-10-14T23:37:36.11Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload-time = "2024-10-14T23:37:37.683Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185, upload-time = "2024-10-14T23:37:40.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256, upload-time = "2024-10-14T23:37:42.839Z" }, - { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323, upload-time = "2024-10-14T23:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, - { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, - { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, - { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, - { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, - { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, - { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, - { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] name = "virtualenv" -version = "20.34.0" +version = "21.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, + { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, ] [[package]] @@ -6844,170 +7825,185 @@ wheels = [ [[package]] name = "watchfiles" -version = "1.1.0" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/dd/579d1dc57f0f895426a1211c4ef3b0cb37eb9e642bb04bdcd962b5df206a/watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc", size = 405757, upload-time = "2025-06-15T19:04:51.058Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a0/7a0318cd874393344d48c34d53b3dd419466adf59a29ba5b51c88dd18b86/watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df", size = 397511, upload-time = "2025-06-15T19:04:52.79Z" }, - { url = "https://files.pythonhosted.org/packages/06/be/503514656d0555ec2195f60d810eca29b938772e9bfb112d5cd5ad6f6a9e/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68", size = 450739, upload-time = "2025-06-15T19:04:54.203Z" }, - { url = "https://files.pythonhosted.org/packages/4e/0d/a05dd9e5f136cdc29751816d0890d084ab99f8c17b86f25697288ca09bc7/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc", size = 458106, upload-time = "2025-06-15T19:04:55.607Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fa/9cd16e4dfdb831072b7ac39e7bea986e52128526251038eb481effe9f48e/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97", size = 484264, upload-time = "2025-06-15T19:04:57.009Z" }, - { url = "https://files.pythonhosted.org/packages/32/04/1da8a637c7e2b70e750a0308e9c8e662ada0cca46211fa9ef24a23937e0b/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c", size = 597612, upload-time = "2025-06-15T19:04:58.409Z" }, - { url = "https://files.pythonhosted.org/packages/30/01/109f2762e968d3e58c95731a206e5d7d2a7abaed4299dd8a94597250153c/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5", size = 477242, upload-time = "2025-06-15T19:04:59.786Z" }, - { url = "https://files.pythonhosted.org/packages/b5/b8/46f58cf4969d3b7bc3ca35a98e739fa4085b0657a1540ccc29a1a0bc016f/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9", size = 453148, upload-time = "2025-06-15T19:05:01.103Z" }, - { url = "https://files.pythonhosted.org/packages/a5/cd/8267594263b1770f1eb76914940d7b2d03ee55eca212302329608208e061/watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72", size = 626574, upload-time = "2025-06-15T19:05:02.582Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2f/7f2722e85899bed337cba715723e19185e288ef361360718973f891805be/watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc", size = 624378, upload-time = "2025-06-15T19:05:03.719Z" }, - { url = "https://files.pythonhosted.org/packages/bf/20/64c88ec43d90a568234d021ab4b2a6f42a5230d772b987c3f9c00cc27b8b/watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587", size = 279829, upload-time = "2025-06-15T19:05:04.822Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/a9c1ed33de7af80935e4eac09570de679c6e21c07070aa99f74b4431f4d6/watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82", size = 292192, upload-time = "2025-06-15T19:05:06.348Z" }, - { url = "https://files.pythonhosted.org/packages/8b/78/7401154b78ab484ccaaeef970dc2af0cb88b5ba8a1b415383da444cdd8d3/watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2", size = 405751, upload-time = "2025-06-15T19:05:07.679Z" }, - { url = "https://files.pythonhosted.org/packages/76/63/e6c3dbc1f78d001589b75e56a288c47723de28c580ad715eb116639152b5/watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c", size = 397313, upload-time = "2025-06-15T19:05:08.764Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a2/8afa359ff52e99af1632f90cbf359da46184207e893a5f179301b0c8d6df/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d", size = 450792, upload-time = "2025-06-15T19:05:09.869Z" }, - { url = "https://files.pythonhosted.org/packages/1d/bf/7446b401667f5c64972a57a0233be1104157fc3abf72c4ef2666c1bd09b2/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7", size = 458196, upload-time = "2025-06-15T19:05:11.91Z" }, - { url = "https://files.pythonhosted.org/packages/58/2f/501ddbdfa3fa874ea5597c77eeea3d413579c29af26c1091b08d0c792280/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c", size = 484788, upload-time = "2025-06-15T19:05:13.373Z" }, - { url = "https://files.pythonhosted.org/packages/61/1e/9c18eb2eb5c953c96bc0e5f626f0e53cfef4bd19bd50d71d1a049c63a575/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575", size = 597879, upload-time = "2025-06-15T19:05:14.725Z" }, - { url = "https://files.pythonhosted.org/packages/8b/6c/1467402e5185d89388b4486745af1e0325007af0017c3384cc786fff0542/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8", size = 477447, upload-time = "2025-06-15T19:05:15.775Z" }, - { url = "https://files.pythonhosted.org/packages/2b/a1/ec0a606bde4853d6c4a578f9391eeb3684a9aea736a8eb217e3e00aa89a1/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f", size = 453145, upload-time = "2025-06-15T19:05:17.17Z" }, - { url = "https://files.pythonhosted.org/packages/90/b9/ef6f0c247a6a35d689fc970dc7f6734f9257451aefb30def5d100d6246a5/watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4", size = 626539, upload-time = "2025-06-15T19:05:18.557Z" }, - { url = "https://files.pythonhosted.org/packages/34/44/6ffda5537085106ff5aaa762b0d130ac6c75a08015dd1621376f708c94de/watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d", size = 624472, upload-time = "2025-06-15T19:05:19.588Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e3/71170985c48028fa3f0a50946916a14055e741db11c2e7bc2f3b61f4d0e3/watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2", size = 279348, upload-time = "2025-06-15T19:05:20.856Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/3e39c68b68a7a171070f81fc2561d23ce8d6859659406842a0e4bebf3bba/watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12", size = 292607, upload-time = "2025-06-15T19:05:21.937Z" }, - { url = "https://files.pythonhosted.org/packages/61/9f/2973b7539f2bdb6ea86d2c87f70f615a71a1fc2dba2911795cea25968aea/watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a", size = 285056, upload-time = "2025-06-15T19:05:23.12Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b8/858957045a38a4079203a33aaa7d23ea9269ca7761c8a074af3524fbb240/watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179", size = 402339, upload-time = "2025-06-15T19:05:24.516Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/98b222cca751ba68e88521fabd79a4fab64005fc5976ea49b53fa205d1fa/watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5", size = 394409, upload-time = "2025-06-15T19:05:25.469Z" }, - { url = "https://files.pythonhosted.org/packages/86/50/dee79968566c03190677c26f7f47960aff738d32087087bdf63a5473e7df/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297", size = 450939, upload-time = "2025-06-15T19:05:26.494Z" }, - { url = "https://files.pythonhosted.org/packages/40/45/a7b56fb129700f3cfe2594a01aa38d033b92a33dddce86c8dfdfc1247b72/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0", size = 457270, upload-time = "2025-06-15T19:05:27.466Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c8/fa5ef9476b1d02dc6b5e258f515fcaaecf559037edf8b6feffcbc097c4b8/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e", size = 483370, upload-time = "2025-06-15T19:05:28.548Z" }, - { url = "https://files.pythonhosted.org/packages/98/68/42cfcdd6533ec94f0a7aab83f759ec11280f70b11bfba0b0f885e298f9bd/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee", size = 598654, upload-time = "2025-06-15T19:05:29.997Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/b2a1544224118cc28df7e59008a929e711f9c68ce7d554e171b2dc531352/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd", size = 478667, upload-time = "2025-06-15T19:05:31.172Z" }, - { url = "https://files.pythonhosted.org/packages/8c/77/e3362fe308358dc9f8588102481e599c83e1b91c2ae843780a7ded939a35/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f", size = 452213, upload-time = "2025-06-15T19:05:32.299Z" }, - { url = "https://files.pythonhosted.org/packages/6e/17/c8f1a36540c9a1558d4faf08e909399e8133599fa359bf52ec8fcee5be6f/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4", size = 626718, upload-time = "2025-06-15T19:05:33.415Z" }, - { url = "https://files.pythonhosted.org/packages/26/45/fb599be38b4bd38032643783d7496a26a6f9ae05dea1a42e58229a20ac13/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f", size = 623098, upload-time = "2025-06-15T19:05:34.534Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/fdf40e038475498e160cd167333c946e45d8563ae4dd65caf757e9ffe6b4/watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd", size = 279209, upload-time = "2025-06-15T19:05:35.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d3/3ae9d5124ec75143bdf088d436cba39812122edc47709cd2caafeac3266f/watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47", size = 292786, upload-time = "2025-06-15T19:05:36.559Z" }, - { url = "https://files.pythonhosted.org/packages/26/2f/7dd4fc8b5f2b34b545e19629b4a018bfb1de23b3a496766a2c1165ca890d/watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6", size = 284343, upload-time = "2025-06-15T19:05:37.5Z" }, - { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" }, - { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" }, - { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" }, - { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" }, - { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" }, - { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" }, - { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" }, - { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" }, - { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" }, - { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" }, - { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" }, - { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" }, - { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" }, - { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" }, - { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" }, - { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" }, - { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" }, - { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" }, - { url = "https://files.pythonhosted.org/packages/2c/00/70f75c47f05dea6fd30df90f047765f6fc2d6eb8b5a3921379b0b04defa2/watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297", size = 402114, upload-time = "2025-06-15T19:06:06.186Z" }, - { url = "https://files.pythonhosted.org/packages/53/03/acd69c48db4a1ed1de26b349d94077cca2238ff98fd64393f3e97484cae6/watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018", size = 393879, upload-time = "2025-06-15T19:06:07.369Z" }, - { url = "https://files.pythonhosted.org/packages/2f/c8/a9a2a6f9c8baa4eceae5887fecd421e1b7ce86802bcfc8b6a942e2add834/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0", size = 450026, upload-time = "2025-06-15T19:06:08.476Z" }, - { url = "https://files.pythonhosted.org/packages/fe/51/d572260d98388e6e2b967425c985e07d47ee6f62e6455cefb46a6e06eda5/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12", size = 457917, upload-time = "2025-06-15T19:06:09.988Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/4258e52917bf9f12909b6ec314ff9636276f3542f9d3807d143f27309104/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb", size = 483602, upload-time = "2025-06-15T19:06:11.088Z" }, - { url = "https://files.pythonhosted.org/packages/84/99/bee17a5f341a4345fe7b7972a475809af9e528deba056f8963d61ea49f75/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77", size = 596758, upload-time = "2025-06-15T19:06:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/e4bec1d59b25b89d2b0716b41b461ed655a9a53c60dc78ad5771fda5b3e6/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92", size = 477601, upload-time = "2025-06-15T19:06:13.391Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fa/a514292956f4a9ce3c567ec0c13cce427c158e9f272062685a8a727d08fc/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e", size = 451936, upload-time = "2025-06-15T19:06:14.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/5d/c3bf927ec3bbeb4566984eba8dd7a8eb69569400f5509904545576741f88/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b", size = 626243, upload-time = "2025-06-15T19:06:16.232Z" }, - { url = "https://files.pythonhosted.org/packages/e6/65/6e12c042f1a68c556802a84d54bb06d35577c81e29fba14019562479159c/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259", size = 623073, upload-time = "2025-06-15T19:06:17.457Z" }, - { url = "https://files.pythonhosted.org/packages/89/ab/7f79d9bf57329e7cbb0a6fd4c7bd7d0cee1e4a8ef0041459f5409da3506c/watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f", size = 400872, upload-time = "2025-06-15T19:06:18.57Z" }, - { url = "https://files.pythonhosted.org/packages/df/d5/3f7bf9912798e9e6c516094db6b8932df53b223660c781ee37607030b6d3/watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e", size = 392877, upload-time = "2025-06-15T19:06:19.55Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c5/54ec7601a2798604e01c75294770dbee8150e81c6e471445d7601610b495/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa", size = 449645, upload-time = "2025-06-15T19:06:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/0a/04/c2f44afc3b2fce21ca0b7802cbd37ed90a29874f96069ed30a36dfe57c2b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8", size = 457424, upload-time = "2025-06-15T19:06:21.712Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b0/eec32cb6c14d248095261a04f290636da3df3119d4040ef91a4a50b29fa5/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f", size = 481584, upload-time = "2025-06-15T19:06:22.777Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/ca4bb71c68a937d7145aa25709e4f5d68eb7698a25ce266e84b55d591bbd/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e", size = 596675, upload-time = "2025-06-15T19:06:24.226Z" }, - { url = "https://files.pythonhosted.org/packages/a1/dd/b0e4b7fb5acf783816bc950180a6cd7c6c1d2cf7e9372c0ea634e722712b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb", size = 477363, upload-time = "2025-06-15T19:06:25.42Z" }, - { url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240, upload-time = "2025-06-15T19:06:26.552Z" }, - { url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607, upload-time = "2025-06-15T19:06:27.606Z" }, - { url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" }, - { url = "https://files.pythonhosted.org/packages/be/7c/a3d7c55cfa377c2f62c4ae3c6502b997186bc5e38156bafcb9b653de9a6d/watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5", size = 406748, upload-time = "2025-06-15T19:06:44.2Z" }, - { url = "https://files.pythonhosted.org/packages/38/d0/c46f1b2c0ca47f3667b144de6f0515f6d1c670d72f2ca29861cac78abaa1/watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d", size = 398801, upload-time = "2025-06-15T19:06:45.774Z" }, - { url = "https://files.pythonhosted.org/packages/70/9c/9a6a42e97f92eeed77c3485a43ea96723900aefa3ac739a8c73f4bff2cd7/watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea", size = 451528, upload-time = "2025-06-15T19:06:46.791Z" }, - { url = "https://files.pythonhosted.org/packages/51/7b/98c7f4f7ce7ff03023cf971cd84a3ee3b790021ae7584ffffa0eb2554b96/watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6", size = 454095, upload-time = "2025-06-15T19:06:48.211Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6b/686dcf5d3525ad17b384fd94708e95193529b460a1b7bf40851f1328ec6e/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3", size = 406910, upload-time = "2025-06-15T19:06:49.335Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d3/71c2dcf81dc1edcf8af9f4d8d63b1316fb0a2dd90cbfd427e8d9dd584a90/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c", size = 398816, upload-time = "2025-06-15T19:06:50.433Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fa/12269467b2fc006f8fce4cd6c3acfa77491dd0777d2a747415f28ccc8c60/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432", size = 451584, upload-time = "2025-06-15T19:06:51.834Z" }, - { url = "https://files.pythonhosted.org/packages/bd/d3/254cea30f918f489db09d6a8435a7de7047f8cb68584477a515f160541d6/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792", size = 454009, upload-time = "2025-06-15T19:06:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] [[package]] name = "websockets" -version = "13.1" +version = "15.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e2/73/9223dbc7be3dcaf2a7bbf756c351ec8da04b1fa573edaf545b95f6b0c7fd/websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878", size = 158549, upload-time = "2024-09-21T17:34:21.54Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/94/d15dbfc6a5eb636dbc754303fba18208f2e88cf97e733e1d64fb9cb5c89e/websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee", size = 157815, upload-time = "2024-09-21T17:32:27.107Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/c04af33f4663945a26f5e8cf561eb140c35452b50af47a83c3fbcfe62ae1/websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7", size = 155466, upload-time = "2024-09-21T17:32:28.428Z" }, - { url = "https://files.pythonhosted.org/packages/35/e8/719f08d12303ea643655e52d9e9851b2dadbb1991d4926d9ce8862efa2f5/websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6", size = 155716, upload-time = "2024-09-21T17:32:29.905Z" }, - { url = "https://files.pythonhosted.org/packages/91/e1/14963ae0252a8925f7434065d25dcd4701d5e281a0b4b460a3b5963d2594/websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b", size = 164806, upload-time = "2024-09-21T17:32:31.384Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fa/ab28441bae5e682a0f7ddf3d03440c0c352f930da419301f4a717f675ef3/websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa", size = 163810, upload-time = "2024-09-21T17:32:32.384Z" }, - { url = "https://files.pythonhosted.org/packages/44/77/dea187bd9d16d4b91566a2832be31f99a40d0f5bfa55eeb638eb2c3bc33d/websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700", size = 164125, upload-time = "2024-09-21T17:32:33.398Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d9/3af14544e83f1437eb684b399e6ba0fa769438e869bf5d83d74bc197fae8/websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c", size = 164532, upload-time = "2024-09-21T17:32:35.109Z" }, - { url = "https://files.pythonhosted.org/packages/1c/8a/6d332eabe7d59dfefe4b8ba6f46c8c5fabb15b71c8a8bc3d2b65de19a7b6/websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0", size = 163948, upload-time = "2024-09-21T17:32:36.214Z" }, - { url = "https://files.pythonhosted.org/packages/1a/91/a0aeadbaf3017467a1ee03f8fb67accdae233fe2d5ad4b038c0a84e357b0/websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f", size = 163898, upload-time = "2024-09-21T17:32:37.277Z" }, - { url = "https://files.pythonhosted.org/packages/71/31/a90fb47c63e0ae605be914b0b969d7c6e6ffe2038cd744798e4b3fbce53b/websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe", size = 158706, upload-time = "2024-09-21T17:32:38.755Z" }, - { url = "https://files.pythonhosted.org/packages/93/ca/9540a9ba80da04dc7f36d790c30cae4252589dbd52ccdc92e75b0be22437/websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a", size = 159141, upload-time = "2024-09-21T17:32:40.495Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f0/cf0b8a30d86b49e267ac84addbebbc7a48a6e7bb7c19db80f62411452311/websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19", size = 157813, upload-time = "2024-09-21T17:32:42.188Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e7/22285852502e33071a8cf0ac814f8988480ec6db4754e067b8b9d0e92498/websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5", size = 155469, upload-time = "2024-09-21T17:32:43.858Z" }, - { url = "https://files.pythonhosted.org/packages/68/d4/c8c7c1e5b40ee03c5cc235955b0fb1ec90e7e37685a5f69229ad4708dcde/websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd", size = 155717, upload-time = "2024-09-21T17:32:44.914Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/c50999b9b848b1332b07c7fd8886179ac395cb766fda62725d1539e7bc6c/websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02", size = 165379, upload-time = "2024-09-21T17:32:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/bc/49/4a4ad8c072f18fd79ab127650e47b160571aacfc30b110ee305ba25fffc9/websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7", size = 164376, upload-time = "2024-09-21T17:32:46.987Z" }, - { url = "https://files.pythonhosted.org/packages/af/9b/8c06d425a1d5a74fd764dd793edd02be18cf6fc3b1ccd1f29244ba132dc0/websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096", size = 164753, upload-time = "2024-09-21T17:32:48.046Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5b/0acb5815095ff800b579ffc38b13ab1b915b317915023748812d24e0c1ac/websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084", size = 165051, upload-time = "2024-09-21T17:32:49.271Z" }, - { url = "https://files.pythonhosted.org/packages/30/93/c3891c20114eacb1af09dedfcc620c65c397f4fd80a7009cd12d9457f7f5/websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3", size = 164489, upload-time = "2024-09-21T17:32:50.392Z" }, - { url = "https://files.pythonhosted.org/packages/28/09/af9e19885539759efa2e2cd29b8b3f9eecef7ecefea40d46612f12138b36/websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9", size = 164438, upload-time = "2024-09-21T17:32:52.223Z" }, - { url = "https://files.pythonhosted.org/packages/b6/08/6f38b8e625b3d93de731f1d248cc1493327f16cb45b9645b3e791782cff0/websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f", size = 158710, upload-time = "2024-09-21T17:32:53.244Z" }, - { url = "https://files.pythonhosted.org/packages/fb/39/ec8832ecb9bb04a8d318149005ed8cee0ba4e0205835da99e0aa497a091f/websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557", size = 159137, upload-time = "2024-09-21T17:32:54.721Z" }, - { url = "https://files.pythonhosted.org/packages/df/46/c426282f543b3c0296cf964aa5a7bb17e984f58dde23460c3d39b3148fcf/websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc", size = 157821, upload-time = "2024-09-21T17:32:56.442Z" }, - { url = "https://files.pythonhosted.org/packages/aa/85/22529867010baac258da7c45848f9415e6cf37fef00a43856627806ffd04/websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49", size = 155480, upload-time = "2024-09-21T17:32:57.698Z" }, - { url = "https://files.pythonhosted.org/packages/29/2c/bdb339bfbde0119a6e84af43ebf6275278698a2241c2719afc0d8b0bdbf2/websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd", size = 155715, upload-time = "2024-09-21T17:32:59.429Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/8612029ea04c5c22bf7af2fd3d63876c4eaeef9b97e86c11972a43aa0e6c/websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0", size = 165647, upload-time = "2024-09-21T17:33:00.495Z" }, - { url = "https://files.pythonhosted.org/packages/56/04/1681ed516fa19ca9083f26d3f3a302257e0911ba75009533ed60fbb7b8d1/websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6", size = 164592, upload-time = "2024-09-21T17:33:02.223Z" }, - { url = "https://files.pythonhosted.org/packages/38/6f/a96417a49c0ed132bb6087e8e39a37db851c70974f5c724a4b2a70066996/websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9", size = 165012, upload-time = "2024-09-21T17:33:03.288Z" }, - { url = "https://files.pythonhosted.org/packages/40/8b/fccf294919a1b37d190e86042e1a907b8f66cff2b61e9befdbce03783e25/websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68", size = 165311, upload-time = "2024-09-21T17:33:04.728Z" }, - { url = "https://files.pythonhosted.org/packages/c1/61/f8615cf7ce5fe538476ab6b4defff52beb7262ff8a73d5ef386322d9761d/websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14", size = 164692, upload-time = "2024-09-21T17:33:05.829Z" }, - { url = "https://files.pythonhosted.org/packages/5c/f1/a29dd6046d3a722d26f182b783a7997d25298873a14028c4760347974ea3/websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf", size = 164686, upload-time = "2024-09-21T17:33:06.823Z" }, - { url = "https://files.pythonhosted.org/packages/0f/99/ab1cdb282f7e595391226f03f9b498f52109d25a2ba03832e21614967dfa/websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c", size = 158712, upload-time = "2024-09-21T17:33:07.877Z" }, - { url = "https://files.pythonhosted.org/packages/46/93/e19160db48b5581feac8468330aa11b7292880a94a37d7030478596cc14e/websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3", size = 159145, upload-time = "2024-09-21T17:33:09.202Z" }, - { url = "https://files.pythonhosted.org/packages/51/20/2b99ca918e1cbd33c53db2cace5f0c0cd8296fc77558e1908799c712e1cd/websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6", size = 157828, upload-time = "2024-09-21T17:33:10.987Z" }, - { url = "https://files.pythonhosted.org/packages/b8/47/0932a71d3d9c0e9483174f60713c84cee58d62839a143f21a2bcdbd2d205/websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708", size = 155487, upload-time = "2024-09-21T17:33:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/a9/60/f1711eb59ac7a6c5e98e5637fef5302f45b6f76a2c9d64fd83bbb341377a/websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418", size = 155721, upload-time = "2024-09-21T17:33:13.909Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e6/ba9a8db7f9d9b0e5f829cf626ff32677f39824968317223605a6b419d445/websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a", size = 165609, upload-time = "2024-09-21T17:33:14.967Z" }, - { url = "https://files.pythonhosted.org/packages/c1/22/4ec80f1b9c27a0aebd84ccd857252eda8418ab9681eb571b37ca4c5e1305/websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f", size = 164556, upload-time = "2024-09-21T17:33:17.113Z" }, - { url = "https://files.pythonhosted.org/packages/27/ac/35f423cb6bb15600438db80755609d27eda36d4c0b3c9d745ea12766c45e/websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5", size = 164993, upload-time = "2024-09-21T17:33:18.168Z" }, - { url = "https://files.pythonhosted.org/packages/31/4e/98db4fd267f8be9e52e86b6ee4e9aa7c42b83452ea0ea0672f176224b977/websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135", size = 165360, upload-time = "2024-09-21T17:33:19.233Z" }, - { url = "https://files.pythonhosted.org/packages/3f/15/3f0de7cda70ffc94b7e7024544072bc5b26e2c1eb36545291abb755d8cdb/websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2", size = 164745, upload-time = "2024-09-21T17:33:20.361Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6e/66b6b756aebbd680b934c8bdbb6dcb9ce45aad72cde5f8a7208dbb00dd36/websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", size = 164732, upload-time = "2024-09-21T17:33:23.103Z" }, - { url = "https://files.pythonhosted.org/packages/35/c6/12e3aab52c11aeb289e3dbbc05929e7a9d90d7a9173958477d3ef4f8ce2d/websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", size = 158709, upload-time = "2024-09-21T17:33:24.196Z" }, - { url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144, upload-time = "2024-09-21T17:33:25.96Z" }, - { url = "https://files.pythonhosted.org/packages/2d/75/6da22cb3ad5b8c606963f9a5f9f88656256fecc29d420b4b2bf9e0c7d56f/websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238", size = 155499, upload-time = "2024-09-21T17:33:54.917Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ba/22833d58629088fcb2ccccedfae725ac0bbcd713319629e97125b52ac681/websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5", size = 155737, upload-time = "2024-09-21T17:33:56.052Z" }, - { url = "https://files.pythonhosted.org/packages/95/54/61684fe22bdb831e9e1843d972adadf359cf04ab8613285282baea6a24bb/websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9", size = 157095, upload-time = "2024-09-21T17:33:57.21Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/6652fb82440813822022a9301a30afde85e5ff3fb2aebb77f34aabe2b4e8/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6", size = 156701, upload-time = "2024-09-21T17:33:59.061Z" }, - { url = "https://files.pythonhosted.org/packages/67/33/ae82a7b860fa8a08aba68818bdf7ff61f04598aa5ab96df4cd5a3e418ca4/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a", size = 156654, upload-time = "2024-09-21T17:34:00.944Z" }, - { url = "https://files.pythonhosted.org/packages/63/0b/a1b528d36934f833e20f6da1032b995bf093d55cb416b9f2266f229fb237/websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23", size = 159192, upload-time = "2024-09-21T17:34:02.656Z" }, - { url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134, upload-time = "2024-09-21T17:34:19.904Z" }, + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]] name = "wheel" -version = "0.45.1" +version = "0.46.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" }, ] [[package]] @@ -7088,103 +8084,262 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" }, + { url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" }, + { url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" }, + { url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" }, + { url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, + { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" }, + { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" }, + { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" }, + { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" }, + { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" }, +] + [[package]] name = "yarl" -version = "1.20.1" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" }, - { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" }, - { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" }, - { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" }, - { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" }, - { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" }, - { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" }, - { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" }, - { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" }, - { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" }, - { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" }, - { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, - { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, - { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, - { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, - { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, - { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, - { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, - { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, - { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, - { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, - { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, - { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, - { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, - { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, - { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, - { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, - { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, - { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, - { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, - { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, - { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, - { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, - { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, - { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, - { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, - { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, - { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, - { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, - { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, - { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, - { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0d/9cc638702f6fc3c7a3685bcc8cf2a9ed7d6206e932a49f5242658047ef51/yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", size = 123764, upload-time = "2026-03-01T22:04:09.7Z" }, + { url = "https://files.pythonhosted.org/packages/7a/35/5a553687c5793df5429cd1db45909d4f3af7eee90014888c208d086a44f0/yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", size = 86282, upload-time = "2026-03-01T22:04:11.892Z" }, + { url = "https://files.pythonhosted.org/packages/68/2e/c5a2234238f8ce37a8312b52801ee74117f576b1539eec8404a480434acc/yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", size = 86053, upload-time = "2026-03-01T22:04:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/74/3f/bbd8ff36fb038622797ffbaf7db314918bb4d76f1cc8a4f9ca7a55fe5195/yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", size = 99395, upload-time = "2026-03-01T22:04:15.133Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/9516bc4e269d2a3ec9c6779fcdeac51ce5b3a9b0156f06ac7152e5bba864/yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", size = 92143, upload-time = "2026-03-01T22:04:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/c7/63/88802d1f6b1cb1fc67d67a58cd0cf8a1790de4ce7946e434240f1d60ab4a/yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", size = 107643, upload-time = "2026-03-01T22:04:18.519Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4f9b838f4d8bdd6f0f385aed8bbf21c71ed11a0b9983305c302cbd557815/yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", size = 108700, upload-time = "2026-03-01T22:04:20.373Z" }, + { url = "https://files.pythonhosted.org/packages/50/12/95a1d33f04a79c402664070d43b8b9f72dc18914e135b345b611b0b1f8cc/yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", size = 102769, upload-time = "2026-03-01T22:04:23.055Z" }, + { url = "https://files.pythonhosted.org/packages/86/65/91a0285f51321369fd1a8308aa19207520c5f0587772cfc2e03fc2467e90/yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", size = 101114, upload-time = "2026-03-01T22:04:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/58/80/c7c8244fc3e5bc483dc71a09560f43b619fab29301a0f0a8f936e42865c7/yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", size = 98883, upload-time = "2026-03-01T22:04:27.281Z" }, + { url = "https://files.pythonhosted.org/packages/86/e7/71ca9cc9ca79c0b7d491216177d1aed559d632947b8ffb0ee60f7d8b23e3/yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", size = 94172, upload-time = "2026-03-01T22:04:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3f/6c6c8a0fe29c26fb2db2e8d32195bb84ec1bfb8f1d32e7f73b787fcf349b/yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", size = 107010, upload-time = "2026-03-01T22:04:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/56/38/12730c05e5ad40a76374d440ed8b0899729a96c250516d91c620a6e38fc2/yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", size = 100285, upload-time = "2026-03-01T22:04:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/34/92/6a7be9239f2347234e027284e7a5f74b1140cc86575e7b469d13fba1ebfe/yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", size = 108230, upload-time = "2026-03-01T22:04:33.844Z" }, + { url = "https://files.pythonhosted.org/packages/5e/81/4aebccfa9376bd98b9d8bfad20621a57d3e8cfc5b8631c1fa5f62cdd03f4/yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", size = 103008, upload-time = "2026-03-01T22:04:35.856Z" }, + { url = "https://files.pythonhosted.org/packages/38/0f/0b4e3edcec794a86b853b0c6396c0a888d72dfce19b2d88c02ac289fb6c1/yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", size = 83073, upload-time = "2026-03-01T22:04:38.268Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/ad95c33da18897e4c636528bbc24a1dd23fe16797de8bc4ec667b8db0ba4/yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", size = 87328, upload-time = "2026-03-01T22:04:39.558Z" }, + { url = "https://files.pythonhosted.org/packages/e2/14/dfa369523c79bccf9c9c746b0a63eb31f65db9418ac01275f7950962e504/yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", size = 82463, upload-time = "2026-03-01T22:04:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, + { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, + { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, + { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] [[package]]