fix(decisions): preserve upstream error body and response on decisions exception mapping (fixes #48539)

Signed-off-by: gauravatwork117 <gauravatwork117@gmail.com>
This commit is contained in:
gauravatwork117 2026-10-29 00:37:58 +05:30
parent 7abff3dd63
commit 7625eaf3cd
3 changed files with 47 additions and 3 deletions

View file

@ -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 _:

View file

@ -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

View file

@ -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: