[Frontend] Add stateless /v1/responses/render endpoint (#50195)

Signed-off-by: Francisco Javier Arceo <farceo@redhat.com> Co-authored-by: OpenAI Codex <codex@openai.com>

Francisco Javier Arceo committed Sep 7, 2026 at 08:50 UTC 6a2a2bb02b563b83f946012959fd3927984d072a
19 files changed +1228 -298
docs/serving/online_serving/README.md
+2
@@ -141,6 +141,8 @@ For further details on renderer APIs, please refer to [this page](renderer.md).
141 - Render completion requests
142 - [Chat Completions Render API](renderer.md) (`/v1/chat/completions/render`)
143 - Render chat completions
144 +- [Responses Render API](renderer.md) (`/v1/responses/render`)
145 + - Render self-contained Responses requests
146
147 ### Derenderer APIs
148
docs/serving/online_serving/renderer.md
+23
@@ -25,6 +25,29 @@ VLLM_ENABLE_SCALE_OUT_ENDPOINTS=1 vllm serve <model>
25 - Render completion requests
26 - [Chat Completions Render API](renderer.md) (`/v1/chat/completions/render`)
27 - Render chat completions
28 +- [Responses Render API](renderer.md) (`/v1/responses/render`)
29 + - Render a self-contained Responses request
30 +
31 +The Responses render endpoint uses the same prompt construction as
32 +`/v1/responses` and returns one token-in `GenerateRequest`. It is stateless:
33 +inline history is supported, but `previous_response_id` is not. Callers must
34 +resolve stored response state and include the resulting history in the request
35 +before rendering.
36 +
37 +For multimodal requests, the `GenerateRequest` contains the model-processed
38 +multimodal payload, which can be substantially larger than the source image or
39 +video. The caller must forward that payload unchanged to the generation service
40 +and provision transport limits and memory accordingly.
41 +
42 +```bash
43 +curl http://localhost:8000/v1/responses/render \
44 + -H "Content-Type: application/json" \
45 + -d '{
46 + "model": "meta-llama/Llama-3.1-8B-Instruct",
47 + "input": "Explain prefix caching in one sentence.",
48 + "max_output_tokens": 32
49 + }'
50 +```
51
52 For the post processing counterpart that turns generated token IDs back into OpenAI compatible responses, see the [Derenderer APIs](derenderer.md).
53
docs/usage/security.md
+1
@@ -170,6 +170,7 @@ When `--api-key` is configured, the following `/v1` endpoints require Bearer tok
170 - `/v1/messages/render` - Render Anthropic-compatible messages (available on `vllm serve` only when `VLLM_ENABLE_SCALE_OUT_ENDPOINTS=1`, or on `vllm launch render` unless explicitly disabled)
171 - `/v1/messages/count_tokens` - Count tokens for Anthropic messages
172 - `/v1/responses` - Create a response
173 +- `/v1/responses/render` - Render a self-contained response request (available on `vllm serve` only when `VLLM_ENABLE_SCALE_OUT_ENDPOINTS=1`, or on `vllm launch render` unless explicitly disabled)
174 - `/v1/responses/{response_id}` - Retrieve a response
175 - `/v1/responses/{response_id}/cancel` - Cancel a response
176 - `/v1/score` - Scoring API
tests/entrypoints/launchers/test_cli_args.py
+22
@@ -2,9 +2,11 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 +from types import SimpleNamespace
6
7 import pytest
8
9 +import vllm.entrypoints.launchers.cli_args as cli_args_module
10 from tests.utils import VLLM_PATH
11 from vllm.entrypoints.launchers.cli_args import (
12 make_arg_parser,
@@ -332,6 +334,26 @@ def test_default_chat_template_kwargs_default_none(serve_parser):
334 assert args.default_chat_template_kwargs is None
335
336
337 +@pytest.mark.parametrize(
338 + "default_kwargs,cohere_format,expected",
339 + [
340 + (None, "cmd3", {"cohere_format": "cmd3"}),
341 + (
342 + {"cohere_format": "cmd4", "custom": True},
343 + "cmd3",
344 + {"cohere_format": "cmd4", "custom": True},
345 + ),
346 + ],
347 +)
348 +def test_resolve_default_chat_template_kwargs(default_kwargs, cohere_format, expected):
349 + args = SimpleNamespace(
350 + default_chat_template_kwargs=default_kwargs,
351 + cohere_format=cohere_format,
352 + )
353 +
354 + assert cli_args_module.resolve_default_chat_template_kwargs(args) == expected
355 +
356 +
357 def test_default_chat_template_kwargs_invalid_json(serve_parser):
358 """Ensure invalid JSON raises an error"""
359 with pytest.raises(SystemExit):
tests/entrypoints/openai/responses/test_mcp_tools.py
+24
@@ -4,14 +4,19 @@
4
5 from __future__ import annotations
6
7 +from types import SimpleNamespace
8 +from unittest.mock import AsyncMock, MagicMock
9 +
10 import pytest
11 import pytest_asyncio
12 from openai import OpenAI
13 from openai_harmony import Message, ToolDescription, ToolNamespaceConfig
14
15 +import vllm.entrypoints.mcp.tool_server as tool_server_module
16 from tests.utils import RemoteOpenAIServer
17 from vllm.entrypoints.mcp.tool_server import (
18 MCPToolServer,
19 + init_tool_server,
20 post_process_tools_description,
21 )
22
@@ -39,6 +44,25 @@ _PYTHON_TOOL_INSTRUCTION = (
44 )
45
46
47 +@pytest.mark.asyncio
48 +async def test_init_tool_server_without_configuration():
49 + args = SimpleNamespace(tool_server=None)
50 +
51 + assert await init_tool_server(args) is None
52 +
53 +
54 +@pytest.mark.asyncio
55 +async def test_init_tool_server_with_mcp_url(monkeypatch: pytest.MonkeyPatch):
56 + server = MagicMock()
57 + server.add_tool_server = AsyncMock()
58 + monkeypatch.setattr(tool_server_module, "MCPToolServer", lambda: server)
59 +
60 + result = await init_tool_server(SimpleNamespace(tool_server="http://mcp.test"))
61 +
62 + assert result is server
63 + server.add_tool_server.assert_awaited_once_with("http://mcp.test")
64 +
65 +
66 @pytest.mark.skip_global_cleanup
67 class TestMCPToolServerUnit:
68 """Test MCP tool description processing and filtering.
tests/entrypoints/openai/responses/test_serving_responses.py
+479 -3
@@ -2,12 +2,16 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from contextlib import AsyncExitStack
5 -from unittest.mock import MagicMock
5 +from types import SimpleNamespace
6 +from unittest.mock import AsyncMock, MagicMock
7
8 import pytest
9 import pytest_asyncio
10 from openai.types.responses import (
11 + ResponseFunctionToolCall,
12 ResponseOutputItemDoneEvent,
13 + ResponseOutputMessage,
14 + ResponseOutputText,
15 ResponseReasoningItem,
16 ResponseReasoningTextDeltaEvent,
17 ResponseReasoningTextDoneEvent,
@@ -23,6 +27,8 @@ from openai.types.responses.tool import (
27 Mcp,
28 Tool,
29 )
30 +from openai_harmony import Message as OpenAIHarmonyMessage
31 +from openai_harmony import Role
32
33 import vllm.envs as envs
34 from vllm.entrypoints.generate.base.protocol import (
@@ -32,7 +38,11 @@ from vllm.entrypoints.generate.base.protocol import (
38 RequestResponseMetadata,
39 )
40 from vllm.entrypoints.mcp.tool_server import ToolServer
35 -from vllm.entrypoints.openai.responses.context import ConversationContext, SimpleContext
41 +from vllm.entrypoints.openai.responses.context import (
42 + ConversationContext,
43 + HarmonyContext,
44 + SimpleContext,
45 +)
46 from vllm.entrypoints.openai.responses.protocol import (
47 ResponseCreatedEvent,
48 ResponseRawMessageAndToken,
@@ -42,7 +52,6 @@ from vllm.entrypoints.openai.responses.protocol import (
52 )
53 from vllm.entrypoints.openai.responses.serving import (
54 OpenAIServingResponses,
45 - _extract_allowed_tools_from_mcp_requests,
55 extract_tool_types,
56 )
57 from vllm.entrypoints.openai.responses.streaming_events import (
@@ -52,8 +61,23 @@ from vllm.entrypoints.serve.engine.protocol import ErrorResponse
61 from vllm.inputs import tokens_input
62 from vllm.outputs import CompletionOutput, RequestOutput
63 from vllm.parser.harmony import Segment
64 +from vllm.renderers import TokenizeParams
65 +from vllm.renderers.online_renderer import (
66 + OnlineRenderer,
67 + _extract_allowed_tools_from_mcp_requests,
68 +)
69 from vllm.sampling_params import SamplingParams
70
71 +pytestmark = pytest.mark.skip_global_cleanup
72 +
73 +
74 +def _new_online_renderer() -> OnlineRenderer:
75 + renderer = OnlineRenderer.__new__(OnlineRenderer)
76 + renderer.exclude_tools_when_tool_choice_none = False
77 + renderer.parser = None
78 + renderer.trust_request_chat_template = True
79 + return renderer
80 +
81
82 class MockConversationContext(ConversationContext):
83 """Mock conversation context for testing"""
@@ -205,6 +229,325 @@ def test_response_created_event_uses_public_json_schema_alias() -> None:
229 assert event.response.text.format.model_dump(by_alias=True)["schema"] == schema
230
231
232 +@pytest.mark.asyncio
233 +async def test_online_renderer_renders_non_harmony_responses_with_explicit_history():
234 + renderer = _new_online_renderer()
235 + renderer.use_harmony = False
236 + renderer.chat_template = "server-template"
237 + renderer.chat_template_content_format = "string"
238 + renderer.default_chat_template_kwargs = {"server_default": "kept"}
239 + renderer.parser = None
240 + renderer.preprocess_chat = AsyncMock(
241 + return_value=(
242 + [],
243 + [tokens_input([11, 12, 13], cache_salt="request-salt")],
244 + )
245 + )
246 + previous_messages = [
247 + {"role": "system", "content": "old instructions"},
248 + {"role": "user", "content": "first"},
249 + ]
250 + previous_outputs = [
251 + ResponseOutputMessage(
252 + id="msg_1",
253 + content=[
254 + ResponseOutputText(
255 + annotations=[],
256 + text="prior answer",
257 + type="output_text",
258 + )
259 + ],
260 + role="assistant",
261 + status="completed",
262 + type="message",
263 + )
264 + ]
265 + request = ResponsesRequest(
266 + input="next",
267 + instructions="new instructions",
268 + tools=[],
269 + cache_salt="request-salt",
270 + chat_template_kwargs={"request_default": "kept"},
271 + )
272 +
273 + result = await renderer.render_responses(
274 + request,
275 + previous_messages=previous_messages,
276 + previous_response_outputs=previous_outputs,
277 + )
278 +
279 + assert not isinstance(result, ErrorResponse)
280 + assert result.messages == [
281 + {"role": "system", "content": "new instructions"},
282 + {"role": "user", "content": "first"},
283 + {"role": "assistant", "content": "prior answer"},
284 + {"role": "user", "content": "next"},
285 + ]
286 + assert result.engine_input["prompt_token_ids"] == [11, 12, 13]
287 + assert result.engine_input["cache_salt"] == "request-salt"
288 + assert previous_messages[0]["content"] == "old instructions"
289 + assert previous_outputs[0].content[0].text == "prior answer"
290 +
291 + preprocess_kwargs = renderer.preprocess_chat.call_args.kwargs
292 + assert preprocess_kwargs["default_template"] == "server-template"
293 + assert preprocess_kwargs["default_template_content_format"] == "string"
294 + assert preprocess_kwargs["default_template_kwargs"]["server_default"] == "kept"
295 + assert preprocess_kwargs["default_template_kwargs"]["request_default"] == "kept"
296 +
297 +
298 +@pytest.mark.asyncio
299 +async def test_online_renderer_rejects_untrusted_responses_chat_template():
300 + renderer = _new_online_renderer()
301 + renderer.use_harmony = False
302 + renderer.chat_template = "server-template"
303 + renderer.chat_template_content_format = "string"
304 + renderer.default_chat_template_kwargs = {}
305 + renderer.exclude_tools_when_tool_choice_none = False
306 + renderer.trust_request_chat_template = False
307 + renderer.parser = None
308 + renderer.preprocess_chat = AsyncMock(
309 + return_value=([], [tokens_input([1])]),
310 + )
311 + request = ResponsesRequest(
312 + input="hello",
313 + chat_template_kwargs={"chat_template": "{{ messages }}"},
314 + )
315 +
316 + result = await renderer.render_responses(request)
317 +
318 + assert isinstance(result, ErrorResponse)
319 + assert result.error.code == 400
320 + assert "untrusted chat template" in result.error.message.lower()
321 + renderer.preprocess_chat.assert_not_awaited()
322 +
323 +
324 +@pytest.mark.asyncio
325 +async def test_online_renderer_rejects_harmony_history_for_non_harmony_model():
326 + renderer = _new_online_renderer()
327 + renderer.use_harmony = False
328 + request = ResponsesRequest(input="next")
329 +
330 + result = await renderer.render_responses(
331 + request,
332 + previous_messages=[
333 + OpenAIHarmonyMessage.from_role_and_content(Role.USER, "first")
334 + ],
335 + )
336 +
337 + assert isinstance(result, ErrorResponse)
338 + assert result.error.param == "previous_response_id"
339 +
340 +
341 +@pytest.mark.asyncio
342 +async def test_online_renderer_rejects_chat_history_for_harmony_model():
343 + renderer = _new_online_renderer()
344 + renderer.use_harmony = True
345 + request = ResponsesRequest(input="next")
346 +
347 + result = await renderer.render_responses(
348 + request,
349 + previous_messages=[{"role": "user", "content": "first"}],
350 + )
351 +
352 + assert isinstance(result, ErrorResponse)
353 + assert result.error.param == "previous_response_id"
354 +
355 +
356 +@pytest.mark.asyncio
357 +async def test_online_renderer_adjusts_harmony_tool_choice(
358 + monkeypatch: pytest.MonkeyPatch,
359 +):
360 + class StubParser:
361 + def __init__(self, tokenizer, tools, *, model_config):
362 + pass
363 +
364 + def adjust_request(self, request):
365 + request.cache_salt = "adjusted"
366 + return request
367 +
368 + renderer = _new_online_renderer()
369 + renderer.use_harmony = True
370 + renderer.parser = StubParser
371 + renderer.model_config = SimpleNamespace(max_model_len=100)
372 + tokenizer = SimpleNamespace(truncation_side="left")
373 + renderer.renderer = SimpleNamespace(
374 + tokenizer=tokenizer,
375 + get_tokenizer=lambda: tokenizer,
376 + )
377 + monkeypatch.setattr(
378 + "vllm.renderers.online_renderer.render_for_completion",
379 + lambda messages: [1],
380 + )
381 + request = ResponsesRequest(
382 + input="next",
383 + tools=[
384 + {
385 + "type": "function",
386 + "name": "lookup",
387 + "parameters": {"type": "object", "properties": {}},
388 + }
389 + ],
390 + tool_choice={"type": "function", "name": "lookup"},
391 + )
392 +
393 + result = await renderer.render_responses(request)
394 +
395 + assert not isinstance(result, ErrorResponse)
396 + assert result.engine_input["cache_salt"] == "adjusted"
397 +
398 +
399 +@pytest.mark.asyncio
400 +async def test_online_renderer_applies_responses_token_budget_to_harmony_prompt(
401 + monkeypatch: pytest.MonkeyPatch,
402 +):
403 + renderer = _new_online_renderer()
404 + renderer.use_harmony = True
405 + renderer.model_config = SimpleNamespace(max_model_len=5)
406 + renderer.renderer = SimpleNamespace(
407 + tokenizer=SimpleNamespace(truncation_side="left")
408 + )
409 + monkeypatch.setattr(
410 + "vllm.renderers.online_renderer.render_for_completion",
411 + lambda messages: [1, 2, 3, 4, 5, 6],
412 + )
413 + request = ResponsesRequest(
414 + input="hello",
415 + max_output_tokens=2,
416 + truncation="auto",
417 + )
418 +
419 + result = await renderer.render_responses(request)
420 +
421 + assert not isinstance(result, ErrorResponse)
422 + assert result.engine_input["prompt_token_ids"] == [4, 5, 6]
423 +
424 +
425 +@pytest.mark.asyncio
426 +async def test_online_renderer_renders_harmony_continuation_with_call_linkage(
427 + monkeypatch: pytest.MonkeyPatch,
428 +):
429 + renderer = _new_online_renderer()
430 + renderer.use_harmony = True
431 + renderer.model_config = SimpleNamespace(max_model_len=100)
432 + renderer.renderer = SimpleNamespace(
433 + tokenizer=SimpleNamespace(truncation_side="left")
434 + )
435 + monkeypatch.setattr(
436 + "vllm.renderers.online_renderer.render_for_completion",
437 + lambda messages: [1],
438 + )
439 + previous_messages = [
440 + OpenAIHarmonyMessage.from_role_and_content(Role.USER, "first"),
441 + ]
442 + previous_outputs = [
443 + ResponseFunctionToolCall(
444 + arguments='{"city":"Paris"}',
445 + call_id="call_1",
446 + name="lookup_weather",
447 + type="function_call",
448 + )
449 + ]
450 + request = ResponsesRequest(
451 + input=[
452 + {
453 + "type": "function_call_output",
454 + "call_id": "call_1",
455 + "output": "sunny",
456 + }
457 + ],
458 + instructions="replacement instructions are ignored on continuations",
459 + reasoning={"effort": "high"},
460 + tools=[
461 + {
462 + "type": "function",
463 + "name": "lookup_weather",
464 + "parameters": {"type": "object", "properties": {}},
465 + }
466 + ],
467 + cache_salt="request-salt",
468 + )
469 +
470 + result = await renderer.render_responses(
471 + request,
472 + previous_messages=previous_messages,
473 + previous_response_outputs=previous_outputs,
474 + )
475 +
476 + assert not isinstance(result, ErrorResponse)
477 + assert result.messages[0] is previous_messages[0]
478 + tool_output = result.messages[-1]
479 + assert tool_output.author.role == "tool"
480 + assert tool_output.author.name == "functions.lookup_weather"
481 + assert tool_output.channel == "commentary"
482 + assert tool_output.recipient == "assistant"
483 + assert result.engine_input["cache_salt"] == "request-salt"
484 + assert "arrival_time" in result.engine_input
485 + assert previous_messages == [
486 + OpenAIHarmonyMessage.from_role_and_content(Role.USER, "first"),
487 + ]
488 + assert previous_outputs[0].call_id == "call_1"
489 +
490 +
491 +@pytest.mark.asyncio
492 +async def test_online_renderer_returns_typed_error_for_bad_harmony_call_reference():
493 + renderer = _new_online_renderer()
494 + renderer.use_harmony = True
495 + request = ResponsesRequest(
496 + input=[
497 + {
498 + "type": "function_call_output",
499 + "call_id": "missing",
500 + "output": "orphaned",
501 + }
502 + ],
503 + tools=[],
504 + )
505 +
506 + result = await renderer.render_responses(request)
507 +
508 + assert isinstance(result, ErrorResponse)
509 + assert result.error.type == "invalid_request_error"
510 + assert result.error.param == "input"
511 + assert "No call message found for missing" in result.error.message
512 +
513 +
514 +@pytest.mark.asyncio
515 +async def test_online_renderer_preserves_missing_harmony_builtin_tool_behavior(
516 + monkeypatch: pytest.MonkeyPatch,
517 +):
518 + renderer = _new_online_renderer()
519 + renderer.use_harmony = True
520 + renderer.model_config = SimpleNamespace(max_model_len=100)
521 + renderer.renderer = SimpleNamespace(
522 + tokenizer=SimpleNamespace(truncation_side="left")
523 + )
524 + monkeypatch.setattr(
525 + "vllm.renderers.online_renderer.render_for_completion",
526 + lambda messages: [1],
527 + )
528 + request = ResponsesRequest(
529 + input="search",
530 + tools=[{"type": "web_search_preview"}],
531 + )
532 +
533 + result = await renderer.render_responses(request, tool_server=None)
534 +
535 + assert not isinstance(result, ErrorResponse)
536 +
537 +
538 +@pytest.mark.parametrize(
539 + "engine_inputs",
540 + [[], [tokens_input([1]), tokens_input([2])]],
541 +)
542 +def test_responses_render_result_rejects_non_single_prompt(engine_inputs):
543 + renderer = _new_online_renderer()
544 +
545 + result = renderer._responses_render_result([], engine_inputs)
546 +
547 + assert isinstance(result, ErrorResponse)
548 + assert f"got {len(engine_inputs)}" in result.error.message
549 +
550 +
551 class TestInitializeToolSessions:
552 """Test class for _initialize_tool_sessions method"""
553
@@ -240,6 +583,139 @@ class TestInitializeToolSessions:
583
584 return instance
585
586 + @pytest.mark.asyncio
587 + async def test_stateful_harmony_delegates_named_tool_choice_to_renderer(
588 + self,
589 + serving_responses_instance,
590 + ):
591 + serving_responses_instance.use_harmony = True
592 + serving_responses_instance.online_renderer.render_responses = AsyncMock(
593 + return_value=SimpleNamespace(
594 + messages=[],
595 + engine_input=tokens_input([1]),
596 + )
597 + )
598 + request = ResponsesRequest(
599 + input="hello",
600 + tool_choice={"type": "function", "name": "lookup"},
601 + tools=[
602 + {
603 + "type": "function",
604 + "name": "lookup",
605 + "parameters": {"type": "object", "properties": {}},
606 + }
607 + ],
608 + )
609 +
610 + result = await serving_responses_instance._render_resolved_response_inputs(
611 + request,
612 + None,
613 + )
614 +
615 + assert result.engine_input["prompt_token_ids"] == [1]
616 + serving_responses_instance.online_renderer.render_responses.assert_awaited_once()
617 +
618 + @pytest.mark.asyncio
619 + async def test_render_next_turn_reuses_shared_responses_renderer(
620 + self, serving_responses_instance
621 + ):
622 + serving_responses_instance.online_renderer.render_responses = AsyncMock(
623 + return_value=SimpleNamespace(
624 + messages=[],
625 + engine_input=tokens_input([8, 9], cache_salt="request-salt"),
626 + )
627 + )
628 + serving_responses_instance.online_renderer.preprocess_chat = AsyncMock(
629 + return_value=([], [tokens_input([1])])
630 + )
631 + request = ResponsesRequest(
632 + input="first",
633 + instructions="initial instructions",
634 + cache_salt="request-salt",
635 + )
636 + turn_messages = [
637 + {"role": "user", "content": "first"},
638 + {"role": "assistant", "content": "tool call"},
639 + {"role": "tool", "content": "tool output"},
640 + ]
641 +
642 + engine_inputs = await serving_responses_instance._render_next_turn(
643 + request,
644 + turn_messages,
645 + )
646 +
647 + assert engine_inputs[0]["prompt_token_ids"] == [8, 9]
648 + render_request = (
649 + serving_responses_instance.online_renderer.render_responses.call_args.args[
650 + 0
651 + ]
652 + )
653 + assert render_request.input == turn_messages
654 + assert render_request.instructions is None
655 + assert render_request.cache_salt == "request-salt"
656 +
657 + @pytest.mark.asyncio
658 + async def test_harmony_tool_followup_preserves_render_params(
659 + self, serving_responses_instance, monkeypatch: pytest.MonkeyPatch
660 + ):
661 + class ToolCallingHarmonyContext(HarmonyContext):
662 + def __init__(self):
663 + self._messages = []
664 + self._needs_tool_call = True
665 +
666 + def append_output(self, output) -> None:
667 + pass
668 +
669 + def need_builtin_tool_call(self) -> bool:
670 + return self._needs_tool_call
671 +
672 + async def call_tool(self):
673 + self._needs_tool_call = False
674 + return []
675 +
676 + def append_tool_output(self, output) -> None:
677 + self._messages.extend(output)
678 +
679 + async def generate_output():
680 + yield MagicMock()
681 +
682 + context = ToolCallingHarmonyContext()
683 + serving_responses_instance.engine_client.generate.side_effect = (
684 + lambda *args, **kwargs: generate_output()
685 + )
686 + monkeypatch.setattr(
687 + "vllm.renderers.online_renderer.render_for_completion",
688 + lambda messages: [1, 2, 3, 4],
689 + )
690 + online_renderer = serving_responses_instance.online_renderer
691 + online_renderer.renderer = SimpleNamespace(
692 + tokenizer=SimpleNamespace(truncation_side="left")
693 + )
694 + serving_responses_instance.online_renderer.render_responses_harmony_messages = (
695 + OnlineRenderer.render_responses_harmony_messages.__get__(online_renderer)
696 + )
697 + serving_responses_instance._extract_prompt_len = MagicMock(return_value=2)
698 +
699 + async for _ in serving_responses_instance._generate_with_builtin_tools(
700 + request_id="req",
701 + engine_input=tokens_input([1], cache_salt="request-salt"),
702 + sampling_params=MagicMock(),
703 + context=context,
704 + tok_params=TokenizeParams(
705 + max_total_tokens=3,
706 + max_output_tokens=1,
707 + truncate_prompt_tokens=-1,
708 + ),
709 + ):
710 + pass
711 +
712 + assert serving_responses_instance.engine_client.generate.call_count == 2
713 + followup_engine_input = (
714 + serving_responses_instance.engine_client.generate.call_args_list[1].args[0]
715 + )
716 + assert followup_engine_input.get("cache_salt") == "request-salt"
717 + assert followup_engine_input["prompt_token_ids"] == [3, 4]
718 +
719 @pytest.mark.asyncio
720 async def test_initialize_tool_sessions(
721 self, serving_responses_instance, mock_context, mock_exit_stack
tests/entrypoints/openai/test_render_parity.py
+2 -1
@@ -151,7 +151,8 @@ async def _capture_responses(
151 request: ResponsesRequest,
152 ) -> CapturedRenderInputs:
153 capture = RenderCapture(serving.online_renderer)
154 - await serving._make_request(request, prev_response=None)
154 + result = await serving.online_renderer.render_responses(request)
155 + assert not isinstance(result, ErrorResponse), result
156 return capture.take()
157
158
tests/entrypoints/scale_out/render/test_render.py
+188
@@ -3,15 +3,138 @@
3
4 """Tests for the /render endpoints that expose prompt preprocessing."""
5
6 +from http import HTTPStatus
7 +from types import SimpleNamespace
8 +from unittest.mock import AsyncMock, MagicMock
9 +
10 import httpx
11 import pytest
12 import pytest_asyncio
13
14 from tests.utils import RemoteLaunchRenderServer
15 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
16 +from vllm.entrypoints.scale_out.render.api_router import router
17 +from vllm.entrypoints.scale_out.render.serving import ServingRender
18 +from vllm.entrypoints.serve.engine.protocol import ErrorResponse
19 +from vllm.renderers.online_renderer import OnlineRenderer
20
21 MODEL_NAME = "hmellor/tiny-random-LlamaForCausalLM"
22
23
24 +def _build_responses_serving_render() -> ServingRender:
25 + serving = ServingRender.__new__(ServingRender)
26 + serving.model_config = SimpleNamespace(
27 + max_model_len=100,
28 + is_encoder_decoder=False,
29 + )
30 + serving.default_sampling_params = {}
31 + serving.override_max_tokens = None
32 + serving.tool_server = MagicMock()
33 + serving.online_renderer = MagicMock()
34 + serving.online_renderer.create_error_response = (
35 + OnlineRenderer.create_error_response.__get__(serving.online_renderer)
36 + )
37 + serving.online_renderer.validate_chat_template = (
38 + OnlineRenderer.validate_chat_template.__get__(serving.online_renderer)
39 + )
40 + serving.online_renderer.trust_request_chat_template = True
41 + serving._check_model = AsyncMock(return_value=None)
42 + return serving
43 +
44 +
45 +@pytest.mark.skip_global_cleanup
46 +def test_responses_render_route_is_registered():
47 + assert any(route.path == "/v1/responses/render" for route in router.routes)
48 +
49 +
50 +@pytest.mark.skip_global_cleanup
51 +def test_responses_render_route_documents_not_implemented():
52 + route = next(
53 + route for route in router.routes if route.path == "/v1/responses/render"
54 + )
55 +
56 + assert HTTPStatus.NOT_IMPLEMENTED.value in route.responses
57 +
58 +
59 +@pytest.mark.asyncio
60 +@pytest.mark.skip_global_cleanup
61 +async def test_render_responses_returns_generate_request_without_stored_state():
62 + serving = _build_responses_serving_render()
63 + serving.online_renderer.render_responses = AsyncMock(
64 + return_value=MagicMock(
65 + messages=[{"role": "user", "content": "Test prompt"}],
66 + engine_input={"prompt_token_ids": [7, 8, 9]},
67 + )
68 + )
69 + request = ResponsesRequest(
70 + model=MODEL_NAME,
71 + input="Test prompt",
72 + max_output_tokens=12,
73 + stream=True,
74 + cache_salt="request-salt",
75 + priority=3,
76 + kv_transfer_params={"do_remote_prefill": True},
77 + ec_transfer_params={"remote": True},
78 + )
79 +
80 + response = await serving.render_responses_request(request)
81 +
82 + assert response.token_ids == [7, 8, 9]
83 + assert response.request_id == request.request_id
84 + assert response.sampling_params.max_tokens == 12
85 + assert response.model == MODEL_NAME
86 + assert response.stream is True
87 + assert response.cache_salt == "request-salt"
88 + assert response.priority == 3
89 + assert response.kv_transfer_params == {"do_remote_prefill": True}
90 + assert response.ec_transfer_params == {"remote": True}
91 + serving.online_renderer.render_responses.assert_awaited_once_with(
92 + request,
93 + previous_messages=None,
94 + previous_response_outputs=None,
95 + tool_server=serving.tool_server,
96 + skip_mm_cache=True,
97 + )
98 +
99 +
100 +@pytest.mark.asyncio
101 +@pytest.mark.skip_global_cleanup
102 +async def test_render_responses_rejects_previous_response_id():
103 + serving = _build_responses_serving_render()
104 + serving.online_renderer.render_responses = AsyncMock()
105 + request = ResponsesRequest(
106 + model=MODEL_NAME,
107 + input="Test prompt",
108 + previous_response_id="resp_previous",
109 + )
110 +
111 + response = await serving.render_responses_request(request)
112 +
113 + assert isinstance(response, ErrorResponse)
114 + assert response.error.code == 400
115 + assert response.error.param == "previous_response_id"
116 + serving.online_renderer.render_responses.assert_not_awaited()
117 +
118 +
119 +@pytest.mark.asyncio
120 +@pytest.mark.skip_global_cleanup
121 +async def test_render_responses_rejects_empty_token_ids():
122 + serving = _build_responses_serving_render()
123 + serving.online_renderer.render_responses = AsyncMock(
124 + return_value=MagicMock(
125 + messages=[],
126 + engine_input={"prompt_token_ids": []},
127 + )
128 + )
129 +
130 + response = await serving.render_responses_request(
131 + ResponsesRequest(model=MODEL_NAME, input="Test prompt")
132 + )
133 +
134 + assert isinstance(response, ErrorResponse)
135 + assert response.error.message == "No token_ids rendered"
136 +
137 +
138 @pytest.fixture(scope="module")
139 def server():
140 args: list[str] = ["--trust-request-chat-template"]
@@ -28,6 +151,71 @@ async def client(server):
151 yield http_client
152
153
154 +@pytest.mark.asyncio
155 +async def test_responses_render_basic(client):
156 + response = await client.post(
157 + "/v1/responses/render",
158 + json={
159 + "model": MODEL_NAME,
160 + "input": "When should a Responses handler return an empty string?",
161 + "max_output_tokens": 7,
162 + },
163 + )
164 +
165 + assert response.status_code == 200
166 + data = response.json()
167 + assert data["model"] == MODEL_NAME
168 + assert data["request_id"].startswith("resp_")
169 + assert data["sampling_params"]["max_tokens"] == 7
170 + assert data["token_ids"]
171 +
172 +
173 +@pytest.mark.asyncio
174 +async def test_responses_render_includes_prior_input_items(client):
175 + current_turn = {
176 + "role": "user",
177 + "content": "Which color should I remember?",
178 + }
179 + current_turn_response = await client.post(
180 + "/v1/responses/render",
181 + json={"model": MODEL_NAME, "input": [current_turn]},
182 + )
183 + multi_turn_response = await client.post(
184 + "/v1/responses/render",
185 + json={
186 + "model": MODEL_NAME,
187 + "input": [
188 + {"role": "user", "content": "Remember the color cobalt."},
189 + {"role": "assistant", "content": "I will remember cobalt."},
190 + current_turn,
191 + ],
192 + },
193 + )
194 +
195 + assert current_turn_response.status_code == 200
196 + assert multi_turn_response.status_code == 200
197 + current_turn_token_ids = current_turn_response.json()["token_ids"]
198 + multi_turn_token_ids = multi_turn_response.json()["token_ids"]
199 + assert len(multi_turn_token_ids) > len(current_turn_token_ids)
200 +
201 +
202 +@pytest.mark.asyncio
203 +async def test_responses_render_rejects_previous_response_id_over_http(client):
204 + response = await client.post(
205 + "/v1/responses/render",
206 + json={
207 + "model": MODEL_NAME,
208 + "input": "Continue the previous response.",
209 + "previous_response_id": "resp_previous",
210 + },
211 + )
212 +
213 + assert response.status_code == 400
214 + error = response.json()["error"]
215 + assert error["type"] == "invalid_request_error"
216 + assert error["param"] == "previous_response_id"
217 +
218 +
219 @pytest.mark.asyncio
220 async def test_completion_render_basic(client):
221 """Test basic completion render endpoint."""
tests/entrypoints/scale_out/test_factories.py
+1
@@ -15,6 +15,7 @@ RENDER_PATHS = {
15 "/v1/completions/render",
16 "/v1/completions/derender",
17 "/v1/messages/render",
18 + "/v1/responses/render",
19 }
20 GENERATE_PATH = "/inference/v1/generate"
21 SCALE_OUT_PATHS = RENDER_PATHS | {GENERATE_PATH}
vllm/entrypoints/generate/api_router.py
+6 -33
@@ -1,6 +1,6 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -from typing import TYPE_CHECKING
3 +from typing import TYPE_CHECKING, Any
4
5 from fastapi import FastAPI
6
@@ -60,6 +60,7 @@ async def init_generate_state(
60 args: "Namespace",
61 request_logger: RequestLogger | None,
62 supported_tasks: tuple["SupportedTask", ...],
63 + default_chat_template_kwargs: dict[str, Any],
64 ):
65 from vllm.entrypoints.anthropic.serving import AnthropicServingMessages
66 from vllm.entrypoints.chat_utils import load_chat_template
@@ -79,12 +80,6 @@ async def init_generate_state(
80 CohereServingChatV2 = None # type: ignore[assignment,misc]
81 else:
82 CohereServingChatV2 = None # type: ignore[assignment,misc]
82 -
83 - from vllm.entrypoints.mcp.tool_server import (
84 - DemoToolServer,
85 - MCPToolServer,
86 - ToolServer,
87 - )
83 from vllm.entrypoints.openai.chat_completion.batch_serving import (
84 OpenAIServingChatBatch,
85 )
@@ -100,33 +95,11 @@ async def init_generate_state(
95 getattr(args, "fingerprint_value", None),
96 )
97
103 - if args.tool_server == "demo":
104 - tool_server: ToolServer | None = DemoToolServer()
105 - assert isinstance(tool_server, DemoToolServer)
106 - await tool_server.init_and_validate()
107 - elif args.tool_server:
108 - tool_server = MCPToolServer()
109 - await tool_server.add_tool_server(args.tool_server)
110 - else:
111 - tool_server = None
98 resolved_chat_template = load_chat_template(args.chat_template)
99
114 - # Fold the dedicated ``--cohere-format`` CLI flag into the renderer's
115 - # default chat-template kwargs. The cohere renderer reads
116 - # ``chat_template_kwargs["cohere_format"]`` to pick cmd3 vs cmd4
117 - # rendering; making this a first-class flag keeps the right format
118 - # discoverable for ``vllm serve --tokenizer-mode cohere`` users
119 - # without forcing them to hand-construct a JSON dict for
120 - # ``--default-chat-template-kwargs``. Per-request overrides still
121 - # take precedence (see ``merge_kwargs`` in
122 - # ``ChatCompletionRequest.build_chat_params``).
123 - default_chat_template_kwargs = dict(args.default_chat_template_kwargs or {})
124 - if getattr(args, "cohere_format", None):
125 - default_chat_template_kwargs.setdefault("cohere_format", args.cohere_format)
126 -
127 - # Render endpoints are always backed by OnlineRenderer so that
128 - # /v1/chat/completions/render and /v1/completions/render work on both
129 - # generate-mode and render-only servers. Created in init_app_state.
100 + # Render endpoints are always backed by OnlineRenderer so that chat,
101 + # completion, and Responses rendering work on both generate-mode and
102 + # render-only servers. Created in init_app_state.
103
104 state.openai_serving_responses = (
105 OpenAIServingResponses(
@@ -139,7 +112,7 @@ async def init_generate_state(
112 return_tokens_as_token_ids=args.return_tokens_as_token_ids,
113 enable_auto_tools=args.enable_auto_tool_choice,
114 tool_parser=args.tool_call_parser,
142 - tool_server=tool_server,
115 + tool_server=state.tool_server,
116 reasoning_parser=args.structured_outputs_config.reasoning_parser,
117 enable_prompt_tokens_details=args.enable_prompt_tokens_details,
118 enable_force_include_usage=args.enable_force_include_usage,
vllm/entrypoints/launchers/api_server/app_state.py
+15 -4
@@ -8,6 +8,8 @@ from starlette.datastructures import State
8
9 from vllm.engine.protocol import EngineClient
10 from vllm.entrypoints.chat_utils import load_chat_template
11 +from vllm.entrypoints.launchers.cli_args import resolve_default_chat_template_kwargs
12 +from vllm.entrypoints.mcp.tool_server import init_tool_server
13 from vllm.entrypoints.openai.models.protocol import BaseModelPath
14 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
15 from vllm.entrypoints.serve.tokenize.serving import ServingTokenization
@@ -63,6 +65,10 @@ async def init_app_state(
65 state.vllm_config = vllm_config
66 state.args = args
67 resolved_chat_template = load_chat_template(args.chat_template)
68 + default_chat_template_kwargs = resolve_default_chat_template_kwargs(args)
69 + state.tool_server = (
70 + await init_tool_server(args) if "generate" in supported_tasks else None
71 + )
72
73 # Merge default_mm_loras into the static lora_modules
74 default_mm_loras = (
@@ -90,7 +96,7 @@ async def init_app_state(
96 exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
97 tool_parser=args.tool_call_parser,
98 reasoning_parser=args.structured_outputs_config.reasoning_parser,
93 - default_chat_template_kwargs=args.default_chat_template_kwargs,
99 + default_chat_template_kwargs=default_chat_template_kwargs,
100 log_error_stack=args.log_error_stack,
101 )
102 state.online_renderer.warmup()
@@ -106,7 +112,7 @@ async def init_app_state(
112 exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
113 tool_parser=args.tool_call_parser,
114 reasoning_parser=args.structured_outputs_config.reasoning_parser,
109 - default_chat_template_kwargs=args.default_chat_template_kwargs,
115 + default_chat_template_kwargs=default_chat_template_kwargs,
116 log_error_stack=args.log_error_stack,
117 )
118
@@ -116,7 +122,7 @@ async def init_app_state(
122 request_logger=request_logger,
123 chat_template=resolved_chat_template,
124 chat_template_content_format=args.chat_template_content_format,
119 - default_chat_template_kwargs=args.default_chat_template_kwargs,
125 + default_chat_template_kwargs=default_chat_template_kwargs,
126 trust_request_chat_template=args.trust_request_chat_template,
127 )
128
@@ -124,7 +130,12 @@ async def init_app_state(
130 from vllm.entrypoints.generate.api_router import init_generate_state
131
132 await init_generate_state(
127 - engine_client, state, args, request_logger, supported_tasks
133 + engine_client,
134 + state,
135 + args,
136 + request_logger,
137 + supported_tasks,
138 + default_chat_template_kwargs,
139 )
140
141 from vllm.entrypoints.scale_out.factories import init_scale_out_state
vllm/entrypoints/launchers/cli_args.py
+10
@@ -370,6 +370,16 @@ class FrontendArgs(BaseFrontendArgs):
370 return frontend_kwargs
371
372
373 +def resolve_default_chat_template_kwargs(
374 + args: argparse.Namespace,
375 +) -> dict[str, Any]:
376 + """Resolve renderer defaults, including the dedicated Cohere format flag."""
377 + kwargs = dict(args.default_chat_template_kwargs or {})
378 + if getattr(args, "cohere_format", None):
379 + kwargs.setdefault("cohere_format", args.cohere_format)
380 + return kwargs
381 +
382 +
383 def make_arg_parser(parser: FlexibleArgumentParser) -> FlexibleArgumentParser:
384 """Create the CLI argument parser used by the OpenAI API server.
385
vllm/entrypoints/launchers/render/app_state.py
+7 -3
@@ -6,6 +6,8 @@ from starlette.datastructures import State
6
7 from vllm.config import VllmConfig
8 from vllm.entrypoints.chat_utils import load_chat_template
9 +from vllm.entrypoints.launchers.cli_args import resolve_default_chat_template_kwargs
10 +from vllm.entrypoints.mcp.tool_server import init_tool_server
11 from vllm.entrypoints.openai.models.protocol import BaseModelPath
12 from vllm.entrypoints.openai.models.serving import OpenAIModelRegistry
13 from vllm.entrypoints.scale_out.factories import init_render_state
@@ -46,6 +48,8 @@ async def init_render_app_state(
48
49 renderer = renderer_from_config(vllm_config)
50 resolved_chat_template = load_chat_template(args.chat_template)
51 + default_chat_template_kwargs = resolve_default_chat_template_kwargs(args)
52 + state.tool_server = await init_tool_server(args)
53
54 state.online_renderer = OnlineRenderer(
55 model_config=vllm_config.model_config,
@@ -58,7 +62,7 @@ async def init_render_app_state(
62 exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
63 tool_parser=args.tool_call_parser,
64 reasoning_parser=args.reasoning_parser,
61 - default_chat_template_kwargs=args.default_chat_template_kwargs,
65 + default_chat_template_kwargs=default_chat_template_kwargs,
66 log_error_stack=args.log_error_stack,
67 )
68 state.online_renderer.warmup()
@@ -74,7 +78,7 @@ async def init_render_app_state(
78 exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
79 tool_parser=args.tool_call_parser,
80 reasoning_parser=args.reasoning_parser,
77 - default_chat_template_kwargs=args.default_chat_template_kwargs,
81 + default_chat_template_kwargs=default_chat_template_kwargs,
82 log_error_stack=args.log_error_stack,
83 )
84
@@ -85,7 +89,7 @@ async def init_render_app_state(
89 request_logger=request_logger,
90 chat_template=resolved_chat_template,
91 chat_template_content_format=args.chat_template_content_format,
88 - default_chat_template_kwargs=args.default_chat_template_kwargs,
92 + default_chat_template_kwargs=default_chat_template_kwargs,
93 trust_request_chat_template=args.trust_request_chat_template,
94 )
95
vllm/entrypoints/mcp/tool_server.py
+15
@@ -12,6 +12,8 @@ from vllm.logger import init_logger
12 logger = init_logger(__name__)
13
14 if TYPE_CHECKING:
15 + from argparse import Namespace
16 +
17 from mcp.types import ListToolsResult
18
19
@@ -232,3 +234,16 @@ class DemoToolServer(ToolServer):
234 if tool_name not in self.tools:
235 raise KeyError(f"Tool '{tool_name}' is not supported")
236 yield self.tools[tool_name]
237 +
238 +
239 +async def init_tool_server(args: "Namespace") -> ToolServer | None:
240 + tool_server = getattr(args, "tool_server", None)
241 + if tool_server == "demo":
242 + demo_server = DemoToolServer()
243 + await demo_server.init_and_validate()
244 + return demo_server
245 + if tool_server:
246 + mcp_server = MCPToolServer()
247 + await mcp_server.add_tool_server(tool_server)
248 + return mcp_server
249 + return None
vllm/entrypoints/openai/responses/serving.py
+76 -252
@@ -6,13 +6,11 @@ import time
6 from collections import deque
7 from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping, Sequence
8 from contextlib import AsyncExitStack
9 -from copy import copy
9 from http import HTTPStatus
11 -from typing import Any, Final
10 +from typing import Any, Final, cast
11
12 from fastapi import Request
13 from openai.types.responses import (
15 - ResponseFunctionToolCall,
14 ResponseOutputItem,
15 ResponseOutputMessage,
16 ResponseOutputText,
@@ -20,15 +18,12 @@ from openai.types.responses import (
18 response_text_delta_event,
19 )
20 from openai.types.responses.response_output_text import Logprob, LogprobTopLogprob
23 -from openai.types.responses.tool import Mcp, Tool
24 -from openai_harmony import Message as OpenAIHarmonyMessage
21 from pydantic import TypeAdapter
22
23 from vllm import envs
24 from vllm.config.utils import replace
25 from vllm.engine.protocol import EngineClient
26 from vllm.entrypoints.chat_utils import (
31 - ChatCompletionMessageParam,
27 ChatTemplateContentFormatOption,
28 )
29 from vllm.entrypoints.generate.base.protocol import (
@@ -38,24 +33,13 @@ from vllm.entrypoints.generate.base.protocol import (
33 from vllm.entrypoints.generate.base.serving import GenerateBaseServing
34 from vllm.entrypoints.mcp.tool_server import ToolServer
35 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
41 -from vllm.entrypoints.openai.parser.harmony_utils import (
42 - build_harmony_preamble,
43 - extract_instructions_from_messages,
44 - get_user_message,
45 - has_custom_tools,
46 - render_for_completion,
47 -)
36 from vllm.entrypoints.openai.responses.context import (
37 ConversationContext,
38 HarmonyContext,
39 ParsableContext,
40 SimpleContext,
41 )
54 -from vllm.entrypoints.openai.responses.harmony import (
55 - construct_harmony_previous_input_messages,
56 - harmony_to_response_output,
57 - response_input_to_harmony,
58 -)
42 +from vllm.entrypoints.openai.responses.harmony import harmony_to_response_output
43 from vllm.entrypoints.openai.responses.protocol import (
44 InputTokensDetails,
45 OutputTokensDetails,
@@ -80,8 +64,6 @@ from vllm.entrypoints.openai.responses.streaming_events import (
64 )
65 from vllm.entrypoints.openai.responses.utils import (
66 build_response_output_items,
83 - construct_input_messages,
84 - construct_tool_dicts,
67 extract_function_tool_names,
68 extract_tool_types,
69 )
@@ -89,14 +71,19 @@ from vllm.entrypoints.serve.engine.protocol import ErrorResponse
71 from vllm.entrypoints.serve.utils.api_utils import get_max_tokens
72 from vllm.entrypoints.serve.utils.request_logger import RequestLogger
73 from vllm.exceptions import GenerationError, VLLMValidationError
92 -from vllm.inputs import EngineInput, tokens_input
74 +from vllm.inputs import EngineInput
75 from vllm.logger import init_logger
76 from vllm.logprobs import Logprob as SampleLogprob
77 from vllm.logprobs import SampleLogprobs
78 from vllm.lora.request import LoRARequest
79 from vllm.outputs import CompletionOutput
80 from vllm.parser import Parser, ParserManager
99 -from vllm.renderers.online_renderer import OnlineRenderer
81 +from vllm.renderers import TokenizeParams
82 +from vllm.renderers.online_renderer import (
83 + OnlineRenderer,
84 + ResponsesPreviousMessages,
85 + ResponsesRenderResult,
86 +)
87 from vllm.sampling_params import SamplingParams, StructuredOutputsParams
88 from vllm.tokenizers import TokenizerLike
89 from vllm.utils import random_uuid
@@ -105,45 +92,6 @@ from vllm.utils.collection_utils import as_list
92 logger = init_logger(__name__)
93
94
108 -def _extract_allowed_tools_from_mcp_requests(
109 - tools: list[Tool],
110 -) -> dict[str, list[str] | None]:
111 - """
112 - Extract allowed_tools mapping from MCP tool requests.
113 -
114 - Returns a dictionary mapping server_label to allowed_tools list.
115 - Handles both list format and McpAllowedToolsMcpToolFilter object format.
116 -
117 - Special handling:
118 - - If allowed_tools is None, returns None (allows all tools)
119 - - If allowed_tools contains "*", returns None (allows all tools)
120 - - Otherwise, returns the list of specific tool names
121 -
122 - This function can be reused for both harmony and non-harmony MCP calls.
123 - """
124 - allowed_tools_map: dict[str, list[str] | None] = {}
125 - for tool in tools:
126 - if not isinstance(tool, Mcp):
127 - continue
128 -
129 - # allowed_tools can be a list or an object with tool_names
130 - # Extract the actual list of tool names
131 - allowed_tools_val = None
132 - if tool.allowed_tools is not None:
133 - if isinstance(tool.allowed_tools, list):
134 - allowed_tools_val = tool.allowed_tools
135 - elif hasattr(tool.allowed_tools, "tool_names"):
136 - # It's an McpAllowedToolsMcpToolFilter object
137 - allowed_tools_val = tool.allowed_tools.tool_names
138 -
139 - # Normalize "*" to None (both mean "allow all tools")
140 - if allowed_tools_val is not None and "*" in allowed_tools_val:
141 - allowed_tools_val = None
142 -
143 - allowed_tools_map[tool.server_label] = allowed_tools_val
144 - return allowed_tools_map
145 -
146 -
95 class OpenAIServingResponses(GenerateBaseServing):
96 def __init__(
97 self,
@@ -226,7 +174,7 @@ class OpenAIServingResponses(GenerateBaseServing):
174 # HACK(woosuk): This is a hack. We should use a better store.
175 # FIXME: If enable_store=True, this may cause a memory leak since we
176 # never remove messages from the store.
229 - self.msg_store: dict[str, list[ChatCompletionMessageParam]] = {}
177 + self.msg_store: dict[str, ResponsesPreviousMessages] = {}
178
179 # HACK(wuhang): This is a hack. We should use a better store.
180 # FIXME: If enable_store=True, this may cause a memory leak since we
@@ -322,6 +270,33 @@ class OpenAIServingResponses(GenerateBaseServing):
270 )
271 return None
272
273 + async def _render_resolved_response_inputs(
274 + self,
275 + request: ResponsesRequest,
276 + prev_response: ResponsesResponse | None,
277 + *,
278 + skip_mm_cache: bool = False,
279 + ) -> ResponsesRenderResult | ErrorResponse:
280 + previous_messages: ResponsesPreviousMessages | None = None
281 + previous_response_outputs: list[ResponseOutputItem] | None = None
282 + if prev_response is not None:
283 + stored_messages = (
284 + self.msg_store[prev_response.id]
285 + if self.use_harmony
286 + else self.msg_store.get(prev_response.id)
287 + )
288 + if stored_messages is not None:
289 + previous_messages = stored_messages.copy()
290 + previous_response_outputs = list(prev_response.output)
291 +
292 + return await self.online_renderer.render_responses(
293 + request,
294 + previous_messages=previous_messages,
295 + previous_response_outputs=previous_response_outputs,
296 + tool_server=self.tool_server,
297 + skip_mm_cache=skip_mm_cache,
298 + )
299 +
300 async def create_responses(
301 self,
302 request: ResponsesRequest,
@@ -374,12 +349,14 @@ class OpenAIServingResponses(GenerateBaseServing):
349 lora_request = self._maybe_get_adapters(request)
350 model_name = self.models.model_name(lora_request)
351
377 - if self.use_harmony:
378 - messages, engine_inputs = self._make_request_with_harmony(
379 - request, prev_response
380 - )
381 - else:
382 - messages, engine_inputs = await self._make_request(request, prev_response)
352 + render_result = await self._render_resolved_response_inputs(
353 + request,
354 + prev_response,
355 + )
356 + if isinstance(render_result, ErrorResponse):
357 + return render_result
358 + messages = render_result.messages
359 + engine_inputs = [render_result.engine_input]
360
361 request_metadata = RequestResponseMetadata(request_id=request.request_id)
362 if raw_request:
@@ -508,6 +485,11 @@ class OpenAIServingResponses(GenerateBaseServing):
485 engine_input=engine_input,
486 sampling_params=sampling_params,
487 context=context,
488 + tok_params=(
489 + request.build_tok_params(self.model_config)
490 + if isinstance(context, HarmonyContext)
491 + else None
492 + ),
493 lora_request=lora_request,
494 priority=self._get_priority(request, raw_request),
495 trace_headers=trace_headers,
@@ -599,60 +581,28 @@ class OpenAIServingResponses(GenerateBaseServing):
581 request_metadata,
582 )
583
602 - async def _make_request(
603 - self,
604 - request: ResponsesRequest,
605 - prev_response: ResponsesResponse | None,
606 - ):
607 - tool_dicts = construct_tool_dicts(
608 - request.tools,
609 - request.tool_choice,
610 - exclude_tools_when_tool_choice_none=(
611 - self.online_renderer.exclude_tools_when_tool_choice_none
612 - ),
613 - )
614 - # Construct the input messages.
615 - messages = construct_input_messages(
616 - request_instructions=request.instructions,
617 - request_input=request.input,
618 - prev_msg=self.msg_store.get(prev_response.id) if prev_response else None,
619 - prev_response_output=prev_response.output if prev_response else None,
620 - )
621 - chat_template_kwargs = self._effective_chat_template_kwargs(request)
622 - _, engine_inputs = await self.online_renderer.preprocess_chat(
623 - request,
624 - messages,
625 - default_template=self.chat_template,
626 - default_template_content_format=self.chat_template_content_format,
627 - default_template_kwargs=chat_template_kwargs,
628 - tool_dicts=tool_dicts,
629 - parser=self.parser,
630 - )
631 - return messages, engine_inputs
632 -
584 async def _render_next_turn(
585 self,
586 request: ResponsesRequest,
587 messages: list[ResponseInputOutputItem],
637 - tool_dicts: list[dict[str, Any]] | None,
638 - parser: type[Parser] | None,
639 - chat_template: str | None,
640 - chat_template_content_format: ChatTemplateContentFormatOption,
641 - ):
642 - new_messages = construct_input_messages(
643 - request_input=messages,
588 + ) -> list[EngineInput]:
589 + render_request = request.model_copy(
590 + update={
591 + "input": list(messages),
592 + "instructions": None,
593 + "previous_input_messages": None,
594 + "previous_response_id": None,
595 + }
596 )
645 - chat_template_kwargs = self._effective_chat_template_kwargs(request)
646 - _, engine_inputs = await self.online_renderer.preprocess_chat(
647 - request,
648 - new_messages,
649 - default_template=chat_template,
650 - default_template_content_format=chat_template_content_format,
651 - default_template_kwargs=chat_template_kwargs,
652 - tool_dicts=tool_dicts,
653 - parser=parser,
597 + render_result = await self.online_renderer.render_responses(
598 + render_request,
599 + previous_messages=None,
600 + previous_response_outputs=None,
601 + tool_server=self.tool_server,
602 )
655 - return engine_inputs
603 + if isinstance(render_result, ErrorResponse):
604 + raise VLLMValidationError(render_result.error.message)
605 + return [render_result.engine_input]
606
607 async def _generate_with_builtin_tools(
608 self,
@@ -660,6 +610,7 @@ class OpenAIServingResponses(GenerateBaseServing):
610 engine_input: EngineInput,
611 sampling_params: SamplingParams,
612 context: ConversationContext,
613 + tok_params: TokenizeParams | None = None,
614 lora_request: LoRARequest | None = None,
615 priority: int = 0,
616 trace_headers: Mapping[str, str] | None = None,
@@ -667,6 +618,7 @@ class OpenAIServingResponses(GenerateBaseServing):
618 reasoning_parser_kwargs: dict[str, Any] | None = None,
619 ):
620 max_model_len = self.model_config.max_model_len
621 + cache_salt = cast(str | None, engine_input.get("cache_salt"))
622
623 orig_priority = priority
624 sub_request = 0
@@ -711,18 +663,19 @@ class OpenAIServingResponses(GenerateBaseServing):
663 # Create inputs for the next turn.
664 # Render the next prompt token ids and update sampling_params.
665 if isinstance(context, HarmonyContext):
714 - token_ids = context.render_for_completion()
715 - engine_input = tokens_input(token_ids)
666 + engine_input = self.online_renderer.render_responses_harmony_messages(
667 + context.messages,
668 + cache_salt=cache_salt,
669 + tok_params=tok_params,
670 + )
671
717 - sampling_params.max_tokens = max_model_len - len(token_ids)
672 + sampling_params.max_tokens = max_model_len - self._extract_prompt_len(
673 + engine_input
674 + )
675 elif isinstance(context, ParsableContext):
676 (engine_input,) = await self._render_next_turn(
677 context.request,
678 context.response_messages,
722 - context.tool_dicts,
723 - context.parser_cls,
724 - context.chat_template,
725 - context.chat_template_content_format,
679 )
680
681 sampling_params.max_tokens = get_max_tokens(
@@ -740,28 +693,6 @@ class OpenAIServingResponses(GenerateBaseServing):
693 priority = orig_priority - 1
694 sub_request += 1
695
743 - def _make_request_with_harmony(
744 - self,
745 - request: ResponsesRequest,
746 - prev_response: ResponsesResponse | None,
747 - ):
748 - if self.parser is not None:
749 - # HarmonyParser doesn't need chat_template_kwargs
750 - # TODO: Unify adjust_request() call with non-harmony branch
751 - self.parser(
752 - self.renderer.get_tokenizer(),
753 - request.tools,
754 - model_config=self.model_config,
755 - ).adjust_request(request=request)
756 -
757 - arrival_time = time.time()
758 - messages = self._construct_input_messages_with_harmony(request, prev_response)
759 - prompt_token_ids = render_for_completion(messages)
760 - engine_input = tokens_input(prompt_token_ids, cache_salt=request.cache_salt)
761 - engine_input["arrival_time"] = arrival_time
762 -
763 - return messages, [engine_input]
764 -
696 async def _initialize_tool_sessions(
697 self,
698 request: ResponsesRequest,
@@ -1104,113 +1035,6 @@ class OpenAIServingResponses(GenerateBaseServing):
1035 )
1036 ]
1037
1107 - def _get_harmony_builtin_tool_descriptions(
1108 - self, request: ResponsesRequest, tool_types: set[str]
1109 - ) -> dict[str, str | None]:
1110 - # Extract allowed_tools from MCP tool requests
1111 - allowed_tools_map = _extract_allowed_tools_from_mcp_requests(request.tools)
1112 -
1113 - # Get filtered tool descriptions first.
1114 - # If get_tool_description returns None (due to filtering), the tool is disabled.
1115 - browser_description = (
1116 - self.tool_server.get_tool_description(
1117 - "browser", allowed_tools_map.get("web_search_preview")
1118 - )
1119 - if "web_search_preview" in tool_types
1120 - and self.tool_server is not None
1121 - and self.tool_server.has_tool("browser")
1122 - else None
1123 - )
1124 - python_description = (
1125 - self.tool_server.get_tool_description(
1126 - "python", allowed_tools_map.get("code_interpreter")
1127 - )
1128 - if "code_interpreter" in tool_types
1129 - and self.tool_server is not None
1130 - and self.tool_server.has_tool("python")
1131 - else None
1132 - )
1133 - container_description = (
1134 - self.tool_server.get_tool_description(
1135 - "container", allowed_tools_map.get("container")
1136 - )
1137 - if "container" in tool_types
1138 - and self.tool_server is not None
1139 - and self.tool_server.has_tool("container")
1140 - else None
1141 - )
1142 - return {
1143 - "browser_description": browser_description,
1144 - "python_description": python_description,
1145 - "container_description": container_description,
1146 - }
1147 -
1148 - def _construct_input_messages_with_harmony(
1149 - self,
1150 - request: ResponsesRequest,
1151 - prev_response: ResponsesResponse | None,
1152 - ) -> list[OpenAIHarmonyMessage]:
1153 - messages: list[OpenAIHarmonyMessage] = []
1154 - request_input = request.input
1155 - if prev_response is None:
1156 - # New conversation.
1157 - tool_types = extract_tool_types(request.tools)
1158 - with_custom_tools = has_custom_tools(tool_types)
1159 - instructions = request.instructions
1160 - if instructions is None and isinstance(request_input, list):
1161 - instructions, request_input = extract_instructions_from_messages(
1162 - request_input
1163 - )
1164 - tool_descriptions = self._get_harmony_builtin_tool_descriptions(
1165 - request, tool_types
1166 - )
1167 - tools = request.tools if with_custom_tools else None
1168 - messages.extend(
1169 - build_harmony_preamble(
1170 - instructions=instructions,
1171 - tools=tools,
1172 - reasoning_effort=(
1173 - request.reasoning.effort if request.reasoning else None
1174 - ),
1175 - with_custom_tools=with_custom_tools,
1176 - **tool_descriptions,
1177 - )
1178 - )
1179 - messages += construct_harmony_previous_input_messages(request)
1180 -
1181 - else:
1182 - # Continue the previous conversation.
1183 - # FIXME(woosuk): Currently, request params like reasoning and
1184 - # instructions are ignored.
1185 - prev_msgs = self.msg_store[prev_response.id]
1186 -
1187 - messages.extend(prev_msgs)
1188 - # Append the new input.
1189 - # Responses API supports simple text inputs without chat format.
1190 - if isinstance(request_input, str):
1191 - # Skip empty string input when previous_input_messages supplies
1192 - # the full conversation history --- an empty trailing user message
1193 - # confuses the model into thinking nothing was sent.
1194 - if request_input or not request.previous_input_messages:
1195 - messages.append(get_user_message(request_input))
1196 - else:
1197 - if prev_response is not None:
1198 - prev_outputs = copy(prev_response.output)
1199 - else:
1200 - prev_outputs = []
1201 - for response_msg in request_input:
1202 - new_msg = response_input_to_harmony(response_msg, prev_outputs)
1203 - if new_msg is not None:
1204 - messages.append(new_msg)
1205 -
1206 - # User passes in a tool call request and its output. We need
1207 - # to add the tool call request to prev_outputs so that
1208 - # response_input_to_harmony can find the tool call request when
1209 - # parsing the tool call output.
1210 - if isinstance(response_msg, ResponseFunctionToolCall):
1211 - prev_outputs.append(response_msg)
1212 - return messages
1213 -
1038 async def _run_background_request_stream(
1039 self,
1040 request: ResponsesRequest,
vllm/entrypoints/scale_out/factories.py
+1
@@ -31,6 +31,7 @@ def init_render_state(
31 state.openai_serving_models,
32 state.online_renderer,
33 request_logger=request_logger,
34 + tool_server=state.tool_server,
35 )
36
37 state.serving_derender = ServingDerender(
vllm/entrypoints/scale_out/render/api_router.py
+23
@@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse
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.responses.protocol import ResponsesRequest
12 from vllm.entrypoints.serve.engine.protocol import ErrorResponse
13 from vllm.entrypoints.serve.utils.api_utils import validate_json_request
14 from vllm.logger import init_logger
@@ -95,3 +96,25 @@ async def render_completion(request: CompletionRequest, raw_request: Request):
96 return JSONResponse(content=result.model_dump(), status_code=result.error.code)
97
98 return JSONResponse(content=[item.model_dump() for item in result])
99 +
100 +
101 +@router.post(
102 + "/v1/responses/render",
103 + dependencies=[Depends(validate_json_request)],
104 + response_model=GenerateRequest,
105 + responses={
106 + HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
107 + HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
108 + HTTPStatus.NOT_IMPLEMENTED.value: {"model": ErrorResponse},
109 + HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
110 + },
111 +)
112 +async def render_responses(request: ResponsesRequest, raw_request: Request):
113 + handler = render(raw_request)
114 + if handler is None:
115 + raise NotImplementedError("The model does not support Responses Render API")
116 +
117 + result = await handler.render_responses_request(request)
118 + if isinstance(result, ErrorResponse):
119 + return JSONResponse(content=result.model_dump(), status_code=result.error.code)
120 + return JSONResponse(content=result.model_dump())
vllm/entrypoints/scale_out/render/serving.py
+66
@@ -1,5 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from typing import TYPE_CHECKING
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,6 +10,7 @@ from vllm.entrypoints.openai.models.serving import (
10 OpenAIModelRegistry,
11 OpenAIServingModels,
12 )
13 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
14 from vllm.entrypoints.scale_out.token_in_token_out.mm_features import (
15 extract_mm_features,
16 )
@@ -31,6 +34,9 @@ from vllm.utils import random_uuid
34
35 logger = init_logger(__name__)
36
37 +if TYPE_CHECKING:
38 + from vllm.entrypoints.mcp.tool_server import ToolServer
39 +
40
41 class ServingRender(BaseServing):
42 def __init__(
@@ -39,6 +45,7 @@ class ServingRender(BaseServing):
45 online_renderer: "OnlineRenderer",
46 *,
47 request_logger: RequestLogger | None = None,
48 + tool_server: "ToolServer | None" = None,
49 ) -> None:
50 super().__init__(
51 models=models,
@@ -47,6 +54,7 @@ class ServingRender(BaseServing):
54 )
55
56 self.online_renderer = online_renderer
57 + self.tool_server = tool_server
58
59 self._merge_inline_system = (
60 AnthropicServingMessages._detect_merge_inline_system(
@@ -229,6 +237,64 @@ class ServingRender(BaseServing):
237
238 return generate_requests
239
240 + async def render_responses_request(
241 + self,
242 + request: ResponsesRequest,
243 + ) -> GenerateRequest | ErrorResponse:
244 + error_check_ret = await self._check_model(request)
245 + if error_check_ret is not None:
246 + return error_check_ret
247 + if request.previous_response_id is not None:
248 + return self.create_error_response(
249 + message=(
250 + "previous_response_id is not supported by the stateless "
251 + "render endpoint."
252 + ),
253 + err_type="invalid_request_error",
254 + param="previous_response_id",
255 + )
256 +
257 + result = await self.online_renderer.render_responses(
258 + request,
259 + previous_messages=None,
260 + previous_response_outputs=None,
261 + tool_server=self.tool_server,
262 + skip_mm_cache=True,
263 + )
264 + if isinstance(result, ErrorResponse):
265 + return result
266 +
267 + engine_input = result.engine_input
268 + prompt_components = extract_prompt_components(self.model_config, engine_input)
269 + token_ids = prompt_components.token_ids
270 + if not token_ids:
271 + return self.create_error_response("No token_ids rendered")
272 +
273 + input_length = extract_prompt_len(self.model_config, engine_input)
274 + max_tokens = get_max_tokens(
275 + self.model_config.max_model_len,
276 + request.max_output_tokens,
277 + input_length,
278 + self.default_sampling_params,
279 + self.override_max_tokens,
280 + truncate_prompt_tokens=(-1 if request.truncation != "disabled" else None),
281 + )
282 + params = request.to_sampling_params(max_tokens, self.default_sampling_params)
283 +
284 + return GenerateRequest(
285 + request_id=request.request_id,
286 + token_ids=list(token_ids),
287 + features=self._extract_mm_features(engine_input),
288 + sampling_params=params,
289 + model=request.model,
290 + stream=bool(request.stream),
291 + cache_salt=request.cache_salt,
292 + priority=request.priority,
293 + kv_transfer_params=request.kv_transfer_params,
294 + ec_transfer_params=request.ec_transfer_params,
295 + token_offsets=engine_input.get("prompt_token_offsets"),
296 + )
297 +
298 def _placeholder_metadata_fields(self, modality: str) -> set[str]:
299 """Fields the EC consumer still requires after embeddings transfer."""
300 if self._placeholder_metadata_parser_failed:
vllm/renderers/online_renderer.py
+267 -2
@@ -1,13 +1,19 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import time
4 from collections.abc import Sequence
5 +from dataclasses import dataclass
6 from http import HTTPStatus
5 -from typing import Any
7 +from typing import TYPE_CHECKING, Any, TypeAlias
8
9 +from openai.types.responses import ResponseFunctionToolCall, ResponseOutputItem
10 +from openai.types.responses.tool import Mcp, Tool
11 from openai_harmony import Message as OpenAIMessage
12 +from openai_harmony import ToolNamespaceConfig
13
14 from vllm.config import ModelConfig
15 from vllm.entrypoints.chat_utils import (
16 + ChatCompletionMessageParam,
17 ChatTemplateContentFormatOption,
18 ConversationMessage,
19 )
@@ -19,12 +25,24 @@ from vllm.entrypoints.openai.completion.protocol import (
25 CompletionRequest,
26 )
27 from vllm.entrypoints.openai.parser.harmony_utils import (
28 + BUILTIN_TOOL_TO_MCP_SERVER_LABEL,
29 build_harmony_preamble,
30 extract_instructions_from_messages,
31 + get_user_message,
32 + has_custom_tools,
33 parse_chat_inputs_to_harmony_messages,
34 render_for_completion,
35 )
36 +from vllm.entrypoints.openai.responses.harmony import (
37 + construct_harmony_previous_input_messages,
38 + response_input_to_harmony,
39 +)
40 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
41 +from vllm.entrypoints.openai.responses.utils import (
42 + construct_input_messages,
43 + construct_tool_dicts,
44 + extract_tool_types,
45 +)
46 from vllm.entrypoints.serve import create_error_response
47 from vllm.entrypoints.serve.engine.protocol import ErrorResponse
48 from vllm.entrypoints.serve.utils.request_logger import RequestLogger
@@ -32,11 +50,12 @@ from vllm.inputs import (
50 EngineInput,
51 PromptType,
52 SingletonPrompt,
53 + TokensPrompt,
54 tokens_input,
55 )
56 from vllm.logger import init_logger
57 from vllm.parser import Parser, ParserManager
39 -from vllm.renderers import BaseRenderer, ChatParams, merge_kwargs
58 +from vllm.renderers import BaseRenderer, ChatParams, TokenizeParams, merge_kwargs
59 from vllm.renderers.inputs.preprocess import (
60 parse_model_prompt,
61 prompt_to_seq,
@@ -46,6 +65,39 @@ from vllm.utils.mistral import mt as _mt
65
66 logger = init_logger(__name__)
67
68 +if TYPE_CHECKING:
69 + from vllm.entrypoints.mcp.tool_server import ToolServer
70 +
71 +
72 +ResponsesPreviousMessages: TypeAlias = (
73 + list[ChatCompletionMessageParam] | list[OpenAIMessage]
74 +)
75 +
76 +
77 +@dataclass(frozen=True)
78 +class ResponsesRenderResult:
79 + messages: ResponsesPreviousMessages
80 + engine_input: EngineInput
81 +
82 +
83 +def _extract_allowed_tools_from_mcp_requests(
84 + tools: list[Tool],
85 +) -> dict[str, list[str] | None]:
86 + allowed_tools_map: dict[str, list[str] | None] = {}
87 + for tool in tools:
88 + if not isinstance(tool, Mcp):
89 + continue
90 +
91 + allowed_tools = None
92 + if isinstance(tool.allowed_tools, list):
93 + allowed_tools = tool.allowed_tools
94 + elif tool.allowed_tools is not None:
95 + allowed_tools = tool.allowed_tools.tool_names
96 + if allowed_tools is not None and "*" in allowed_tools:
97 + allowed_tools = None
98 + allowed_tools_map[tool.server_label] = allowed_tools
99 + return allowed_tools_map
100 +
101
102 def _reused_prompt_token_ids(request: Any) -> list[int] | None:
103 """Pop prompt token ids forwarded for decode-side reuse, if any.
@@ -217,6 +269,219 @@ class OnlineRenderer:
269
270 return conversation, engine_inputs
271
272 + async def render_responses(
273 + self,
274 + request: ResponsesRequest,
275 + *,
276 + previous_messages: ResponsesPreviousMessages | None = None,
277 + previous_response_outputs: list[ResponseOutputItem] | None = None,
278 + tool_server: "ToolServer | None" = None,
279 + skip_mm_cache: bool = False,
280 + ) -> ResponsesRenderResult | ErrorResponse:
281 + """Render a Responses request using only explicitly supplied history."""
282 + template_error = self.validate_chat_template(
283 + request_chat_template=None,
284 + chat_template_kwargs=request.chat_template_kwargs,
285 + trust_request_chat_template=self.trust_request_chat_template,
286 + )
287 + if template_error is not None:
288 + return template_error
289 +
290 + if self.use_harmony:
291 + return self._render_responses_with_harmony(
292 + request,
293 + previous_messages=previous_messages,
294 + previous_response_outputs=previous_response_outputs,
295 + tool_server=tool_server,
296 + )
297 +
298 + if previous_messages is not None and any(
299 + isinstance(message, OpenAIMessage) for message in previous_messages
300 + ):
301 + return self.create_error_response(
302 + "Non-Harmony Responses history must use chat messages.",
303 + err_type="invalid_request_error",
304 + param="previous_response_id",
305 + )
306 +
307 + tool_dicts = construct_tool_dicts(
308 + request.tools,
309 + request.tool_choice,
310 + exclude_tools_when_tool_choice_none=(
311 + self.exclude_tools_when_tool_choice_none
312 + ),
313 + )
314 + messages = construct_input_messages(
315 + request_instructions=request.instructions,
316 + request_input=request.input,
317 + prev_msg=list(previous_messages) if previous_messages is not None else None,
318 + prev_response_output=(
319 + list(previous_response_outputs)
320 + if previous_response_outputs is not None
321 + else None
322 + ),
323 + )
324 + chat_template_kwargs = (
325 + request.build_chat_params(
326 + self.chat_template,
327 + self.chat_template_content_format,
328 + )
329 + .with_defaults(self.default_chat_template_kwargs)
330 + .chat_template_kwargs
331 + )
332 + _, engine_inputs = await self.preprocess_chat(
333 + request,
334 + messages,
335 + default_template=self.chat_template,
336 + default_template_content_format=self.chat_template_content_format,
337 + default_template_kwargs=chat_template_kwargs,
338 + tool_dicts=tool_dicts,
339 + parser=self.parser,
340 + skip_mm_cache=skip_mm_cache,
341 + )
342 + return self._responses_render_result(messages, engine_inputs)
343 +
344 + def _render_responses_with_harmony(
345 + self,
346 + request: ResponsesRequest,
347 + *,
348 + previous_messages: ResponsesPreviousMessages | None,
349 + previous_response_outputs: list[ResponseOutputItem] | None,
350 + tool_server: "ToolServer | None",
351 + ) -> ResponsesRenderResult | ErrorResponse:
352 + if self.parser is not None:
353 + # HarmonyParser doesn't need chat_template_kwargs
354 + # TODO: Unify adjust_request() call with non-harmony branch
355 + self.parser(
356 + self.renderer.get_tokenizer(),
357 + request.tools,
358 + model_config=self.model_config,
359 + ).adjust_request(request=request)
360 +
361 + if previous_messages is not None and any(
362 + not isinstance(message, OpenAIMessage) for message in previous_messages
363 + ):
364 + return self.create_error_response(
365 + "Harmony Responses history must use Harmony messages.",
366 + err_type="invalid_request_error",
367 + param="previous_response_id",
368 + )
369 +
370 + messages: list[OpenAIMessage] = []
371 + request_input = request.input
372 + if previous_messages is None:
373 + tool_types = extract_tool_types(request.tools)
374 + with_custom_tools = has_custom_tools(tool_types)
375 + instructions = request.instructions
376 + if instructions is None and isinstance(request_input, list):
377 + instructions, request_input = extract_instructions_from_messages(
378 + request_input
379 + )
380 + descriptions = self._get_harmony_builtin_tool_descriptions(
381 + request.tools,
382 + tool_types,
383 + tool_server,
384 + )
385 + messages.extend(
386 + build_harmony_preamble(
387 + instructions=instructions,
388 + tools=request.tools if with_custom_tools else None,
389 + reasoning_effort=(
390 + request.reasoning.effort if request.reasoning else None
391 + ),
392 + with_custom_tools=with_custom_tools,
393 + **descriptions,
394 + )
395 + )
396 + messages.extend(construct_harmony_previous_input_messages(request))
397 + else:
398 + messages.extend(previous_messages)
399 +
400 + previous_outputs = list(previous_response_outputs or ())
401 + try:
402 + if isinstance(request_input, str):
403 + if request_input or not request.previous_input_messages:
404 + messages.append(get_user_message(request_input))
405 + else:
406 + for response_message in request_input:
407 + new_message = response_input_to_harmony(
408 + response_message,
409 + previous_outputs,
410 + )
411 + if new_message is not None:
412 + messages.append(new_message)
413 + if isinstance(response_message, ResponseFunctionToolCall):
414 + previous_outputs.append(response_message)
415 + except ValueError as exc:
416 + return self.create_error_response(
417 + str(exc),
418 + err_type="invalid_request_error",
419 + param="input",
420 + )
421 +
422 + return ResponsesRenderResult(
423 + messages=messages,
424 + engine_input=self.render_responses_harmony_messages(
425 + messages,
426 + cache_salt=request.cache_salt,
427 + tok_params=request.build_tok_params(self.model_config),
428 + ),
429 + )
430 +
431 + def _get_harmony_builtin_tool_descriptions(
432 + self,
433 + tools: list[Tool],
434 + tool_types: set[str],
435 + tool_server: "ToolServer | None",
436 + ) -> dict[str, ToolNamespaceConfig | None]:
437 + allowed_tools = _extract_allowed_tools_from_mcp_requests(tools)
438 + descriptions: dict[str, ToolNamespaceConfig | None] = {}
439 + for server_name, request_name in BUILTIN_TOOL_TO_MCP_SERVER_LABEL.items():
440 + description = (
441 + tool_server.get_tool_description(
442 + server_name,
443 + allowed_tools.get(request_name),
444 + )
445 + if request_name in tool_types
446 + and tool_server is not None
447 + and tool_server.has_tool(server_name)
448 + else None
449 + )
450 + descriptions[f"{server_name}_description"] = description
451 + return descriptions
452 +
453 + def render_responses_harmony_messages(
454 + self,
455 + messages: list[OpenAIMessage],
456 + *,
457 + cache_salt: str | None,
458 + tok_params: TokenizeParams | None = None,
459 + ) -> EngineInput:
460 + arrival_time = time.time()
461 + prompt = TokensPrompt(prompt_token_ids=render_for_completion(messages))
462 + if tok_params is not None:
463 + tok_params.apply_post_tokenization(
464 + self.renderer.tokenizer,
465 + prompt,
466 + )
467 + engine_input = tokens_input(prompt["prompt_token_ids"], cache_salt=cache_salt)
468 + engine_input["arrival_time"] = arrival_time
469 + return engine_input
470 +
471 + def _responses_render_result(
472 + self,
473 + messages: list[ChatCompletionMessageParam],
474 + engine_inputs: list[EngineInput],
475 + ) -> ResponsesRenderResult | ErrorResponse:
476 + if len(engine_inputs) != 1:
477 + return self.create_error_response(
478 + f"Expected exactly 1 engine prompt, got {len(engine_inputs)}"
479 + )
480 + return ResponsesRenderResult(
481 + messages=messages,
482 + engine_input=engine_inputs[0],
483 + )
484 +
485 def _make_request_with_harmony(
486 self,
487 request: ChatCompletionRequest,