From a45c2023ce67beaf18c99f1c8ddfce3f2b6d5c26 Mon Sep 17 00:00:00 2001 From: shrey Date: Wed, 28 Oct 2026 12:20:17 -0700 Subject: [PATCH] fix(audio): return 400 for unsupported transcription providers (#47971) --- litellm/__init__.py | 1 + litellm/exceptions.py | 5 + litellm/main.py | 12 +- litellm/router.py | 25 +++- litellm/router_utils/cooldown_handlers.py | 2 +- .../router_utils/fallback_event_handlers.py | 5 +- litellm/router_utils/get_retry_from_policy.py | 3 + .../router_code_coverage.py | 1 + .../router_utils/test_cooldown_handlers.py | 35 ++++++ .../test_get_retry_from_policy.py | 12 ++ tests/unit/test_main.py | 31 +++++ tests/unit/test_router/test_router.py | 110 ++++++++++++++++++ 12 files changed, 234 insertions(+), 8 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 2cff21bf29a..af771b2f0e6 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1449,6 +1449,7 @@ from .exceptions import ( Timeout, APIConnectionError, UnsupportedParamsError, + UnsupportedEndpointError as UnsupportedEndpointError, APIResponseValidationError, UnprocessableEntityError, InternalServerError, diff --git a/litellm/exceptions.py b/litellm/exceptions.py index c8eee9ad1f8..cd5139f5f79 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -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, diff --git a/litellm/main.py b/litellm/main.py index 79d2d0370f7..75bd4431120 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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 diff --git a/litellm/router.py b/litellm/router.py index d5cad013e8d..e8d83e1203e 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -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): diff --git a/litellm/router_utils/cooldown_handlers.py b/litellm/router_utils/cooldown_handlers.py index e1baf66b201..128bca6cca0 100644 --- a/litellm/router_utils/cooldown_handlers.py +++ b/litellm/router_utils/cooldown_handlers.py @@ -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" ) diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index 6c93fe6d239..0a77ad5b9ea 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -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", "")) diff --git a/litellm/router_utils/get_retry_from_policy.py b/litellm/router_utils/get_retry_from_policy.py index 7c412dc759b..835cfaacfd5 100644 --- a/litellm/router_utils/get_retry_from_policy.py +++ b/litellm/router_utils/get_retry_from_policy.py @@ -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 ) diff --git a/tests/code_coverage_tests/router_code_coverage.py b/tests/code_coverage_tests/router_code_coverage.py index 3183b9687ca..eee02a2f6b6 100644 --- a/tests/code_coverage_tests/router_code_coverage.py +++ b/tests/code_coverage_tests/router_code_coverage.py @@ -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 diff --git a/tests/unit/router_utils/test_cooldown_handlers.py b/tests/unit/router_utils/test_cooldown_handlers.py index 4299b61c703..0d39016ea79 100644 --- a/tests/unit/router_utils/test_cooldown_handlers.py +++ b/tests/unit/router_utils/test_cooldown_handlers.py @@ -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() diff --git a/tests/unit/router_utils/test_get_retry_from_policy.py b/tests/unit/router_utils/test_get_retry_from_policy.py index 42580989a8c..4ff869849dd 100644 --- a/tests/unit/router_utils/test_get_retry_from_policy.py +++ b/tests/unit/router_utils/test_get_retry_from_policy.py @@ -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) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index f12129af1fc..ba39fc3f5ac 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -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" diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 9875f1bd00a..63ee1c43edb 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -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",