[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,