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:
Paul Kompfner
2026-04-29 10:56:04 -04:00
parent 96756bc1f6
commit 814f00ce41
18 changed files with 86 additions and 43 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
}

View File

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

View File

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

View 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]:

View File

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

View File

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

View File

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