Compare commits
26 Commits
mb/togethe
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dcab14675 | ||
|
|
e3b69a93d9 | ||
|
|
53388e0426 | ||
|
|
edf16c5533 | ||
|
|
d4f69dd333 | ||
|
|
a32f558b07 | ||
|
|
4e99cb39b0 | ||
|
|
10b3bff525 | ||
|
|
95ee096622 | ||
|
|
6799995b0a | ||
|
|
05abc95b5f | ||
|
|
18e654b3f0 | ||
|
|
790a23d2e5 | ||
|
|
d70df1d8b0 | ||
|
|
5000b040dd | ||
|
|
248419a7c4 | ||
|
|
024e2ebd4e | ||
|
|
091f88e42e | ||
|
|
e11b486312 | ||
|
|
7e60320a74 | ||
|
|
89cb0f089e | ||
|
|
abb8bae6f7 | ||
|
|
2801439e48 | ||
|
|
3b8d040e41 | ||
|
|
2f7c441c1c | ||
|
|
79b7a0f969 |
1
changelog/4026.fixed.md
Normal file
1
changelog/4026.fixed.md
Normal 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
1
changelog/4046.fixed.md
Normal file
@@ -0,0 +1 @@
|
||||
Fixed `SonioxSTTService` and `OpenAIRealtimeSTTService` crash when language parameters contain plain strings instead of `Language` enum values.
|
||||
1
changelog/4048.changed.md
Normal file
1
changelog/4048.changed.md
Normal 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
1
changelog/4057.fixed.md
Normal 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
1
changelog/4058.fixed.md
Normal 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
1
changelog/4063.fixed.md
Normal 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.
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
51
tests/test_deepgram_stt.py
Normal file
51
tests/test_deepgram_stt.py
Normal 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)
|
||||
251
tests/test_service_language.py
Normal file
251
tests/test_service_language.py
Normal 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]
|
||||
@@ -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()
|
||||
|
||||
@@ -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
12
uv.lock
generated
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user