fix(gemini): raise incomplete transcription interactions as non-retryable 400 and drop response bodies from Vertex transcription errors

This commit is contained in:
mateo-berri 2026-11-05 10:18:43 -08:00
parent e5a17a1032
commit 59eb7741ee
6 changed files with 63 additions and 3 deletions

View file

@ -38,6 +38,7 @@ from litellm.types.utils import (
INTERACTIONS_API_REVISION: Final = "2026-05-20"
WORD_INFO_ANNOTATION_TYPE: Final = "word_info"
_DECODED_JSON: Final = TypeAdapter(object)
_RETRYABLE_INTERACTION_STATUSES: Final = frozenset({"failed"})
class GeminiAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
@ -143,7 +144,7 @@ class GeminiAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
parsed: Final = GeminiTranscriptionInteractionResponse.model_validate(response_json)
if parsed.status != "completed":
raise GeminiError(
status_code=raw_response.status_code,
status_code=500 if parsed.status in _RETRYABLE_INTERACTION_STATUSES else 400,
message=f"Gemini transcription interaction did not complete (status={parsed.status})",
)
text_contents: Final = tuple(

View file

@ -183,7 +183,7 @@ class VertexGeminiAudioTranscriptionConfig(BaseAudioTranscriptionConfig, VertexB
except ValueError:
raise VertexAIError(
status_code=raw_response.status_code,
message=f"Received non-JSON response from Vertex AI Gemini transcription: {raw_response.text}",
message="Received non-JSON response from Vertex AI Gemini transcription",
)
parsed: Final = VertexGeminiTranscriptionResponse.model_validate(response_json)
texts: Final = tuple(

View file

@ -172,7 +172,7 @@ class VertexAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig, VertexBase)
except ValueError:
raise VertexAIError(
status_code=raw_response.status_code,
message=f"Received non-JSON response from Google Speech-to-Text: {raw_response.text}",
message="Received non-JSON response from Google Speech-to-Text",
)
parsed: Final = VertexSpeechToTextRecognizeResponse.model_validate(response_json)
transcripts: Final = tuple(

View file

@ -3,6 +3,7 @@ import json
import httpx
import pytest
import respx
import litellm
from litellm.llms.gemini.audio_transcription.transformation import (
@ -264,6 +265,52 @@ class TestTransformResponse:
assert response.get("duration") is None
class TestNonCompletedInteractionThroughRouter:
@pytest.fixture
def router(self, monkeypatch):
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
return litellm.Router(
model_list=[
{
"model_name": "transcribe",
"litellm_params": {"model": "gemini/gemini-3.5-transcribe", "api_key": "test-gemini-key"},
}
],
num_retries=2,
)
def _interaction(self, status):
payload = json.loads(json.dumps(COMPLETED_RESPONSE))
payload["status"] = status
payload["steps"][0]["content"][0]["text"] = "sensitive transcript text"
return payload
@pytest.mark.asyncio
@respx.mock
async def test_incomplete_interaction_fails_once_as_bad_request_without_transcript(self, router):
route = respx.post("https://generativelanguage.googleapis.com/v1beta/interactions").mock(
return_value=httpx.Response(200, json=self._interaction("incomplete"))
)
with pytest.raises(litellm.BadRequestError) as excinfo:
await router.atranscription(model="transcribe", file=("call.wav", AUDIO_BYTES, "audio/wav"))
assert route.call_count == 1
assert excinfo.value.status_code == 400
assert "status=incomplete" in str(excinfo.value)
assert "sensitive transcript text" not in str(excinfo.value)
@pytest.mark.asyncio
@respx.mock
async def test_failed_interaction_is_retried(self, router):
route = respx.post("https://generativelanguage.googleapis.com/v1beta/interactions").mock(
return_value=httpx.Response(200, json=self._interaction("failed"))
)
with pytest.raises(litellm.InternalServerError) as excinfo:
await router.atranscription(model="transcribe", file=("call.wav", AUDIO_BYTES, "audio/wav"))
assert route.call_count == 3
assert "sensitive transcript text" not in str(excinfo.value)
class TestSubtitleSynthesisThroughHandler:
def _transform(self, config, response_format):
from unittest.mock import Mock

View file

@ -208,6 +208,12 @@ class TestTransformResponse:
response = config.transform_audio_transcription_response(raw_response)
assert response.text == ""
def test_non_json_error_omits_response_body(self, config):
raw_response = httpx.Response(status_code=200, text='{"results": [{"alternatives": [{"transcript": "sensitive')
with pytest.raises(VertexAIError, match="non-JSON") as excinfo:
config.transform_audio_transcription_response(raw_response)
assert "sensitive" not in str(excinfo.value)
def test_fractional_billed_duration(self, config):
raw_response = httpx.Response(
status_code=200,

View file

@ -255,6 +255,12 @@ class TestTransformResponse:
with pytest.raises(VertexAIError, match="non-JSON"):
config.transform_audio_transcription_response(raw_response)
def test_non_json_error_omits_response_body(self, config):
raw_response = httpx.Response(status_code=200, text='{"candidates": [{"content": {"parts": [{"text": "sensitive')
with pytest.raises(VertexAIError) as excinfo:
config.transform_audio_transcription_response(raw_response)
assert "sensitive" not in str(excinfo.value)
class TestValidateEnvironment:
def test_sets_oauth_headers(self):