Compare commits

...

26 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
3dcab14675 fix: propagate append_to_context from TextFrame through TTS _process_text_frame
Co-authored-by: jamsea <614910+jamsea@users.noreply.github.com>
2026-03-18 04:24:25 +00:00
copilot-swe-agent[bot]
e3b69a93d9 Initial plan 2026-03-18 04:21:01 +00:00
Mark Backman
53388e0426 Merge pull request #4063 from pipecat-ai/mb/wake-word-start-strategy 2026-03-17 21:05:10 -04:00
Mark Backman
edf16c5533 fix: pass list-type Deepgram settings as lists instead of stringifying
List-valued settings like keyterm, keywords, search, redact, and replace
were being converted to strings before being passed to the SDK connect()
method. The SDK expects lists so its encode_query can produce repeated
query params (keyterm=a&keyterm=b).
2026-03-17 18:24:20 -04:00
Mark Backman
d4f69dd333 Merge pull request #4046 from pipecat-ai/mb/fix-4045
Fix SonioxSTTService crash when language_hints contains plain strings…
2026-03-17 16:41:11 -04:00
Mark Backman
a32f558b07 Merge pull request #4026 from pipecat-ai/mb/fix-deepgram-base-url
Fix DeepgramSTTService base_url forcing HTTPS/WSS schemes
2026-03-17 16:39:24 -04:00
Mark Backman
4e99cb39b0 Merge pull request #4056 from pipecat-ai/mb/fix-filter-turns-deprecation
Fix deprecation warning when using filter_incomplete_user_turns
2026-03-17 16:23:43 -04:00
Mark Backman
10b3bff525 Merge pull request #4058 from pipecat-ai/mb/improve-stt-tts-language-code-robustness
fix: resolve raw language strings through Language enum for proper service conversion
2026-03-17 16:20:12 -04:00
Mark Backman
95ee096622 Merge pull request #4057 from pipecat-ai/mb/fix-4053
Fix stale state in user turn stop strategies between turns
2026-03-17 16:19:31 -04:00
Mark Backman
6799995b0a Merge pull request #4062 from pipecat-ai/mb/update-pyasn1-0.6.3
Update uv.lock with pyasn1 v0.6.3
2026-03-17 16:19:13 -04:00
Mark Backman
05abc95b5f Update uv.lock with pyasn1 v0.6.3 2026-03-17 16:10:35 -04:00
Mark Backman
18e654b3f0 docs: add changelog for #4058 2026-03-17 12:01:50 -04:00
Mark Backman
790a23d2e5 fix: resolve raw language strings through Language enum for proper service conversion
Raw strings like "de-DE" passed as the language parameter to TTS/STT services
were bypassing the Language enum resolution logic, causing silent failures
(e.g. ElevenLabs expects "de" not "de-DE"). Now raw strings are first converted
to Language enums so they go through the same resolve_language() path, with a
warning logged for unrecognized strings.
2026-03-17 12:00:28 -04:00
Mark Backman
d70df1d8b0 Add changelog for #4057 2026-03-17 11:35:38 -04:00
Mark Backman
5000b040dd Fix stale state in user turn stop strategies between turns
Reset stop strategies at turn start (not just turn stop) so that late
transcriptions arriving between turns do not leave stale _text that
causes premature stops on the next turn. Also cancel pending timeout
tasks in reset() for both SpeechTimeout and TurnAnalyzer strategies.
2026-03-17 11:31:08 -04:00
Mark Backman
248419a7c4 Merge pull request #4050 from pipecat-ai/copilot/update-enable-dialout-to-false
Fix PSTN runner defaulting enable_dialout to True
2026-03-17 11:07:23 -04:00
Mark Backman
024e2ebd4e Fix deprecation warning when using filter_incomplete_user_turns 2026-03-17 10:51:01 -04:00
Mark Backman
091f88e42e feat: add enable_dialout parameter to configure() for dial-out rooms
Expose enable_dialout as a configure() parameter (default False) so
dial-out examples can opt in without needing to build DailyRoomProperties
manually.
2026-03-17 09:03:50 -04:00
Mark Backman
e11b486312 fix: clean up configure() type hints, deduplicate token expiry, and improve comment
Narrow misleading Optional type hints on parameters that never accept
None, extract the duplicated token_exp_duration * 60 * 60 calculation,
remove unnecessary forward-reference quotes on DailyMeetingTokenProperties,
and clarify why enable_dialout is explicitly set to False.
2026-03-17 08:54:07 -04:00
copilot-swe-agent[bot]
7e60320a74 fix: set enable_dialout to False in PSTN runner to prevent room creation failures
Co-authored-by: jamsea <614910+jamsea@users.noreply.github.com>
2026-03-17 04:04:11 +00:00
copilot-swe-agent[bot]
89cb0f089e Initial plan 2026-03-17 04:01:00 +00:00
Mark Backman
abb8bae6f7 Add changelog for #4046 2026-03-16 19:51:37 -04:00
Mark Backman
2801439e48 Fix OpenAI STT crash when language is a plain string instead of Language enum 2026-03-16 19:48:49 -04:00
Mark Backman
3b8d040e41 Fix SonioxSTTService crash when language_hints contains plain strings (#4045)
Refactor language_to_soniox_language to use resolve_language + LANGUAGE_MAP
pattern consistent with other services. Fix resolve_language fallback to use
str(language) instead of language.value so plain strings don't crash.
2026-03-16 19:45:03 -04:00
Mark Backman
2f7c441c1c Add changelog for #4026 2026-03-13 13:55:27 -04:00
Mark Backman
79b7a0f969 Fix DeepgramSTTService base_url forcing HTTPS/WSS schemes
The base_url parameter previously forced wss:// and https:// schemes,
breaking air-gapped or private deployments that need ws:// or http://.
Extract URL derivation into _derive_deepgram_urls() helper that respects
the developers scheme choice while deriving the paired WebSocket and
HTTP URLs the Deepgram SDK requires.

Closes #4019
2026-03-13 13:53:06 -04:00
22 changed files with 642 additions and 35 deletions

1
changelog/4026.fixed.md Normal file
View File

@@ -0,0 +1 @@
- 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.

1
changelog/4046.fixed.md Normal file
View File

@@ -0,0 +1 @@
Fixed `SonioxSTTService` and `OpenAIRealtimeSTTService` crash when language parameters contain plain strings instead of `Language` enum values.

View File

@@ -0,0 +1 @@
- 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.

1
changelog/4057.fixed.md Normal file
View File

@@ -0,0 +1 @@
- 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.

1
changelog/4058.fixed.md Normal file
View File

@@ -0,0 +1 @@
- 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.

1
changelog/4063.fixed.md Normal file
View File

@@ -0,0 +1 @@
- 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.

View File

@@ -75,6 +75,7 @@ from pipecat.processors.aggregators.llm_context_summarizer import (
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
@@ -561,10 +562,10 @@ class LLMUserAggregator(LLMContextAggregator):
# Enable the feature on the LLM with config
await self.push_frame(
LLMUpdateSettingsFrame(
settings={
"filter_incomplete_user_turns": True,
"user_turn_completion_config": config,
}
delta=LLMSettings(
filter_incomplete_user_turns=True,
user_turn_completion_config=config,
)
)
)

View File

@@ -79,16 +79,17 @@ 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.
@@ -105,6 +106,8 @@ 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
@@ -159,6 +162,7 @@ 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,
@@ -184,6 +188,8 @@ 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_ROOM_URL")
if existing_room_url and not sip_enabled:
@@ -192,11 +198,12 @@ async def configure(
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
@@ -229,7 +236,7 @@ async def configure(
provider=sip_provider,
)
room_properties.sip = sip_params
room_properties.enable_dialout = True # Enable outbound calls if needed
room_properties.enable_dialout = enable_dialout
room_properties.start_video_off = not sip_enable_video # Voice-only by default
# Create room parameters
@@ -241,7 +248,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)

View File

@@ -247,6 +247,45 @@ class DeepgramSTTSettings(STTSettings):
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.
@@ -445,8 +484,7 @@ class DeepgramSTTService(STTService):
try:
from deepgram import DeepgramClientEnvironment
ws_url = base_url if base_url.startswith("wss://") else f"wss://{base_url}"
http_url = base_url if base_url.startswith("https://") else f"https://{base_url}"
ws_url, http_url = _derive_deepgram_urls(base_url)
environment = DeepgramClientEnvironment(
base=http_url,
production=ws_url,
@@ -554,7 +592,15 @@ class DeepgramSTTService(STTService):
value = getattr(s, f.name)
if not is_given(value) or value is None:
continue
kwargs[f.name] = str(value).lower() if isinstance(value, bool) else str(value)
# 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:
@@ -580,7 +626,12 @@ class DeepgramSTTService(STTService):
# Any remaining values in extra (that didn't map to declared fields)
for key, value in s.extra.items():
if value is not None:
kwargs[key] = str(value).lower() if isinstance(value, bool) else str(value)
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():

View File

@@ -358,8 +358,8 @@ class OpenAIRealtimeSTTService(WebsocketSTTService):
Returns:
Two-letter ISO-639-1 language code.
"""
# Language.value is e.g. "en", "en-US", "fr", "zh".
return language.value.split("-")[0].lower()
# 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.

View File

@@ -27,7 +27,7 @@ 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
@@ -118,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(

View File

@@ -124,6 +124,18 @@ class STTService(AIService):
# 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:
@@ -294,7 +306,20 @@ class STTService(AIService):
Returns:
Dict mapping changed field names to their previous values.
"""
# Translate language *before* applying so the stored value is canonical
# 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:

View File

@@ -248,6 +248,18 @@ class TTSService(AIService):
# 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:
@@ -610,7 +622,20 @@ class TTSService(AIService):
Returns:
Dict mapping changed field names to their previous values.
"""
# Translate language *before* applying so the stored value is canonical
# 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:
@@ -908,7 +933,9 @@ class TTSService(AIService):
# 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
AggregatedTextFrame(aggregate.text, aggregate.type),
includes_inter_frame_spaces,
append_tts_text_to_context=frame.append_to_context,
)
async def _push_tts_frames(

View File

@@ -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

View File

@@ -64,6 +64,9 @@ class SpeechTimeoutUserTurnStopStrategy(BaseUserTurnStopStrategy):
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.

View File

@@ -68,6 +68,9 @@ class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy):
self._vad_user_speaking = False
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.

View File

@@ -256,6 +256,10 @@ class UserTurnController(BaseObject):
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(

View File

@@ -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)

View File

@@ -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]

View File

@@ -15,6 +15,7 @@ from pipecat.frames.frames import (
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.turns.user_start import VADUserTurnStartStrategy
from pipecat.turns.user_start.min_words_user_turn_start_strategy import (
MinWordsUserTurnStartStrategy,
)
@@ -199,6 +200,73 @@ class TestUserTurnController(unittest.IsolatedAsyncioTestCase):
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()

View File

@@ -493,6 +493,50 @@ class TestSpeechTimeoutUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
# 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="")
)
await strategy.process_frame(VADUserStoppedSpeakingFrame())
# Wait for timeout — should get turn 2 stop with the real transcription
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
self.assertEqual(stop_count, 2)
class TestExternalUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
async def test_external_strategy(self):

12
uv.lock generated
View File

@@ -2126,6 +2126,7 @@ wheels = [
{ 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" },
@@ -2133,6 +2134,7 @@ wheels = [
{ 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" },
@@ -2141,6 +2143,7 @@ wheels = [
{ 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" },
@@ -2149,6 +2152,7 @@ wheels = [
{ 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" },
@@ -2157,6 +2161,7 @@ wheels = [
{ 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" },
@@ -2165,6 +2170,7 @@ wheels = [
{ 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" },
@@ -5217,11 +5223,11 @@ wheels = [
[[package]]
name = "pyasn1"
version = "0.6.2"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
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/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
{ 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]]