diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 47ec8331c83..462ec942236 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -2328,6 +2328,7 @@ def _map_exception_by_status( return message: Final = f"{exception_provider} - {error_str}" response: Final = original_exception.response if hasattr(original_exception, "response") else None + body: Final = getattr(original_exception, "body", None) match status_code: case 401: raise AuthenticationError( @@ -2366,6 +2367,7 @@ def _map_exception_by_status( model=model, llm_provider=custom_llm_provider, response=response, + body=body, litellm_debug_info=extra_information, ) case 500: @@ -2414,6 +2416,7 @@ def _map_exception_by_status( model=model, llm_provider=custom_llm_provider, response=response, + body=body, litellm_debug_info=extra_information, ) case _: diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index afa12b58b29..a1ddcdb6147 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -4,7 +4,7 @@ import inspect import json import ssl from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Iterator, Mapping, Sequence -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from functools import lru_cache, partial, reduce from itertools import chain from types import MappingProxyType @@ -573,6 +573,14 @@ def _unwrap_provider_error(error: Exception) -> Exception: return error +def _parse_error_body(text: object) -> object | None: + if not isinstance(text, str) or not text.strip().startswith(("{", "[")): + return None + with suppress(json.JSONDecodeError, ValueError): + return json.loads(text) + return None + + def _merge_messages_header_sources(*sources: Mapping[str, object] | None) -> Mapping[str, object]: """Later sources win per header, except ``anthropic-beta``, which unions every source so client betas never drop a deployment's configured ones.""" @@ -6689,11 +6697,15 @@ class BaseLLMHTTPHandler: else: error_headers = {} + parsed_body: Final = _parse_error_body(error_text) + if provider_config is None: raise BaseLLMException( status_code=status_code, message=error_text, headers=error_headers, + response=error_response if isinstance(error_response, httpx.Response) else None, + body=parsed_body, status_code_is_synthesized=not isinstance(received_status_code, int), ) @@ -6702,6 +6714,11 @@ class BaseLLMHTTPHandler: status_code=status_code, headers=error_headers, ) + if isinstance(provider_error, BaseLLMException): + if isinstance(error_response, httpx.Response): + provider_error.response = error_response + if isinstance(parsed_body, dict) and provider_error.body is None: + provider_error.body = parsed_body if not isinstance(received_status_code, int): provider_error.status_code_is_synthesized = True raise provider_error diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index b9813289749..0168d3985da 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -761,18 +761,42 @@ async def test_invalid_question_is_rejected_before_http(respx_mock: respx.MockRo def test_upstream_bad_request_maps_to_litellm_error(respx_mock: respx.MockRouter) -> None: + error_payload: Final = {"error": {"message": "invalid decision"}} respx_mock.post("https://api.perplexity.ai/v1/decisions").respond( status_code=400, - json={"error": {"message": "invalid decision"}}, + json=error_payload, ) - with pytest.raises(litellm.BadRequestError): + with pytest.raises(litellm.BadRequestError) as exc_info: litellm.systemone( model="perplexity/pplx-decider-v1-27b", state="review", questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, api_key="caller-key", ) + assert exc_info.value.body == error_payload + assert exc_info.value.response is not None + assert json.loads(exc_info.value.response.text) == error_payload + + +async def test_decisions_upstream_error_preserves_body_and_response(respx_mock: respx.MockRouter) -> None: + error_payload: Final = {"detail": {"error_type": "api_usage_error", "message": "Unknown model: jev-9"}} + respx_mock.post("https://api.typesafe.ai/v1/systemone").respond( + status_code=400, + json=error_payload, + ) + + with pytest.raises(litellm.BadRequestError) as exc_info: + await litellm.asystemone( + model="typesafe/jev-1.13.0", + state={"finding": "x"}, + questions={"exp": {"type": "noul", "instructions": "Is it exploitable?"}}, + api_key="sk-test", + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.body == error_payload + assert exc_info.value.response is not None + assert json.loads(exc_info.value.response.text) == error_payload def test_unreachable_upstream_maps_to_a_connection_error(respx_mock: respx.MockRouter) -> None: