fix: clear 19 TTS/STT/etc. services from pyright ignore list
Several adjacent fix shapes that together drop 19 files from the pyrightconfig.json ignore list (96 → 77) and full-pyright errors from 605 → 580. Default pyright stays clean. TTS voice/context_id None handling — most files in this batch had a single error of the shape "value typed `T | None` passed where `T` is required" coming out of `assert_given(self._settings.voice)` (which strips `_NotGiven` but not `None`) or `get_active_audio_context_id()`. Two patterns: - For services where a missing voice means the request can't proceed (hume, openai, xtts, groq, kokoro, piper), added an explicit None check. Inside `run_tts` we yield an `ErrorFrame` and return — matching each service's existing error-emission style (a few wrap `Exception` broadly and were fine; openai/hume/xtts had narrower or no try blocks so a bare `raise ValueError` would have escaped uncaught). Piper validates in `__init__`, where failing fast at construction is the right shape. OpenAI also gained a `voice not in VALID_VOICES` guard with a clear message listing supported voices. - For services where a missing audio context just means "skip this message" (fish, lmnt, smallest, sarvam, neuphonic), widened `TTSService.append_to_audio_context`'s `context_id` signature to `str | None`. The function body already explicitly handled the None case with a debug log + early return, so the prior `str` annotation was a lie; making it honest cleared call sites without local guards. inworld's `_close_context` got the same treatment. google.genai imports — switched `from google import genai` to `import google.genai as genai` in google/image.py and google/llm.py. The dotted form sidesteps a PEP 420 namespace-package stub gap (the `google` namespace stubs come from a different distribution and don't declare `genai`), which means pyright now resolves `genai` to the real module rather than `Unknown`. IDE autocomplete on `genai.<x>` works for the first time. In image.py this surfaced three latent bugs that the `Unknown` resolution had been hiding (model was `str | _NotGiven | None` not narrowed before passing to the SDK; two spots accessed `.image_bytes` on an `Image | None` without a guard) — all fixed. llm.py's dotted import surfaced 8 errors (Content-list typing nuances, internal `_api_client` access, a few small Optionals); deferred to a future pass since they're outside this commit's scope, so the file stays in the ignore list with the dotted import. Latent bug fixes spotted along the way: - resembleai/tts.py was calling `push_error(ErrorFrame(...))`, but `push_error` takes a string — there's a separate `push_error_frame` for the frame case. Switched to the right method. - openai/base_llm.py: `max_completion_tokens` was the only sibling field on `OpenAILLMSettings` missing `| None` in its type, which caused the assignment in openai/llm.py from `params.max_completion_tokens` (`int | None`) to fail. Added `| None` for consistency with `max_tokens` etc. - heygen/base_api.py: `livekit_url: str = None` and `ws_url: str = None` declared `str` while defaulting to `None`. Removed the bogus defaults — both fields are required at construction in every in-tree call site, and the previous `str = None` was a Pydantic footgun. Other small ones: gladia/stt.py needed a None guard on `_session_url` before `websocket_connect`; openrouter/llm.py's `build_chat_completion_params` override widened to `dict[str, Any]` diverging from the parent's `OpenAILLMInvocationParams` — restored the parent's type; neuphonic/tts.py guarded the receive loop's `async for message in self._websocket` with a local-variable narrowing matching the pattern from 9e9b1f39e. groq/tts.py: tightened `output_format`'s typing to `Literal["flac","mp3","mulaw","ogg","wav"] | str = "wav"`. The literal side gives IDE autocomplete hints for the currently-supported set; the `| str` side keeps callers unblocked if groq adds a new format before this list is updated. A `cast` at the API boundary satisfies groq's stricter `Literal` parameter type. The literal alias mirrors the inlined Literal on `groq.resources.audio.speech.AsyncSpeech.create`'s `response_format` (the SDK doesn't export it as a named symbol). websocket_service.py: scoped `# pyright: ignore[reportAttributeAccessIssue]` on `websockets.WebSocketClientProtocol`. That alias is now a deprecated re-export from the legacy submodule and pyright doesn't surface it on the top-level `websockets` namespace; runtime is fine. Migrating to `websockets.ClientConnection` is a separate piece of work (transports/websocket/client.py uses the same alias four times) and left for a future commit. Files dropped from the ignore list: fish/tts.py, gladia/stt.py, google/image.py, groq/tts.py, heygen/base_api.py, hume/tts.py, inworld/tts.py, kokoro/tts.py, lmnt/tts.py, neuphonic/tts.py, openai/llm.py, openai/tts.py, openrouter/llm.py, piper/tts.py, resembleai/tts.py, sarvam/tts.py, smallest/tts.py, websocket_service.py, xtts/tts.py.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user