From 676f841534e7c83bcf5d9afb65f5c37bf741af44 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 29 Aug 2026 03:47:38 -0700
Subject: [PATCH 01/79] feat(mistral): add text-to-speech support for
/v1/audio/speech
---
.../mistral/audio_speech/transformation.py | 209 ++++++++++++++++++
litellm/main.py | 28 +++
...odel_prices_and_context_window_backup.json | 4 +-
litellm/router.py | 4 +-
litellm/utils.py | 6 +
model_prices_and_context_window.json | 4 +-
...est_mistral_audio_speech_transformation.py | 198 +++++++++++++++++
tests/test_litellm/test_cost_calculator.py | 12 +
tests/test_litellm/test_main.py | 28 +++
tests/test_litellm/test_router.py | 26 +++
10 files changed, 513 insertions(+), 6 deletions(-)
create mode 100644 litellm/llms/mistral/audio_speech/transformation.py
create mode 100644 tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
diff --git a/litellm/llms/mistral/audio_speech/transformation.py b/litellm/llms/mistral/audio_speech/transformation.py
new file mode 100644
index 00000000000..6d1a693268a
--- /dev/null
+++ b/litellm/llms/mistral/audio_speech/transformation.py
@@ -0,0 +1,209 @@
+"""
+Support for Mistral Voxtral text-to-speech via ``/v1/audio/speech``.
+
+API reference: https://docs.mistral.ai/api/#tag/audio/operation/audio_speech_v1_audio_speech_post
+"""
+
+import base64
+import json
+from collections.abc import Mapping
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Final
+
+import httpx
+
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.base_llm.text_to_speech.transformation import (
+ BaseTextToSpeechConfig,
+ TextToSpeechRequestData,
+)
+from litellm.secret_managers.main import get_secret_str
+
+if TYPE_CHECKING:
+ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+ from litellm.types.llms.openai import HttpxBinaryResponseContent
+
+
+class MistralTextToSpeechException(BaseLLMException):
+ pass
+
+
+class MistralTextToSpeechConfig(BaseTextToSpeechConfig):
+ TTS_BASE_URL: Final[str] = "https://api.mistral.ai/v1"
+ AUDIO_CONTENT_TYPES: Final[MappingProxyType[str, str]] = MappingProxyType(
+ {
+ "mp3": "audio/mpeg",
+ "wav": "audio/wav",
+ "pcm": "audio/pcm",
+ "flac": "audio/flac",
+ "opus": "audio/ogg",
+ }
+ )
+ DROPPED_RESPONSE_HEADERS: Final[frozenset[str]] = frozenset(
+ {"content-encoding", "transfer-encoding", "content-length", "content-type"}
+ )
+ OPENAI_VOICE_ALIASES: Final[MappingProxyType[str, str]] = MappingProxyType(
+ {
+ "alloy": "en_paul_neutral",
+ "echo": "gb_oliver_neutral",
+ "fable": "en_paul_cheerful",
+ "onyx": "en_paul_confident",
+ "nova": "gb_jane_sarcasm",
+ "shimmer": "gb_jane_sarcasm",
+ }
+ )
+
+ def get_supported_openai_params(self, model: str) -> list: # mutable-ok: base class contract returns a plain list
+ return ["voice", "response_format"] # mutable-ok: base class contract returns a plain list
+
+ def _map_openai_voice(self, voice_id: str) -> str:
+ return self.OPENAI_VOICE_ALIASES.get(voice_id.lower(), voice_id)
+
+ def _resolve_voice_id(self, voice: object) -> str | None:
+ if isinstance(voice, str) and voice.strip():
+ return self._map_openai_voice(voice.strip())
+ if isinstance(voice, Mapping):
+ candidates: Final = (voice.get(key) for key in ("voice_id", "id", "name"))
+ resolved: Final = next(
+ (candidate.strip() for candidate in candidates if isinstance(candidate, str) and candidate.strip()),
+ None,
+ )
+ return self._map_openai_voice(resolved) if resolved else None
+ return None
+
+ def map_openai_params(
+ self,
+ model: str,
+ optional_params: Mapping[str, object],
+ voice: object = None,
+ drop_params: bool = False,
+ kwargs: Mapping[str, object] | None = None,
+ ) -> tuple[str | None, dict]: # mutable-ok: base class contract returns a plain dict
+ response_format: Final = optional_params.get("response_format")
+ ref_audio: Final = kwargs.get("ref_audio") if kwargs else None
+ voice_id_kwarg: Final = kwargs.get("voice_id") if kwargs else None
+ mapped_voice: Final = self._resolve_voice_id(voice) or self._resolve_voice_id(voice_id_kwarg)
+ mapped_params: Final = { # mutable-ok: base class contract returns a plain dict
+ key: value
+ for key, value in (("response_format", response_format), ("ref_audio", ref_audio))
+ if isinstance(value, str)
+ }
+ return mapped_voice, mapped_params
+
+ def validate_environment(
+ self,
+ headers: Mapping[str, str],
+ model: str,
+ api_key: str | None = None,
+ api_base: str | None = None,
+ ) -> dict: # mutable-ok: base class contract returns a plain dict
+ resolved_key: Final = api_key or get_secret_str("MISTRAL_API_KEY")
+ if resolved_key is None:
+ raise MistralTextToSpeechException(
+ status_code=401,
+ message="Mistral API key is required. Set MISTRAL_API_KEY or pass api_key.",
+ )
+ return { # mutable-ok: base class contract returns a plain dict
+ **headers,
+ "Authorization": f"Bearer {resolved_key}",
+ "Content-Type": "application/json",
+ }
+
+ def get_complete_url(
+ self,
+ model: str,
+ api_base: str | None,
+ litellm_params: Mapping[str, object],
+ ) -> str:
+ base_url: Final = api_base or get_secret_str("MISTRAL_API_BASE") or self.TTS_BASE_URL
+ return f"{base_url.rstrip('/')}/audio/speech"
+
+ def transform_text_to_speech_request(
+ self,
+ model: str,
+ input: str,
+ voice: str | None,
+ optional_params: Mapping[str, object],
+ litellm_params: Mapping[str, object],
+ headers: Mapping[str, str],
+ ) -> TextToSpeechRequestData:
+ response_format: Final = optional_params.get("response_format")
+ ref_audio: Final = optional_params.get("ref_audio")
+ request_data: Final[TextToSpeechRequestData] = {
+ "dict_body": {
+ "model": model,
+ "input": input,
+ **({"voice_id": voice} if voice else {}),
+ **({"response_format": response_format} if isinstance(response_format, str) else {}),
+ **({"ref_audio": ref_audio} if isinstance(ref_audio, str) else {}),
+ },
+ "headers": {"Content-Type": "application/json"},
+ }
+ return request_data
+
+ def _requested_content_type(self, request: httpx.Request) -> str:
+ request_body: Final = json.loads(request.content or b"{}")
+ requested_format: Final = request_body.get("response_format")
+ if not isinstance(requested_format, str):
+ return "audio/mpeg"
+ return self.AUDIO_CONTENT_TYPES.get(requested_format, "audio/mpeg")
+
+ def transform_text_to_speech_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ logging_obj: "LiteLLMLoggingObj",
+ ) -> "HttpxBinaryResponseContent":
+ from litellm.types.llms.openai import HttpxBinaryResponseContent
+
+ try:
+ response_json: Final = raw_response.json()
+ except (json.JSONDecodeError, ValueError):
+ raise MistralTextToSpeechException(
+ status_code=raw_response.status_code,
+ message=f"Non-JSON response from Mistral speech API: {raw_response.text[:500]}",
+ headers=raw_response.headers,
+ )
+ audio_b64: Final = response_json.get("audio_data")
+ if not isinstance(audio_b64, str) or not audio_b64:
+ raise MistralTextToSpeechException(
+ status_code=500,
+ message=f"No audio_data in Mistral speech response. Response keys: {tuple(response_json.keys())}",
+ headers=raw_response.headers,
+ )
+ try:
+ audio_bytes: Final = base64.b64decode(audio_b64)
+ except ValueError:
+ raise MistralTextToSpeechException(
+ status_code=500,
+ message="Invalid base64 audio_data in Mistral speech response.",
+ headers=raw_response.headers,
+ )
+ retained_headers: Final = tuple(
+ (key, value)
+ for key, value in raw_response.headers.items()
+ if key.lower() not in self.DROPPED_RESPONSE_HEADERS
+ )
+ response_headers: Final = retained_headers + (
+ ("content-length", str(len(audio_bytes))),
+ ("content-type", self._requested_content_type(raw_response.request)),
+ )
+ binary_response: Final = httpx.Response(
+ status_code=200,
+ headers=response_headers,
+ content=audio_bytes,
+ request=raw_response.request,
+ )
+ return HttpxBinaryResponseContent(binary_response)
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: dict | httpx.Headers, # mutable-ok: BaseLLMException takes a plain dict or httpx.Headers
+ ) -> BaseLLMException:
+ return MistralTextToSpeechException(
+ message=error_message,
+ status_code=status_code,
+ headers=headers,
+ )
diff --git a/litellm/main.py b/litellm/main.py
index cafa1e4718f..692df23b3f9 100644
--- a/litellm/main.py
+++ b/litellm/main.py
@@ -8367,6 +8367,34 @@ def speech(
client=client,
_is_async=aspeech or False,
)
+ elif custom_llm_provider == "mistral":
+ from litellm.llms.mistral.audio_speech.transformation import (
+ MistralTextToSpeechConfig,
+ )
+
+ mistral_tts_config: Final = text_to_speech_provider_config or MistralTextToSpeechConfig()
+
+ if api_base is not None:
+ litellm_params_dict["api_base"] = api_base
+ if api_key is not None:
+ litellm_params_dict["api_key"] = api_key
+
+ mistral_voice: Final[str | None] = voice if isinstance(voice, str) else None
+
+ response = base_llm_http_handler.text_to_speech_handler(
+ model=model,
+ input=input,
+ voice=mistral_voice,
+ text_to_speech_provider_config=mistral_tts_config,
+ text_to_speech_optional_params=optional_params,
+ custom_llm_provider=custom_llm_provider,
+ litellm_params=litellm_params_dict,
+ logging_obj=logging_obj,
+ timeout=timeout,
+ extra_headers=extra_headers,
+ client=client,
+ _is_async=aspeech or False,
+ )
elif custom_llm_provider == "aws_polly":
from litellm.llms.aws_polly.text_to_speech.transformation import (
AWSPollyTextToSpeechConfig,
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index bebbcc32181..620fe2f8030 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -31126,9 +31126,9 @@
"supports_audio_input": true
},
"mistral/voxtral-mini-tts-latest": {
+ "input_cost_per_character": 1.6e-05,
"litellm_provider": "mistral",
"mode": "audio_speech",
- "output_cost_per_character": 1.6e-05,
"source": "https://docs.mistral.ai/models/model-cards/voxtral-tts-26-03",
"supported_endpoints": [
"/v1/audio/speech"
@@ -51677,9 +51677,9 @@
"supports_audio_input": true
},
"mistral/voxtral-mini-tts-2603": {
+ "input_cost_per_character": 1.6e-05,
"litellm_provider": "mistral",
"mode": "audio_speech",
- "output_cost_per_character": 1.6e-05,
"source": "https://docs.mistral.ai/models/model-cards/voxtral-tts-26-03",
"supported_endpoints": [
"/v1/audio/speech"
diff --git a/litellm/router.py b/litellm/router.py
index c93c1753f0e..d6ec5e57467 100644
--- a/litellm/router.py
+++ b/litellm/router.py
@@ -4270,7 +4270,7 @@ class Router:
self.fail_calls[model_name] += 1
raise e
- async def aspeech(self, model: str, input: str, voice: str, **kwargs):
+ async def aspeech(self, model: str, input: str, voice: str | None = None, **kwargs):
"""
Example Usage:
@@ -4322,7 +4322,7 @@ class Router:
)
raise e
- async def _aspeech(self, model: str, input: str, voice: str, **kwargs):
+ async def _aspeech(self, model: str, input: str, voice: str | None = None, **kwargs):
model_name: Final = model
try:
verbose_router_logger.debug("Inside _aspeech()- model: %s; kwargs: %s", model, kwargs)
diff --git a/litellm/utils.py b/litellm/utils.py
index 5cd9bfc5f32..74a9b4ce935 100644
--- a/litellm/utils.py
+++ b/litellm/utils.py
@@ -9408,6 +9408,12 @@ class ProviderConfigManager:
)
return MinimaxTextToSpeechConfig()
+ elif litellm.LlmProviders.MISTRAL == provider:
+ from litellm.llms.mistral.audio_speech.transformation import (
+ MistralTextToSpeechConfig,
+ )
+
+ return MistralTextToSpeechConfig()
elif litellm.LlmProviders.AWS_POLLY == provider:
from litellm.llms.aws_polly.text_to_speech.transformation import (
AWSPollyTextToSpeechConfig,
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index bebbcc32181..620fe2f8030 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -31126,9 +31126,9 @@
"supports_audio_input": true
},
"mistral/voxtral-mini-tts-latest": {
+ "input_cost_per_character": 1.6e-05,
"litellm_provider": "mistral",
"mode": "audio_speech",
- "output_cost_per_character": 1.6e-05,
"source": "https://docs.mistral.ai/models/model-cards/voxtral-tts-26-03",
"supported_endpoints": [
"/v1/audio/speech"
@@ -51677,9 +51677,9 @@
"supports_audio_input": true
},
"mistral/voxtral-mini-tts-2603": {
+ "input_cost_per_character": 1.6e-05,
"litellm_provider": "mistral",
"mode": "audio_speech",
- "output_cost_per_character": 1.6e-05,
"source": "https://docs.mistral.ai/models/model-cards/voxtral-tts-26-03",
"supported_endpoints": [
"/v1/audio/speech"
diff --git a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
new file mode 100644
index 00000000000..20d07699cb8
--- /dev/null
+++ b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
@@ -0,0 +1,198 @@
+import base64
+from typing import Final
+from unittest.mock import MagicMock
+
+import httpx
+import pytest
+
+import litellm
+from litellm.llms.base_llm.text_to_speech.transformation import BaseTextToSpeechConfig
+from litellm.llms.mistral.audio_speech.transformation import (
+ MistralTextToSpeechConfig,
+ MistralTextToSpeechException,
+)
+from litellm.utils import ProviderConfigManager
+
+SPEECH_URL: Final = "https://api.mistral.ai/v1/audio/speech"
+
+
+def test_mistral_text_to_speech_config_installed():
+ config: Final = ProviderConfigManager.get_provider_text_to_speech_config(
+ model="voxtral-mini-tts-2603",
+ provider=litellm.LlmProviders.MISTRAL,
+ )
+ assert isinstance(config, BaseTextToSpeechConfig)
+ assert isinstance(config, MistralTextToSpeechConfig)
+
+
+def test_map_openai_params_drops_speed_and_instructions():
+ config: Final = MistralTextToSpeechConfig()
+ voice, params = config.map_openai_params(
+ model="voxtral-mini-tts-2603",
+ optional_params={"response_format": "wav", "speed": 1.5, "instructions": "sound cheerful"},
+ voice="en_paul_neutral",
+ )
+ assert voice == "en_paul_neutral"
+ assert params == {"response_format": "wav"}
+
+
+def test_map_openai_params_accepts_voice_dict_and_ref_audio():
+ config: Final = MistralTextToSpeechConfig()
+ voice, params = config.map_openai_params(
+ model="voxtral-mini-tts-2603",
+ optional_params={},
+ voice={"voice_id": "1f3a8b0c-voice-uuid"},
+ kwargs={"ref_audio": "bXktdm9pY2Utc2FtcGxl"},
+ )
+ assert voice == "1f3a8b0c-voice-uuid"
+ assert params == {"ref_audio": "bXktdm9pY2Utc2FtcGxl"}
+
+
+def test_transform_request_builds_mistral_body():
+ config: Final = MistralTextToSpeechConfig()
+ data: Final = config.transform_text_to_speech_request(
+ model="voxtral-mini-tts-2603",
+ input="hello from litellm",
+ voice="en_paul_neutral",
+ optional_params={"response_format": "wav"},
+ litellm_params={},
+ headers={},
+ )
+ assert data["dict_body"] == {
+ "model": "voxtral-mini-tts-2603",
+ "input": "hello from litellm",
+ "voice_id": "en_paul_neutral",
+ "response_format": "wav",
+ }
+ assert data["headers"] == {"Content-Type": "application/json"}
+
+
+def test_transform_request_omits_voice_for_ref_audio_cloning():
+ config: Final = MistralTextToSpeechConfig()
+ data: Final = config.transform_text_to_speech_request(
+ model="voxtral-mini-tts-2603",
+ input="clone me",
+ voice=None,
+ optional_params={"ref_audio": "bXktdm9pY2Utc2FtcGxl"},
+ litellm_params={},
+ headers={},
+ )
+ assert data["dict_body"] == {
+ "model": "voxtral-mini-tts-2603",
+ "input": "clone me",
+ "ref_audio": "bXktdm9pY2Utc2FtcGxl",
+ }
+
+
+def test_get_complete_url_default_base(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.delenv("MISTRAL_API_BASE", raising=False)
+ 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_get_complete_url_custom_base():
+ config: Final = MistralTextToSpeechConfig()
+ url: Final = config.get_complete_url(
+ model="voxtral-mini-tts-2603",
+ api_base="https://custom.api.example.com/v1/",
+ litellm_params={},
+ )
+ assert url == "https://custom.api.example.com/v1/audio/speech"
+
+
+def test_validate_environment_sets_bearer_header():
+ config: Final = MistralTextToSpeechConfig()
+ headers: Final = config.validate_environment(
+ headers={"x-custom": "1"},
+ model="voxtral-mini-tts-2603",
+ api_key="sk-mistral-test",
+ )
+ assert headers == {
+ "x-custom": "1",
+ "Authorization": "Bearer sk-mistral-test",
+ "Content-Type": "application/json",
+ }
+
+
+def test_validate_environment_requires_key(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
+ config: Final = MistralTextToSpeechConfig()
+ with pytest.raises(MistralTextToSpeechException, match="MISTRAL_API_KEY"):
+ config.validate_environment(headers={}, model="voxtral-mini-tts-2603")
+
+
+def test_transform_response_decodes_base64_audio():
+ config: Final = MistralTextToSpeechConfig()
+ audio_bytes: Final = b"RIFF-fake-wav-bytes"
+ raw_response: Final = httpx.Response(
+ 200,
+ json={"audio_data": base64.b64encode(audio_bytes).decode()},
+ headers={"x-request-id": "req-123"},
+ request=httpx.Request(
+ "POST",
+ SPEECH_URL,
+ json={"model": "voxtral-mini-tts-2603", "input": "hi", "response_format": "wav"},
+ ),
+ )
+ result: Final = config.transform_text_to_speech_response(
+ model="voxtral-mini-tts-2603",
+ raw_response=raw_response,
+ logging_obj=MagicMock(),
+ )
+ assert result.content == audio_bytes
+ assert result.response.headers["content-type"] == "audio/wav"
+ assert result.response.headers["content-length"] == str(len(audio_bytes))
+ assert result.response.headers["x-request-id"] == "req-123"
+
+
+def test_transform_response_missing_audio_data_raises():
+ config: Final = MistralTextToSpeechConfig()
+ raw_response: Final = httpx.Response(
+ 200,
+ json={"detail": "unexpected"},
+ request=httpx.Request("POST", SPEECH_URL, json={"model": "voxtral-mini-tts-2603", "input": "hi"}),
+ )
+ with pytest.raises(MistralTextToSpeechException, match="audio_data"):
+ config.transform_text_to_speech_response(
+ model="voxtral-mini-tts-2603",
+ raw_response=raw_response,
+ logging_obj=MagicMock(),
+ )
+
+
+def test_map_openai_params_maps_openai_voice_aliases():
+ config: Final = MistralTextToSpeechConfig()
+ alloy_voice, _ = config.map_openai_params(
+ model="voxtral-mini-tts-2603",
+ optional_params={},
+ voice="alloy",
+ )
+ nova_voice, _ = config.map_openai_params(
+ model="voxtral-mini-tts-2603",
+ optional_params={},
+ voice="Nova",
+ )
+ passthrough_voice, _ = config.map_openai_params(
+ model="voxtral-mini-tts-2603",
+ optional_params={},
+ voice="en_paul_happy",
+ )
+ assert alloy_voice == "en_paul_neutral"
+ assert nova_voice == "gb_jane_sarcasm"
+ assert passthrough_voice == "en_paul_happy"
+
+
+def test_transform_response_invalid_base64_raises():
+ config: Final = MistralTextToSpeechConfig()
+ raw_response: Final = httpx.Response(
+ status_code=200,
+ json={"audio_data": "!!!not-base64!!!"},
+ request=httpx.Request("POST", SPEECH_URL),
+ )
+ with pytest.raises(MistralTextToSpeechException, match="base64"):
+ config.transform_text_to_speech_response(
+ model="voxtral-mini-tts-2603",
+ raw_response=raw_response,
+ logging_obj=MagicMock(),
+ )
diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py
index 7c2174018e8..db08ee486bf 100644
--- a/tests/test_litellm/test_cost_calculator.py
+++ b/tests/test_litellm/test_cost_calculator.py
@@ -4473,3 +4473,15 @@ def test_explicit_pricing_precedes_private_provider_response_model(
)
assert selected == expected
+
+
+def test_cost_per_token_mistral_voxtral_tts_bills_per_input_character(_local_model_cost_map):
+ prompt_usd, completion_usd = cost_per_token(
+ model="voxtral-mini-tts-2603",
+ custom_llm_provider="mistral",
+ call_type="speech",
+ prompt_characters=1000,
+ )
+
+ assert prompt_usd == pytest.approx(1000 * 1.6e-05)
+ assert completion_usd == 0.0
diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py
index 8cf878d05d9..47293d9c413 100644
--- a/tests/test_litellm/test_main.py
+++ b/tests/test_litellm/test_main.py
@@ -3181,3 +3181,31 @@ def test_stream_chunk_builder_defers_cost_to_logging_obj_when_usage_cost_absent(
assert response is not None
assert response._hidden_params.get("response_cost") is None
+
+
+def test_speech_mistral_dispatches_and_decodes_audio(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test")
+ audio_bytes: Final = b"ID3-fake-mp3-bytes"
+ mock_route: Final = respx_mock.post("https://api.mistral.ai/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",
+ response_format="wav",
+ speed=2,
+ instructions="sound cheerful",
+ )
+
+ assert mock_route.called
+ request_body: Final = json.loads(mock_route.calls.last.request.content)
+ assert request_body == {
+ "model": "voxtral-mini-tts-2603",
+ "input": "hello from litellm",
+ "voice_id": "en_paul_neutral",
+ "response_format": "wav",
+ }
+ assert mock_route.calls.last.request.headers["authorization"] == "Bearer sk-mistral-test"
+ assert response.content == audio_bytes
diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py
index 97286017ffe..388011b7d5d 100644
--- a/tests/test_litellm/test_router.py
+++ b/tests/test_litellm/test_router.py
@@ -11530,3 +11530,29 @@ class TestTierParamsTheTargetAccepts:
accepted = router._tier_params_the_target_accepts("no-such-group", {"reasoning_effort": "max"}, {})
assert accepted == {"reasoning_effort": "max"}
+
+
+@pytest.mark.asyncio
+async def test_router_aspeech_without_voice_dispatches_ref_audio_cloning(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"},
+ }
+ ]
+ )
+
+ response = await router.aspeech(model="voxtral-tts", input="clone me", ref_audio="ZmFrZQ==")
+
+ request_body = json.loads(respx_mock.calls.last.request.content)
+ assert request_body == {"model": "voxtral-mini-tts-2603", "input": "clone me", "ref_audio": "ZmFrZQ=="}
+ assert response.content == audio_bytes
From 7c4cf2dcffbbff32b087d7756d4c0c0a4c590a91 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 29 Aug 2026 11:41:24 -0700
Subject: [PATCH 02/79] fix(mistral): reject malformed base64 audio_data with
strict validation
---
litellm/llms/mistral/audio_speech/transformation.py | 2 +-
.../audio_speech/test_mistral_audio_speech_transformation.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/litellm/llms/mistral/audio_speech/transformation.py b/litellm/llms/mistral/audio_speech/transformation.py
index 6d1a693268a..e7f7d510346 100644
--- a/litellm/llms/mistral/audio_speech/transformation.py
+++ b/litellm/llms/mistral/audio_speech/transformation.py
@@ -172,7 +172,7 @@ class MistralTextToSpeechConfig(BaseTextToSpeechConfig):
headers=raw_response.headers,
)
try:
- audio_bytes: Final = base64.b64decode(audio_b64)
+ audio_bytes: Final = base64.b64decode(audio_b64, validate=True)
except ValueError:
raise MistralTextToSpeechException(
status_code=500,
diff --git a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
index 20d07699cb8..6d250901e50 100644
--- a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
+++ b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
@@ -187,7 +187,7 @@ def test_transform_response_invalid_base64_raises():
config: Final = MistralTextToSpeechConfig()
raw_response: Final = httpx.Response(
status_code=200,
- json={"audio_data": "!!!not-base64!!!"},
+ json={"audio_data": "QUJD!QUJD"},
request=httpx.Request("POST", SPEECH_URL),
)
with pytest.raises(MistralTextToSpeechException, match="base64"):
From 7c91b0120fadccd2a97c5a8ae5db77d6d8f59ec8 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Wed, 2 Sep 2026 13:32:51 -0700
Subject: [PATCH 03/79] fix(mistral): ensure /v1 on the Voxtral TTS base URL
A host-only api_base or MISTRAL_API_BASE (the documented form, https://api.mistral.ai) built
https://api.mistral.ai/audio/speech and 404ed. Match the chat and OCR configs by appending /v1
when the configured base does not already end with it.
---
.../mistral/audio_speech/transformation.py | 5 +++--
...est_mistral_audio_speech_transformation.py | 19 +++++++++++++------
2 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/litellm/llms/mistral/audio_speech/transformation.py b/litellm/llms/mistral/audio_speech/transformation.py
index e7f7d510346..7f5a659bd08 100644
--- a/litellm/llms/mistral/audio_speech/transformation.py
+++ b/litellm/llms/mistral/audio_speech/transformation.py
@@ -115,8 +115,9 @@ class MistralTextToSpeechConfig(BaseTextToSpeechConfig):
api_base: str | None,
litellm_params: Mapping[str, object],
) -> str:
- base_url: Final = api_base or get_secret_str("MISTRAL_API_BASE") or self.TTS_BASE_URL
- return f"{base_url.rstrip('/')}/audio/speech"
+ configured_base: Final = (api_base or get_secret_str("MISTRAL_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"
def transform_text_to_speech_request(
self,
diff --git a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
index 6d250901e50..108d4107db1 100644
--- a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
+++ b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
@@ -91,16 +91,23 @@ def test_get_complete_url_default_base(monkeypatch: pytest.MonkeyPatch):
assert url == SPEECH_URL
-def test_get_complete_url_custom_base():
+@pytest.mark.parametrize(
+ "api_base",
+ ["https://custom.api.example.com/v1/", "https://custom.api.example.com/v1", "https://custom.api.example.com"],
+)
+def test_get_complete_url_custom_base_always_versioned(api_base: str):
config: Final = MistralTextToSpeechConfig()
- url: Final = config.get_complete_url(
- model="voxtral-mini-tts-2603",
- api_base="https://custom.api.example.com/v1/",
- litellm_params={},
- )
+ url: Final = config.get_complete_url(model="voxtral-mini-tts-2603", api_base=api_base, litellm_params={})
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(
From 0a2581c14caaa43f76f1db2399aa66a9812d63bf Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Thu, 3 Sep 2026 00:30:07 -0700
Subject: [PATCH 04/79] 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
---
.../mistral/audio_speech/transformation.py | 2 +-
litellm/router.py | 2 +-
...est_mistral_audio_speech_transformation.py | 10 +---
tests/test_litellm/test_main.py | 18 +++++++
tests/test_litellm/test_router.py | 50 +++++++++++++++++++
5 files changed, 71 insertions(+), 11 deletions(-)
diff --git a/litellm/llms/mistral/audio_speech/transformation.py b/litellm/llms/mistral/audio_speech/transformation.py
index 7f5a659bd08..2b3264dc756 100644
--- a/litellm/llms/mistral/audio_speech/transformation.py
+++ b/litellm/llms/mistral/audio_speech/transformation.py
@@ -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"
diff --git a/litellm/router.py b/litellm/router.py
index ee3a3ced023..b1357374f5c 100644
--- a/litellm/router.py
+++ b/litellm/router.py
@@ -4422,7 +4422,7 @@ class Router:
**{
**data,
"input": input,
- "voice": voice,
+ **({"voice": voice} if voice is not None else {}),
"client": model_client,
**kwargs,
}
diff --git a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
index 108d4107db1..d819a79cef1 100644
--- a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
+++ b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
@@ -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(
diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py
index 9b5122c3f75..e01679048e6 100644
--- a/tests/test_litellm/test_main.py
+++ b/tests/test_litellm/test_main.py
@@ -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
diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py
index 048536d887f..014937b35cd 100644
--- a/tests/test_litellm/test_router.py
+++ b/tests/test_litellm/test_router.py
@@ -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
From c79d1d12ae75a8eaec1de15fe4f0de01330cc553 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Thu, 3 Sep 2026 02:15:57 -0700
Subject: [PATCH 05/79] fix(ai-gateway): install a rustls crypto provider
before dialing upstream WebSockets
The gateway's dependency graph turns on two rustls crypto backends at once:
reqwest's rustls-tls pulls in ring, and litellm-core's bedrock-auth pulls in
aws-lc-rs through aws-config. rustls 0.23 refuses to guess between them, so
ClientConfig::builder panics, and that is exactly how tokio-tungstenite builds
its TLS config. Every outbound WebSocket dial killed its tokio worker and the
client saw the socket vanish with no close frame.
reqwest and the AWS SDK both pick a provider explicitly, so only the tungstenite
path was affected. Route all three dial sites through one helper that installs
ring once per process before connecting.
---
litellm-rust/Cargo.lock | 1 +
litellm-rust/Cargo.toml | 1 +
litellm-rust/crates/ai-gateway/Cargo.toml | 3 +
litellm-rust/crates/ai-gateway/src/io/mod.rs | 1 +
.../crates/ai-gateway/src/io/realtime.rs | 6 +-
.../crates/ai-gateway/src/io/responses_ws.rs | 12 ++--
litellm-rust/crates/ai-gateway/src/io/tls.rs | 67 +++++++++++++++++++
7 files changed, 84 insertions(+), 7 deletions(-)
create mode 100644 litellm-rust/crates/ai-gateway/src/io/tls.rs
diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock
index b3dac5ca935..2ed998174e4 100644
--- a/litellm-rust/Cargo.lock
+++ b/litellm-rust/Cargo.lock
@@ -1415,6 +1415,7 @@ dependencies = [
"litellm-core",
"pyo3",
"reqwest",
+ "rustls 0.23.42",
"serde",
"serde_json",
"sha2 0.10.9",
diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml
index a13dd4c04b0..15074082981 100644
--- a/litellm-rust/Cargo.toml
+++ b/litellm-rust/Cargo.toml
@@ -26,6 +26,7 @@ pythonize = "0.29.0"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "http2", "stream"] }
rstest = "0.26.1"
+rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
diff --git a/litellm-rust/crates/ai-gateway/Cargo.toml b/litellm-rust/crates/ai-gateway/Cargo.toml
index e3dbdf24ce6..414abc2356d 100644
--- a/litellm-rust/crates/ai-gateway/Cargo.toml
+++ b/litellm-rust/crates/ai-gateway/Cargo.toml
@@ -19,6 +19,9 @@ litellm-core = { workspace = true, features = ["bedrock-auth"] }
# reqwest (rustls + json) is used by io/ocr and ships realtime logs to the
# Python proxy callbacks API.
reqwest.workspace = true
+# rustls is a direct dependency so `io::tls` can install a process-level
+# crypto provider; see that module for why the graph needs one.
+rustls.workspace = true
# `sync` powers the bounded mpsc channel the realtime logger drains.
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
tokio-tungstenite.workspace = true
diff --git a/litellm-rust/crates/ai-gateway/src/io/mod.rs b/litellm-rust/crates/ai-gateway/src/io/mod.rs
index cce56dd2121..7098d67993f 100644
--- a/litellm-rust/crates/ai-gateway/src/io/mod.rs
+++ b/litellm-rust/crates/ai-gateway/src/io/mod.rs
@@ -3,3 +3,4 @@ pub mod ocr;
pub mod realtime;
pub mod realtime_pool;
pub mod responses_ws;
+pub(crate) mod tls;
diff --git a/litellm-rust/crates/ai-gateway/src/io/realtime.rs b/litellm-rust/crates/ai-gateway/src/io/realtime.rs
index 662f7328982..53d87848342 100644
--- a/litellm-rust/crates/ai-gateway/src/io/realtime.rs
+++ b/litellm-rust/crates/ai-gateway/src/io/realtime.rs
@@ -23,10 +23,12 @@ use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
-use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
+use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use litellm_core::providers::openai::realtime::transformation::OPENAI_REALTIME_CONFIG;
+use crate::io::tls::connect_upstream;
+
/// Environment variable holding the OpenAI API key (last-resort fallback).
const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
@@ -84,7 +86,7 @@ pub(crate) async fn dial_upstream(
.map_err(|err| Error::Auth(err.to_string()))?,
);
- let (upstream, _response) = connect_async(request)
+ let (upstream, _response) = connect_upstream(request)
.await
.map_err(|err| Error::Network(err.to_string()))?;
Ok(upstream)
diff --git a/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs b/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs
index 0b01747b1a5..ee181791413 100644
--- a/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs
+++ b/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs
@@ -14,7 +14,9 @@ use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::{AUTHORIZATION, HeaderName};
-use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
+use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
+
+use crate::io::tls::connect_upstream;
use crate::constants::{
DEFAULT_RESPONSES_WS_CONNECT_TIMEOUT_SECS, DEFAULT_RESPONSES_WS_IDLE_TIMEOUT_SECS,
@@ -49,14 +51,14 @@ impl ResponsesWebSocketConnection {
.map_err(|error| Error::InvalidRequest(error.to_string()))?;
request.headers_mut().insert(header_name, header_value);
}
- let connect = connect_async(request);
+ let connect = connect_upstream(request);
let result = match timeout {
Some(timeout) => tokio::time::timeout(timeout, connect).await.map_err(|_| {
Error::Network("Responses WebSocket connection timed out".to_string())
})?,
None => connect.await,
};
- let (socket, _) = result.map_err(|error| match error {
+ let (socket, _) = result.map_err(|error| match *error {
tokio_tungstenite::tungstenite::Error::Http(response) => Error::Http {
status: response.status().as_u16(),
body: String::new(),
@@ -138,13 +140,13 @@ async fn dial_upstream(
);
let result = tokio::time::timeout(
Duration::from_secs(DEFAULT_RESPONSES_WS_CONNECT_TIMEOUT_SECS),
- connect_async(request),
+ connect_upstream(request),
)
.await
.map_err(|_| Error::Network("Responses WebSocket connection timed out".to_string()))?;
result
.map(|(socket, _)| socket)
- .map_err(|error| match error {
+ .map_err(|error| match *error {
tokio_tungstenite::tungstenite::Error::Http(response) => Error::Http {
status: response.status().as_u16(),
body: String::new(),
diff --git a/litellm-rust/crates/ai-gateway/src/io/tls.rs b/litellm-rust/crates/ai-gateway/src/io/tls.rs
new file mode 100644
index 00000000000..e3754e44002
--- /dev/null
+++ b/litellm-rust/crates/ai-gateway/src/io/tls.rs
@@ -0,0 +1,67 @@
+//! Outbound WebSocket dials, with the rustls crypto provider settled first.
+//!
+//! `reqwest/rustls-tls` enables `rustls/ring` and `litellm-core`'s `bedrock-auth`
+//! enables `rustls/aws-lc-rs`, so `ClientConfig::builder()` — which is how
+//! `tokio-tungstenite` builds its TLS config — panics rather than guess between
+//! them. reqwest and the AWS SDK pick a provider explicitly and never panic.
+//!
+//! Installing from the dial rather than from a `main` also covers the `cdylib`
+//! the Python bridge loads, the tests, and the benches, none of which have one.
+//! ring is what reqwest already falls back to, so installing it changes no
+//! working path, and an embedder that installed its own provider first keeps it.
+
+use std::sync::Once;
+
+use tokio::net::TcpStream;
+use tokio_tungstenite::tungstenite::Error;
+use tokio_tungstenite::tungstenite::client::IntoClientRequest;
+use tokio_tungstenite::tungstenite::handshake::client::Response;
+use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
+
+static INSTALL_CRYPTO_PROVIDER: Once = Once::new();
+
+pub(crate) fn ensure_crypto_provider() {
+ INSTALL_CRYPTO_PROVIDER.call_once(|| {
+ let _ = rustls::crypto::ring::default_provider().install_default();
+ });
+}
+
+pub(crate) async fn connect_upstream