dograh/api/tests/test_xai_tts_service_factory.py
Tararais a2240db45a
feat(tts): add xAI as a Voice (TTS) provider (#476)
* feat(tts): add xAI as a Voice (TTS) provider

pipecat already ships an xAI TTS service (XAITTSService, WebSocket
streaming) but dograh never wired it into the service configuration, so
xAI could not be selected as a Voice provider in the cascading pipeline.

Wire it through:
- registry: ServiceProviders.XAI + XAITTSConfiguration (voices
  eve/ara/leo/rex/sal, language, computed model) registered in TTSConfig
- service_factory: build XAITTSService in create_tts_service
- check_validity: api-key validation hook
- tests for the factory + docs

The Voice provider dropdown is schema-driven, so xAI appears with no UI
changes.

* fix(tts): validate xAI API key and drop misleading auto-language hint

Addresses review feedback on the xAI Voice provider:

- check_validity: replace the no-op xAI key check with real validation
  against xAI's OpenAI-compatible API (models.list on https://api.x.ai/v1),
  so a bad BYOK key is caught at configuration time instead of at call time.
- registry: remove the "auto" language hint from the field description.
  pipecat's Language enum has no "auto" member, so the factory fell back to
  English silently; the description no longer advertises detection we don't do.
- tests: cover xAI key validation (registered, accepts valid, rejects bad).

* fix(tts): validate xAI key against the TTS voices endpoint

xAI supports endpoint-scoped API keys, so a key scoped to Text-to-Speech
may lack the /v1/models ACL and would be wrongly rejected by the previous
models.list() check. Validate against GET /v1/tts/voices instead — the
scope the key actually needs for TTS — treating 401/403 as an invalid key
and connection errors as a clean, actionable message.

* fix: harden xAI TTS integration

---------

Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
Co-authored-by: Sabiha Khan <87858386+chewwbaka@users.noreply.github.com>
2026-07-08 09:05:43 +05:30

160 lines
5.2 KiB
Python

from types import SimpleNamespace
from unittest.mock import patch
import pytest
from pipecat.transcriptions.language import Language
from api.services.configuration.check_validity import UserConfigurationValidator
from api.services.configuration.registry import (
XAI_TTS_VOICES,
ServiceProviders,
XAITTSConfiguration,
)
from api.services.pipecat.service_factory import create_tts_service
def test_xai_tts_configuration_defaults():
config = XAITTSConfiguration(api_key="test-key")
assert config.provider == ServiceProviders.XAI
assert config.voice == "eve"
assert config.language == "en"
# xAI TTS has no model selector; a constant satisfies the shared contract.
assert config.model == "xai-tts"
assert XAI_TTS_VOICES == ["eve", "ara", "leo", "rex", "sal"]
@pytest.mark.parametrize("transport_out_sample_rate", [8000, 16000])
def test_create_xai_tts_service_uses_pipeline_compatible_audio_format(
transport_out_sample_rate,
):
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.XAI.value,
api_key="test-key",
model="xai-tts",
voice="rex",
language="en",
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=transport_out_sample_rate,
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
create_tts_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["api_key"] == "test-key"
assert kwargs["sample_rate"] == transport_out_sample_rate
assert kwargs["encoding"] == "pcm"
assert kwargs["settings"].voice == "rex"
assert kwargs["settings"].language == Language.EN
def test_create_xai_tts_service_converts_language():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.XAI.value,
api_key="test-key",
model="xai-tts",
voice="eve",
language="fr",
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=24000,
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
assert kwargs["settings"].language == Language.FR
def test_create_xai_tts_service_falls_back_to_english_for_unknown_language():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.XAI.value,
api_key="test-key",
model="xai-tts",
voice="eve",
language="not-a-language",
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=24000,
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
assert kwargs["settings"].language == Language.EN
def test_create_xai_tts_service_preserves_auto_language():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.XAI.value,
api_key="test-key",
model="xai-tts",
voice="eve",
language="auto",
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=24000,
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
assert kwargs["settings"].language == "auto"
def test_xai_is_registered_for_key_validation():
validator = UserConfigurationValidator()
assert ServiceProviders.XAI.value in validator._validator_map
def test_xai_key_validation_accepts_valid_key():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
mock_get.return_value.status_code = 200
assert validator._check_xai_api_key("xai", "xai-valid-key") is True
# Validates against the TTS-scoped voices endpoint, not /v1/models.
called_url = mock_get.call_args.args[0]
assert called_url == "https://api.x.ai/v1/tts/voices"
assert (
mock_get.call_args.kwargs["headers"]["Authorization"]
== "Bearer xai-valid-key"
)
def test_xai_key_validation_rejects_bad_key():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
mock_get.return_value.status_code = 401
with pytest.raises(ValueError):
validator._check_xai_api_key("xai", "bad-key")
def test_xai_key_validation_allows_scoped_key_without_voice_list_access():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
mock_get.return_value.status_code = 403
assert validator._check_xai_api_key("xai", "tts-scoped-key") is True