The AudioSocket/media_runtime playback pipeline already supports chunked TTS streaming (voice_v2_streaming_tts), but every provider inherited the base TTSProvider.synthesize_chunks(), which just called the blocking synthesize() and yielded the entire finished audio as a single "chunk" - so the caller waited for full-utterance synthesis before any playback could start regardless of the flag. ElevenLabs is the production default (AI_VOICE_TTS_PROVIDER=elevenlabs in deployment/docker-compose.server.yml), so give it a real implementation that POSTs to the /stream endpoint and yields audio as network chunks arrive, instead of waiting for the whole response body. Chunk boundaries are re-aligned to whole 16-bit PCM samples so a split sample at a network read boundary can't corrupt playback. The full synthesized audio is still written to the on-disk cache afterwards so repeat phrases stay fast and skip the vendor call entirely, matching the existing synthesize() cache behavior. Added test_elevenlabs_tts_provider_streams_chunks_and_caches_full_audio to cover: chunk splitting mid-sample gets re-aligned, all yielded chunks are sample-aligned, the full audio round-trips through the cache, and a cached synthesis is replayed without invoking the streaming endpoint again. Verified via tests/test_ai_voice_tts_provider.py (9/9 pass) and a wider voice/tts-filtered run across the suite: the only failures present are the same pre-existing, already-documented ones (sales_service test cross-file isolation ordering, one known persona-prompt assertion) - identical set to before this change, no new failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
444 lines
18 KiB
Python
444 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from services.ai_voice_runtime_service.runtime_tts_provider import RuntimeConfiguredTTSProvider
|
|
from services.ai_voice_runtime_service.providers import tts as tts_module
|
|
from services.shared.db import get_session
|
|
from services.shared.sql_init import init_sql_schema
|
|
from services.shared.sql_models import VoiceTTSSettingsRow
|
|
from services.shared.voice_tts_config import save_voice_tts_config, voice_tts_default_config
|
|
|
|
init_sql_schema()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_voice_tts_settings():
|
|
session = get_session()
|
|
try:
|
|
row = session.execute(select(VoiceTTSSettingsRow)).scalar_one_or_none()
|
|
if row is not None:
|
|
session.delete(row)
|
|
session.commit()
|
|
finally:
|
|
session.close()
|
|
yield
|
|
session = get_session()
|
|
try:
|
|
row = session.execute(select(VoiceTTSSettingsRow)).scalar_one_or_none()
|
|
if row is not None:
|
|
session.delete(row)
|
|
session.commit()
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
class _DummyResponse:
|
|
def __init__(self, content: bytes = b"", json_payload: dict | None = None) -> None:
|
|
self.content = content
|
|
self._json_payload = json_payload or {}
|
|
|
|
def raise_for_status(self) -> None:
|
|
return None
|
|
|
|
def json(self) -> dict:
|
|
return self._json_payload
|
|
|
|
|
|
def test_openai_tts_provider_persists_cache_on_disk(tmp_path, monkeypatch):
|
|
calls: list[dict] = []
|
|
|
|
class _DummyClient:
|
|
def __init__(self, *, timeout: float) -> None:
|
|
self.timeout = timeout
|
|
|
|
def __enter__(self) -> _DummyClient:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
def post(self, url: str, *, headers: dict[str, str], json: dict[str, str]) -> _DummyResponse:
|
|
calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout})
|
|
return _DummyResponse(b"\x01\x00\x02\x00")
|
|
|
|
monkeypatch.setenv("AI_API_KEY", "test-key")
|
|
monkeypatch.setenv("AI_API_BASE", "https://example.test/v1")
|
|
monkeypatch.setenv("AI_VOICE_TTS_MODEL", "gpt-4o-mini-tts")
|
|
monkeypatch.setenv("AI_VOICE_TTS_VOICE", "alloy")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path))
|
|
monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient)
|
|
|
|
provider = tts_module.OpenAITTSProvider()
|
|
first = provider.synthesize("Hello from cache", language="ru")
|
|
second = provider.synthesize("Hello from cache", language="ru")
|
|
|
|
assert first.audio_bytes == b"\x01\x00\x02\x00"
|
|
assert second.audio_bytes == first.audio_bytes
|
|
assert len(calls) == 1
|
|
assert list(Path(tmp_path).rglob("*.pcm"))
|
|
assert list(Path(tmp_path).rglob("*.json"))
|
|
|
|
|
|
def test_openai_tts_provider_uses_cached_audio_without_api_key(tmp_path, monkeypatch):
|
|
calls: list[dict] = []
|
|
|
|
class _DummyClient:
|
|
def __init__(self, *, timeout: float) -> None:
|
|
self.timeout = timeout
|
|
|
|
def __enter__(self) -> _DummyClient:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
def post(self, url: str, *, headers: dict[str, str], json: dict[str, str]) -> _DummyResponse:
|
|
calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout})
|
|
return _DummyResponse(b"\x04\x00\x08\x00")
|
|
|
|
monkeypatch.setenv("AI_API_KEY", "test-key")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path))
|
|
monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient)
|
|
|
|
provider = tts_module.OpenAITTSProvider()
|
|
cached = provider.synthesize("Server-side cached prompt", language="ru")
|
|
assert cached.audio_bytes == b"\x04\x00\x08\x00"
|
|
assert len(calls) == 1
|
|
|
|
monkeypatch.delenv("AI_API_KEY", raising=False)
|
|
|
|
def _unexpected_client(*args, **kwargs):
|
|
raise AssertionError("OpenAI TTS should not be called when cached audio already exists")
|
|
|
|
monkeypatch.setattr(tts_module.httpx, "Client", _unexpected_client)
|
|
|
|
provider_without_key = tts_module.OpenAITTSProvider()
|
|
replay = provider_without_key.synthesize("Server-side cached prompt", language="ru")
|
|
|
|
assert replay.audio_bytes == cached.audio_bytes
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_yandex_tts_provider_posts_lpcm_with_api_key(tmp_path, monkeypatch):
|
|
calls: list[dict] = []
|
|
encoded_audio = base64.b64encode(b"\x10\x00\x20\x00").decode("ascii")
|
|
|
|
class _DummyClient:
|
|
def __init__(self, *, timeout: float) -> None:
|
|
self.timeout = timeout
|
|
|
|
def __enter__(self) -> _DummyClient:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
def post(self, url: str, *, headers: dict[str, str], json: dict) -> _DummyResponse:
|
|
calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout})
|
|
return _DummyResponse(json_payload={"result": {"audioChunk": {"data": encoded_audio}}})
|
|
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_KEY", "yandex-test-key")
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_BASE", "https://tts.example.test")
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_VOICE", "jane")
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ", "8000")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path))
|
|
monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient)
|
|
|
|
provider = tts_module.YandexTTSProvider()
|
|
synthesis = provider.synthesize("Привет из Yandex TTS", language="ru")
|
|
|
|
assert synthesis.audio_bytes == b"\x10\x00\x20\x00"
|
|
assert synthesis.sample_rate_hz == 8000
|
|
assert len(calls) == 1
|
|
assert calls[0]["url"] == "https://tts.example.test/tts/v3/utteranceSynthesis"
|
|
assert calls[0]["headers"]["Authorization"] == "Api-Key yandex-test-key"
|
|
assert calls[0]["headers"]["Accept"] == "application/json"
|
|
assert calls[0]["json"]["hints"][0]["voice"] == "jane"
|
|
assert calls[0]["json"]["hints"][1]["speed"] == 1.1
|
|
assert calls[0]["json"]["outputAudioSpec"]["rawAudio"]["audioEncoding"] == "LINEAR16_PCM"
|
|
assert calls[0]["json"]["outputAudioSpec"]["rawAudio"]["sampleRateHertz"] == 8000
|
|
assert list(Path(tmp_path).rglob("*.pcm"))
|
|
assert list(Path(tmp_path).rglob("*.json"))
|
|
|
|
|
|
def test_yandex_tts_provider_prefers_per_utterance_role_hint_over_global_role(tmp_path, monkeypatch):
|
|
calls: list[dict] = []
|
|
encoded_audio = base64.b64encode(b"\x12\x00\x34\x00").decode("ascii")
|
|
|
|
class _DummyClient:
|
|
def __init__(self, *, timeout: float) -> None:
|
|
self.timeout = timeout
|
|
|
|
def __enter__(self) -> _DummyClient:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
def post(self, url: str, *, headers: dict[str, str], json: dict) -> _DummyResponse:
|
|
calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout})
|
|
return _DummyResponse(json_payload={"result": {"audioChunk": {"data": encoded_audio}}})
|
|
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_KEY", "yandex-test-key")
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_BASE", "https://tts.example.test")
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_VOICE", "jane")
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_ROLE", "neutral")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "0")
|
|
monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient)
|
|
|
|
provider = tts_module.YandexTTSProvider()
|
|
synthesis = provider.synthesize("Эмоциональный быстрый отклик", language="ru", style_hints={"role": "good"})
|
|
|
|
assert synthesis.audio_bytes == b"\x12\x00\x34\x00"
|
|
assert len(calls) == 1
|
|
assert calls[0]["json"]["hints"][0]["voice"] == "jane"
|
|
assert calls[0]["json"]["hints"][2]["role"] == "good"
|
|
|
|
|
|
def test_yandex_tts_provider_uses_iam_token_with_folder_id(tmp_path, monkeypatch):
|
|
calls: list[dict] = []
|
|
encoded_audio = base64.b64encode(b"\x30\x00\x40\x00").decode("ascii")
|
|
|
|
class _DummyClient:
|
|
def __init__(self, *, timeout: float) -> None:
|
|
self.timeout = timeout
|
|
|
|
def __enter__(self) -> _DummyClient:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
def post(self, url: str, *, headers: dict[str, str], json: dict) -> _DummyResponse:
|
|
calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout})
|
|
return _DummyResponse(json_payload={"audioChunk": {"data": encoded_audio}})
|
|
|
|
monkeypatch.delenv("AI_VOICE_TTS_YANDEX_API_KEY", raising=False)
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_IAM_TOKEN", "iam-test-token")
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_FOLDER_ID", "folder-test")
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ", "16000")
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_ROLE", "friendly")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "0")
|
|
monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient)
|
|
|
|
provider = tts_module.YandexTTSProvider()
|
|
synthesis = provider.synthesize("Kazakh prompt", language="kk")
|
|
|
|
assert synthesis.audio_bytes == b"\x30\x00\x40\x00"
|
|
assert synthesis.sample_rate_hz == 16000
|
|
assert len(calls) == 1
|
|
assert calls[0]["headers"]["Authorization"] == "Bearer iam-test-token"
|
|
assert calls[0]["headers"]["x-folder-id"] == "folder-test"
|
|
assert calls[0]["json"]["hints"][0]["voice"] == "amira"
|
|
assert calls[0]["json"]["hints"][2]["role"] == "friendly"
|
|
assert calls[0]["json"]["outputAudioSpec"]["rawAudio"]["sampleRateHertz"] == 16000
|
|
|
|
|
|
def test_yandex_tts_provider_uses_cached_audio_without_credentials(tmp_path, monkeypatch):
|
|
calls: list[dict] = []
|
|
encoded_audio = base64.b64encode(b"\x55\x00\x66\x00").decode("ascii")
|
|
|
|
class _DummyClient:
|
|
def __init__(self, *, timeout: float) -> None:
|
|
self.timeout = timeout
|
|
|
|
def __enter__(self) -> _DummyClient:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
def post(self, url: str, *, headers: dict[str, str], json: dict) -> _DummyResponse:
|
|
calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout})
|
|
return _DummyResponse(json_payload={"result": {"audioChunk": {"data": encoded_audio}}})
|
|
|
|
monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_KEY", "yandex-test-key")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path))
|
|
monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient)
|
|
|
|
provider = tts_module.YandexTTSProvider()
|
|
cached = provider.synthesize("Server-side cached yandex prompt", language="ru")
|
|
assert cached.audio_bytes == b"\x55\x00\x66\x00"
|
|
assert len(calls) == 1
|
|
|
|
monkeypatch.delenv("AI_VOICE_TTS_YANDEX_API_KEY", raising=False)
|
|
monkeypatch.delenv("AI_VOICE_TTS_YANDEX_IAM_TOKEN", raising=False)
|
|
|
|
def _unexpected_client(*args, **kwargs):
|
|
raise AssertionError("Yandex TTS should not be called when cached audio already exists")
|
|
|
|
monkeypatch.setattr(tts_module.httpx, "Client", _unexpected_client)
|
|
|
|
provider_without_credentials = tts_module.YandexTTSProvider()
|
|
replay = provider_without_credentials.synthesize("Server-side cached yandex prompt", language="ru")
|
|
|
|
assert replay.audio_bytes == cached.audio_bytes
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_elevenlabs_tts_provider_posts_voice_id_model_and_language_code(tmp_path, monkeypatch):
|
|
calls: list[dict] = []
|
|
|
|
class _DummyClient:
|
|
def __init__(self, *, timeout: float) -> None:
|
|
self.timeout = timeout
|
|
|
|
def __enter__(self) -> _DummyClient:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
def post(self, url: str, *, headers: dict[str, str], params: dict[str, str], json: dict) -> _DummyResponse:
|
|
calls.append({"url": url, "headers": headers, "params": params, "json": json, "timeout": self.timeout})
|
|
return _DummyResponse(b"\x01\x00\x02\x00")
|
|
|
|
monkeypatch.setenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "elevenlabs-key")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "0")
|
|
monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient)
|
|
|
|
provider = tts_module.ElevenLabsTTSProvider(
|
|
api_base="https://api.elevenlabs.example",
|
|
ru_voice="nPczCjzI2devNBz1zQrb",
|
|
kz_voice="nPczCjzI2devNBz1zQrb",
|
|
ru_model="eleven_v3",
|
|
kz_model="eleven_v3",
|
|
ru_language_code="ru",
|
|
kz_language_code="kk",
|
|
output_format="pcm_16000",
|
|
)
|
|
synthesis = provider.synthesize("Привет из ElevenLabs", language="kz")
|
|
|
|
assert synthesis.audio_bytes == b"\x01\x00\x02\x00"
|
|
assert synthesis.sample_rate_hz == 16000
|
|
assert len(calls) == 1
|
|
assert calls[0]["url"] == "https://api.elevenlabs.example/v1/text-to-speech/nPczCjzI2devNBz1zQrb"
|
|
assert calls[0]["headers"]["xi-api-key"] == "elevenlabs-key"
|
|
assert calls[0]["params"]["output_format"] == "pcm_16000"
|
|
assert calls[0]["json"]["model_id"] == "eleven_v3"
|
|
assert calls[0]["json"]["language_code"] == "kk"
|
|
|
|
|
|
def test_elevenlabs_tts_provider_streams_chunks_and_caches_full_audio(tmp_path, monkeypatch):
|
|
calls: list[dict] = []
|
|
|
|
class _DummyStreamResponse:
|
|
def __init__(self, chunks: list[bytes]) -> None:
|
|
self._chunks = chunks
|
|
|
|
def raise_for_status(self) -> None:
|
|
return None
|
|
|
|
def iter_bytes(self):
|
|
yield from self._chunks
|
|
|
|
class _DummyStreamContext:
|
|
def __init__(self, response: _DummyStreamResponse) -> None:
|
|
self._response = response
|
|
|
|
def __enter__(self) -> _DummyStreamResponse:
|
|
return self._response
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
class _DummyClient:
|
|
def __init__(self, *, timeout: float) -> None:
|
|
self.timeout = timeout
|
|
|
|
def __enter__(self) -> _DummyClient:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
def stream(self, method: str, url: str, *, headers: dict[str, str], params: dict[str, str], json: dict):
|
|
calls.append({"method": method, "url": url, "headers": headers, "params": params, "json": json})
|
|
# Split mid-sample on purpose to exercise the 2-byte alignment guard.
|
|
return _DummyStreamContext(_DummyStreamResponse([b"\x01\x00\x02", b"\x00\x03\x00\x04\x00"]))
|
|
|
|
monkeypatch.setenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "elevenlabs-key")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path))
|
|
monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient)
|
|
|
|
provider = tts_module.ElevenLabsTTSProvider(
|
|
api_base="https://api.elevenlabs.example",
|
|
ru_voice="nPczCjzI2devNBz1zQrb",
|
|
output_format="pcm_16000",
|
|
)
|
|
|
|
chunks = list(provider.synthesize_chunks("Привет из ElevenLabs", language="ru"))
|
|
|
|
assert b"".join(chunk.audio_bytes for chunk in chunks) == b"\x01\x00\x02\x00\x03\x00\x04\x00"
|
|
assert all(len(chunk.audio_bytes) % 2 == 0 for chunk in chunks)
|
|
assert len(calls) == 1
|
|
assert calls[0]["url"] == "https://api.elevenlabs.example/v1/text-to-speech/nPczCjzI2devNBz1zQrb/stream"
|
|
assert list(Path(tmp_path).rglob("*.pcm"))
|
|
|
|
def _unexpected_stream(*args, **kwargs):
|
|
raise AssertionError("ElevenLabs streaming TTS should not be called again once cached")
|
|
|
|
monkeypatch.setattr(_DummyClient, "stream", _unexpected_stream)
|
|
replay_provider = tts_module.ElevenLabsTTSProvider(
|
|
api_base="https://api.elevenlabs.example",
|
|
ru_voice="nPczCjzI2devNBz1zQrb",
|
|
output_format="pcm_16000",
|
|
)
|
|
replay_chunks = list(replay_provider.synthesize_chunks("Привет из ElevenLabs", language="ru"))
|
|
|
|
assert len(replay_chunks) == 1
|
|
assert replay_chunks[0].audio_bytes == b"\x01\x00\x02\x00\x03\x00\x04\x00"
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_runtime_configured_tts_provider_uses_database_selected_provider(monkeypatch):
|
|
calls: list[dict] = []
|
|
|
|
class _DummyClient:
|
|
def __init__(self, *, timeout: float) -> None:
|
|
self.timeout = timeout
|
|
|
|
def __enter__(self) -> _DummyClient:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
return None
|
|
|
|
def post(self, url: str, *, headers: dict[str, str], params: dict[str, str], json: dict) -> _DummyResponse:
|
|
calls.append({"url": url, "headers": headers, "params": params, "json": json, "timeout": self.timeout})
|
|
return _DummyResponse(b"\x10\x00\x20\x00")
|
|
|
|
monkeypatch.setenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "elevenlabs-key")
|
|
monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "0")
|
|
monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient)
|
|
|
|
session = get_session()
|
|
try:
|
|
payload = voice_tts_default_config().model_dump()
|
|
payload["provider"] = "elevenlabs"
|
|
payload["elevenlabs"]["ru"]["voice"] = "nPczCjzI2devNBz1zQrb"
|
|
payload["elevenlabs"]["kz"]["voice"] = "nPczCjzI2devNBz1zQrb"
|
|
payload["elevenlabs"]["ru"]["model_id"] = "eleven_v3"
|
|
payload["elevenlabs"]["kz"]["model_id"] = "eleven_v3"
|
|
save_voice_tts_config(session, payload)
|
|
finally:
|
|
session.close()
|
|
|
|
provider = RuntimeConfiguredTTSProvider(default_provider_name="yandex")
|
|
synthesis = provider.synthesize("Сәлем", language="kz")
|
|
|
|
assert provider.current_provider_name() == "elevenlabs"
|
|
assert synthesis.audio_bytes == b"\x10\x00\x20\x00"
|
|
assert len(calls) == 1
|
|
assert calls[0]["json"]["language_code"] == "kk"
|