[Frontend] Add `/v1/messages/render` endpoint for the Anthropic Messages API (#45803)

Signed-off-by: HyunKyun Moon <mhg5303@gmail.com>

Hyunkyun Moon committed Aug 26, 2026 at 12:33 UTC f25c2692fe3081bdf89b58208d6bb995b43e92f0
5 files changed +165 -5
tests/entrypoints/anthropic/test_anthropic_messages_conversion.py
+2 -2
@@ -3,7 +3,7 @@
3 """Unit tests for Anthropic-to-OpenAI request conversion.
4
5 Tests the image source handling and tool_result content parsing in
6 -AnthropicServingMessages._convert_anthropic_to_openai_request().
6 +AnthropicServingMessages.to_chat_completion_request().
7
8 Also covers extended-thinking edge cases such as ``redacted_thinking``
9 blocks echoed back by Anthropic clients, and streaming conversion in
@@ -51,7 +51,7 @@ from vllm.entrypoints.serve.exception_handling.handlers.validation import (
51 )
52 from vllm.exceptions import VLLMValidationError
53
54 -_convert = AnthropicServingMessages._convert_anthropic_to_openai_request
54 +_convert = AnthropicServingMessages.to_chat_completion_request
55 _img_url = AnthropicServingMessages._convert_image_source_to_url
56
57
tests/entrypoints/scale_out/render/test_render.py
+112
@@ -493,3 +493,115 @@ async def test_chat_completion_render_assistant_tokens_mask_with_generation_tags
493 assert detok_rest.status_code == 200
494 assert "Hi!" not in detok_rest.json()["prompt"]
495 assert "Bye" in detok_rest.json()["prompt"]
496 +
497 +
498 +@pytest.mark.asyncio
499 +async def test_messages_render_basic(client):
500 + """Test basic Anthropic Messages render endpoint."""
501 + response = await client.post(
502 + "/v1/messages/render",
503 + json={
504 + "model": MODEL_NAME,
505 + "max_tokens": 16,
506 + "messages": [{"role": "user", "content": "Render this Anthropic message."}],
507 + },
508 + )
509 +
510 + assert response.status_code == 200
511 + data = response.json()
512 +
513 + # Single GenerateRequest, like chat render.
514 + assert isinstance(data, dict)
515 + assert "token_ids" in data
516 + assert "sampling_params" in data
517 + assert "model" in data
518 + assert data["model"] == MODEL_NAME
519 +
520 + token_ids = data["token_ids"]
521 + assert isinstance(token_ids, list)
522 + assert len(token_ids) > 0
523 + assert all(isinstance(tid, int) for tid in token_ids)
524 + assert token_ids[0] == 1 # BOS
525 +
526 +
527 +@pytest.mark.asyncio
528 +async def test_messages_render_system_and_multi_turn(client):
529 + """System field + multi-turn messages render to a single prompt."""
530 + response = await client.post(
531 + "/v1/messages/render",
532 + json={
533 + "model": MODEL_NAME,
534 + "max_tokens": 16,
535 + "system": "You are a helpful assistant.",
536 + "messages": [
537 + {"role": "user", "content": "Hello"},
538 + {"role": "assistant", "content": "Hi! How can I help?"},
539 + {"role": "user", "content": "What is 2 + 2?"},
540 + ],
541 + },
542 + )
543 +
544 + assert response.status_code == 200
545 + data = response.json()
546 + assert isinstance(data, dict)
547 + assert len(data["token_ids"]) > 0
548 + assert data["token_ids"][0] == 1 # BOS
549 +
550 +
551 +@pytest.mark.asyncio
552 +async def test_messages_render_merges_inline_system(client):
553 + """Inline system messages merge into the leading system block.
554 +
555 + Without a --chat-template arg the /v1/messages server path detects
556 + merge_inline_system=True, so render must produce the same tokens as
557 + the manually pre-merged request.
558 + """
559 + inline = await client.post(
560 + "/v1/messages/render",
561 + json={
562 + "model": MODEL_NAME,
563 + "max_tokens": 16,
564 + "system": "You are a helpful assistant.",
565 + "messages": [
566 + {"role": "user", "content": "Hello"},
567 + {"role": "assistant", "content": "Hi! How can I help?"},
568 + {"role": "system", "content": "Be brief."},
569 + {"role": "user", "content": "What is 2 + 2?"},
570 + ],
571 + },
572 + )
573 + assert inline.status_code == 200
574 +
575 + merged = await client.post(
576 + "/v1/messages/render",
577 + json={
578 + "model": MODEL_NAME,
579 + "max_tokens": 16,
580 + "system": "You are a helpful assistant.Be brief.",
581 + "messages": [
582 + {"role": "user", "content": "Hello"},
583 + {"role": "assistant", "content": "Hi! How can I help?"},
584 + {"role": "user", "content": "What is 2 + 2?"},
585 + ],
586 + },
587 + )
588 + assert merged.status_code == 200
589 +
590 + assert inline.json()["token_ids"] == merged.json()["token_ids"]
591 +
592 +
593 +@pytest.mark.asyncio
594 +async def test_messages_render_error_invalid_model(client):
595 + """Messages render with an invalid model returns an error."""
596 + response = await client.post(
597 + "/v1/messages/render",
598 + json={
599 + "model": "invalid-model-name",
600 + "max_tokens": 16,
601 + "messages": [{"role": "user", "content": "Hello"}],
602 + },
603 + )
604 +
605 + assert response.status_code == 404
606 + data = response.json()
607 + assert "error" in data
vllm/entrypoints/anthropic/serving.py
+3 -3
@@ -191,7 +191,7 @@ class AnthropicServingMessages(OpenAIServingChat):
191 return f"data:{media_type};base64,{data}"
192
193 @classmethod
194 - def _convert_anthropic_to_openai_request(
194 + def to_chat_completion_request(
195 cls,
196 anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest,
197 *,
@@ -602,7 +602,7 @@ class AnthropicServingMessages(OpenAIServingChat):
602 """
603 if logger.isEnabledFor(logging.DEBUG):
604 logger.debug("Received messages request %s", request.model_dump_json())
605 - chat_req = self._convert_anthropic_to_openai_request(
605 + chat_req = self.to_chat_completion_request(
606 request,
607 merge_inline_system=self._merge_inline_system,
608 )
@@ -1023,7 +1023,7 @@ class AnthropicServingMessages(OpenAIServingChat):
1023 raw_request: Request | None = None,
1024 ) -> AnthropicCountTokensResponse | ErrorResponse:
1025 """Implements Anthropic's messages.count_tokens endpoint."""
1026 - chat_req = self._convert_anthropic_to_openai_request(
1026 + chat_req = self.to_chat_completion_request(
1027 request,
1028 merge_inline_system=self._merge_inline_system,
1029 )
vllm/entrypoints/scale_out/render/api_router.py
+25
@@ -5,6 +5,7 @@ from http import HTTPStatus
5 from fastapi import APIRouter, Depends, Request
6 from fastapi.responses import JSONResponse
7
8 +from vllm.entrypoints.anthropic.protocol import AnthropicMessagesRequest
9 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
10 from vllm.entrypoints.openai.completion.protocol import CompletionRequest
11 from vllm.entrypoints.openai.engine.protocol import ErrorResponse
@@ -49,6 +50,30 @@ async def render_chat_completion(request: ChatCompletionRequest, raw_request: Re
50 return JSONResponse(content=result.model_dump())
51
52
53 +@router.post(
54 + "/v1/messages/render",
55 + dependencies=[Depends(validate_json_request)],
56 + response_model=GenerateRequest,
57 + responses={
58 + HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
59 + HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
60 + HTTPStatus.NOT_IMPLEMENTED.value: {"model": ErrorResponse},
61 + HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
62 + },
63 +)
64 +async def render_messages(request: AnthropicMessagesRequest, raw_request: Request):
65 + handler = render(raw_request)
66 + if handler is None:
67 + raise NotImplementedError("The model does not support Messages Render API")
68 +
69 + result = await handler.render_messages_request(request)
70 +
71 + if isinstance(result, ErrorResponse):
72 + return JSONResponse(content=result.model_dump(), status_code=result.error.code)
73 +
74 + return JSONResponse(content=result.model_dump())
75 +
76 +
77 @router.post(
78 "/v1/completions/render",
79 dependencies=[Depends(validate_json_request)],
vllm/entrypoints/scale_out/render/serving.py
+23
@@ -2,6 +2,8 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from typing import cast
4
5 +from vllm.entrypoints.anthropic.protocol import AnthropicMessagesRequest
6 +from vllm.entrypoints.anthropic.serving import AnthropicServingMessages
7 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
8 from vllm.entrypoints.openai.completion.protocol import CompletionRequest
9 from vllm.entrypoints.openai.engine.protocol import ErrorResponse
@@ -51,6 +53,12 @@ class ServingRender(BaseServing):
53
54 self.online_renderer = online_renderer
55
56 + self._merge_inline_system = (
57 + AnthropicServingMessages._detect_merge_inline_system(
58 + online_renderer.chat_template
59 + )
60 + )
61 +
62 self.default_sampling_params = (
63 online_renderer.model_config.get_diff_sampling_param()
64 )
@@ -149,6 +157,21 @@ class ServingRender(BaseServing):
157 token_offsets=engine_input.get("prompt_token_offsets"),
158 )
159
160 + async def render_messages_request(
161 + self,
162 + request: AnthropicMessagesRequest,
163 + ) -> GenerateRequest | ErrorResponse:
164 + """Validate the model and preprocess an Anthropic Messages request.
165 +
166 + Converts the request to the OpenAI chat format using the same
167 + conversion as the /v1/messages server path, then delegates to
168 + render_chat_request so the rendered tokens match the server exactly.
169 + """
170 + chat_req = AnthropicServingMessages.to_chat_completion_request(
171 + request, merge_inline_system=self._merge_inline_system
172 + )
173 + return await self.render_chat_request(chat_req)
174 +
175 async def render_completion_request(
176 self,
177 request: CompletionRequest,