fix(audio): return 400 for unsupported transcription providers (#47971)

This commit is contained in:
shrey 2026-10-28 12:20:17 -07:00 • committed by GitHub
parent a0c9a5993b
commit a45c2023ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 234 additions and 8 deletions

View file

@ -1449,6 +1449,7 @@ from .exceptions import (
Timeout,
APIConnectionError,
UnsupportedParamsError,
UnsupportedEndpointError as UnsupportedEndpointError,
APIResponseValidationError,
UnprocessableEntityError,
InternalServerError,

View file

@ -317,6 +317,10 @@ class MalformedRequestFieldError(BadRequestError):
pass
class UnsupportedEndpointError(BadRequestError):
pass
class UnprocessableEntityError(openai.UnprocessableEntityError):
def __init__(
self,
@ -1056,6 +1060,7 @@ LITELLM_EXCEPTION_TYPES: Final = [
BadRequestError,
UnprocessableEntityError,
UnsupportedParamsError,
UnsupportedEndpointError,
Timeout,
PermissionDeniedError,
RateLimitError,

View file

@ -8390,7 +8390,7 @@ def transcription(
custom_llm_provider=custom_llm_provider,
)
response: TranscriptionResponse | Coroutine[object, object, TranscriptionResponse] | None = None
response: TranscriptionResponse | Coroutine[object, object, TranscriptionResponse]
provider_config: Final = ProviderConfigManager.get_provider_audio_transcription_config(
model=model,
@ -8552,10 +8552,16 @@ def transcription(
provider_config=provider_config,
shared_session=shared_session,
)
else:
raise litellm.UnsupportedEndpointError(
message=f"{custom_llm_provider}/{model} does not support audio transcription.",
model=model,
llm_provider=custom_llm_provider,
)
# Store duration in _hidden_params for cost calculation without
# exposing it in the response body (see sync path comment above).
if response is not None and not isinstance(response, Coroutine):
if not isinstance(response, Coroutine):
existing_duration: Final = getattr(response, "duration", None)
if existing_duration is None:
calculated_duration: Final = calculate_request_duration(file)
@ -8565,8 +8571,6 @@ def transcription(
)
response_hidden_params["audio_transcription_duration"] = calculated_duration
if response is None:
raise ValueError("Unmapped provider passed in. Unable to get the response.")
return response

View file

@ -633,6 +633,7 @@ _DEPLOYMENT_PARAMS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | GenericLite
_OPAQUE_VALUE_ADAPTER: Final = TypeAdapter(object)
_DEPLOYMENT_SELECTED_EVENT: Final = "litellm.request.deployment_selected"
_CONTENT_BLOCKS_ADAPTER: Final = TypeAdapter(list[dict[str, object]])
_DEPLOYMENTS_ADAPTER: Final = TypeAdapter(Sequence[Mapping[str, object]])
def _extend_trailing_text_block(blocks: Sequence[Mapping[str, object]], text: str) -> Sequence[Mapping[str, object]]:
@ -694,6 +695,12 @@ def _configured_model_info(deployment: Mapping[str, object]) -> Mapping[str, obj
return _MODEL_INFO_ADAPTER.validate_python(deployment.get("model_info") or {})
def _every_deployment_refused(
deployments: Sequence[Mapping[str, object]], refused_deployment_ids: tuple[str, ...]
) -> bool:
return all(_configured_model_info(deployment).get("id") in refused_deployment_ids for deployment in deployments)
def _silent_experiment_targets(silent_model: object) -> tuple[str, ...]:
if silent_model is None:
return ()
@ -4771,7 +4778,13 @@ class Router:
@staticmethod
def _stamp_retry_skip_deployment_id(exception: Exception, kwargs: Mapping[str, object]) -> None:
effective_model_info: Final = kwargs.get("model_info")
deployment_id: Final = effective_model_info.get("id") if isinstance(effective_model_info, Mapping) else None
if not isinstance(effective_model_info, Mapping):
return
model_info: Final = _MODEL_INFO_ADAPTER.validate_python(effective_model_info)
configured_deployment_id: Final = (
model_info.get("original_model_id") if isinstance(exception, litellm.UnsupportedEndpointError) else None
)
deployment_id: Final = configured_deployment_id or model_info.get("id")
if isinstance(deployment_id, str) and deployment_id:
exception.retry_skip_deployment_id = deployment_id # pyright: ignore[reportAttributeAccessIssue] # dynamic stamp, read by _deployment_ids_to_skip_on_retry
@ -9115,6 +9128,10 @@ class Router:
)
if first_skipped_ids:
kwargs["_retry_skipped_deployment_ids"] = first_skipped_ids # rebind-ok: the next attempt reads it
if isinstance(original_exception, litellm.UnsupportedEndpointError) and _every_deployment_refused(
_DEPLOYMENTS_ADAPTER.validate_python(_healthy_deployments), first_skipped_ids
):
raise
else:
raise
@ -9194,6 +9211,10 @@ class Router:
)
if skipped_ids:
kwargs["_retry_skipped_deployment_ids"] = skipped_ids # rebind-ok: the next attempt reads it
if isinstance(e, litellm.UnsupportedEndpointError) and _every_deployment_refused(
_DEPLOYMENTS_ADAPTER.validate_python(_healthy_deployments), skipped_ids
):
raise
_timeout = self._time_to_sleep_before_retry(
e=e,
remaining_retries=remaining_retries,
@ -9290,7 +9311,7 @@ class Router:
status_code: Final = getattr(error, "status_code", None)
if status_code is not None and not litellm.should_retry(status_code):
# 401/403 are special cases - allow retry if multiple deployments exist (handled below)
if status_code not in (401, 403):
if status_code not in (401, 403) and not isinstance(error, litellm.UnsupportedEndpointError):
raise error
if isinstance(error, litellm.NotFoundError):

View file

@ -356,7 +356,7 @@ def _should_run_cooldown_logic(
)
return False
if isinstance(original_exception, litellm.MalformedRequestFieldError):
if isinstance(original_exception, (litellm.MalformedRequestFieldError, litellm.UnsupportedEndpointError)):
verbose_router_logger.debug(
"Should Not Run Cooldown Logic: litellm rejected the request before calling the deployment"
)

View file

@ -261,7 +261,10 @@ def is_bad_request_without_fallback(exception: Exception, fallback_on_unsupporte
return False
if isinstance(exception, litellm.UnsupportedParamsError):
return True
if isinstance(exception, (litellm.ContextWindowExceededError, litellm.ContentPolicyViolationError)):
if isinstance(
exception,
(litellm.ContextWindowExceededError, litellm.ContentPolicyViolationError, litellm.UnsupportedEndpointError),
):
return False
return exception.status_code == 400 and bool(getattr(exception, "llm_provider", ""))

View file

@ -13,6 +13,7 @@ from litellm.exceptions import (
RateLimitError,
ServiceUnavailableError,
Timeout,
UnsupportedEndpointError,
)
from litellm.types.router import RetryPolicy
@ -59,6 +60,8 @@ def get_num_retries_from_retry_policy(
policy: Final = resolve_retry_policy(retry_policy, model_group, model_group_retry_policy)
if policy is None:
return None
if isinstance(exception, UnsupportedEndpointError):
return policy.DefaultRetries
by_class: Final = (
_RETRIES_BY_EXCEPTION_TYPE[cls](policy) for cls in type(exception).__mro__ if cls in _RETRIES_BY_EXCEPTION_TYPE
)

View file

@ -95,6 +95,7 @@ ignored_function_names = [
"arm_routing_read_prefetch", # Tested in tests/unit/caching/test_request_redis_batch_pre_call.py (file lacks "router" in name)
"_deployment_params_base_model", # Tested through log_retry in test_router_helper_utils.py
"_configured_model_info", # Tested through get_configured_service_tiers in test_router.py
"_every_deployment_refused", # Tested through the unsupported transcription retry tests in test_router.py
"_routable_deployments", # Tested through get_configured_service_tiers and get_routable_upstream_model in test_router.py
"_async_get_available_deployment", # Body of the `route {model}` phase wrapper, exercised through async_get_available_deployment in test_router.py
"_async_get_available_deployment_for_pass_through", # Same, through async_get_available_deployment_for_pass_through in test_router.py

View file

@ -35,6 +35,7 @@ from litellm.router_utils.cooldown_handlers import (
cast_exception_status_to_int,
get_cooldown_deployments,
mark_advisor_orchestration_failure,
set_cooldown_deployments,
should_cooldown_based_on_allowed_fails_policy,
)
from litellm.router_utils.fallback_event_handlers import (
@ -55,6 +56,40 @@ from litellm.types.utils import CooldownDeploymentInfo, CredentialItem
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
@pytest.mark.asyncio
@pytest.mark.parametrize(
("exception_type", "expected_cooldown"),
(
(litellm.APIConnectionError, False),
(litellm.UnsupportedEndpointError, False),
(litellm.BadRequestError, True),
),
)
async def test_unsupported_endpoint_preserves_cooldown_exclusion_with_bad_request_policy(
exception_type: type[litellm.APIConnectionError | litellm.BadRequestError], expected_cooldown: bool
) -> None:
router: Final = Router(
model_list=[
{
"model_name": "chat",
"litellm_params": {"model": "anthropic/claude-sonnet-5-5", "api_key": "not-needed"},
"model_info": {"id": "deployment", "allowed_fails_policy": {"BadRequestErrorAllowedFails": 0}},
}
],
)
exception: Final = exception_type(message="Request failed", model="claude-sonnet-5-5", llm_provider="anthropic")
set_cooldown_deployments(
litellm_router_instance=router,
original_exception=exception,
exception_status=exception.status_code,
deployment="deployment",
time_to_cooldown=60,
)
assert await async_get_cooldown_deployments(router, parent_otel_span=None) == (
["deployment"] if expected_cooldown else []
)
class TestGetDeploymentCooldownPolicy:
def _make_router(self, deployment_id: str, model_info: dict | None = None):
router = MagicMock()

View file

@ -89,6 +89,18 @@ def test_payment_required_error_uses_bad_request_retries():
)
@pytest.mark.parametrize("default_retries", (None, 0, 3))
@pytest.mark.parametrize("bad_request_retries", (0, 5))
def test_unsupported_endpoint_preserves_the_connection_error_retry_budget(
default_retries: int | None, bad_request_retries: int
) -> None:
policy: Final = RetryPolicy(BadRequestErrorRetries=bad_request_retries, DefaultRetries=default_retries)
assert get_num_retries_from_retry_policy(
exception=_error(litellm.UnsupportedEndpointError), retry_policy=policy
) == get_num_retries_from_retry_policy(exception=_error(litellm.APIConnectionError), retry_policy=policy)
@pytest.mark.parametrize("exception_type", (litellm.BadGatewayError,))
def test_default_retries_covers_exceptions_without_a_specific_field(exception_type: type[Exception]):
exception: Final = _error(exception_type)

View file

@ -9780,6 +9780,37 @@ async def test_atranscription_answers_a_non_string_provider_hint_with_a_bad_requ
assert not respx_mock.calls
def test_transcription_rejects_an_unsupported_provider_without_an_http_call(respx_mock: respx.MockRouter) -> None:
model: Final = "test-transcription-model"
provider: Final = "anthropic"
with pytest.raises(litellm.UnsupportedEndpointError) as raised:
litellm.transcription(model=f"{provider}/{model}", file=("speech.wav", b"RIFF-audio", "audio/wav"))
assert raised.value.status_code == 400
assert raised.value.message == f"litellm.BadRequestError: {provider}/{model} does not support audio transcription."
assert raised.value.model == model
assert raised.value.llm_provider == provider
assert not respx_mock.calls
@pytest.mark.asyncio
async def test_atranscription_rejects_an_unsupported_provider_without_an_http_call(
respx_mock: respx.MockRouter,
) -> None:
model: Final = "test-transcription-model"
provider: Final = "anthropic"
with pytest.raises(litellm.UnsupportedEndpointError) as raised:
await litellm.atranscription(model=f"{provider}/{model}", file=("speech.wav", b"RIFF-audio", "audio/wav"))
assert raised.value.status_code == 400
assert raised.value.message == f"litellm.BadRequestError: {provider}/{model} does not support audio transcription."
assert raised.value.model == model
assert raised.value.llm_provider == provider
assert not respx_mock.calls
@pytest.mark.asyncio
async def test_aspeech_infers_the_provider_from_base_url(respx_mock: respx.MockRouter) -> None:
base_url: Final = "https://api.groq.com/openai/v1"

View file

@ -21917,6 +21917,116 @@ def test_router_retry_policy_400_moves_off_the_refusing_deployment_on_every_sync
assert accepting.call_count == 1
_TRANSCRIPTION_AUDIO: Final = ("speech.wav", b"RIFF", "audio/wav")
_TRANSCRIPTION_API_BASE: Final = "https://transcriber.local/v1"
def _transcription_deployment(
model_name: str, deployment_id: str, litellm_model: str, weight: int
) -> DeploymentTypedDict:
return {
"model_name": model_name,
"litellm_params": {
"model": litellm_model,
"api_key": "sk-fake",
"api_base": _TRANSCRIPTION_API_BASE,
"weight": weight,
},
"model_info": {"id": deployment_id},
}
@pytest.mark.asyncio
@pytest.mark.parametrize("policy_scope", ["none", "router", "model_group", "request"])
@pytest.mark.parametrize(
"caller_credentials",
({}, {"api_key": "caller-key"}, {"api_base": _TRANSCRIPTION_API_BASE}, {"base_url": _TRANSCRIPTION_API_BASE}),
ids=("configured", "api_key", "api_base", "base_url"),
)
async def test_router_retries_a_transcription_off_a_deployment_whose_provider_cannot_transcribe(
monkeypatch: pytest.MonkeyPatch,
policy_scope: Literal["none", "router", "model_group", "request"],
caller_credentials: Mapping[str, str],
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
policy: Final = RetryPolicy(BadRequestErrorRetries=0)
router: Final = litellm.Router(
model_list=[
_transcription_deployment("transcriber", "cannot-transcribe", "anthropic/claude-sonnet-5-5", weight=1),
_transcription_deployment("transcriber", "can-transcribe", "openai/gpt-transcribe", weight=0),
],
num_retries=1,
retry_policy=policy if policy_scope == "router" else None,
model_group_retry_policy={"transcriber": policy} if policy_scope == "model_group" else None,
disable_cooldowns=True,
)
request_kwargs: Final = {"model_group_retry_policy": {"transcriber": policy}} if policy_scope == "request" else {}
with respx.mock as respx_mock:
upstream: Final = respx_mock.post(f"{_TRANSCRIPTION_API_BASE}/audio/transcriptions").mock(
return_value=httpx.Response(200, json={"text": "hi"})
)
response: Final = await router.atranscription(
model="transcriber", file=_TRANSCRIPTION_AUDIO, **request_kwargs, **caller_credentials
)
assert response.text == "hi"
assert upstream.call_count == 1
@pytest.mark.parametrize("deployment_count", [1, 2, 3])
@pytest.mark.asyncio
@pytest.mark.parametrize(
"caller_credentials",
({}, {"api_key": "caller-key"}, {"api_base": _TRANSCRIPTION_API_BASE}, {"base_url": _TRANSCRIPTION_API_BASE}),
ids=("configured", "api_key", "api_base", "base_url"),
)
async def test_router_tries_each_deployment_that_cannot_transcribe_once_then_answers_400(
deployment_count: int, caller_credentials: Mapping[str, str]
) -> None:
router: Final = litellm.Router(
model_list=[
_transcription_deployment("transcriber", f"cannot-transcribe-{index}", "anthropic/claude-sonnet-5-5", 1)
for index in range(deployment_count)
],
num_retries=5,
disable_cooldowns=True,
)
with respx.mock as respx_mock, pytest.raises(litellm.UnsupportedEndpointError) as raised:
await router.atranscription(model="transcriber", file=_TRANSCRIPTION_AUDIO, **caller_credentials)
assert raised.value.status_code == 400
assert router.fail_calls["transcriber"] == deployment_count
assert not respx_mock.calls
@pytest.mark.asyncio
async def test_router_falls_back_from_a_group_that_cannot_transcribe_when_unsupported_params_do_not(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
router: Final = litellm.Router(
model_list=[
_transcription_deployment("chat-only", "cannot-transcribe", "anthropic/claude-sonnet-5-5", weight=1),
_transcription_deployment("transcriber", "can-transcribe", "openai/gpt-transcribe", weight=1),
],
fallbacks=[{"chat-only": ["transcriber"]}],
fallback_on_unsupported_params=False,
num_retries=0,
disable_cooldowns=True,
)
with respx.mock as respx_mock:
upstream: Final = respx_mock.post(f"{_TRANSCRIPTION_API_BASE}/audio/transcriptions").mock(
return_value=httpx.Response(200, json={"text": "hi"})
)
response: Final = await router.atranscription(model="chat-only", file=_TRANSCRIPTION_AUDIO)
assert response.text == "hi"
assert upstream.call_count == 1
def _make_failure_logging_obj():
return LiteLLMLogging(
model="gpt-5.6",