Files
livekit--agents/tests/test_inference_stt_fallback.py
2026-07-13 13:39:38 +08:00

315 lines
12 KiB
Python

import pytest
from livekit.agents.inference.stt import (
STT,
FallbackModel,
_normalize_fallback,
_parse_model_string,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
)
pytestmark = pytest.mark.unit
def _make_stt(**kwargs):
"""Helper to create STT with required credentials."""
defaults = {
"model": "deepgram",
"api_key": "test-key",
"api_secret": "test-secret",
"base_url": "https://example.livekit.cloud",
}
defaults.update(kwargs)
return STT(**defaults)
class TestParseModelString:
def test_simple_model_without_language(self):
"""Model string without language suffix returns NOT_GIVEN for language."""
model, language = _parse_model_string("deepgram")
assert model == "deepgram"
assert language is NOT_GIVEN
def test_model_with_language_suffix(self):
"""Model string with :language suffix extracts the language."""
model, language = _parse_model_string("deepgram:en")
assert model == "deepgram"
assert language == "en"
def test_provider_model_format_without_language(self):
"""Provider/model format without language suffix."""
model, language = _parse_model_string("deepgram/nova-3")
assert model == "deepgram/nova-3"
assert language is NOT_GIVEN
def test_provider_model_format_with_language(self):
"""Provider/model format with language suffix."""
model, language = _parse_model_string("deepgram/nova-3:en")
assert model == "deepgram/nova-3"
assert language == "en"
@pytest.mark.parametrize(
"model_str,expected_model,expected_lang",
[
("cartesia/ink-whisper:de", "cartesia/ink-whisper", "de"),
("assemblyai:es", "assemblyai", "es"),
("deepgram/nova-2-medical:ja", "deepgram/nova-2-medical", "ja"),
("deepgram/nova-3:multi", "deepgram/nova-3", "multi"),
("cartesia:zh", "cartesia", "zh"),
],
)
def test_various_providers_and_languages(self, model_str, expected_model, expected_lang):
"""Test various provider/model combinations with different languages."""
model, language = _parse_model_string(model_str)
assert model == expected_model
assert language == expected_lang
def test_auto_model(self):
"""Auto model without language."""
model, language = _parse_model_string("auto")
assert model == "auto"
assert language is NOT_GIVEN
def test_auto_model_with_language(self):
"""Auto model with language suffix."""
model, language = _parse_model_string("auto:pt")
assert model == "auto"
assert language == "pt"
class TestNormalizeFallback:
def test_single_string_model(self):
"""Single string model becomes a list with one FallbackModel."""
result = _normalize_fallback("deepgram/nova-3")
assert result == [{"model": "deepgram/nova-3"}]
def test_single_fallback_model_dict(self):
"""Single FallbackModel dict becomes a list with that dict."""
fallback = FallbackModel(model="deepgram/nova-3")
result = _normalize_fallback(fallback)
assert result == [{"model": "deepgram/nova-3"}]
def test_list_of_string_models(self):
"""List of string models becomes list of FallbackModels."""
result = _normalize_fallback(["deepgram/nova-3", "cartesia/ink-whisper"])
assert result == [
{"model": "deepgram/nova-3"},
{"model": "cartesia/ink-whisper"},
]
def test_list_of_fallback_model_dicts(self):
"""List of FallbackModel dicts is preserved."""
fallbacks = [
FallbackModel(model="deepgram/nova-3"),
FallbackModel(model="assemblyai"),
]
result = _normalize_fallback(fallbacks)
assert result == [
{"model": "deepgram/nova-3"},
{"model": "assemblyai"},
]
def test_mixed_list_strings_and_dicts(self):
"""Mixed list of strings and FallbackModel dicts."""
fallbacks = [
"deepgram/nova-3",
FallbackModel(model="cartesia/ink-whisper"),
"assemblyai",
]
result = _normalize_fallback(fallbacks)
assert result == [
{"model": "deepgram/nova-3"},
{"model": "cartesia/ink-whisper"},
{"model": "assemblyai"},
]
def test_string_with_language_suffix_discards_language(self):
"""LanguageCode suffix in string model is discarded."""
result = _normalize_fallback("deepgram/nova-3:en")
assert result == [{"model": "deepgram/nova-3"}]
def test_fallback_model_with_extra_kwargs(self):
"""FallbackModel with extra_kwargs is preserved."""
fallback = FallbackModel(
model="deepgram/nova-3",
extra_kwargs={"keywords": [("livekit", 1.5)], "punctuate": True},
)
result = _normalize_fallback(fallback)
assert result == [
{
"model": "deepgram/nova-3",
"extra_kwargs": {"keywords": [("livekit", 1.5)], "punctuate": True},
}
]
def test_list_with_extra_kwargs_preserved(self):
"""List with FallbackModels containing extra_kwargs."""
fallbacks = [
FallbackModel(model="deepgram/nova-3", extra_kwargs={"punctuate": True}),
"cartesia/ink-whisper",
FallbackModel(model="assemblyai", extra_kwargs={"format_turns": True}),
]
result = _normalize_fallback(fallbacks)
assert result == [
{"model": "deepgram/nova-3", "extra_kwargs": {"punctuate": True}},
{"model": "cartesia/ink-whisper"},
{"model": "assemblyai", "extra_kwargs": {"format_turns": True}},
]
def test_empty_list(self):
"""Empty list returns empty list."""
result = _normalize_fallback([])
assert result == []
def test_multiple_colons_in_model_string(self):
"""Multiple colons in model string - splits on last, discards language."""
result = _normalize_fallback("some:model:part:fr")
assert result == [{"model": "some:model:part"}]
class TestSTTConstructorFallbackAndConnectOptions:
"""Tests for STT constructor focusing on fallback and connect_options args."""
def test_fallback_not_given(self):
"""When fallback is not provided, _opts.fallback is NOT_GIVEN."""
stt = _make_stt()
assert stt._opts.fallback is NOT_GIVEN
def test_fallback_single_string(self):
"""Single string fallback is normalized to list of FallbackModel."""
stt = _make_stt(fallback="cartesia/ink-whisper")
assert stt._opts.fallback == [{"model": "cartesia/ink-whisper"}]
def test_fallback_list_of_strings(self):
"""List of string fallbacks is normalized."""
stt = _make_stt(fallback=["deepgram/nova-3", "assemblyai"])
assert stt._opts.fallback == [
{"model": "deepgram/nova-3"},
{"model": "assemblyai"},
]
def test_fallback_single_fallback_model(self):
"""Single FallbackModel is normalized to list."""
stt = _make_stt(fallback=FallbackModel(model="deepgram/nova-3"))
assert stt._opts.fallback == [{"model": "deepgram/nova-3"}]
def test_fallback_with_extra_kwargs(self):
"""FallbackModel with extra_kwargs is preserved in _opts."""
stt = _make_stt(
fallback=FallbackModel(
model="deepgram/nova-3",
extra_kwargs={"punctuate": True, "keywords": [("livekit", 1.5)]},
)
)
assert stt._opts.fallback == [
{
"model": "deepgram/nova-3",
"extra_kwargs": {"punctuate": True, "keywords": [("livekit", 1.5)]},
}
]
def test_fallback_mixed_list(self):
"""Mixed list of strings and FallbackModels is normalized."""
stt = _make_stt(
fallback=[
"deepgram/nova-3",
FallbackModel(model="cartesia", extra_kwargs={"min_volume": 0.5}),
"assemblyai",
]
)
assert stt._opts.fallback == [
{"model": "deepgram/nova-3"},
{"model": "cartesia", "extra_kwargs": {"min_volume": 0.5}},
{"model": "assemblyai"},
]
def test_fallback_string_with_language_discarded(self):
"""LanguageCode suffix in fallback string is discarded."""
stt = _make_stt(fallback="deepgram/nova-3:en")
assert stt._opts.fallback == [{"model": "deepgram/nova-3"}]
def test_connect_options_not_given_uses_default(self):
"""When connect_options is not provided, uses DEFAULT_API_CONNECT_OPTIONS."""
stt = _make_stt()
assert stt._opts.conn_options == DEFAULT_API_CONNECT_OPTIONS
def test_connect_options_custom_timeout(self):
"""Custom connect_options with timeout is stored."""
custom_opts = APIConnectOptions(timeout=30.0)
stt = _make_stt(conn_options=custom_opts)
assert stt._opts.conn_options == custom_opts
assert stt._opts.conn_options.timeout == 30.0
def test_connect_options_custom_max_retry(self):
"""Custom conn_options with max_retry is stored."""
custom_opts = APIConnectOptions(max_retry=5)
stt = _make_stt(conn_options=custom_opts)
assert stt._opts.conn_options == custom_opts
assert stt._opts.conn_options.max_retry == 5
def test_connect_options_full_custom(self):
"""Fully custom connect_options is stored correctly."""
custom_opts = APIConnectOptions(timeout=60.0, max_retry=10, retry_interval=2.0)
stt = _make_stt(conn_options=custom_opts)
assert stt._opts.conn_options == custom_opts
assert stt._opts.conn_options.timeout == 60.0
assert stt._opts.conn_options.max_retry == 10
assert stt._opts.conn_options.retry_interval == 2.0
class TestSTTDiarizationCapabilities:
"""Tests for STT diarization capability detection from extra_kwargs."""
def test_no_diarization_by_default(self):
"""Without diarization params, capabilities.diarization is False."""
stt = _make_stt()
assert stt.capabilities.diarization is False
def test_diarization_enabled_with_deepgram_diarize(self):
"""Deepgram's 'diarize' param enables diarization capability."""
stt = _make_stt(extra_kwargs={"diarize": True})
assert stt.capabilities.diarization is True
def test_diarization_disabled_with_diarize_false(self):
"""Deepgram's 'diarize: False' keeps diarization capability False."""
stt = _make_stt(extra_kwargs={"diarize": False})
assert stt.capabilities.diarization is False
def test_diarization_enabled_with_assemblyai_speaker_labels(self):
"""AssemblyAI's 'speaker_labels' param enables diarization capability."""
stt = _make_stt(
model="assemblyai/universal-streaming", extra_kwargs={"speaker_labels": True}
)
assert stt.capabilities.diarization is True
def test_update_options_toggles_diarization(self):
"""update_options can enable and disable diarization capability."""
stt = _make_stt()
assert stt.capabilities.diarization is False
stt.update_options(extra={"diarize": True})
assert stt.capabilities.diarization is True
stt.update_options(extra={"diarize": False})
assert stt.capabilities.diarization is False
def test_diarization_enabled_with_xai_diarize(self):
"""xAI shares the 'diarize' key with Deepgram; capability flips True."""
stt = _make_stt(model="xai/stt-1", extra_kwargs={"diarize": True})
assert stt.capabilities.diarization is True
def test_update_options_extra_preserves_unrelated_flags(self):
"""Partial extra updates merge into existing extra_kwargs instead of
replacing them — so a prior diarize=True is retained when the new
extra only mentions an unrelated key.
"""
stt = _make_stt(extra_kwargs={"diarize": True})
assert stt.capabilities.diarization is True
stt.update_options(extra={"endpointing": 500})
# diarize should still be active after the partial update.
assert stt._opts.extra_kwargs.get("diarize") is True
assert stt._opts.extra_kwargs.get("endpointing") == 500
assert stt.capabilities.diarization is True