From 817b5f44f6712f130c0493ee74ab6ff8d5577141 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:48:14 -0700 Subject: [PATCH] fix(anthropic_endpoints): serialize dict-detail HTTPExceptions on /v1/messages like sibling surfaces --- .../proxy/anthropic_endpoints/endpoints.py | 4 ++ litellm/proxy/common_request_processing.py | 31 ++++++------- .../anthropic_endpoints/test_endpoints.py | 43 +++++++++++++++++++ .../proxy/test_common_request_processing.py | 28 ++++++++++++ 4 files changed, 91 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index f742965ade2..7f0045c1d93 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -22,6 +22,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_request_processing import ( ProxyBaseLLMRequestProcessing, create_response, + proxy_exception_from_http_exception, ) from litellm.proxy.common_utils.http_parsing_utils import _read_request_body from litellm.types.utils import TokenCountResponse @@ -214,6 +215,9 @@ async def anthropic_response( litellm_logging_obj=None, ) + if isinstance(e, HTTPException): + raise proxy_exception_from_http_exception(e, headers) + error_msg: Final = f"{e}" raise ProxyException( message=getattr(e, "message", error_msg), diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index ff6c8d1b1f8..95b99ad93e9 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -533,6 +533,21 @@ def _serialize_http_exception_detail( return str(detail), None +def proxy_exception_from_http_exception(exc: HTTPException, headers: dict[str, str]) -> ProxyException: + raw_detail: Final = _getattr_object(exc, "detail", str(exc)) + message, structured_fields = _serialize_http_exception_detail(raw_detail) + existing_fields: Final = getattr(exc, "provider_specific_fields", None) or {} + merged_fields: Final = {**existing_fields, **structured_fields} if structured_fields else (existing_fields or None) + return ProxyException( + message=message, + type=getattr(exc, "type", "None"), + param=getattr(exc, "param", "None"), + code=getattr(exc, "status_code", status.HTTP_400_BAD_REQUEST), + provider_specific_fields=merged_fields, + headers=headers, + ) + + def _collect_response_file_search_vector_store_ids(data: Mapping[str, object]) -> set[str]: vector_store_ids: Final[set[str]] = set() tools: Final = data.get("tools") @@ -3244,21 +3259,7 @@ class ProxyBaseLLMRequestProcessing: raise e if isinstance(e, HTTPException): - raw_detail: Final = _getattr_object(e, "detail", str(e)) - message, structured_fields = _serialize_http_exception_detail(raw_detail) - existing_fields: Final = getattr(e, "provider_specific_fields", None) or {} - if structured_fields: - merged_fields: dict | None = {**existing_fields, **structured_fields} - else: - merged_fields = existing_fields or None - raise ProxyException( - message=message, - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - provider_specific_fields=merged_fields, - headers=safe_headers, - ) + raise proxy_exception_from_http_exception(e, safe_headers) elif isinstance(e, httpx.HTTPStatusError): # Handle httpx.HTTPStatusError - extract actual error from response # This matches the original behavior before the refactor in commit 511d435f6f diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py b/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py index 9a90daeccb7..c83ba142011 100644 --- a/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py @@ -164,6 +164,49 @@ class TestProxyExceptionPassthrough: mock_logging.post_call_failure_hook.assert_awaited_once() +class TestHttpExceptionDictDetail: + @pytest.mark.asyncio + async def test_anthropic_response_serializes_dict_detail_http_exception(self): + """LIT-6466: a post_call guardrail's HTTPException(detail=) must + surface with a clean message plus provider_specific_fields, matching + /v1/chat/completions and /v1/responses, not the str() of the exception.""" + from fastapi import HTTPException + + import litellm.proxy.anthropic_endpoints.endpoints as ep + import litellm.proxy.proxy_server as proxy_server + from litellm.proxy._types import ProxyException, UserAPIKeyAuth + + detail = { + "error": "Content blocked: keyword 'kumquat' detected", + "keyword": "kumquat", + "guardrail": "keyword-block", + } + exc = HTTPException(status_code=400, detail=detail) + + with ( + patch.object(ep, "_read_request_body", new=AsyncMock(return_value={})), # test-quality-ok: endpoint reads the body via a module function; no injection seam + patch.object( # test-quality-ok: the guardrail raise happens deep inside this call; the test targets the endpoint's except block + ep.ProxyBaseLLMRequestProcessing, + "base_process_llm_request", + new=AsyncMock(side_effect=exc), + ), + patch.object(proxy_server, "proxy_logging_obj") as mock_logging, # test-quality-ok: module global imported at call time; no injection seam + ): + mock_logging.post_call_failure_hook = AsyncMock() + with pytest.raises(ProxyException) as exc_info: + await ep.anthropic_response( + fastapi_response=MagicMock(), + request=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(), + ) + + assert exc_info.value.message == "Content blocked: keyword 'kumquat' detected" + assert "{'error'" not in exc_info.value.message + assert exc_info.value.provider_specific_fields == detail + assert exc_info.value.code == "400" + mock_logging.post_call_failure_hook.assert_awaited_once() + + class TestFailureHookRequestData: @pytest.mark.asyncio async def test_failure_hook_gets_post_setup_data_with_logging_obj(self): diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 71d4666416d..dd5049b0f28 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -1683,6 +1683,34 @@ class TestCommonRequestProcessingHelpers: assert _serialize_http_exception_detail(42) == ("42", None) + async def test_proxy_exception_from_http_exception_helper(self): + """The shared HTTPException -> ProxyException conversion keeps a clean + message, merges structured detail over existing provider_specific_fields, + and passes headers through.""" + from litellm.proxy.common_request_processing import ( + proxy_exception_from_http_exception, + ) + + exc = HTTPException( + status_code=400, + detail={"error": "Content blocked", "guardrail": "keyword-block"}, + ) + exc.provider_specific_fields = {"existing": "field", "guardrail": "stale"} + result = proxy_exception_from_http_exception(exc, {"x-litellm-call-id": "abc"}) + assert result.message == "Content blocked" + assert result.code == "400" + assert result.provider_specific_fields == { + "existing": "field", + "error": "Content blocked", + "guardrail": "keyword-block", + } + assert result.headers == {"x-litellm-call-id": "abc"} + + plain = proxy_exception_from_http_exception(HTTPException(status_code=429, detail="slow down"), {}) + assert plain.message == "slow down" + assert plain.code == "429" + assert plain.provider_specific_fields is None + async def test_create_streaming_response_first_chunk_error_string_code(self): """ Test that when the first chunk contains a string error code, a JSON error response is returned