diff --git a/pyrightconfig.json b/pyrightconfig.json index d882b2106..0e54af4c7 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -37,58 +37,39 @@ "src/pipecat/services/deepgram/sagemaker/tts.py", "src/pipecat/services/deepgram/tts.py", "src/pipecat/services/elevenlabs/tts.py", - "src/pipecat/services/fish/tts.py", - "src/pipecat/services/gladia/stt.py", "src/pipecat/services/google/gemini_live/llm.py", "src/pipecat/services/google/gemini_live/vertex/llm.py", - "src/pipecat/services/google/image.py", "src/pipecat/services/google/llm.py", "src/pipecat/services/google/stt.py", "src/pipecat/services/google/tts.py", "src/pipecat/services/gradium/stt.py", - "src/pipecat/services/groq/tts.py", "src/pipecat/services/heygen/api_interactive_avatar.py", - "src/pipecat/services/heygen/base_api.py", "src/pipecat/services/heygen/client.py", "src/pipecat/services/heygen/video.py", - "src/pipecat/services/hume/tts.py", "src/pipecat/services/inworld/realtime/llm.py", - "src/pipecat/services/inworld/tts.py", - "src/pipecat/services/kokoro/tts.py", "src/pipecat/services/llm_service.py", - "src/pipecat/services/lmnt/tts.py", "src/pipecat/services/mem0/memory.py", "src/pipecat/services/mistral/stt.py", "src/pipecat/services/mistral/tts.py", "src/pipecat/services/moondream/vision.py", - "src/pipecat/services/neuphonic/tts.py", "src/pipecat/services/nvidia/stt.py", "src/pipecat/services/nvidia/tts.py", "src/pipecat/services/openai/base_llm.py", "src/pipecat/services/openai/image.py", - "src/pipecat/services/openai/llm.py", "src/pipecat/services/openai/realtime/llm.py", "src/pipecat/services/openai/responses/llm.py", "src/pipecat/services/openai/stt.py", - "src/pipecat/services/openai/tts.py", - "src/pipecat/services/openrouter/llm.py", - "src/pipecat/services/piper/tts.py", - "src/pipecat/services/resembleai/tts.py", "src/pipecat/services/rime/tts.py", "src/pipecat/services/sambanova/llm.py", "src/pipecat/services/sarvam/stt.py", - "src/pipecat/services/sarvam/tts.py", "src/pipecat/services/simli/video.py", - "src/pipecat/services/smallest/tts.py", "src/pipecat/services/speechmatics/stt.py", "src/pipecat/services/stt_service.py", "src/pipecat/services/tavus/video.py", "src/pipecat/services/tts_service.py", "src/pipecat/services/ultravox/llm.py", - "src/pipecat/services/websocket_service.py", "src/pipecat/services/whisper/stt.py", "src/pipecat/services/xai/realtime/llm.py", - "src/pipecat/services/xtts/tts.py", "src/pipecat/transports/base_output.py", "src/pipecat/transports/daily/transport.py", "src/pipecat/transports/heygen/transport.py", diff --git a/src/pipecat/services/gladia/stt.py b/src/pipecat/services/gladia/stt.py index cc8e5279b..26fea653c 100644 --- a/src/pipecat/services/gladia/stt.py +++ b/src/pipecat/services/gladia/stt.py @@ -542,6 +542,8 @@ class GladiaSTTService(WebsocketSTTService): logger.debug(f"{self}Connecting to Gladia WebSocket") + if self._session_url is None: + raise RuntimeError(f"{self} session URL is not initialized") self._websocket = await websocket_connect(self._session_url) self._connection_active = True diff --git a/src/pipecat/services/google/image.py b/src/pipecat/services/google/image.py index 1ed8adc2c..bada7d0d6 100644 --- a/src/pipecat/services/google/image.py +++ b/src/pipecat/services/google/image.py @@ -30,7 +30,7 @@ from pipecat.services.image_service import ImageGenService from pipecat.services.settings import NOT_GIVEN, ImageGenSettings, _NotGiven, assert_given try: - from google import genai + import google.genai as genai from google.genai import types except ModuleNotFoundError as e: logger.error(f"Exception: {e}") @@ -153,8 +153,12 @@ class GoogleImageGenService(ImageGenService): await self.start_ttfb_metrics() try: + model = assert_given(self._settings.model) + if model is None: + yield ErrorFrame("Google image generation model must be specified") + return response = await self._client.aio.models.generate_images( - model=self._settings.model, + model=model, prompt=prompt, config=types.GenerateImagesConfig( number_of_images=assert_given(self._settings.number_of_images), @@ -169,8 +173,9 @@ class GoogleImageGenService(ImageGenService): for img_response in response.generated_images: # Google returns the image data directly - image_bytes = img_response.image.image_bytes - image = Image.open(io.BytesIO(image_bytes)) + if img_response.image is None or img_response.image.image_bytes is None: + continue + image = Image.open(io.BytesIO(img_response.image.image_bytes)) frame = URLImageRawFrame( url=None, # Google doesn't provide URLs, only image data diff --git a/src/pipecat/services/google/llm.py b/src/pipecat/services/google/llm.py index da9659872..599c203ab 100644 --- a/src/pipecat/services/google/llm.py +++ b/src/pipecat/services/google/llm.py @@ -52,7 +52,7 @@ from pipecat.utils.tracing.service_decorators import traced_llm os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" try: - from google import genai + import google.genai as genai from google.api_core.exceptions import DeadlineExceeded from google.genai.types import ( GenerateContentConfig, diff --git a/src/pipecat/services/groq/tts.py b/src/pipecat/services/groq/tts.py index 36e01ff8b..00038f2f0 100644 --- a/src/pipecat/services/groq/tts.py +++ b/src/pipecat/services/groq/tts.py @@ -10,6 +10,7 @@ import io import wave from collections.abc import AsyncGenerator from dataclasses import dataclass, field +from typing import Literal, cast from loguru import logger from pydantic import BaseModel @@ -31,6 +32,19 @@ except ModuleNotFoundError as e: logger.error("In order to use Groq, you need to `pip install pipecat-ai[groq]`.") raise Exception(f"Missing module: {e}") +# Hint set for `output_format`. The values mirror the Literal that +# `groq.resources.audio.speech.AsyncSpeech.create` accepts on its +# `response_format` parameter (also visible as the `response_format` field of +# `groq.types.audio.SpeechCreateParams`). The groq SDK does not export this as +# a named alias, so we redeclare it here. +# +# This alias is used in unions like `GroqAudioFormat | str`, so pyright shows +# these values as completion hints without rejecting other strings. If groq +# adds a new format before this list is updated, callers can still pass it and +# we forward it through (with a cast at the API boundary). Keep in sync on a +# best-effort basis when bumping the groq dep. +GroqAudioFormat = Literal["flac", "mp3", "mulaw", "ogg", "wav"] + @dataclass class GroqTTSSettings(TTSSettings): @@ -74,7 +88,7 @@ class GroqTTSService(TTSService): self, *, api_key: str, - output_format: str = "wav", + output_format: GroqAudioFormat | str = "wav", params: InputParams | None = None, model_name: str | None = None, voice_id: str | None = None, @@ -147,7 +161,7 @@ class GroqTTSService(TTSService): ) self._api_key = api_key - self._output_format = output_format + self._output_format: str = output_format self._client = AsyncGroq(api_key=self._api_key) @@ -178,12 +192,18 @@ class GroqTTSService(TTSService): speed = assert_given(self._settings.speed) if model is None: raise ValueError("Groq TTS model must be specified") + if voice is None: + raise ValueError("Groq TTS voice must be specified") if speed is None: raise ValueError("Groq TTS speed must be specified") response = await self._client.audio.speech.create( model=model, voice=voice, - response_format=self._output_format, + # Cast satisfies groq's stricter Literal typing while letting + # callers pass any string (e.g. a newer groq format we haven't + # yet added to GroqAudioFormat). If the value is unsupported, + # groq's API will surface a runtime error with a clear message. + response_format=cast(GroqAudioFormat, self._output_format), # Note: as of 2026-02-25, only a speed of 1.0 is supported, but # here we pass it for completeness and future-proofing speed=speed, diff --git a/src/pipecat/services/heygen/base_api.py b/src/pipecat/services/heygen/base_api.py index c866cfb11..57ca2e19e 100644 --- a/src/pipecat/services/heygen/base_api.py +++ b/src/pipecat/services/heygen/base_api.py @@ -33,8 +33,8 @@ class StandardSessionResponse(BaseModel): access_token: str livekit_agent_token: str - livekit_url: str = None - ws_url: str = None + livekit_url: str + ws_url: str raw_response: Any diff --git a/src/pipecat/services/hume/tts.py b/src/pipecat/services/hume/tts.py index ac17efd48..75a6d5b72 100644 --- a/src/pipecat/services/hume/tts.py +++ b/src/pipecat/services/hume/tts.py @@ -19,6 +19,7 @@ from pipecat import version as pipecat_version from pipecat.frames.frames import ( CancelFrame, EndFrame, + ErrorFrame, Frame, InterruptionFrame, StartFrame, @@ -289,10 +290,14 @@ class HumeTTSService(TTSService): """ logger.debug(f"{self}: Generating Hume TTS: [{text}]") + voice_id = assert_given(self._settings.voice) + if voice_id is None: + yield ErrorFrame(error="Hume TTS voice must be specified") + return # Build the request payload utterance_kwargs: dict[str, Any] = { "text": text, - "voice": PostedUtteranceVoiceWithId(id=assert_given(self._settings.voice)), + "voice": PostedUtteranceVoiceWithId(id=voice_id), } if self._settings.description is not None: utterance_kwargs["description"] = self._settings.description diff --git a/src/pipecat/services/inworld/tts.py b/src/pipecat/services/inworld/tts.py index ef3f0f956..0e087082c 100644 --- a/src/pipecat/services/inworld/tts.py +++ b/src/pipecat/services/inworld/tts.py @@ -794,8 +794,10 @@ class InworldTTSService(WebsocketTTSService): return word_times - async def _close_context(self, context_id: str): - if context_id and self._websocket: + async def _close_context(self, context_id: str | None): + if not context_id: + return + if self._websocket: logger.info(f"{self}: Closing context {context_id} due to interruption or completion") try: await self._send_close_context(context_id) diff --git a/src/pipecat/services/kokoro/tts.py b/src/pipecat/services/kokoro/tts.py index 81c90487f..dd96ee90b 100644 --- a/src/pipecat/services/kokoro/tts.py +++ b/src/pipecat/services/kokoro/tts.py @@ -216,6 +216,8 @@ class KokoroTTSService(TTSService): await self.start_tts_usage_metrics(text) voice = assert_given(self._settings.voice) + if voice is None: + raise ValueError("Kokoro TTS voice must be specified") lang = assert_given(self._settings.language) if lang is None: raise ValueError("Kokoro TTS language must be specified") diff --git a/src/pipecat/services/neuphonic/tts.py b/src/pipecat/services/neuphonic/tts.py index 6a405a1d2..5f27efaf1 100644 --- a/src/pipecat/services/neuphonic/tts.py +++ b/src/pipecat/services/neuphonic/tts.py @@ -331,7 +331,10 @@ class NeuphonicTTSService(InterruptibleTTSService): async def _receive_messages(self): """Receive and process messages from Neuphonic WebSocket.""" - async for message in self._websocket: + websocket = self._websocket + if websocket is None: + return + async for message in websocket: if isinstance(message, str): msg = json.loads(message) if msg.get("data") and msg["data"].get("audio"): diff --git a/src/pipecat/services/openai/base_llm.py b/src/pipecat/services/openai/base_llm.py index b5287067f..ae4b03560 100644 --- a/src/pipecat/services/openai/base_llm.py +++ b/src/pipecat/services/openai/base_llm.py @@ -66,7 +66,7 @@ class OpenAILLMSettings(LLMSettings): ) top_p: float | None | _NotGiven | OpenAINotGiven = field(default_factory=lambda: _NOT_GIVEN) max_tokens: int | None | _NotGiven | OpenAINotGiven = field(default_factory=lambda: _NOT_GIVEN) - max_completion_tokens: int | _NotGiven | OpenAINotGiven = field( + max_completion_tokens: int | None | _NotGiven | OpenAINotGiven = field( default_factory=lambda: _NOT_GIVEN ) diff --git a/src/pipecat/services/openai/tts.py b/src/pipecat/services/openai/tts.py index a0e6a93dd..a6528f59e 100644 --- a/src/pipecat/services/openai/tts.py +++ b/src/pipecat/services/openai/tts.py @@ -235,12 +235,22 @@ class OpenAITTSService(TTSService): Frame: Audio frames containing the synthesized speech data. """ logger.debug(f"{self}: Generating TTS [{text}]") + voice = assert_given(self._settings.voice) + if voice is None: + yield ErrorFrame(error="OpenAI TTS voice must be specified") + return + if voice not in VALID_VOICES: + yield ErrorFrame( + error=f"OpenAI TTS voice {voice!r} is not supported " + f"(must be one of: {', '.join(sorted(VALID_VOICES))})" + ) + return try: # Setup API parameters create_params = { "input": text, "model": self._settings.model, - "voice": VALID_VOICES[assert_given(self._settings.voice)], + "voice": VALID_VOICES[voice], "response_format": "pcm", } diff --git a/src/pipecat/services/openrouter/llm.py b/src/pipecat/services/openrouter/llm.py index 624dd3141..4de5190dd 100644 --- a/src/pipecat/services/openrouter/llm.py +++ b/src/pipecat/services/openrouter/llm.py @@ -15,6 +15,7 @@ from typing import Any from loguru import logger +from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.settings import assert_given @@ -96,7 +97,9 @@ class OpenRouterLLMService(OpenAILLMService): logger.debug(f"Creating OpenRouter client with api {base_url}") return super().create_client(api_key, base_url, **kwargs) - def build_chat_completion_params(self, params_from_context: dict[str, Any]) -> dict[str, Any]: + def build_chat_completion_params( + self, params_from_context: OpenAILLMInvocationParams + ) -> dict[str, Any]: """Builds chat parameters, handling model-specific constraints. Args: diff --git a/src/pipecat/services/piper/tts.py b/src/pipecat/services/piper/tts.py index 5cc725c2c..1bb9eaac8 100644 --- a/src/pipecat/services/piper/tts.py +++ b/src/pipecat/services/piper/tts.py @@ -101,6 +101,8 @@ class PiperTTSService(TTSService): download_dir = download_dir or Path.cwd() _voice = assert_given(self._settings.voice) + if _voice is None: + raise ValueError("Piper TTS voice must be specified") model_file = f"{_voice}.onnx" model_path_resolved = Path(download_dir) / model_file diff --git a/src/pipecat/services/resembleai/tts.py b/src/pipecat/services/resembleai/tts.py index 3053e0b70..c1dfc8a92 100644 --- a/src/pipecat/services/resembleai/tts.py +++ b/src/pipecat/services/resembleai/tts.py @@ -409,7 +409,9 @@ class ResembleAITTSService(WebsocketTTSService): await self.push_frame(TTSStoppedFrame(context_id=context_id)) await self.stop_all_metrics() - await self.push_error(ErrorFrame(error=f"{self} error: {error_name} - {error_msg}")) + await self.push_error_frame( + ErrorFrame(error=f"{self} error: {error_name} - {error_msg}") + ) # Check if this is an unrecoverable error (connection-level failure) if status_code in [401, 403]: diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index f386b5bcb..5cc4a4b45 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -1174,16 +1174,18 @@ class TTSService(AIService): logger.trace(f"{self} created audio context {context_id}") async def append_to_audio_context( - self, context_id: str, frame: Frame | _WordTimestampEntry | None + self, context_id: str | None, frame: Frame | _WordTimestampEntry | None ): """Append a frame or word-timestamp entry to an existing audio context queue. - Passing ``None`` signals end-of-context (used by remove_audio_context to mark - the queue for deletion). If the context no longer exists but the context_id + Passing a ``frame`` of ``None`` signals end-of-context (used by remove_audio_context + to mark the queue for deletion). If the context no longer exists but the context_id matches the active turn, the context is transparently recreated before appending. Args: - context_id: The context to append to. + context_id: The context to append to. ``None`` is accepted as a no-op + (with a debug log) so callers can pass through values from + ``get_active_audio_context_id()`` without an explicit guard. frame: The frame, word-timestamp entry, or ``None`` (end-of-context sentinel) to append. """ diff --git a/src/pipecat/services/websocket_service.py b/src/pipecat/services/websocket_service.py index 2e6040ead..d7d3cd505 100644 --- a/src/pipecat/services/websocket_service.py +++ b/src/pipecat/services/websocket_service.py @@ -42,7 +42,7 @@ class WebsocketService(ABC): reconnect_on_error: Whether to automatically reconnect on connection errors. **kwargs: Additional arguments (unused, for compatibility). """ - self._websocket: websockets.WebSocketClientProtocol | None = None + self._websocket: websockets.WebSocketClientProtocol | None = None # pyright: ignore[reportAttributeAccessIssue] self._reconnect_on_error = reconnect_on_error self._reconnect_in_progress: bool = False self._disconnecting: bool = False diff --git a/src/pipecat/services/xtts/tts.py b/src/pipecat/services/xtts/tts.py index d323e9b67..0d1a068f7 100644 --- a/src/pipecat/services/xtts/tts.py +++ b/src/pipecat/services/xtts/tts.py @@ -214,7 +214,11 @@ class XTTSService(TTSService): logger.error(f"{self} no studio speakers available") return - embeddings = self._studio_speakers[assert_given(self._settings.voice)] + voice = assert_given(self._settings.voice) + if voice is None: + yield ErrorFrame(error="XTTS voice must be specified") + return + embeddings = self._studio_speakers[voice] url = self._base_url + "/tts_stream"