Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ class ModelConfig:
"""Immutable configuration for a Sarvam STT model.

Attributes:
supports_prompt: Whether the model accepts prompt parameter.
supports_prompt: Whether the model's endpoint accepts the initial
config/prompt message (documented only for the legacy translate
endpoint used by saaras:v2.5).
supports_mode: Whether the model accepts mode parameter.
supports_language: Whether the model accepts language parameter.
supports_vad_params: Whether the model accepts fine-grained VAD parameters.
Expand Down Expand Up @@ -170,7 +172,10 @@ class ModelConfig:
allowed_languages=SAARIKA_V25_LANGUAGES,
),
"saaras:v3": ModelConfig(
supports_prompt=True,
# the /speech-to-text/ws endpoint saaras:v3 connects to documents no
# config/prompt message; only the legacy translate endpoint does (see
# _model_supports_prompt)
supports_prompt=False,
supports_mode=True,
supports_language=True,
supports_vad_params=True,
Expand Down Expand Up @@ -245,12 +250,19 @@ def _validate_language_for_model(model: str, language: str | None) -> str | None


def _model_supports_prompt(model: str) -> bool:
"""Check whether the model supports prompt parameter."""
"""Check whether the model accepts the initial ``config``/``prompt`` message.

Sarvam only documents that message on the legacy translate endpoint
(``/speech-to-text-translate/ws``, used by ``saaras:v2.5``); the current
``/speech-to-text/ws`` endpoint has no such message in its schema, so
sending one there is a silent no-op server-side.
"""
model_config = _get_model_config(model)
if model_config:
return model_config.supports_prompt
# Fallback for unknown models: assume saaras-family supports prompt
return model.startswith("saaras")
# Unknown models are routed to the non-translate endpoint by
# _get_urls_for_model, which has no config/prompt message.
return False


def _model_supports_mode(model: str) -> bool:
Expand Down Expand Up @@ -289,7 +301,8 @@ class SarvamSTTOptions:
mode: Mode for saaras:v3 (transcribe/translate/verbatim/translit/codemix)
base_url: API endpoint URL (auto-determined from model if not provided)
streaming_url: WebSocket streaming URL (auto-determined from model if not provided)
prompt: Optional prompt for STT translate (saaras models only)
prompt: Optional prompt for STT translate (saaras:v2.5 only; ignored by
other models — their endpoint has no config/prompt message)
"""

language: str # BCP-47 language code, e.g., "hi-IN", "en-IN"
Expand All @@ -298,7 +311,7 @@ class SarvamSTTOptions:
mode: SarvamSTTModes | str = "transcribe"
base_url: str | None = None
streaming_url: str | None = None
prompt: str | None = None # Optional prompt for STT translate (saaras models only)
prompt: str | None = None # Optional prompt for STT translate (saaras:v2.5 only)
high_vad_sensitivity: bool | None = None
sample_rate: int = 16000
flush_signal: bool | None = None
Expand Down Expand Up @@ -329,6 +342,13 @@ def __post_init__(self) -> None:
self.mode = _validate_mode_for_model(self.model, self.mode)
if self.sample_rate <= 0:
raise ValueError("sample_rate must be greater than zero")
if self.prompt and not _model_supports_prompt(self.model):
logger.warning(
"prompt is ignored for model %s: its streaming endpoint has no "
"config/prompt message (only saaras:v2.5 on the legacy translate "
"endpoint documents one)",
self.model,
)


def _get_urls_for_model(model: str) -> tuple[str, str]:
Expand Down Expand Up @@ -482,7 +502,8 @@ class STT(stt.STT):
api_key: Sarvam.ai API key (falls back to SARVAM_API_KEY env var)
base_url: API endpoint URL
http_session: Optional aiohttp session to use
prompt: Optional prompt for STT translate (saaras models only)
prompt: Optional prompt for STT translate (saaras:v2.5 only; ignored by
other models — their endpoint has no config/prompt message)
"""

def __init__(
Expand Down Expand Up @@ -1142,6 +1163,13 @@ def update_options(
self._opts.base_url, self._opts.streaming_url = _get_urls_for_model(model)
if prompt is not None:
self._opts.prompt = prompt
if prompt and not _model_supports_prompt(model):
self._logger.warning(
"prompt is ignored for model %s: its streaming endpoint has no "
"config/prompt message (only saaras:v2.5 on the legacy translate "
"endpoint documents one)",
model,
)

# Use centralised validation
self._opts.mode = _validate_mode_for_model(model, mode)
Expand All @@ -1154,7 +1182,7 @@ def update_options(
self._reconnect_event.set()

async def _send_initial_config(self, ws: aiohttp.ClientWebSocketResponse) -> None:
"""Send initial configuration message with prompt for saaras models."""
"""Send the config/prompt message (translate endpoint, saaras:v2.5 only)."""
try:
config_message = {"prompt": self._opts.prompt, "type": "config"}
await ws.send_str(json.dumps(config_message))
Expand Down
95 changes: 95 additions & 0 deletions tests/test_sarvam_stt_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Hermetic tests for Sarvam STT model capabilities (no network, no credentials).

Regression tests for issue #6606: ``saaras:v3`` connects to
``wss://api.sarvam.ai/speech-to-text/ws``, whose schema documents no
``config``/``prompt`` message — only the legacy translate endpoint used by
``saaras:v2.5`` does. The plugin used to mark ``saaras:v3`` as
``supports_prompt=True`` and send a config message the endpoint silently
drops, so callers built hotword biasing on a parameter that was never wired
to anything.
"""

import logging

import pytest

from livekit.plugins.sarvam.stt import (
SARVAM_STT_STREAMING_URL,
SARVAM_STT_TRANSLATE_STREAMING_URL,
SarvamSTTOptions,
_get_urls_for_model,
_model_supports_prompt,
)

pytestmark = pytest.mark.unit


class TestModelSupportsPrompt:
def test_saaras_v3_does_not_support_prompt(self) -> None:
# /speech-to-text/ws has no config/prompt message in its schema
assert _model_supports_prompt("saaras:v3") is False

def test_saaras_v25_supports_prompt(self) -> None:
# the legacy translate endpoint documents the config/prompt message
assert _model_supports_prompt("saaras:v2.5") is True

def test_saarika_does_not_support_prompt(self) -> None:
assert _model_supports_prompt("saarika:v2.5") is False

def test_unknown_model_does_not_support_prompt(self) -> None:
# unknown models are routed to the non-translate endpoint, which has
# no config/prompt message — regardless of the model-family name
assert _model_supports_prompt("saaras:v99") is False
assert _model_supports_prompt("some-future-model") is False

def test_prompt_support_matches_translate_endpoint(self) -> None:
# the config/prompt message only exists on the translate endpoint;
# any model claiming prompt support must connect there
from livekit.plugins.sarvam.stt import MODEL_CONFIGS

for model, config in MODEL_CONFIGS.items():
if config.supports_prompt:
_, streaming_url = _get_urls_for_model(model)
assert streaming_url == SARVAM_STT_TRANSLATE_STREAMING_URL, (
f"{model} claims prompt support but connects to {streaming_url}, "
"which has no config/prompt message"
)


class TestEndpointRouting:
def test_saaras_v3_uses_speech_to_text_endpoint(self) -> None:
_, streaming_url = _get_urls_for_model("saaras:v3")
assert streaming_url == SARVAM_STT_STREAMING_URL

def test_saaras_v25_uses_translate_endpoint(self) -> None:
_, streaming_url = _get_urls_for_model("saaras:v2.5")
assert streaming_url == SARVAM_STT_TRANSLATE_STREAMING_URL


class TestPromptWarning:
def test_warns_when_prompt_set_for_unsupported_model(
self, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING, logger="livekit.plugins.sarvam"):
SarvamSTTOptions(
language="en-IN",
api_key="test-key",
model="saaras:v3",
prompt="guests, one, two, three",
)
assert any("prompt is ignored" in r.message for r in caplog.records)

def test_no_warning_for_supported_model(self, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING, logger="livekit.plugins.sarvam"):
SarvamSTTOptions(
language="en-IN",
api_key="test-key",
model="saaras:v2.5",
prompt="guests, one, two, three",
)
assert not any("prompt is ignored" in r.message for r in caplog.records)

def test_no_warning_without_prompt(self, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING, logger="livekit.plugins.sarvam"):
SarvamSTTOptions(language="en-IN", api_key="test-key", model="saaras:v3")
assert not any("prompt is ignored" in r.message for r in caplog.records)