[Bugfix][Anthropic] Return 4xx for client-caused errors in /v1/messages (#52246)

Signed-off-by: Do_it_now_! <lizhengcheng233@gmail.com>

Do_it_now_! committed Aug 16, 2026 at 16:05 UTC 70aaec832bde70ada17cbad2061105bbd07541dc
3 files changed +100 -20
tests/entrypoints/anthropic/test_anthropic_messages_conversion.py
+95
@@ -15,12 +15,14 @@ Also covers cache usage computation in ``_build_anthropic_usage``.
15 import json
16 from argparse import Namespace
17 from http import HTTPStatus
18 +from typing import Annotated
19 from unittest.mock import MagicMock
20
21 import pytest
22 from fastapi import FastAPI
23 from fastapi.exceptions import RequestValidationError
24 from fastapi.testclient import TestClient
25 +from pydantic import BaseModel, Field, ValidationError
26
27 from vllm.entrypoints.anthropic.api_router import attach_router
28 from vllm.entrypoints.anthropic.protocol import (
@@ -47,6 +49,7 @@ from vllm.entrypoints.openai.engine.protocol import (
49 from vllm.entrypoints.serve.exception_handling.handlers.validation import (
50 validation_exception_handler,
51 )
52 +from vllm.exceptions import VLLMValidationError
53
54 _convert = AnthropicServingMessages._convert_anthropic_to_openai_request
55 _img_url = AnthropicServingMessages._convert_image_source_to_url
@@ -1458,3 +1461,95 @@ class TestCacheSalt:
1461
1462 assert response.status_code == HTTPStatus.BAD_REQUEST
1463 handler.create_messages.assert_not_awaited()
1464 +
1465 +
1466 +# ======================================================================
1467 +# Client-caused errors are 4xx, not 500 (Issue #52088)
1468 +# ======================================================================
1469 +
1470 +
1471 +class TestClientErrorResponses:
1472 + @staticmethod
1473 + def _make_api_app(handler: MagicMock):
1474 + app = FastAPI()
1475 + attach_router(app)
1476 + app.state.args = Namespace(log_error_stack=False)
1477 + app.exception_handler(RequestValidationError)(validation_exception_handler)
1478 + app.state.anthropic_serving_messages = handler
1479 + return app
1480 +
1481 + @staticmethod
1482 + def _request_body() -> dict:
1483 + return {
1484 + "model": "test-model",
1485 + "max_tokens": 1,
1486 + "messages": [{"role": "user", "content": "Hello"}],
1487 + }
1488 +
1489 + @staticmethod
1490 + def _conversion_error() -> ValidationError:
1491 + """A real pydantic ValidationError like the one ChatCompletionRequest
1492 + construction raises when Anthropic input violates the OpenAI schema."""
1493 +
1494 + class _StubRequest(BaseModel):
1495 + stop: Annotated[list[str], Field(max_length=4)] | None = None
1496 +
1497 + with pytest.raises(ValidationError) as exc_info:
1498 + _StubRequest(stop=["a"] * 6)
1499 + return exc_info.value
1500 +
1501 + def test_validation_error_returns_bad_request(self):
1502 + """A pydantic ValidationError during Anthropic->OpenAI conversion is
1503 + surfaced as a 400 BadRequestError, not a 500."""
1504 + handler = MagicMock(spec=AnthropicServingMessages)
1505 + handler.create_messages.side_effect = self._conversion_error()
1506 +
1507 + app = self._make_api_app(handler)
1508 + with TestClient(app, raise_server_exceptions=False) as client:
1509 + response = client.post("/v1/messages", json=self._request_body())
1510 +
1511 + assert response.status_code == HTTPStatus.BAD_REQUEST
1512 + body = response.json()
1513 + assert body["type"] == "error"
1514 + assert body["error"]["type"] == "BadRequestError"
1515 + assert "at most 4 items" in body["error"]["message"]
1516 +
1517 + def test_vllm_client_error_returns_bad_request(self):
1518 + """VLLMClientError raised by the serving layer maps to 400."""
1519 + handler = MagicMock(spec=AnthropicServingMessages)
1520 + handler.create_messages.side_effect = VLLMValidationError(
1521 + "Invalid value for stop", parameter="stop"
1522 + )
1523 +
1524 + app = self._make_api_app(handler)
1525 + with TestClient(app, raise_server_exceptions=False) as client:
1526 + response = client.post("/v1/messages", json=self._request_body())
1527 +
1528 + assert response.status_code == HTTPStatus.BAD_REQUEST
1529 + assert response.json()["error"]["type"] == "BadRequestError"
1530 +
1531 + def test_generic_error_still_returns_internal_server_error(self):
1532 + """Non-client errors keep the existing 500 behaviour."""
1533 + handler = MagicMock(spec=AnthropicServingMessages)
1534 + handler.create_messages.side_effect = RuntimeError("boom")
1535 +
1536 + app = self._make_api_app(handler)
1537 + with TestClient(app, raise_server_exceptions=False) as client:
1538 + response = client.post("/v1/messages", json=self._request_body())
1539 +
1540 + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
1541 + assert response.json()["error"]["type"] == "InternalServerError"
1542 +
1543 + def test_count_tokens_validation_error_returns_bad_request(self):
1544 + """The count_tokens route maps conversion errors to 400 as well."""
1545 + handler = MagicMock(spec=AnthropicServingMessages)
1546 + handler.count_tokens.side_effect = self._conversion_error()
1547 +
1548 + app = self._make_api_app(handler)
1549 + with TestClient(app, raise_server_exceptions=False) as client:
1550 + response = client.post(
1551 + "/v1/messages/count_tokens", json=self._request_body()
1552 + )
1553 +
1554 + assert response.status_code == HTTPStatus.BAD_REQUEST
1555 + assert response.json()["error"]["type"] == "BadRequestError"
tests/entrypoints/serve/exception_handling/test_error_sanitization.py
-1
@@ -116,7 +116,6 @@ class TestAffectedModulesUseSanitize:
116 @pytest.mark.parametrize(
117 "module",
118 [
119 - "vllm.entrypoints.anthropic.api_router",
119 "vllm.entrypoints.anthropic.serving",
120 "vllm.entrypoints.speech_to_text.realtime.connection",
121 ],
vllm/entrypoints/anthropic/api_router.py
+5 -19
@@ -17,7 +17,9 @@ from vllm.entrypoints.anthropic.protocol import (
17 )
18 from vllm.entrypoints.anthropic.serving import AnthropicServingMessages
19 from vllm.entrypoints.openai.engine.protocol import ErrorResponse
20 -from vllm.entrypoints.serve.exception_handling.utils import sanitize_message
20 +from vllm.entrypoints.serve.exception_handling.error_response import (
21 + create_error_response,
22 +)
23 from vllm.entrypoints.serve.utils.api_utils import (
24 load_aware_call,
25 validate_json_request,
@@ -71,15 +73,7 @@ async def create_messages(request: AnthropicMessagesRequest, raw_request: Reques
73 generator = await handler.create_messages(request, raw_request)
74 except Exception as e:
75 logger.exception("Error in create_messages: %s", e)
74 - return JSONResponse(
75 - status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
76 - content=AnthropicErrorResponse(
77 - error=AnthropicError(
78 - type="internal_error",
79 - message=sanitize_message(str(e)),
80 - )
81 - ).model_dump(),
82 - )
76 + return translate_error_response(create_error_response(e))
77
78 if isinstance(generator, ErrorResponse):
79 return translate_error_response(generator)
@@ -117,15 +111,7 @@ async def count_tokens(request: AnthropicCountTokensRequest, raw_request: Reques
111 response = await handler.count_tokens(request, raw_request)
112 except Exception as e:
113 logger.exception("Error in count_tokens: %s", e)
120 - return JSONResponse(
121 - status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
122 - content=AnthropicErrorResponse(
123 - error=AnthropicError(
124 - type="internal_error",
125 - message=sanitize_message(str(e)),
126 - )
127 - ).model_dump(),
128 - )
114 + return translate_error_response(create_error_response(e))
115
116 if isinstance(response, ErrorResponse):
117 return translate_error_response(response)