fix(audio-transcription): keep reply bodies out of watsonx, whisper, xai and validation errors

Watsonx and whisper no longer print the parsed reply after Got: when it has no text field. TranscriptionResponse and the xAI response model hide input in validation errors, so a malformed field cannot quote transcript fragments. Deepgram and ElevenLabs keep the original error text, which is now safe.
This commit is contained in:
mateo-berri 2026-11-05 14:02:20 -08:00
parent f05d67b000
commit 17e7686beb
12 changed files with 75 additions and 23 deletions

View file

@ -158,7 +158,7 @@ class DeepgramAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
return response
except Exception as e:
raise ValueError(f"Error transforming Deepgram response: {type(e).__name__}") from None
raise ValueError(f"Error transforming Deepgram response: {e}") from None
def _reconstruct_diarized_transcript(self, words: list) -> str:
"""

View file

@ -160,7 +160,7 @@ class ElevenLabsAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
return response
except Exception as e:
raise ValueError(f"Error transforming ElevenLabs response: {type(e).__name__}") from None
raise ValueError(f"Error transforming ElevenLabs response: {e}") from None
def get_complete_url(
self,

View file

@ -141,7 +141,4 @@ class OpenAIWhisperAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
if any(key in raw_response_json for key in TranscriptionResponse.model_fields):
return TranscriptionResponse(**raw_response_json)
else:
raise ValueError(
"Invalid response format. Received response does not match the expected format. Got: ",
raw_response_json,
)
raise ValueError("Invalid response format. Received response does not match the expected format.")

View file

@ -191,10 +191,7 @@ class IBMWatsonXAudioTranscriptionConfig(IBMWatsonXMixin, OpenAIWhisperAudioTran
response_kwargs["usage"] = usage
if not response_kwargs:
raise ValueError(
"Invalid response format. Received response does not match the expected format. Got: ",
response_object,
)
raise ValueError("Invalid response format. Received response does not match the expected format.")
response: Final = TranscriptionResponse(**response_kwargs)

View file

@ -39,7 +39,7 @@ class _XAISttWord(LiteLLMBaseModel):
class _XAISttResponse(LiteLLMBaseModel):
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra="allow", hide_input_in_errors=True)
text: str = ""
language: str = "unknown"
duration: float | None = None

View file

@ -2984,6 +2984,8 @@ _DETECTED_LANGUAGES: Final = TypeAdapter(tuple[TranscriptionDetectedLanguage, ..
class TranscriptionResponse(OpenAIObject):
model_config = ConfigDict(hide_input_in_errors=True)
text: str | None = None
usage: TranscriptionUsageDurationObject | TranscriptionUsageTokensObject | None = None
languages: Sequence[TranscriptionDetectedLanguage] | None = None

View file

@ -601,18 +601,21 @@ def test_transform_response_keeps_the_transcript_out_of_malformed_words_errors(w
@pytest.mark.parametrize(
("payload", "error"),
[
({"results": None}, "TypeError"),
(_deepgram_payload({"transcript": "hello", "words": 5}, {}), "TypeError"),
({**_deepgram_payload({"transcript": "hello"}, {}), "metadata": None}, "TypeError"),
(_deepgram_payload({"transcript": ["hello"]}, {}), "ValidationError"),
({"results": None}, "'NoneType' object is not subscriptable"),
(_deepgram_payload({"transcript": "hello", "words": 5}, {}), "object of type 'int' has no len()"),
(
{**_deepgram_payload({"transcript": "hello"}, {}), "metadata": None},
"'NoneType' object is not subscriptable",
),
(_deepgram_payload({"transcript": ["hello"]}, {}), "1 validation error for TranscriptionResponse\ntext\n"),
],
)
def test_transform_response_names_only_the_failure_type_of_a_malformed_body(payload: object, error: str):
def test_transform_response_wraps_the_error_of_the_failing_lookup_without_the_body(payload: object, error: str):
raw_response: Final = httpx.Response(200, json=payload)
with pytest.raises(ValueError, match="Error transforming Deepgram response: ") as exc_info:
DeepgramAudioTranscriptionConfig().transform_audio_transcription_response(raw_response)
assert exc_info.type is ValueError
assert str(exc_info.value) == f"Error transforming Deepgram response: {error}"
assert str(exc_info.value).startswith(f"Error transforming Deepgram response: {error}")
assert "hello" not in "".join(traceback.format_exception(exc_info.value))

View file

@ -129,6 +129,15 @@ def test_transform_audio_transcription_response_keeps_the_body_out_of_malformed_
assert "sensitive" not in "".join(traceback.format_exception(exc_info.value))
def test_transform_audio_transcription_response_names_the_failing_lookup_of_a_malformed_payload():
raw_response = httpx.Response(200, json={"text": "sensitive", "words": [None]})
with pytest.raises(ValueError, match=r"^Error transforming ElevenLabs response: 1 validation error for ") as exc_info:
ElevenLabsAudioTranscriptionConfig().transform_audio_transcription_response(raw_response=raw_response)
assert "sensitive" not in "".join(traceback.format_exception(exc_info.value))
class TestElevenLabsAudioTranscription:
@pytest.mark.usefixtures("elevenlabs_api_key_env")
def test_elevenlabs_diarize_parameter_passthrough(self):

View file

@ -4,6 +4,7 @@ from unittest.mock import MagicMock
import httpx
import litellm
import pytest
from pydantic import ValidationError
from litellm.llms.base_llm.audio_transcription.transformation import (
BaseAudioTranscriptionConfig,
@ -202,3 +203,13 @@ def test_mistral_audio_transcription_response_transform_keeps_non_json_body_out_
MistralAudioTranscriptionConfig().transform_audio_transcription_response(raw_response)
assert "sensitive" not in str(exc_info.value)
@pytest.mark.parametrize("text", [["sensitive"], {"value": "sensitive"}])
def test_mistral_audio_transcription_response_transform_keeps_a_malformed_text_field_out_of_the_error(text: object):
raw_response = httpx.Response(200, json={"text": text, "segments": [{"text": "sensitive"}]})
with pytest.raises(ValidationError) as exc_info:
MistralAudioTranscriptionConfig().transform_audio_transcription_response(raw_response)
assert "sensitive" not in str(exc_info.value)

View file

@ -5,8 +5,10 @@ and transform_audio_transcription_response.
import io
import json
import traceback
from unittest.mock import MagicMock
import httpx
import pytest
from litellm.llms.openai.transcriptions.whisper_transformation import (
@ -104,3 +106,10 @@ class TestWhisperTransformResponse:
is_json=False,
)
)
def test_json_object_without_transcription_fields_keeps_the_body_out_of_the_error(self):
config = OpenAIWhisperAudioTranscriptionConfig()
raw_response = httpx.Response(200, json={"segments": [{"text": "sensitive"}]})
with pytest.raises(ValueError, match="Invalid response format") as exc_info:
config.transform_audio_transcription_response(raw_response)
assert "sensitive" not in "".join(traceback.format_exception(exc_info.value))

View file

@ -4,6 +4,7 @@ Tests for IBM WatsonX Audio Transcription.
Validates the WatsonX transcription response transformation.
"""
import traceback
from unittest.mock import MagicMock
import httpx
@ -120,17 +121,21 @@ def test_transform_audio_transcription_response_copies_every_field_except_model(
assert {key: response[key] for key in expected_extras} == expected_extras
@pytest.mark.parametrize("payload", [{}, {"model": "whisper"}, {"duration": 2.0}, {"text": None, "usage": None}])
def test_transform_audio_transcription_response_without_text_or_usage_reports_the_body(
@pytest.mark.parametrize(
"payload",
[
{"model": "sensitive"},
{"duration": 2.0, "segments": [{"text": "sensitive"}]},
{"text": None, "usage": None, "note": "sensitive"},
],
)
def test_transform_audio_transcription_response_without_text_or_usage_keeps_the_body_out_of_the_error(
payload: dict[str, object],
) -> None:
with pytest.raises(ValueError, match="Invalid response format") as exc_info:
_transform_transcription_response(payload)
assert exc_info.value.args == (
"Invalid response format. Received response does not match the expected format. Got: ",
payload,
)
assert "sensitive" not in "".join(traceback.format_exception(exc_info.value))
@pytest.mark.parametrize("payload", [["not", "an", "object"], "plain text", 7, True])

View file

@ -1,3 +1,5 @@
import traceback
import httpx
import pytest
@ -146,6 +148,23 @@ def test_transform_response_raises_on_error_status():
assert "Incorrect API key provided" in exc.value.message
@pytest.mark.parametrize(
"content",
[
b'{"text": ["sensitive"]}',
b'{"text": "ok", "words": [{"text": ["sensitive"]}]}',
b'{"text": "sensitive',
],
)
def test_transform_response_keeps_a_malformed_body_out_of_the_error(content):
raw = httpx.Response(200, content=content, request=httpx.Request("POST", "https://api.x.ai/v1/stt"))
with pytest.raises(XAIAudioTranscriptionError, match="Error parsing xAI response") as exc:
CONFIG.transform_audio_transcription_response(raw_response=raw)
assert "sensitive" not in "".join(traceback.format_exception(exc.value))
def test_transcription_routes_to_xai_stt(monkeypatch):
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.setattr(litellm, "xai_key", None)