mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
fix(audio): return 400 for unsupported transcription providers (#47971)
This commit is contained in:
parent
a0c9a5993b
commit
a45c2023ce
12 changed files with 234 additions and 8 deletions
|
|
@ -1449,6 +1449,7 @@ from .exceptions import (
|
|||
Timeout,
|
||||
APIConnectionError,
|
||||
UnsupportedParamsError,
|
||||
UnsupportedEndpointError as UnsupportedEndpointError,
|
||||
APIResponseValidationError,
|
||||
UnprocessableEntityError,
|
||||
InternalServerError,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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", ""))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue