mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
Merge 17f65297e0 into b6f91a221b
This commit is contained in:
commit
746f161557
2 changed files with 110 additions and 53 deletions
|
|
@ -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
|
||||
|
|
@ -29,6 +29,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:
|
||||
|
|
@ -76,16 +90,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
|
||||
|
|
@ -97,7 +109,7 @@ class ElevenLabsAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
|
|||
)
|
||||
|
||||
for key, value in provider_specific_params.items():
|
||||
form_data[key] = str(value)
|
||||
form_data[key] = _to_form_value(value)
|
||||
#########################################################
|
||||
#########################################################
|
||||
|
||||
|
|
@ -147,6 +159,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,62 @@ 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"
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue