Prhub

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

原始 PR 作者 SayHelloToWorld 合并时间 2026-08-16 16:05 文件变更 3 提交数 4 评论 15 代码增减 +100 / -20

执行摘要

Anthropic 入口将客户端错误统一映射为 4xx

Issue #52088 报告:CI 的 Schemathesis 测试生成的 Anthropic 请求可包含多于 4 个 stop_sequences(Anthropic 侧 schema 允许),但 Anthropic→OpenAI 转换构造 ChatCompletionRequest 时抛出 pydantic ValidationError(OpenAI stop 字段最多 4 项)。api_router 的 except Exception 把该异常吞成 internal_error 导致 HTTP 500,与 Issue 中『Invalid user input should not result in HTTP 500』的预期不符。PR body 明确指出 schema 半部分已由 #51997 解决,本 PR 修复剩余的转换错误映射缺口。

值得精读。这是一个小而完整的『入口错误处理对齐共享框架』示例:评审推动作者放弃手写类型分支、复用 create_error_response,最终以极小 diff 完成语义修复,并配套了高质量回归测试(用真实 pydantic.ValidationError 复现 Issue 场景)。对维护其他 entrypoint 的团队,本 PR 展示了如何在共享异常框架演进时保持各入口一致,值得作为参考模式。

讨论亮点

核心讨论围绕『是否手写异常类型判断』展开:DarkLight1337 在本 PR 进行中提醒存在 #52261 异常处理重构,要求跟随 vllm/entrypoints/serve/utils/server_utils.py 的既有异常处理器逻辑,而不是在 api_router 里再维护一套类型检查。作者据此把首版手写的 _translate_exception 分支删除,改为直接调用 create_error_response(e),让分类语义由共享模块统一保证;api_router 只负责把结果翻译成 Anthropic 格式。此外,rebase 到 #52261 后出现了两个行为变化需要同步:通用错误映射出的 type 从 internal_error 变为 OpenAI 风格 InternalServerError(测试断言随之更新);sanitize_message 不再被 api_router 直接调用(源级检查清单随之移除)。流程上还有多次 merge 冲突提示与 /ci run、/ci retry 触发,最终由 DarkLight1337 Approve(『Thanks for fixing!』)。

实现拆解

  1. 变更入口:vllm/entrypoints/anthropic/api_router.pycreate_messagescount_tokens 两个路由的异常分支从「手写 JSON 500 + internal_error 响应体」改为 translate_error_response(create_error_response(e))create_error_response 来自 vllm/entrypoints/serve/exception_handling/error_response.py(#52261 整合后的共享异常映射入口),按异常类型分类:pydantic.ValidationErrorValueError 子类)与 VLLMClientError 类客户端错误映射为 400 BadRequestError,其余异常保持 500。translate_error_response 负责把 OpenAI 风格 ErrorResponse 翻译成 Anthropic 错误格式(type + message),状态码沿用 ErrorResponse.error.code。同时删除对 sanitize_message 的直接 import——脱敏改由 create_error_response 内部完成。
  2. 实现演进(4 个 commit):首版(c90de82)手写 _translate_exception 类型分支区分客户端错误;评审(DarkLight1337)要求跟随 server_utils.py 的异常处理器逻辑;第二版(c21c8ed)删除手写类型检查,直接复用 create_error_response,净 diff 收敛为 +6/−26,并去掉不再使用的 VLLMClientError/ValidationError import;第三版(4bb117d)按 AGENTS.md 精简冗余注释;第四版(90b3250)rebase 到 #52261 后修正两处测试。
  3. 测试配套:tests/entrypoints/anthropic/test_anthropic_messages_conversion.py。新增 TestClientErrorResponses 类,覆盖 4 个场景:转换期 ValidationError → 400 且错误 type 为 BadRequestError、错误消息保留『at most 4 items』;VLLMValidationError → 400;RuntimeError → 500(回归保护);count_tokens 路由 → 400。测试用 _StubRequeststop: Annotated[list[str], Field(max_length=4)] + 6 个元素)构造真实 pydantic.ValidationError,与线上 ChatCompletionRequest 校验行为等价。
  4. 配套调整:tests/entrypoints/serve/exception_handling/test_error_sanitization.py。rebase 后 api_router 改为间接脱敏,从『必须直接调用 sanitize_message』的源级检查参数化清单中移除 vllm.entrypoints.anthropic.api_router
  5. 无配置/schema/部署改动:路由的 OpenAPI responses 本就声明 400/500 两类响应,无需变更文档。
文件 模块 状态 重要度
vllm/entrypoints/anthropic/api_router.py 请求路由 modified 6.86
tests/entrypoints/anthropic/test_anthropic_messages_conversion.py 消息转换 modified 6.91
tests/entrypoints/serve/exception_handling/test_error_sanitization.py 错误脱敏 modified 2.71

关键符号

create_messages count_tokens translate_error_response TestClientErrorResponses._conversion_error TestClientErrorResponses.test_validation_error_returns_bad_request TestClientErrorResponses.test_generic_error_still_returns_internal_server_error

关键源码片段

tests/entrypoints/anthropic/test_anthropic_messages_conversion.py test-coverage

新增 TestClientErrorResponses 回归测试类,覆盖转换期 ValidationError、VLLMValidationError → 400,RuntimeError → 500,以及 count_tokens 路由 → 400,是本 PR 语义修复的验证主体。

# tests/entrypoints/anthropic/test_anthropic_messages_conversion.py
# 客户端导致的错误必须是 4xx,而不是 500(Issue #52088)
class TestClientErrorResponses:
    @staticmethod
    def _make_api_app(handler: MagicMock):
        app = FastAPI()
        attach_router(app)
        app.state.args = Namespace(log_error_stack=False)
        # 注册 FastAPI 请求校验异常处理器,保持 schema 校验错误 → 4xx 语义
        app.exception_handler(RequestValidationError)(validation_exception_handler)
        app.state.anthropic_serving_messages = handler
        return app
​
    @staticmethod
    def _request_body() -> dict:
        return {
            "model": "test-model",
            "max_tokens": 1,
            "messages": [{"role": "user", "content": "Hello"}],
        }
​
    @staticmethod
    def _conversion_error() -> ValidationError:
        """构造真实 pydantic ValidationError,等价于 ChatCompletionRequest
        构造时对 stop 字段超长的校验失败(复现 Issue #52088 场景)。"""
​
        class _StubRequest(BaseModel):
            stop: Annotated[list[str], Field(max_length=4)] | None = None
​
        with pytest.raises(ValidationError) as exc_info:
            _StubRequest(stop=["a"] * 6)
        return exc_info.value
​
    def test_validation_error_returns_bad_request(self):
        # 转换阶段 pydantic ValidationError 应映射为 400,而非 500
        handler = MagicMock(spec=AnthropicServingMessages)
        handler.create_messages.side_effect = self._conversion_error()
​
        app = self._make_api_app(handler)
        with TestClient(app, raise_server_exceptions=False) as client:
            response = client.post("/v1/messages", json=self._request_body())
​
        assert response.status_code == HTTPStatus.BAD_REQUEST
        body = response.json()
        assert body["type"] == "error"
        assert body["error"]["type"] == "BadRequestError"
        assert "at most 4 items" in body["error"]["message"]
​
    def test_vllm_client_error_returns_bad_request(self):
        # Serving 层抛出的 VLLMValidationError 也映射为 400
        handler = MagicMock(spec=AnthropicServingMessages)
        handler.create_messages.side_effect = VLLMValidationError(
            "Invalid value for stop", parameter="stop"
        )
​
        app = self._make_api_app(handler)
        with TestClient(app, raise_server_exceptions=False) as client:
            response = client.post("/v1/messages", json=self._request_body())
​
        assert response.status_code == HTTPStatus.BAD_REQUEST
        assert response.json()["error"]["type"] == "BadRequestError"
​
    def test_generic_error_still_returns_internal_server_error(self):
        # 非客户端错误必须保持 500,防止服务端故障被误报成客户端问题
        handler = MagicMock(spec=AnthropicServingMessages)
        handler.create_messages.side_effect = RuntimeError("boom")
​
        app = self._make_api_app(handler)
        with TestClient(app, raise_server_exceptions=False) as client:
            response = client.post("/v1/messages", json=self._request_body())
​
        assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
        assert response.json()["error"]["type"] == "InternalServerError"
​
    def test_count_tokens_validation_error_returns_bad_request(self):
        # count_tokens 路由同样需要 4xx 语义
        handler = MagicMock(spec=AnthropicServingMessages)
        handler.count_tokens.side_effect = self._conversion_error()
​
        app = self._make_api_app(handler)
        with TestClient(app, raise_server_exceptions=False) as client:
            response = client.post(
                "/v1/messages/count_tokens", json=self._request_body()
            )
​
        assert response.status_code == HTTPStatus.BAD_REQUEST
        assert response.json()["error"]["type"] == "BadRequestError"

评论区精华

复用共享异常处理 create_error_response 而非手写类型分支 设计

DarkLight1337 建议跟随 vllm/entrypoints/serve/utils/server_utils.py 的异常处理器逻辑;作者首版手写 _translate_exception 类型检查来区分 ValidationError/VLLMClientError 与其他异常,后删除并直接调用 create_error_response(e)。

结论:api_router 统一走 create_error_response 做异常分类,再经 translate_error_response 转成 Anthropic 错误格式;净 diff 收敛为 +6/−26。 · 已解决

rebase 到 #52261 后的错误类型与 sanitize 检查变化 设计

评审提醒近期有异常处理整合重构(#52261);rebase 后 create_error_response 把通用异常映射为 OpenAI 风格 'InternalServerError' 而非原来的 'internal_error',且脱敏改为间接完成,导致两处测试需要调整。

结论:测试断言更新为 InternalServerError;test_error_sanitization.py 的参数化清单移除 api_router。 · 已解决

merge 冲突与 CI 触发流程 other

mergify 提示存在 merge 冲突需 rebase;作者多次 /ci run 触发 Buildkite CI(#83880/#84017/#84029/#84030),并对失败 job 执行 /ci retry。

结论:rebase 到最新 main(含 #52261)后无冲突合并,CI 通过。 · 已解决

风险与影响

  1. 错误类型字符串变化:rebase 到 #52261 后,通用异常映射出的错误 type 从原来的 internal_error 变为 OpenAI 风格的 InternalServerError,依赖该字符串做监控或客户端分支的用户会观察到差异;测试已同步更新,但线上兼容性仍值得留意。
  2. 依赖共享异常映射的语义create_error_response 对异常的分类逻辑集中在 vllm/entrypoints/serve/exception_handling/error_response.py,其中 pydantic.ValidationError 因是 ValueError 子类被归为客户端错误。若 serving 层未来用 ValueError 表达服务端故障,会被误映射为 4xx;若共享模块调整分类规则,Anthropic 入口会自动继承新行为,属于「单一职责收益」与「隐式耦合风险」并存。
  3. 流式响应中途错误不在本映射范围StreamingResponse 开始输出后发生的迭代异常发生在 try/except 之外,本次改动未覆盖,属既有行为,不是本 PR 引入的回归。
  4. 兼容性:路由 OpenAPI responses 原本就声明 400/500,无需文档或客户端适配;错误消息脱敏仍生效(间接调用 sanitize_message)。

用户侧:Anthropic Messages API 与 count_tokens 的客户端错误从误导性的 500 变为准确的 4xx,便于客户端区分输入问题与服务端故障、制定重试策略;服务端 500 告警的信号价值提升。系统侧:消除了 api_router 中重复的手写错误构造代码,与 #52261 的整合架构对齐,后续异常分类改动只需在共享模块进行。团队侧:新增 TestClientErrorResponses 建立了 400/500 边界的回归防线,并同步维护了 test_error_sanitization.py 的源级检查清单,防止入口脱离统一脱敏路径。影响范围集中在 Anthropic 前端入口层,不涉及核心调度、注意力或模型代码。

错误类型字符串变更 依赖共享异常映射框架语义 任意 ValueError 子类按客户端错误处理 流式中途错误不受本映射保护(既有行为)

关联 Issue

#52088 [CI Failure][Anthropic] /v1/messages returns 500 when stop_sequences has more than 4 items

完整报告

参与讨论