From f22b17a925a2ecc6141e03108a185c8cbc0b7c27 Mon Sep 17 00:00:00 2001 From: Ben Langfeld Date: Mon, 26 Oct 2026 16:01:43 -0300 Subject: [PATCH] fix(elevenlabs): keep the flat word list on diarized responses --- .../audio_transcription/transformation.py | 29 ++++++++++++------- .../test_transformation.py | 8 +++-- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/litellm/llms/elevenlabs/audio_transcription/transformation.py b/litellm/llms/elevenlabs/audio_transcription/transformation.py index c5cb3a91ac7..6de3f07c39d 100644 --- a/litellm/llms/elevenlabs/audio_transcription/transformation.py +++ b/litellm/llms/elevenlabs/audio_transcription/transformation.py @@ -3,7 +3,7 @@ Translates from OpenAI's `/v1/audio/transcriptions` to ElevenLabs's `/v1/speech- """ import io -from collections.abc import Iterable, Iterator, Mapping +from collections.abc import Iterable, Iterator, Mapping, Sequence from itertools import chain, groupby from types import MappingProxyType from typing import Final, Protocol @@ -245,7 +245,10 @@ def _transcription_response(response_object: Mapping[str, object]) -> Transcript words: Final = tuple(_JSON_OBJECTS.validate_python(response_object.get("words", ()))) if any(word.get("speaker_id") is not None for word in words): - return _diarized_response(ElevenLabsSTTChunk.model_validate(response_object)) + response: Final = _diarized_response(ElevenLabsSTTChunk.model_validate(response_object)) + # Callers already passing diarize=True read the flat word list, so it stays alongside the segments + response["words"] = _openai_words(words) + return response return _flat_response(response_object, words) @@ -260,19 +263,23 @@ def _flat_response( # Map ElevenLabs words to OpenAI format if "words" in response_object: - response["words"] = [ - { - "word": word_data.get("text", ""), - "start": word_data.get("start", 0), - "end": word_data.get("end", 0), - } - for word_data in words - if word_data.get("type") == "word" - ] + response["words"] = _openai_words(words) return response +def _openai_words(words: tuple[Mapping[str, object], ...]) -> Sequence[Mapping[str, object]]: + return [ + { + "word": word_data.get("text", ""), + "start": word_data.get("start", 0), + "end": word_data.get("end", 0), + } + for word_data in words + if word_data.get("type") == "word" + ] + + def _diarized_response(transcript: ElevenLabsSTTChunk) -> TranscriptionResponse: response: Final = TranscriptionResponse(text=transcript.text) response["task"] = "transcribe" diff --git a/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py b/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py index b90da5683af..feffbaf4556 100644 --- a/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py +++ b/tests/unit/llms/elevenlabs/audio_transcription/test_transformation.py @@ -48,7 +48,7 @@ def test_transform_audio_transcription_response_keeps_only_spoken_words(): "language_code": "en", "text": "Hello world", "words": [ - {"type": "word", "text": "Hello", "start": 0.0, "end": 0.4}, + {"type": "word", "text": "Hello", "start": 0.0, "end": 0.4, "speaker_id": "speaker_0"}, {"type": "spacing", "text": " ", "start": 0.4, "end": 0.5}, {"type": "audio_event", "text": "(laughter)", "start": 0.5, "end": 0.9}, {"type": "word", "text": "world", "start": 0.9, "end": 1.3}, @@ -247,7 +247,11 @@ def test_diarized_response_builds_speaker_segments(): "type": "transcript.text.segment", }, ) - assert "words" not in response + assert response["words"] == [ + {"word": "Bom", "start": 3.0, "end": 3.4}, + {"word": "dia", "start": 3.4, "end": 3.8}, + {"word": "Oi", "start": 5.0, "end": 5.3}, + ] def test_diarized_response_reports_fractional_duration_as_typed_usage():