mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
fix(anthropic_endpoints): serialize dict-detail HTTPExceptions on /v1/messages like sibling surfaces
This commit is contained in:
parent
ae7e50f096
commit
817b5f44f6
4 changed files with 91 additions and 15 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=<dict>) 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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue