diff --git a/litellm/llms/elevenlabs/audio_transcription/transformation.py b/litellm/llms/elevenlabs/audio_transcription/transformation.py index 8746e92d9f6..abd92e5f6cc 100644 --- a/litellm/llms/elevenlabs/audio_transcription/transformation.py +++ b/litellm/llms/elevenlabs/audio_transcription/transformation.py @@ -2,6 +2,7 @@ Translates from OpenAI's `/v1/audio/transcriptions` to ElevenLabs's `/v1/speech-to-text` """ +import json from typing import List, Optional, Union from httpx import Headers, Response @@ -31,7 +32,7 @@ class ElevenLabsAudioTranscriptionConfig(BaseAudioTranscriptionConfig): def get_supported_openai_params( self, model: str ) -> List[OpenAIAudioTranscriptionOptionalParams]: - return ["language", "temperature"] + return ["language", "prompt", "temperature"] def map_openai_params( self, @@ -46,6 +47,10 @@ class ElevenLabsAudioTranscriptionConfig(BaseAudioTranscriptionConfig): if k == "language": # Map OpenAI language format to ElevenLabs language_code optional_params["language_code"] = v + elif k == "prompt": + # Pass prompt through — ElevenLabs Scribe accepts it + # as vocabulary context + optional_params["prompt"] = v else: optional_params[k] = v return optional_params @@ -57,6 +62,20 @@ class ElevenLabsAudioTranscriptionConfig(BaseAudioTranscriptionConfig): message=error_message, status_code=status_code, headers=headers ) + @staticmethod + def _serialize_form_value(value) -> str: + """Serialize a value for multipart form data. + + - bool → lowercase "true"/"false" + - list/dict → JSON string + - other → str() + """ + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, (list, dict)): + return json.dumps(value) + return str(value) + def transform_audio_transcription_request( self, model: str, @@ -84,8 +103,7 @@ class ElevenLabsAudioTranscriptionConfig(BaseAudioTranscriptionConfig): ######################################################### 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] = self._serialize_form_value(value) ######################################################### # Add Provider Specific Parameters @@ -97,7 +115,7 @@ class ElevenLabsAudioTranscriptionConfig(BaseAudioTranscriptionConfig): ) for key, value in provider_specific_params.items(): - form_data[key] = str(value) + form_data[key] = self._serialize_form_value(value) ######################################################### ######################################################### diff --git a/tests/test_litellm/llms/elevenlabs/__init__.py b/tests/test_litellm/llms/elevenlabs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/elevenlabs/audio_transcription/__init__.py b/tests/test_litellm/llms/elevenlabs/audio_transcription/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/elevenlabs/audio_transcription/test_elevenlabs_audio_transcription_transformation.py b/tests/test_litellm/llms/elevenlabs/audio_transcription/test_elevenlabs_audio_transcription_transformation.py new file mode 100644 index 00000000000..c493d9c27a9 --- /dev/null +++ b/tests/test_litellm/llms/elevenlabs/audio_transcription/test_elevenlabs_audio_transcription_transformation.py @@ -0,0 +1,147 @@ +import json + +import pytest + +import litellm +from litellm.llms.base_llm.audio_transcription.transformation import ( + BaseAudioTranscriptionConfig, +) +from litellm.llms.elevenlabs.audio_transcription.transformation import ( + ElevenLabsAudioTranscriptionConfig, +) +from litellm.utils import ProviderConfigManager + + +@pytest.fixture() +def config(): + return ElevenLabsAudioTranscriptionConfig() + + +def test_elevenlabs_config_registered(): + """Ensure ElevenLabs audio transcription config is registered.""" + config = ProviderConfigManager.get_provider_audio_transcription_config( + model="elevenlabs/scribe_v2", + provider=litellm.LlmProviders.ELEVENLABS, + ) + assert config is not None + assert isinstance(config, BaseAudioTranscriptionConfig) + assert isinstance(config, ElevenLabsAudioTranscriptionConfig) + + +def test_supported_openai_params_includes_prompt(config): + """Fixes #25065 — prompt must be in supported params.""" + params = config.get_supported_openai_params("scribe_v2") + assert "prompt" in params + assert "language" in params + assert "temperature" in params + + +def test_map_openai_params_prompt(config): + """prompt should be mapped through to optional_params.""" + result = config.map_openai_params( + non_default_params={"prompt": "technical terms: LiteLLM, Scribe"}, + optional_params={}, + model="scribe_v2", + drop_params=False, + ) + assert result["prompt"] == "technical terms: LiteLLM, Scribe" + + +def test_map_openai_params_language_mapped_to_language_code(config): + """language should be mapped to language_code for ElevenLabs.""" + result = config.map_openai_params( + non_default_params={"language": "en"}, + optional_params={}, + model="scribe_v2", + drop_params=False, + ) + assert result["language_code"] == "en" + assert "language" not in result + + +def test_serialize_form_value_bool(config): + """Booleans should be lowercase strings.""" + assert config._serialize_form_value(True) == "true" + assert config._serialize_form_value(False) == "false" + + +def test_serialize_form_value_list(config): + """Fixes #25066 — lists must be JSON-serialized, not Python repr.""" + value = ["term1", "term2"] + result = config._serialize_form_value(value) + assert result == '["term1", "term2"]' + # Verify it's valid JSON + assert json.loads(result) == ["term1", "term2"] + + +def test_serialize_form_value_dict(config): + """Dicts must be JSON-serialized.""" + value = {"key": "val"} + result = config._serialize_form_value(value) + assert json.loads(result) == {"key": "val"} + + +def test_serialize_form_value_scalar(config): + """Scalars should use str().""" + assert config._serialize_form_value(0.5) == "0.5" + assert config._serialize_form_value(42) == "42" + assert config._serialize_form_value("hello") == "hello" + + +def test_transform_request_with_prompt(config): + """prompt param should appear in form data after transform.""" + result = config.transform_audio_transcription_request( + model="scribe_v2", + audio_file=b"fake audio bytes", + optional_params={"prompt": "vocabulary hint", "temperature": 0.5}, + litellm_params={}, + ) + assert result.data["prompt"] == "vocabulary hint" + assert result.data["temperature"] == "0.5" + assert result.data["model_id"] == "scribe_v2" + + +def test_transform_request_array_provider_param(config): + """Provider-specific array params should be JSON-encoded, not Python repr.""" + result = config.transform_audio_transcription_request( + model="scribe_v2", + audio_file=b"fake audio bytes", + optional_params={"keyterms": ["Atlas", "KIT"]}, + litellm_params={}, + ) + raw = result.data["keyterms"] + assert raw == '["Atlas", "KIT"]' + assert json.loads(raw) == ["Atlas", "KIT"] + + +def test_transform_request_bool_provider_param(config): + """Provider-specific bool params should be lowercase strings.""" + result = config.transform_audio_transcription_request( + model="scribe_v2", + audio_file=b"fake audio bytes", + optional_params={"diarize": True}, + litellm_params={}, + ) + assert result.data["diarize"] == "true" + + +def test_get_complete_url(config): + url = config.get_complete_url( + api_base=None, + api_key="fake-key", + model="scribe_v2", + optional_params={}, + litellm_params={}, + ) + assert url == "https://api.elevenlabs.io/v1/speech-to-text" + + +def test_get_complete_url_custom_base(config): + url = config.get_complete_url( + api_base="https://custom.api.example.com/", + api_key="fake-key", + model="scribe_v2", + optional_params={}, + litellm_params={}, + ) + assert url == "https://custom.api.example.com/v1/speech-to-text"