From 464223b99f8215584a0c8329c58b701d4255b305 Mon Sep 17 00:00:00 2001 From: Tech Carrement Date: Sat, 17 Oct 2026 19:08:01 +0200 Subject: [PATCH 1/2] fix(elevenlabs): encode bool and list form fields for speech-to-text str(True) ships "True", which ElevenLabs does not recognize, so flags such as diarize or use_multi_channel were silently ignored; str(["a", "b"]) ships the Python repr as a single keyterm. Booleans now go out lowercase and lists as one repeated multipart part per element, which is how httpx encodes a list. --- .../audio_transcription/transformation.py | 24 +++-- .../test_transformation.py | 98 ++++++++++--------- 2 files changed, 69 insertions(+), 53 deletions(-) diff --git a/litellm/llms/elevenlabs/audio_transcription/transformation.py b/litellm/llms/elevenlabs/audio_transcription/transformation.py index 04d2717d64f..8af22690b29 100644 --- a/litellm/llms/elevenlabs/audio_transcription/transformation.py +++ b/litellm/llms/elevenlabs/audio_transcription/transformation.py @@ -2,7 +2,7 @@ Translates from OpenAI's `/v1/audio/transcriptions` to ElevenLabs's `/v1/speech-to-text` """ -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Sequence from typing import Final from httpx import Headers, Response @@ -28,6 +28,20 @@ _JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_i _JSON_OBJECTS: Final = TypeAdapter(Iterable[Mapping[str, object]], config=ConfigDict(hide_input_in_errors=True)) +def _form_scalar(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def _to_form_value(value: str | float | bool | Sequence[object]) -> str | Sequence[str]: + # httpx sends a list as one repeated multipart part per element, which is how + # ElevenLabs reads a list such as keyterms; str(list) would ship the Python repr + if isinstance(value, (list, tuple)): + return [_form_scalar(item) for item in value] + return _form_scalar(value) + + class ElevenLabsAudioTranscriptionConfig(BaseAudioTranscriptionConfig): @property def custom_llm_provider(self) -> str: @@ -75,16 +89,14 @@ class ElevenLabsAudioTranscriptionConfig(BaseAudioTranscriptionConfig): # Use common utility to process the audio file processed_audio: Final = process_audio_file(audio_file) - # Prepare form data - form_data: Final = {"model_id": model} + form_data: Final = {"model_id": _to_form_value(model)} ######################################################### # Add OpenAI Compatible Parameters ######################################################### for key, value in optional_params.items(): if key in self.get_supported_openai_params(model) and value is not None: - # Convert values to strings for form data, but skip None values - form_data[key] = str(value) + form_data[key] = _to_form_value(value) ######################################################### # Add Provider Specific Parameters @@ -96,7 +108,7 @@ class ElevenLabsAudioTranscriptionConfig(BaseAudioTranscriptionConfig): ) for key, value in provider_specific_params.items(): - form_data[key] = str(value) + form_data[key] = _to_form_value(value) ######################################################### ######################################################### diff --git a/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py b/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py index 86252a1a95d..616ad3acfca 100644 --- a/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py +++ b/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py @@ -1,17 +1,15 @@ from collections.abc import Iterator -from typing import Final +from typing import Any, Final +from unittest.mock import MagicMock, patch import httpx import pytest -import respx import litellm from litellm.caching.llm_caching_handler import LLMClientCache from litellm.llms.elevenlabs.audio_transcription.transformation import ElevenLabsAudioTranscriptionConfig from litellm.llms.elevenlabs.text_to_speech.transformation import ElevenLabsTextToSpeechConfig from litellm.types.utils import TranscriptionResponse -from typing import Any, Dict -from unittest.mock import patch, MagicMock ELEVENLABS_API_BASE: Final = "https://api.elevenlabs.io" ELEVENLABS_TRANSCRIPTION_URL: Final = f"{ELEVENLABS_API_BASE}/v1/speech-to-text" @@ -139,9 +137,7 @@ class TestElevenLabsAudioTranscription: # Mock successful response mock_response = MagicMock() mock_response.status_code = 200 - mock_response.text = ( - '{"text": "Four score and seven years ago", "language_code": "en"}' - ) + mock_response.text = '{"text": "Four score and seven years ago", "language_code": "en"}' mock_response.json.return_value = { "text": "Four score and seven years ago", "language_code": "en", @@ -194,39 +190,29 @@ class TestElevenLabsAudioTranscription: # Check basic required parameters assert "model_id" in form_data, "model_id should be in form data" - assert ( - form_data["model_id"] == "scribe_v1" - ), f"Expected model_id 'scribe_v1', got {form_data['model_id']}" + assert form_data["model_id"] == "scribe_v1", ( + f"Expected model_id 'scribe_v1', got {form_data['model_id']}" + ) # Check that diarize parameter is passed through - assert ( - "diarize" in form_data - ), f"diarize should be in form data. Got: {list(form_data.keys())}" - assert ( - form_data["diarize"] == "True" - ), f"Expected diarize='True', got {form_data['diarize']}" + assert "diarize" in form_data, f"diarize should be in form data. Got: {list(form_data.keys())}" + assert form_data["diarize"] == "true", f"Expected diarize='true', got {form_data['diarize']}" # Check that OpenAI language parameter is mapped correctly - assert ( - "language_code" in form_data - ), "language_code should be in form data" - assert ( - form_data["language_code"] == "en" - ), f"Expected language_code='en', got {form_data['language_code']}" + assert "language_code" in form_data, "language_code should be in form data" + assert form_data["language_code"] == "en", ( + f"Expected language_code='en', got {form_data['language_code']}" + ) # Check that temperature is passed through assert "temperature" in form_data, "temperature should be in form data" - assert ( - form_data["temperature"] == "0.5" - ), f"Expected temperature='0.5', got {form_data['temperature']}" + assert form_data["temperature"] == "0.5", f"Expected temperature='0.5', got {form_data['temperature']}" # Check that custom parameters are passed through - assert ( - "custom_param" in form_data - ), "custom_param should be in form data" - assert ( - form_data["custom_param"] == "test_value" - ), f"Expected custom_param='test_value', got {form_data['custom_param']}" + assert "custom_param" in form_data, "custom_param should be in form data" + assert form_data["custom_param"] == "test_value", ( + f"Expected custom_param='test_value', got {form_data['custom_param']}" + ) # Check that files are included files = captured_request_data["files"] @@ -247,7 +233,7 @@ class TestElevenLabsTextToSpeechTransformation: return ElevenLabsTextToSpeechConfig() def test_map_openai_params_maps_voice_and_speed(self, config): - kwargs: Dict[str, Any] = {} + kwargs: dict[str, Any] = {} mapped_voice, mapped_params = config.map_openai_params( model="eleven_multilingual_v2", optional_params={ @@ -261,36 +247,27 @@ class TestElevenLabsTextToSpeechTransformation: assert mapped_voice == config.VOICE_MAPPINGS["alloy"] assert mapped_params["voice_settings"]["speed"] == pytest.approx(1.25) - assert ( - kwargs[config.ELEVENLABS_QUERY_PARAMS_KEY]["output_format"] - == "mp3_44100_128" - ) + assert kwargs[config.ELEVENLABS_QUERY_PARAMS_KEY]["output_format"] == "mp3_44100_128" def test_transform_request_and_url(self, config): - kwargs: Dict[str, Any] = {} + kwargs: dict[str, Any] = {} voice_id, optional_params = config.map_openai_params( model="eleven_multilingual_v2", optional_params={ "response_format": "pcm", "model_id": "eleven_multilingual_v2", - "pronunciation_dictionary_locators": [ - {"pronunciation_dictionary_id": "dict_1"} - ], + "pronunciation_dictionary_locators": [{"pronunciation_dictionary_id": "dict_1"}], }, voice="alloy", kwargs=kwargs, ) - litellm_params: Dict[str, Any] = { + litellm_params: dict[str, Any] = { config.ELEVENLABS_VOICE_ID_KEY: voice_id, - config.ELEVENLABS_QUERY_PARAMS_KEY: kwargs[ - config.ELEVENLABS_QUERY_PARAMS_KEY - ], + config.ELEVENLABS_QUERY_PARAMS_KEY: kwargs[config.ELEVENLABS_QUERY_PARAMS_KEY], } - headers = config.validate_environment( - headers={}, model="eleven_multilingual_v2", api_key="test-key" - ) + headers = config.validate_environment(headers={}, model="eleven_multilingual_v2", api_key="test-key") request_data = config.transform_text_to_speech_request( model="eleven_multilingual_v2", @@ -315,3 +292,30 @@ class TestElevenLabsTextToSpeechTransformation: assert voice_id in url assert "output_format=pcm_44100" in url + + +def _request_form_data(optional_params: dict[str, object]) -> dict[str, object]: + request: Final = ElevenLabsAudioTranscriptionConfig().transform_audio_transcription_request( + model="scribe_v2", + audio_file=("speech.wav", b"RIFF....WAVEfmt ", "audio/wav"), + optional_params=optional_params, + litellm_params={}, + ) + assert isinstance(request.data, dict) + return request.data + + +def test_transform_audio_transcription_request_sends_list_fields_as_repeated_parts(): + """ElevenLabs reads a list such as keyterms as one multipart part per element; + str(list) would ship the Python repr as a single term.""" + form_data: Final = _request_form_data({"keyterms": ["DialogPro", "Sauvage"]}) + + assert form_data["keyterms"] == ["DialogPro", "Sauvage"] + + +def test_transform_audio_transcription_request_sends_bool_fields_lowercase(): + """httpx form encoding does not lowercase booleans; ElevenLabs ignores "True".""" + form_data: Final = _request_form_data({"diarize": True, "use_multi_channel": False}) + + assert form_data["diarize"] == "true" + assert form_data["use_multi_channel"] == "false" From 17f65297e0bf5eda51930542d1eac6a0149428ed Mon Sep 17 00:00:00 2001 From: Tech Carrement Date: Sat, 17 Oct 2026 19:42:52 +0200 Subject: [PATCH 2/2] feat(elevenlabs): surface multichannel transcripts and audio_duration_secs With use_multi_channel=true, ElevenLabs speech-to-text answers with one transcript per channel (channel_index + words) instead of a flat text, and every response carries the billed audio_duration_secs. Both were dropped by the transformation; they now survive on the TranscriptionResponse so stereo callers can attribute words to a speaker and callers can attribute cost. --- .../audio_transcription/transformation.py | 9 ++++++ .../test_transformation.py | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/litellm/llms/elevenlabs/audio_transcription/transformation.py b/litellm/llms/elevenlabs/audio_transcription/transformation.py index 8af22690b29..fd6848cd514 100644 --- a/litellm/llms/elevenlabs/audio_transcription/transformation.py +++ b/litellm/llms/elevenlabs/audio_transcription/transformation.py @@ -158,6 +158,15 @@ class ElevenLabsAudioTranscriptionConfig(BaseAudioTranscriptionConfig): } ) + # use_multi_channel=true answers with one transcript per channel instead of + # a flat text; stereo callers attribute words to a speaker by channel_index + transcripts: Final = response_object.get("transcripts") + if isinstance(transcripts, list): + response["transcripts"] = transcripts + duration: Final = response_object.get("audio_duration_secs") + if duration is not None: + response["audio_duration_secs"] = duration + # Store full response in hidden params response.hidden_params = response_json diff --git a/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py b/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py index 616ad3acfca..ac795b544a0 100644 --- a/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py +++ b/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py @@ -319,3 +319,35 @@ def test_transform_audio_transcription_request_sends_bool_fields_lowercase(): assert form_data["diarize"] == "true" assert form_data["use_multi_channel"] == "false" + + +def test_transform_audio_transcription_response_keeps_multichannel_transcripts(): + """With use_multi_channel=true ElevenLabs answers with one transcript per channel + (channel_index + words) and the billed audio_duration_secs; both must survive.""" + response: Final = _transform( + { + "language_code": "fra", + "transcripts": [ + { + "channel_index": 0, + "text": "bonjour", + "words": [{"text": "bonjour", "type": "word", "start": 0.1, "end": 0.5}], + }, + { + "channel_index": 1, + "text": "allo", + "words": [{"text": "allo", "type": "word", "start": 0.2, "end": 0.6}], + }, + ], + "audio_duration_secs": 12.7, + } + ) + + assert [t["channel_index"] for t in response["transcripts"]] == [0, 1] + assert response["audio_duration_secs"] == 12.7 + + +def test_transform_audio_transcription_response_without_transcripts_has_no_transcripts_key(): + response: Final = _transform({"language_code": "fra", "text": "bonjour", "words": []}) + + assert "transcripts" not in response