fix(mistral): keep the deployment voice default and drop the unreachable api base fallback

Review turned up two real problems in the TTS path.

Router.aspeech forwarded voice=None whenever the caller omitted it, which overwrote a
voice set in the deployment's litellm_params, so a configured fallback voice was
ignored on voice-less requests. It now leaves the key alone when no voice is passed.

get_complete_url also fell back to MISTRAL_API_BASE, but speech() always receives a
non-null api_base from get_llm_provider, whose mistral branch only reads
MISTRAL_AZURE_API_BASE and otherwise hardcodes the public host. That branch could
never run, and its unit test asserted a behavior the real path does not have. The
working override is api_base on the deployment, now pinned by an end-to-end test
This commit is contained in:
mateo-berri 2026-09-03 00:30:07 -07:00
parent 75e7f4c4a5
commit 0a2581c14c
5 changed files with 71 additions and 11 deletions

View file

@ -115,7 +115,7 @@ class MistralTextToSpeechConfig(BaseTextToSpeechConfig):
api_base: str | None,
litellm_params: Mapping[str, object],
) -> str:
configured_base: Final = (api_base or get_secret_str("MISTRAL_API_BASE") or self.TTS_BASE_URL).rstrip("/")
configured_base: Final = (api_base or self.TTS_BASE_URL).rstrip("/")
versioned_base: Final = configured_base if configured_base.endswith("/v1") else f"{configured_base}/v1"
return f"{versioned_base}/audio/speech"

View file

@ -4422,7 +4422,7 @@ class Router:
**{
**data,
"input": input,
"voice": voice,
**({"voice": voice} if voice is not None else {}),
"client": model_client,
**kwargs,
}

View file

@ -84,8 +84,7 @@ def test_transform_request_omits_voice_for_ref_audio_cloning():
}
def test_get_complete_url_default_base(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("MISTRAL_API_BASE", raising=False)
def test_get_complete_url_default_base():
config: Final = MistralTextToSpeechConfig()
url: Final = config.get_complete_url(model="voxtral-mini-tts-2603", api_base=None, litellm_params={})
assert url == SPEECH_URL
@ -101,13 +100,6 @@ def test_get_complete_url_custom_base_always_versioned(api_base: str):
assert url == "https://custom.api.example.com/v1/audio/speech"
def test_get_complete_url_host_only_env_base_gets_v1(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("MISTRAL_API_BASE", "https://api.mistral.ai")
config: Final = MistralTextToSpeechConfig()
url: Final = config.get_complete_url(model="voxtral-mini-tts-2603", api_base=None, litellm_params={})
assert url == SPEECH_URL
def test_validate_environment_sets_bearer_header():
config: Final = MistralTextToSpeechConfig()
headers: Final = config.validate_environment(

View file

@ -3287,3 +3287,21 @@ def test_speech_mistral_dispatches_and_decodes_audio(respx_mock: respx.MockRoute
}
assert mock_route.calls.last.request.headers["authorization"] == "Bearer sk-mistral-test"
assert response.content == audio_bytes
def test_speech_mistral_routes_to_configured_api_base(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test")
audio_bytes: Final = b"ID3-gateway-bytes"
gateway_route: Final = respx_mock.post("https://mistral.gateway.internal/v1/audio/speech").mock(
return_value=httpx.Response(200, json={"audio_data": base64.b64encode(audio_bytes).decode()})
)
response: Final = litellm.speech(
model="mistral/voxtral-mini-tts-2603",
input="hello from litellm",
voice="en_paul_neutral",
api_base="https://mistral.gateway.internal",
)
assert gateway_route.called
assert response.content == audio_bytes

View file

@ -12278,6 +12278,56 @@ async def test_router_aspeech_without_voice_dispatches_ref_audio_cloning(respx_m
assert response.content == audio_bytes
@pytest.mark.asyncio
async def test_router_aspeech_without_voice_keeps_deployment_default_voice(respx_mock, monkeypatch):
import base64
monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
audio_bytes = b"RIFFfake-wav-bytes"
respx_mock.post("https://api.mistral.ai/v1/audio/speech").respond(
json={"audio_data": base64.b64encode(audio_bytes).decode()}
)
router = Router(
model_list=[
{
"model_name": "voxtral-tts",
"litellm_params": {"model": "mistral/voxtral-mini-tts-2603", "voice": "en_paul_neutral"},
}
]
)
await router.aspeech(model="voxtral-tts", input="use my default")
request_body = json.loads(respx_mock.calls.last.request.content)
assert request_body["voice_id"] == "en_paul_neutral"
@pytest.mark.asyncio
async def test_router_aspeech_request_voice_overrides_deployment_default(respx_mock, monkeypatch):
import base64
monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
audio_bytes = b"RIFFfake-wav-bytes"
respx_mock.post("https://api.mistral.ai/v1/audio/speech").respond(
json={"audio_data": base64.b64encode(audio_bytes).decode()}
)
router = Router(
model_list=[
{
"model_name": "voxtral-tts",
"litellm_params": {"model": "mistral/voxtral-mini-tts-2603", "voice": "en_paul_neutral"},
}
]
)
await router.aspeech(model="voxtral-tts", input="override me", voice="gb_oliver_neutral")
request_body = json.loads(respx_mock.calls.last.request.content)
assert request_body["voice_id"] == "gb_oliver_neutral"
class TestPreRoutingTierDrivesFallbacks:
"""#38832: a complexity/auto router picks a tier behind the router name, but fallback
lookup stayed on the router name, so the tier's configured chain never ran and a