main
py 2,182 lines 74.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from contextlib import AsyncExitStack, asynccontextmanager
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,
18 ResponseTextConfig,
19 ResponseTextDeltaEvent,
20 )
21 from openai.types.responses import (
22 ResponseUsage as OpenAIResponseUsage,
23 )
24 from openai.types.responses.response_format_text_json_schema_config import (
25 ResponseFormatTextJSONSchemaConfig,
26 )
27 from openai.types.responses.tool import (
28 CodeInterpreterContainerCodeInterpreterToolAuto,
29 LocalShell,
30 Mcp,
31 Tool,
32 )
33 from openai_harmony import Message as OpenAIHarmonyMessage
34 from openai_harmony import Role
35
36 import vllm.envs as envs
37 from tests.entrypoints.openai.chat_completion.test_serving_chat import MockModelConfig
38 from vllm.entrypoints.generate.base.protocol import (
39 DeltaFunctionCall,
40 DeltaMessage,
41 DeltaToolCall,
42 RequestResponseMetadata,
43 )
44 from vllm.entrypoints.mcp.tool_server import ToolServer
45 from vllm.entrypoints.openai.responses.context import (
46 ConversationContext,
47 HarmonyContext,
48 ParsableContext,
49 SimpleContext,
50 )
51 from vllm.entrypoints.openai.responses.protocol import (
52 ResponseCompletedEvent,
53 ResponseCreatedEvent,
54 ResponseRawMessageAndToken,
55 ResponsesRequest,
56 ResponsesResponse,
57 serialize_message,
58 )
59 from vllm.entrypoints.openai.responses.serving import (
60 OpenAIServingResponses,
61 extract_tool_types,
62 )
63 from vllm.entrypoints.openai.responses.streaming_events import (
64 StreamingState,
65 )
66 from vllm.entrypoints.serve.engine.protocol import ErrorResponse
67 from vllm.inputs import tokens_input
68 from vllm.logprobs import Logprob as SampleLogprob
69 from vllm.outputs import CompletionOutput, RequestOutput
70 from vllm.parser.harmony import Segment
71 from vllm.renderers import TokenizeParams
72 from vllm.renderers.online_renderer import (
73 OnlineRenderer,
74 _extract_allowed_tools_from_mcp_requests,
75 )
76 from vllm.sampling_params import SamplingParams
77 from vllm.v1.metrics.stats import RequestStateStats
78
79 pytestmark = pytest.mark.skip_global_cleanup
80
81
82 def _new_online_renderer() -> OnlineRenderer:
83 renderer = OnlineRenderer.__new__(OnlineRenderer)
84 renderer.exclude_tools_when_tool_choice_none = False
85 renderer.parser = None
86 renderer.trust_request_chat_template = True
87 return renderer
88
89
90 class MockConversationContext(ConversationContext):
91 """Mock conversation context for testing."""
92
93 def __init__(self):
94 self.init_tool_sessions_called = False
95 self.init_tool_sessions_args = None
96 self.init_tool_sessions_kwargs = None
97
98 def append_output(self, output) -> None:
99 pass
100
101 def append_tool_output(self, output) -> None:
102 pass
103
104 async def call_tool(self):
105 return []
106
107 def need_builtin_tool_call(self) -> bool:
108 return False
109
110 def render_for_completion(self):
111 return []
112
113 async def init_tool_sessions(self, tool_server, exit_stack, request_id, mcp_tools):
114 self.init_tool_sessions_called = True
115 self.init_tool_sessions_args = (tool_server, exit_stack, request_id, mcp_tools)
116
117 async def cleanup_session(self) -> None:
118 pass
119
120
121 def test_serialize_message_pydantic_model_returns_dict() -> None:
122 msg = ResponseRawMessageAndToken(message="hello", tokens=[1, 2, 3])
123
124 serialized = serialize_message(msg)
125
126 assert isinstance(serialized, dict)
127 assert serialized["type"] == "raw_message_tokens"
128 assert serialized["message"] == "hello"
129
130
131 @pytest.fixture
132 def mock_context():
133 """Create a mock conversation context."""
134 return MockConversationContext()
135
136
137 @pytest.fixture
138 def mock_exit_stack():
139 """Create a mock async exit stack."""
140 return MagicMock(spec=AsyncExitStack)
141
142
143 def test_extract_tool_types(monkeypatch: pytest.MonkeyPatch) -> None:
144 tools: list[Tool] = []
145 assert extract_tool_types(tools) == set()
146
147 tools.append(LocalShell(type="local_shell"))
148 assert extract_tool_types(tools) == {"local_shell"}
149
150 tools.append(CodeInterpreterContainerCodeInterpreterToolAuto(type="auto"))
151 assert extract_tool_types(tools) == {"local_shell", "auto"}
152
153 tools.extend(
154 [
155 Mcp(type="mcp", server_label="random", server_url=""),
156 Mcp(type="mcp", server_label="container", server_url=""),
157 Mcp(type="mcp", server_label="code_interpreter", server_url=""),
158 Mcp(type="mcp", server_label="web_search_preview", server_url=""),
159 ]
160 )
161 # When envs.VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS is not set,
162 # mcp tool types are all ignored.
163 assert extract_tool_types(tools) == {"local_shell", "auto"}
164
165 # container is allowed, it would be extracted
166 monkeypatch.setenv("VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS", "container")
167 assert extract_tool_types(tools) == {"local_shell", "auto", "container"}
168
169 # code_interpreter and web_search_preview are allowed,
170 # they would be extracted
171 monkeypatch.setenv(
172 "VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS", "code_interpreter,web_search_preview"
173 )
174 assert extract_tool_types(tools) == {
175 "local_shell",
176 "auto",
177 "code_interpreter",
178 "web_search_preview",
179 }
180
181
182 @pytest.mark.skip_global_cleanup
183 def test_response_created_event_uses_public_json_schema_alias() -> None:
184 schema = {
185 "type": "object",
186 "properties": {
187 "event_name": {"type": "string"},
188 "date": {"type": "string"},
189 "participants": {"type": "array", "items": {"type": "string"}},
190 },
191 "required": ["event_name", "date", "participants"],
192 "additionalProperties": False,
193 }
194 text = ResponseTextConfig()
195 text.format = ResponseFormatTextJSONSchemaConfig(
196 type="json_schema",
197 name="calendar_event",
198 schema=schema,
199 description="A calendar event.",
200 strict=True,
201 )
202 request = ResponsesRequest(
203 model="test-model",
204 input="Alice and Bob are going to a science fair on Friday.",
205 text=text,
206 )
207 sampling_params = request.to_sampling_params(default_max_tokens=64)
208 initial_response = ResponsesResponse.from_request(
209 request=request,
210 sampling_params=sampling_params,
211 model_name="test-model",
212 created_time=0,
213 output=[],
214 status="in_progress",
215 usage=None,
216 ).model_dump(mode="json", by_alias=True)
217
218 fmt = initial_response["text"]["format"]
219 assert fmt["schema"] == schema
220 assert "schema_" not in fmt
221
222 event = ResponseCreatedEvent(
223 type="response.created",
224 sequence_number=0,
225 response=initial_response,
226 )
227 assert event.response.text is not None
228 assert event.response.text.format is not 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 _REUSED_IDS = [10, 20, 30]
299 _IMAGE_PART = {
300 "type": "input_image",
301 "image_url": "https://example.com/a.png",
302 "detail": "auto",
303 }
304
305
306 async def _render_responses_with_reuse(request, previous_messages=None):
307 online_renderer = OnlineRenderer(
308 model_config=MockModelConfig(),
309 renderer=MagicMock(),
310 request_logger=None,
311 chat_template=None,
312 chat_template_content_format="auto",
313 )
314 render_chat = AsyncMock(return_value=([[]], [tokens_input([1, 2])]))
315 online_renderer.renderer.render_chat_async = render_chat
316
317 result = await online_renderer.render_responses(
318 request, previous_messages=previous_messages
319 )
320
321 assert not isinstance(result, ErrorResponse)
322 return render_chat, result.engine_input
323
324
325 @pytest.mark.asyncio
326 @pytest.mark.parametrize("ids", [_REUSED_IDS, [1.5]])
327 @pytest.mark.parametrize(
328 "request_input",
329 [
330 [
331 {
332 "role": "user",
333 "content": [{"type": "input_text", "text": "look"}, _IMAGE_PART],
334 }
335 ],
336 [
337 {
338 "type": "function_call",
339 "call_id": "call_1",
340 "name": "shot",
341 "arguments": "{}",
342 },
343 {
344 "type": "function_call_output",
345 "call_id": "call_1",
346 "output": [_IMAGE_PART],
347 },
348 ],
349 ],
350 ids=["input_image", "tool_output_image"],
351 )
352 async def test_responses_kv_transfer_prompt_token_ids_ignored_with_non_text_input(
353 request_input, ids
354 ):
355 request = ResponsesRequest(
356 input=request_input,
357 kv_transfer_params={"do_remote_prefill": True, "prompt_token_ids": ids},
358 )
359
360 render_chat, _ = await _render_responses_with_reuse(request)
361
362 render_chat.assert_awaited_once()
363 assert request.kv_transfer_params == {"do_remote_prefill": True}
364
365
366 @pytest.mark.asyncio
367 async def test_responses_kv_transfer_prompt_token_ids_ignored_with_media_in_history():
368 request = ResponsesRequest(
369 input="next", kv_transfer_params={"prompt_token_ids": _REUSED_IDS}
370 )
371
372 render_chat, _ = await _render_responses_with_reuse(
373 request, previous_messages=[{"role": "user", "content": [_IMAGE_PART]}]
374 )
375
376 render_chat.assert_awaited_once()
377
378
379 @pytest.mark.asyncio
380 async def test_responses_kv_transfer_prompt_token_ids_kept_with_text_input():
381 request = ResponsesRequest(
382 input=[
383 {"role": "user", "content": [{"type": "input_text", "text": "hi"}]},
384 {"role": "assistant", "content": "ok"},
385 ],
386 kv_transfer_params={"prompt_token_ids": _REUSED_IDS},
387 )
388
389 _, engine_input = await _render_responses_with_reuse(request)
390
391 assert engine_input["prompt_token_ids"] == _REUSED_IDS
392
393
394 @pytest.mark.asyncio
395 async def test_online_renderer_rejects_untrusted_responses_chat_template():
396 renderer = _new_online_renderer()
397 renderer.use_harmony = False
398 renderer.chat_template = "server-template"
399 renderer.chat_template_content_format = "string"
400 renderer.default_chat_template_kwargs = {}
401 renderer.exclude_tools_when_tool_choice_none = False
402 renderer.trust_request_chat_template = False
403 renderer.parser = None
404 renderer.preprocess_chat = AsyncMock(
405 return_value=([], [tokens_input([1])]),
406 )
407 request = ResponsesRequest(
408 input="hello",
409 chat_template_kwargs={"chat_template": "{{ messages }}"},
410 )
411
412 result = await renderer.render_responses(request)
413
414 assert isinstance(result, ErrorResponse)
415 assert result.error.code == 400
416 assert "untrusted chat template" in result.error.message.lower()
417 renderer.preprocess_chat.assert_not_awaited()
418
419
420 @pytest.mark.asyncio
421 async def test_online_renderer_rejects_harmony_history_for_non_harmony_model():
422 renderer = _new_online_renderer()
423 renderer.use_harmony = False
424 request = ResponsesRequest(input="next")
425
426 result = await renderer.render_responses(
427 request,
428 previous_messages=[
429 OpenAIHarmonyMessage.from_role_and_content(Role.USER, "first")
430 ],
431 )
432
433 assert isinstance(result, ErrorResponse)
434 assert result.error.param == "previous_response_id"
435
436
437 @pytest.mark.asyncio
438 async def test_online_renderer_rejects_chat_history_for_harmony_model():
439 renderer = _new_online_renderer()
440 renderer.use_harmony = True
441 request = ResponsesRequest(input="next")
442
443 result = await renderer.render_responses(
444 request,
445 previous_messages=[{"role": "user", "content": "first"}],
446 )
447
448 assert isinstance(result, ErrorResponse)
449 assert result.error.param == "previous_response_id"
450
451
452 @pytest.mark.asyncio
453 async def test_online_renderer_adjusts_harmony_tool_choice(
454 monkeypatch: pytest.MonkeyPatch,
455 ):
456 class StubParser:
457 def __init__(self, tokenizer, tools, *, model_config):
458 pass
459
460 def adjust_request(self, request):
461 request.cache_salt = "adjusted"
462 return request
463
464 renderer = _new_online_renderer()
465 renderer.use_harmony = True
466 renderer.parser = StubParser
467 renderer.model_config = SimpleNamespace(max_model_len=100)
468 tokenizer = SimpleNamespace(truncation_side="left")
469 renderer.renderer = SimpleNamespace(
470 tokenizer=tokenizer,
471 get_tokenizer=lambda: tokenizer,
472 )
473 monkeypatch.setattr(
474 "vllm.renderers.online_renderer.render_for_completion",
475 lambda messages: [1],
476 )
477 request = ResponsesRequest(
478 input="next",
479 tools=[
480 {
481 "type": "function",
482 "name": "lookup",
483 "parameters": {"type": "object", "properties": {}},
484 }
485 ],
486 tool_choice={"type": "function", "name": "lookup"},
487 )
488
489 result = await renderer.render_responses(request)
490
491 assert not isinstance(result, ErrorResponse)
492 assert result.engine_input["cache_salt"] == "adjusted"
493
494
495 @pytest.mark.asyncio
496 async def test_online_renderer_applies_responses_token_budget_to_harmony_prompt(
497 monkeypatch: pytest.MonkeyPatch,
498 ):
499 renderer = _new_online_renderer()
500 renderer.use_harmony = True
501 renderer.model_config = SimpleNamespace(max_model_len=5)
502 renderer.renderer = SimpleNamespace(
503 tokenizer=SimpleNamespace(truncation_side="left")
504 )
505 monkeypatch.setattr(
506 "vllm.renderers.online_renderer.render_for_completion",
507 lambda messages: [1, 2, 3, 4, 5, 6],
508 )
509 request = ResponsesRequest(
510 input="hello",
511 max_output_tokens=2,
512 truncation="auto",
513 )
514
515 result = await renderer.render_responses(request)
516
517 assert not isinstance(result, ErrorResponse)
518 assert result.engine_input["prompt_token_ids"] == [4, 5, 6]
519
520
521 @pytest.mark.asyncio
522 async def test_online_renderer_renders_harmony_continuation_with_call_linkage(
523 monkeypatch: pytest.MonkeyPatch,
524 ):
525 renderer = _new_online_renderer()
526 renderer.use_harmony = True
527 renderer.model_config = SimpleNamespace(max_model_len=100)
528 renderer.renderer = SimpleNamespace(
529 tokenizer=SimpleNamespace(truncation_side="left")
530 )
531 monkeypatch.setattr(
532 "vllm.renderers.online_renderer.render_for_completion",
533 lambda messages: [1],
534 )
535 previous_messages = [
536 OpenAIHarmonyMessage.from_role_and_content(Role.USER, "first"),
537 ]
538 previous_outputs = [
539 ResponseFunctionToolCall(
540 arguments='{"city":"Paris"}',
541 call_id="call_1",
542 name="lookup_weather",
543 type="function_call",
544 )
545 ]
546 request = ResponsesRequest(
547 input=[
548 {
549 "type": "function_call_output",
550 "call_id": "call_1",
551 "output": "sunny",
552 }
553 ],
554 instructions="replacement instructions are ignored on continuations",
555 reasoning={"effort": "high"},
556 tools=[
557 {
558 "type": "function",
559 "name": "lookup_weather",
560 "parameters": {"type": "object", "properties": {}},
561 }
562 ],
563 cache_salt="request-salt",
564 )
565
566 result = await renderer.render_responses(
567 request,
568 previous_messages=previous_messages,
569 previous_response_outputs=previous_outputs,
570 )
571
572 assert not isinstance(result, ErrorResponse)
573 assert result.messages[0] is previous_messages[0]
574 tool_output = result.messages[-1]
575 assert tool_output.author.role == "tool"
576 assert tool_output.author.name == "functions.lookup_weather"
577 assert tool_output.channel == "commentary"
578 assert tool_output.recipient == "assistant"
579 assert result.engine_input["cache_salt"] == "request-salt"
580 assert "arrival_time" in result.engine_input
581 assert previous_messages == [
582 OpenAIHarmonyMessage.from_role_and_content(Role.USER, "first"),
583 ]
584 assert previous_outputs[0].call_id == "call_1"
585
586
587 @pytest.mark.asyncio
588 async def test_online_renderer_returns_typed_error_for_bad_harmony_call_reference():
589 renderer = _new_online_renderer()
590 renderer.use_harmony = True
591 request = ResponsesRequest(
592 input=[
593 {
594 "type": "function_call_output",
595 "call_id": "missing",
596 "output": "orphaned",
597 }
598 ],
599 tools=[],
600 )
601
602 result = await renderer.render_responses(request)
603
604 assert isinstance(result, ErrorResponse)
605 assert result.error.type == "invalid_request_error"
606 assert result.error.param == "input"
607 assert "No call message found for missing" in result.error.message
608
609
610 @pytest.mark.asyncio
611 async def test_online_renderer_preserves_missing_harmony_builtin_tool_behavior(
612 monkeypatch: pytest.MonkeyPatch,
613 ):
614 renderer = _new_online_renderer()
615 renderer.use_harmony = True
616 renderer.model_config = SimpleNamespace(max_model_len=100)
617 renderer.renderer = SimpleNamespace(
618 tokenizer=SimpleNamespace(truncation_side="left")
619 )
620 monkeypatch.setattr(
621 "vllm.renderers.online_renderer.render_for_completion",
622 lambda messages: [1],
623 )
624 request = ResponsesRequest(
625 input="search",
626 tools=[{"type": "web_search_preview"}],
627 )
628
629 result = await renderer.render_responses(request, tool_server=None)
630
631 assert not isinstance(result, ErrorResponse)
632
633
634 @pytest.mark.parametrize(
635 "engine_inputs",
636 [[], [tokens_input([1]), tokens_input([2])]],
637 )
638 def test_responses_render_result_rejects_non_single_prompt(engine_inputs):
639 renderer = _new_online_renderer()
640
641 result = renderer._responses_render_result([], engine_inputs)
642
643 assert isinstance(result, ErrorResponse)
644 assert f"got {len(engine_inputs)}" in result.error.message
645
646
647 class TestInitializeToolSessions:
648 """Test class for _initialize_tool_sessions method."""
649
650 @pytest.fixture(params=[ParsableContext, HarmonyContext])
651 def tool_context(self, request):
652 context = request.param.__new__(request.param)
653 context.available_tools = ["browser", "python"]
654 context._tool_sessions = {}
655 context.called_tools = set()
656 return context
657
658 @pytest.mark.asyncio
659 @pytest.mark.parametrize(
660 ("available_tools", "called_tools"),
661 [
662 ([], []),
663 (["browser"], ["browser"]),
664 (["browser", "python"], []),
665 (["browser", "python"], ["browser"]),
666 (["browser", "python"], ["python"]),
667 (["browser", "python"], ["browser", "python"]),
668 ],
669 ids=["empty", "single", "unused", "first", "last", "both"],
670 )
671 async def test_mcp_sessions_cleaned_once_before_close(
672 self, tool_context, available_tools, called_tools
673 ):
674 events = []
675 sessions = {}
676
677 @asynccontextmanager
678 async def new_session(name, *_args):
679 session = MagicMock()
680 session.call_tool = AsyncMock(
681 side_effect=lambda *_: events.append(("cleanup", name))
682 )
683 sessions[name] = session
684 try:
685 yield session
686 finally:
687 events.append(("close", name))
688
689 tool_server = MagicMock(spec=ToolServer)
690 tool_server.new_session.side_effect = new_session
691 tool_context.available_tools = available_tools
692 async with AsyncExitStack() as stack:
693 await tool_context.init_tool_sessions(tool_server, stack, "req", {})
694 # Reinitializing existing sessions must not duplicate cleanup either.
695 await tool_context.init_tool_sessions(tool_server, stack, "req", {})
696 tool_context.called_tools.update(called_tools)
697
698 for name, session in sessions.items():
699 if name in called_tools:
700 session.call_tool.assert_awaited_once_with("cleanup_session", {})
701 assert events.index(("cleanup", name)) < events.index(("close", name))
702 else:
703 session.call_tool.assert_not_awaited()
704 assert [name for event, name in events if event == "close"] == list(
705 reversed(available_tools)
706 )
707
708 @pytest.mark.asyncio
709 async def test_mcp_partial_initialization_closes_opened_session(self, tool_context):
710 closed = []
711 session = MagicMock(call_tool=AsyncMock())
712
713 @asynccontextmanager
714 async def new_session(name, *_args):
715 if name == "python":
716 raise RuntimeError("session initialization failed")
717 try:
718 yield session
719 finally:
720 closed.append(name)
721
722 tool_server = MagicMock(spec=ToolServer)
723 tool_server.new_session.side_effect = new_session
724 with pytest.raises(RuntimeError, match="session initialization failed"):
725 async with AsyncExitStack() as stack:
726 await tool_context.init_tool_sessions(tool_server, stack, "req", {})
727
728 assert closed == ["browser"]
729 session.call_tool.assert_not_awaited()
730
731 @pytest_asyncio.fixture
732 async def serving_responses_instance(self):
733 """Create a real OpenAIServingResponses instance for testing."""
734 # Create minimal mocks for required dependencies
735 engine_client = MagicMock()
736
737 model_config = MagicMock()
738 model_config.max_model_len = 100
739 model_config.hf_config.model_type = "test"
740 model_config.get_diff_sampling_param.return_value = {}
741 engine_client.model_config = model_config
742
743 engine_client.input_processor = MagicMock()
744 engine_client.renderer = MagicMock()
745
746 models = MagicMock()
747
748 tool_server = MagicMock(spec=ToolServer)
749
750 # Create the actual instance
751 instance = OpenAIServingResponses(
752 engine_client=engine_client,
753 models=models,
754 online_renderer=MagicMock(),
755 request_logger=None,
756 chat_template=None,
757 chat_template_content_format="auto",
758 tool_server=tool_server,
759 )
760
761 return instance
762
763 @pytest.mark.asyncio
764 async def test_stateful_harmony_delegates_named_tool_choice_to_renderer(
765 self,
766 serving_responses_instance,
767 ):
768 serving_responses_instance.use_harmony = True
769 serving_responses_instance.online_renderer.render_responses = AsyncMock(
770 return_value=SimpleNamespace(
771 messages=[],
772 engine_input=tokens_input([1]),
773 )
774 )
775 request = ResponsesRequest(
776 input="hello",
777 tool_choice={"type": "function", "name": "lookup"},
778 tools=[
779 {
780 "type": "function",
781 "name": "lookup",
782 "parameters": {"type": "object", "properties": {}},
783 }
784 ],
785 )
786
787 result = await serving_responses_instance._render_resolved_response_inputs(
788 request,
789 None,
790 )
791
792 assert result.engine_input["prompt_token_ids"] == [1]
793 serving_responses_instance.online_renderer.render_responses.assert_awaited_once()
794
795 @pytest.mark.asyncio
796 async def test_render_next_turn_reuses_shared_responses_renderer(
797 self, serving_responses_instance
798 ):
799 serving_responses_instance.online_renderer.render_responses = AsyncMock(
800 return_value=SimpleNamespace(
801 messages=[],
802 engine_input=tokens_input([8, 9], cache_salt="request-salt"),
803 )
804 )
805 serving_responses_instance.online_renderer.preprocess_chat = AsyncMock(
806 return_value=([], [tokens_input([1])])
807 )
808 request = ResponsesRequest(
809 input="first",
810 instructions="initial instructions",
811 cache_salt="request-salt",
812 )
813 turn_messages = [
814 {"role": "user", "content": "first"},
815 {"role": "assistant", "content": "tool call"},
816 {"role": "tool", "content": "tool output"},
817 ]
818
819 engine_inputs = await serving_responses_instance._render_next_turn(
820 request,
821 turn_messages,
822 )
823
824 assert engine_inputs[0]["prompt_token_ids"] == [8, 9]
825 render_request = (
826 serving_responses_instance.online_renderer.render_responses.call_args.args[
827 0
828 ]
829 )
830 assert render_request.input == turn_messages
831 assert render_request.instructions is None
832 assert render_request.cache_salt == "request-salt"
833
834 @pytest.mark.asyncio
835 async def test_harmony_tool_followup_preserves_render_params(
836 self, serving_responses_instance, monkeypatch: pytest.MonkeyPatch
837 ):
838 class ToolCallingHarmonyContext(HarmonyContext):
839 def __init__(self):
840 self._messages = []
841 self.request = None
842 self._needs_tool_call = True
843
844 def append_output(self, output) -> None:
845 pass
846
847 def need_builtin_tool_call(self) -> bool:
848 return self._needs_tool_call
849
850 async def call_tool(self):
851 self._needs_tool_call = False
852 return []
853
854 def append_tool_output(self, output) -> None:
855 self._messages.extend(output)
856
857 async def generate_output():
858 yield MagicMock()
859
860 context = ToolCallingHarmonyContext()
861 serving_responses_instance.engine_client.generate.side_effect = (
862 lambda *args, **kwargs: generate_output()
863 )
864 monkeypatch.setattr(
865 "vllm.renderers.online_renderer.render_for_completion",
866 lambda messages: [1, 2, 3, 4],
867 )
868 online_renderer = serving_responses_instance.online_renderer
869 online_renderer.renderer = SimpleNamespace(
870 tokenizer=SimpleNamespace(truncation_side="left")
871 )
872 serving_responses_instance.online_renderer.render_responses_harmony_messages = (
873 OnlineRenderer.render_responses_harmony_messages.__get__(online_renderer)
874 )
875 serving_responses_instance._extract_prompt_len = MagicMock(return_value=2)
876
877 async for _ in serving_responses_instance._generate_with_builtin_tools(
878 request_id="req",
879 engine_input=tokens_input([1], cache_salt="request-salt"),
880 sampling_params=MagicMock(),
881 context=context,
882 tok_params=TokenizeParams(
883 max_total_tokens=3,
884 max_output_tokens=1,
885 truncate_prompt_tokens=-1,
886 ),
887 ):
888 pass
889
890 assert serving_responses_instance.engine_client.generate.call_count == 2
891 assert context.request_metrics_cover_all_generation_turns is False
892 followup_engine_input = (
893 serving_responses_instance.engine_client.generate.call_args_list[1].args[0]
894 )
895 assert followup_engine_input.get("cache_salt") == "request-salt"
896 assert followup_engine_input["prompt_token_ids"] == [3, 4]
897
898 @pytest.mark.asyncio
899 async def test_harmony_tool_followup_respects_max_output_tokens(
900 self, serving_responses_instance
901 ):
902 class ToolCallingHarmonyContext(HarmonyContext):
903 def __init__(self, request):
904 self._messages = []
905 self.request = request
906 self._needs_tool_call = True
907
908 def append_output(self, output) -> None:
909 pass
910
911 def need_builtin_tool_call(self) -> bool:
912 return self._needs_tool_call
913
914 async def call_tool(self):
915 self._needs_tool_call = False
916 return []
917
918 def append_tool_output(self, output) -> None:
919 pass
920
921 max_tokens_per_turn = []
922
923 async def generate_output(engine_input, sampling_params, *args, **kwargs):
924 max_tokens_per_turn.append(sampling_params.max_tokens)
925 yield MagicMock()
926
927 serving_responses_instance.engine_client.generate.side_effect = generate_output
928 serving_responses_instance.online_renderer.render_responses_harmony_messages = (
929 MagicMock(return_value=tokens_input([1, 2, 3]))
930 )
931 context = ToolCallingHarmonyContext(
932 ResponsesRequest(input="test", max_output_tokens=10)
933 )
934
935 async for _ in serving_responses_instance._generate_with_builtin_tools(
936 request_id="req",
937 engine_input=tokens_input([1]),
938 sampling_params=SamplingParams(max_tokens=10),
939 context=context,
940 ):
941 pass
942
943 assert max_tokens_per_turn == [10, 10]
944
945 @pytest.mark.asyncio
946 async def test_initialize_tool_sessions(
947 self, serving_responses_instance, mock_context, mock_exit_stack
948 ):
949 """Test that method works correctly with only MCP tools."""
950 request = ResponsesRequest(input="test input", tools=[])
951
952 # Call the method
953 await serving_responses_instance._initialize_tool_sessions(
954 request, mock_context, mock_exit_stack
955 )
956 assert mock_context.init_tool_sessions_called is False
957
958 # Create only MCP tools
959 tools = [
960 {"type": "web_search_preview"},
961 {"type": "code_interpreter", "container": {"type": "auto"}},
962 ]
963
964 request = ResponsesRequest(input="test input", tools=tools)
965
966 # Call the method
967 await serving_responses_instance._initialize_tool_sessions(
968 request, mock_context, mock_exit_stack
969 )
970
971 # Verify that init_tool_sessions was called
972 assert mock_context.init_tool_sessions_called
973
974 def test_validate_create_responses_input(
975 self, serving_responses_instance, mock_context, mock_exit_stack
976 ):
977 request = ResponsesRequest(
978 input="test input",
979 previous_input_messages=[
980 {
981 "role": "user",
982 "content": [
983 {
984 "type": "text",
985 "text": "What is my horoscope? I am an Aquarius.",
986 }
987 ],
988 }
989 ],
990 previous_response_id="lol",
991 )
992 error = serving_responses_instance._validate_create_responses_input(request)
993 assert error is not None
994 assert error.error.type == "invalid_request_error"
995
996
997 class TestValidateGeneratorInput:
998 """Test class for _validate_generator_input method."""
999
1000 @pytest_asyncio.fixture
1001 async def serving_responses_instance(self):
1002 """Create a real OpenAIServingResponses instance for testing."""
1003 # Create minimal mocks for required dependencies
1004 engine_client = MagicMock()
1005
1006 model_config = MagicMock()
1007 model_config.max_model_len = 100
1008 model_config.hf_config.model_type = "test"
1009 model_config.get_diff_sampling_param.return_value = {}
1010 engine_client.model_config = model_config
1011
1012 engine_client.input_processor = MagicMock()
1013 engine_client.renderer = MagicMock()
1014
1015 models = MagicMock()
1016
1017 # Create the actual instance
1018 instance = OpenAIServingResponses(
1019 engine_client=engine_client,
1020 models=models,
1021 online_renderer=MagicMock(),
1022 request_logger=None,
1023 chat_template=None,
1024 chat_template_content_format="auto",
1025 )
1026
1027 return instance
1028
1029 def test_validate_generator_input(self, serving_responses_instance):
1030 """Test _validate_generator_input with valid prompt length."""
1031 # Create an engine prompt with valid length (less than max_model_len)
1032 valid_prompt_token_ids = list(range(5)) # 5 tokens < 100 max_model_len
1033 engine_input = tokens_input(valid_prompt_token_ids)
1034
1035 # Call the method
1036 result = serving_responses_instance._validate_generator_input(engine_input)
1037
1038 # Should return None for valid input
1039 assert result is None
1040
1041 # create an invalid engine prompt
1042 invalid_prompt_token_ids = list(range(200)) # 100 tokens >= 100 max_model_len
1043 engine_input = tokens_input(invalid_prompt_token_ids)
1044
1045 # Call the method
1046 result = serving_responses_instance._validate_generator_input(engine_input)
1047
1048 # Should return an ErrorResponse
1049 assert result is not None
1050 assert isinstance(result, ErrorResponse)
1051
1052
1053 class _Qwen3FakeTokenizer:
1054 def __init__(self):
1055 self._vocab = {"<think>": 1, "</think>": 2, "reason": 3, "final": 4}
1056
1057 def get_vocab(self):
1058 return self._vocab
1059
1060 def decode(self, token_ids):
1061 id_to_token = {v: k for k, v in self._vocab.items()}
1062 return "".join(id_to_token.get(token_id, "x") for token_id in token_ids)
1063
1064
1065 def _make_qwen3_serving() -> tuple[OpenAIServingResponses, _Qwen3FakeTokenizer]:
1066 engine_client = MagicMock()
1067 model_config = MagicMock()
1068 model_config.hf_config.model_type = "test"
1069 model_config.hf_text_config = MagicMock()
1070 model_config.get_diff_sampling_param.return_value = {}
1071 engine_client.model_config = model_config
1072 engine_client.input_processor = MagicMock()
1073 engine_client.renderer = MagicMock()
1074
1075 tokenizer = _Qwen3FakeTokenizer()
1076 engine_client.renderer.get_tokenizer.return_value = tokenizer
1077
1078 serving = OpenAIServingResponses(
1079 engine_client=engine_client,
1080 models=MagicMock(),
1081 online_renderer=MagicMock(),
1082 request_logger=None,
1083 chat_template=None,
1084 chat_template_content_format="auto",
1085 reasoning_parser="qwen3",
1086 )
1087 return serving, tokenizer
1088
1089
1090 def _make_text_request_output(text: str, token_ids: list[int]) -> RequestOutput:
1091 return RequestOutput(
1092 request_id="req",
1093 prompt="hi",
1094 prompt_token_ids=[7, 8],
1095 prompt_logprobs=None,
1096 outputs=[
1097 CompletionOutput(
1098 index=0,
1099 text=text,
1100 token_ids=token_ids,
1101 cumulative_logprob=0.0,
1102 logprobs=None,
1103 finish_reason="stop",
1104 stop_reason=None,
1105 )
1106 ],
1107 finished=True,
1108 num_cached_tokens=0,
1109 num_cache_creation_tokens=3,
1110 )
1111
1112
1113 async def _run_full_generator(serving, request, context, tokenizer):
1114 async def dummy_result_generator():
1115 yield None
1116
1117 return await serving.responses_full_generator(
1118 request=request,
1119 sampling_params=SamplingParams(max_tokens=16),
1120 result_generator=dummy_result_generator(),
1121 context=context,
1122 model_name="test-model",
1123 tokenizer=tokenizer,
1124 request_metadata=RequestResponseMetadata(request_id="req"),
1125 )
1126
1127
1128 @pytest.mark.asyncio
1129 async def test_reasoning_tokens_counted_for_text_reasoning_model(monkeypatch):
1130 """Ensure reasoning_tokens usage is derived from thinking token spans."""
1131 # Force non-harmony, SimpleContext path
1132 monkeypatch.setattr(envs, "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", False)
1133
1134 serving, tokenizer = _make_qwen3_serving()
1135 request = ResponsesRequest(input="hi", tools=[], stream=False)
1136 response_parser = serving._make_response_parser(
1137 request,
1138 tokenizer,
1139 serving._effective_chat_template_kwargs(request),
1140 )
1141
1142 # Build a SimpleContext with thinking tokens in the output.
1143 context = SimpleContext(response_parser=response_parser)
1144 # <think> 10 </think> 20 -> reasoning token count = 1
1145 context.append_output(
1146 _make_text_request_output("<think>reason</think>final", [1, 10, 2, 20])
1147 )
1148
1149 response = await _run_full_generator(serving, request, context, tokenizer)
1150
1151 assert response.usage.output_tokens_details.reasoning_tokens == 1
1152 assert response.usage.input_tokens_details.cache_write_tokens == 3
1153
1154 OpenAIResponseUsage.model_validate(response.usage.model_dump(mode="json"))
1155
1156
1157 class TestExtractAllowedToolsFromMcpRequests:
1158 """Test class for _extract_allowed_tools_from_mcp_requests function."""
1159
1160 def test_extract_allowed_tools_basic_formats(self):
1161 """Test extraction with list format, object format, and None."""
1162 from openai.types.responses.tool import McpAllowedToolsMcpToolFilter
1163
1164 tools = [
1165 # List format
1166 Mcp(
1167 type="mcp",
1168 server_label="server1",
1169 allowed_tools=["tool1", "tool2"],
1170 ),
1171 # Object format
1172 Mcp(
1173 type="mcp",
1174 server_label="server2",
1175 allowed_tools=McpAllowedToolsMcpToolFilter(
1176 tool_names=["tool3", "tool4"]
1177 ),
1178 ),
1179 # None (no filter)
1180 Mcp(
1181 type="mcp",
1182 server_label="server3",
1183 allowed_tools=None,
1184 ),
1185 ]
1186 result = _extract_allowed_tools_from_mcp_requests(tools)
1187 assert result == {
1188 "server1": ["tool1", "tool2"],
1189 "server2": ["tool3", "tool4"],
1190 "server3": None,
1191 }
1192
1193 def test_extract_allowed_tools_star_normalization(self):
1194 """Test that '*' wildcard is normalized to None (select all tools).
1195
1196 This is the key test requested by reviewers to explicitly demonstrate
1197 that the "*" select-all scenario is handled correctly.
1198 """
1199 from openai.types.responses.tool import McpAllowedToolsMcpToolFilter
1200
1201 tools = [
1202 # Star in list format
1203 Mcp(
1204 type="mcp",
1205 server_label="server1",
1206 allowed_tools=["*"],
1207 ),
1208 # Star mixed with other tools in list
1209 Mcp(
1210 type="mcp",
1211 server_label="server2",
1212 allowed_tools=["tool1", "*"],
1213 ),
1214 # Star in object format
1215 Mcp(
1216 type="mcp",
1217 server_label="server3",
1218 allowed_tools=McpAllowedToolsMcpToolFilter(tool_names=["*"]),
1219 ),
1220 ]
1221 result = _extract_allowed_tools_from_mcp_requests(tools)
1222 # All should be normalized to None (allows all tools)
1223 assert result == {
1224 "server1": None,
1225 "server2": None,
1226 "server3": None,
1227 }
1228
1229 def test_extract_allowed_tools_filters_non_mcp(self):
1230 """Test that non-MCP tools are ignored during extraction."""
1231 tools = [
1232 Mcp(
1233 type="mcp",
1234 server_label="server1",
1235 allowed_tools=["tool1"],
1236 ),
1237 LocalShell(type="local_shell"), # Non-MCP tool should be ignored
1238 Mcp(
1239 type="mcp",
1240 server_label="server2",
1241 allowed_tools=["tool2"],
1242 ),
1243 ]
1244 result = _extract_allowed_tools_from_mcp_requests(tools)
1245 # Non-MCP tools should be ignored
1246 assert result == {
1247 "server1": ["tool1"],
1248 "server2": ["tool2"],
1249 }
1250
1251
1252 class TestHarmonyPreambleStreaming:
1253 """Tests for preamble (commentary with no recipient) streaming events."""
1254
1255 @staticmethod
1256 def _make_segment(*, channel, recipient, delta="hello"):
1257 """Build a lightweight segment for Harmony streaming tests."""
1258 return Segment(channel=channel, recipient=recipient, delta=delta)
1259
1260 @staticmethod
1261 def _make_previous_item(*, channel, recipient, text="preamble text"):
1262 """Build a lightweight mock previous_item (openai_harmony Message)."""
1263 content_part = MagicMock()
1264 content_part.text = text
1265 item = MagicMock()
1266 item.channel = channel
1267 item.recipient = recipient
1268 item.content = [content_part]
1269 return item
1270
1271 def test_preamble_delta_emits_text_events(self) -> None:
1272 """Commentary + recipient=None should emit output_text.delta events."""
1273 from vllm.entrypoints.openai.responses.streaming_events import (
1274 emit_content_delta_events,
1275 )
1276
1277 segment = self._make_segment(channel="commentary", recipient=None)
1278 state = StreamingState()
1279
1280 events = emit_content_delta_events(segment, state)
1281
1282 type_names = [e.type for e in events]
1283 assert "response.output_text.delta" in type_names
1284 assert "response.output_item.added" in type_names
1285
1286 def test_preamble_delta_second_token_no_added(self) -> None:
1287 """Second preamble token should emit delta only, not added again."""
1288 from vllm.entrypoints.openai.responses.streaming_events import (
1289 emit_content_delta_events,
1290 )
1291
1292 segment = self._make_segment(channel="commentary", recipient=None, delta="w")
1293 state = StreamingState()
1294 state.sent_output_item_added = True
1295 state.current_item_id = "msg_test"
1296 state.current_content_index = 0
1297
1298 events = emit_content_delta_events(segment, state)
1299
1300 type_names = [e.type for e in events]
1301 assert "response.output_text.delta" in type_names
1302 assert "response.output_item.added" not in type_names
1303
1304 def test_commentary_with_function_recipient_not_preamble(self) -> None:
1305 """Commentary + recipient='functions.X' must NOT use preamble path."""
1306 from vllm.entrypoints.openai.responses.streaming_events import (
1307 emit_content_delta_events,
1308 )
1309
1310 segment = self._make_segment(
1311 channel="commentary",
1312 recipient="functions.get_weather",
1313 )
1314 state = StreamingState()
1315
1316 events = emit_content_delta_events(segment, state)
1317
1318 type_names = [e.type for e in events]
1319 assert "response.output_text.delta" not in type_names
1320
1321 def test_preamble_done_emits_text_done_events(self) -> None:
1322 """Completed preamble should emit text done + content_part done +
1323 output_item done, same shape as final channel."""
1324 from vllm.entrypoints.openai.responses.streaming_events import (
1325 emit_previous_item_done_events,
1326 )
1327
1328 previous = self._make_previous_item(channel="commentary", recipient=None)
1329 state = StreamingState()
1330 state.sent_output_item_added = True
1331 state.current_item_id = "msg_test"
1332 state.current_output_index = 0
1333 state.current_content_index = 0
1334
1335 events = emit_previous_item_done_events(previous, state)
1336
1337 type_names = [e.type for e in events]
1338 assert "response.output_text.done" in type_names
1339 assert "response.content_part.done" in type_names
1340 assert "response.output_item.done" in type_names
1341
1342 def test_commentary_with_recipient_no_preamble_done(self) -> None:
1343 """Commentary + recipient='functions.X' should route to function call
1344 done, not preamble done."""
1345 from vllm.entrypoints.openai.responses.streaming_events import (
1346 emit_previous_item_done_events,
1347 )
1348
1349 previous = self._make_previous_item(
1350 channel="commentary", recipient="functions.get_weather"
1351 )
1352 state = StreamingState()
1353 state.is_first_function_call_delta = True
1354 state.current_item_id = "fc_test"
1355 state.current_call_id = "call_test"
1356
1357 events = emit_previous_item_done_events(previous, state)
1358
1359 type_names = [e.type for e in events]
1360 assert "response.output_text.done" not in type_names
1361
1362 @pytest.mark.xfail(
1363 reason=(
1364 "TODO: Ensure added/in-progress events are emitted for zero-delta items."
1365 "So we can safely emit done events for zero-delta items."
1366 ),
1367 strict=True,
1368 )
1369 def test_zero_delta_items_should_preserve_streaming_lifecycle(
1370 self,
1371 ) -> None:
1372 """Zero-delta Harmony items should still produce a coherent lifecycle."""
1373 from vllm.entrypoints.openai.responses.streaming_events import (
1374 emit_previous_item_done_events,
1375 )
1376
1377 cases: list[tuple[str, str | None, str]] = [
1378 ("commentary", None, "msg_stale"),
1379 ("analysis", None, "msg_stale"),
1380 ("commentary", "functions.get_weather", "fc_stale"),
1381 ("commentary", "python", "tool_stale"),
1382 ("commentary", "repo_browser.list", "mcp_stale"),
1383 ]
1384
1385 for channel, recipient, current_item_id in cases:
1386 previous = self._make_previous_item(channel=channel, recipient=recipient)
1387 state = StreamingState()
1388 state.current_item_id = current_item_id
1389 state.current_call_id = "call_stale"
1390 state.current_content_index = 0
1391
1392 events = emit_previous_item_done_events(
1393 previous, state, function_tool_names=None
1394 )
1395
1396 type_names = [e.type for e in events]
1397 assert "response.output_item.added" in type_names
1398 assert "response.output_item.done" in type_names
1399
1400
1401 _PER_REQUEST_STATS = RequestStateStats(
1402 queued_ts=1.0,
1403 scheduled_ts=1.5,
1404 first_token_ts=2.0,
1405 last_token_ts=3.0,
1406 )
1407
1408
1409 def _make_request_output(
1410 text,
1411 token_ids,
1412 metrics: RequestStateStats | None = None,
1413 ):
1414 completion = CompletionOutput(
1415 index=0,
1416 text=text,
1417 token_ids=token_ids,
1418 cumulative_logprob=0.0,
1419 logprobs=None,
1420 finish_reason=None,
1421 stop_reason=None,
1422 )
1423 return RequestOutput(
1424 request_id="req",
1425 prompt="hi",
1426 prompt_token_ids=[7, 8],
1427 prompt_logprobs=None,
1428 outputs=[completion],
1429 finished=False,
1430 num_cached_tokens=0,
1431 metrics=metrics,
1432 )
1433
1434
1435 def _make_simple_context_with_output(
1436 text,
1437 token_ids,
1438 response_parser=None,
1439 metrics: RequestStateStats | None = None,
1440 ):
1441 """Create a SimpleContext with a RequestOutput containing the given text."""
1442 ctx = SimpleContext(response_parser=response_parser)
1443 req_output = _make_request_output(text, token_ids, metrics)
1444 ctx.append_output(req_output)
1445 ctx.request_metrics = metrics
1446 return ctx
1447
1448
1449 def _make_serving_instance(
1450 *,
1451 reasoning_parser: str = "",
1452 tool_parser: str | None = None,
1453 enable_per_request_metrics: bool = False,
1454 ) -> OpenAIServingResponses:
1455 engine_client = MagicMock()
1456 model_config = MagicMock()
1457 model_config.max_model_len = 100
1458 model_config.hf_config.model_type = "test"
1459 model_config.hf_text_config = MagicMock()
1460 model_config.get_diff_sampling_param.return_value = {}
1461 engine_client.model_config = model_config
1462 engine_client.input_processor = MagicMock()
1463 engine_client.renderer = MagicMock()
1464
1465 return OpenAIServingResponses(
1466 engine_client=engine_client,
1467 models=MagicMock(),
1468 online_renderer=MagicMock(),
1469 request_logger=None,
1470 chat_template=None,
1471 chat_template_content_format="auto",
1472 reasoning_parser=reasoning_parser,
1473 enable_auto_tools=tool_parser is not None,
1474 tool_parser=tool_parser,
1475 enable_per_request_metrics=enable_per_request_metrics,
1476 )
1477
1478
1479 async def _empty_context_generator():
1480 if False:
1481 yield
1482
1483
1484 async def _make_full_metrics_response(
1485 enable_per_request_metrics: bool,
1486 request_metrics_cover_all_generation_turns: bool = True,
1487 ):
1488 serving = _make_serving_instance(
1489 enable_per_request_metrics=enable_per_request_metrics
1490 )
1491 request = ResponsesRequest(input="hi", tools=[], stream=False, store=False)
1492 sampling_params = SamplingParams(max_tokens=16)
1493 context = SimpleContext()
1494 context.request_metrics_cover_all_generation_turns = (
1495 request_metrics_cover_all_generation_turns
1496 )
1497
1498 async def generate(*args, **kwargs):
1499 yield _make_request_output("hello", [10, 20], _PER_REQUEST_STATS)
1500
1501 serving.engine_client.generate.side_effect = generate
1502 result_generator = serving._generate_with_builtin_tools(
1503 request_id=request.request_id,
1504 engine_input=tokens_input([7, 8]),
1505 sampling_params=sampling_params,
1506 context=context,
1507 )
1508 response = await serving.responses_full_generator(
1509 request=request,
1510 sampling_params=sampling_params,
1511 result_generator=result_generator,
1512 context=context,
1513 model_name="test-model",
1514 tokenizer=MagicMock(),
1515 request_metadata=RequestResponseMetadata(request_id="req"),
1516 )
1517 assert isinstance(response, ResponsesResponse)
1518 return response
1519
1520
1521 @pytest.mark.asyncio
1522 async def test_responses_per_request_metrics_follow_server_flag():
1523 disabled_response = await _make_full_metrics_response(False)
1524 assert disabled_response.metrics is None
1525 assert "metrics" not in disabled_response.model_dump(mode="json")
1526
1527 enabled_response = await _make_full_metrics_response(True)
1528 assert enabled_response.metrics is not None
1529 assert enabled_response.metrics.time_to_first_token_ms == pytest.approx(500.0)
1530
1531
1532 @pytest.mark.asyncio
1533 async def test_responses_streaming_metrics_only_on_completed_event():
1534 serving = _make_serving_instance(enable_per_request_metrics=True)
1535 request = ResponsesRequest(input="hi", tools=[], stream=True, store=False)
1536 context = _make_simple_context_with_output(
1537 "hello", [10, 20], metrics=_PER_REQUEST_STATS
1538 )
1539
1540 events = [
1541 event
1542 async for event in serving.responses_stream_generator(
1543 request=request,
1544 sampling_params=SamplingParams(max_tokens=16),
1545 result_generator=_empty_context_generator(),
1546 context=context,
1547 model_name="test-model",
1548 tokenizer=MagicMock(),
1549 request_metadata=RequestResponseMetadata(request_id="req"),
1550 )
1551 ]
1552
1553 for event in events[:-1]:
1554 assert "metrics" not in event.response.model_dump(mode="json")
1555 assert isinstance(events[-1], ResponseCompletedEvent)
1556 assert events[-1].response.metrics is not None
1557 assert events[-1].response.metrics.time_to_first_token_ms == pytest.approx(500.0)
1558
1559
1560 @pytest.mark.asyncio
1561 async def test_responses_metrics_suppressed_for_multiple_generation_turns():
1562 response = await _make_full_metrics_response(
1563 True, request_metrics_cover_all_generation_turns=False
1564 )
1565
1566 assert response.metrics is None
1567 assert "metrics" not in response.model_dump(mode="json")
1568
1569
1570 def _make_serving_instance_with_reasoning():
1571 """Create an OpenAIServingResponses with a mocked reasoning parser."""
1572 return _make_serving_instance(reasoning_parser="qwen3")
1573
1574
1575 def _identity_increment(event):
1576 """Simple identity callable for _increment_sequence_number_and_return."""
1577 seq = getattr(_identity_increment, "_counter", 0)
1578 if hasattr(event, "sequence_number"):
1579 event.sequence_number = seq
1580 _identity_increment._counter = seq + 1 # type: ignore
1581 return event
1582
1583
1584 def _mock_parser_with_reasoning(serving, delta_sequence: list[DeltaMessage]):
1585 """Set up serving.parser so that it returns a mock parser instance
1586 with a reasoning parser that returns the given delta_sequence.
1587
1588 The mock has reasoning_parser set (truthy) but tool_parser as None,
1589 so the parser's parse_delta enters the reasoning-only branch.
1590 """
1591 call_count = 0
1592
1593 def mock_parse_delta(**kwargs):
1594 nonlocal call_count
1595 if call_count >= len(delta_sequence):
1596 return None
1597 result = delta_sequence[call_count]
1598 call_count += 1
1599 return result
1600
1601 mock_parser_instance = MagicMock()
1602 mock_parser_instance.reasoning_parser = MagicMock() # truthy
1603 mock_parser_instance.tool_parser = None
1604 mock_parser_instance.parse_delta = mock_parse_delta
1605 mock_parser_instance.is_reasoning_end = MagicMock(return_value=False)
1606 serving.parser = MagicMock(return_value=mock_parser_instance)
1607 return mock_parser_instance
1608
1609
1610 class TestStreamingReasoningToContentTransition:
1611 """Tests for _process_simple_streaming_events reasoning-to-content
1612 transition, specifically the fix for mixed deltas that carry both
1613 reasoning and content simultaneously."""
1614
1615 @pytest.mark.asyncio
1616 async def test_mixed_delta_reasoning_and_content_emits_reasoning_delta(
1617 self, monkeypatch
1618 ):
1619 """When the reasoning parser produces a delta with both reasoning
1620 and content set (e.g. reasoning end and content start in the same
1621 chunk), the trailing reasoning text must be emitted as a
1622 ResponseReasoningTextDeltaEvent and included in the
1623 ResponseReasoningTextDoneEvent text."""
1624 monkeypatch.setattr(envs, "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", False)
1625 serving = _make_serving_instance_with_reasoning()
1626
1627 # Sequence of DeltaMessages the mock orchestrator will return
1628 delta_sequence = [
1629 DeltaMessage(reasoning="thinking..."),
1630 DeltaMessage(reasoning=" end", content="hello"), # mixed delta
1631 DeltaMessage(content=" world"),
1632 ]
1633 response_parser = _mock_parser_with_reasoning(serving, delta_sequence)
1634 # Create contexts for each streaming chunk
1635 contexts = [
1636 _make_simple_context_with_output("chunk1", [10], response_parser),
1637 _make_simple_context_with_output("chunk2", [20], response_parser),
1638 _make_simple_context_with_output("chunk3", [30], response_parser),
1639 ]
1640
1641 async def result_generator():
1642 for ctx in contexts:
1643 yield ctx
1644
1645 request = ResponsesRequest(input="hi", tools=[], stream=True)
1646 sampling_params = SamplingParams(max_tokens=64)
1647 metadata = RequestResponseMetadata(request_id="req")
1648 _identity_increment._counter = 0 # type: ignore
1649
1650 events = []
1651 async for event in serving._process_simple_streaming_events(
1652 request=request,
1653 sampling_params=sampling_params,
1654 result_generator=result_generator(),
1655 context=SimpleContext(response_parser=response_parser),
1656 model_name="test-model",
1657 tokenizer=MagicMock(),
1658 request_metadata=metadata,
1659 created_time=0,
1660 _increment_sequence_number_and_return=_identity_increment,
1661 ):
1662 events.append(event)
1663
1664 # The first reasoning delta should be emitted
1665 reasoning_deltas = [
1666 e for e in events if isinstance(e, ResponseReasoningTextDeltaEvent)
1667 ]
1668 assert len(reasoning_deltas) == 2
1669 assert reasoning_deltas[0].delta == "thinking..."
1670 # The trailing reasoning from the mixed delta must also be emitted
1671 assert reasoning_deltas[1].delta == " end"
1672
1673 # The done event must include both reasoning parts
1674 reasoning_done = [
1675 e for e in events if isinstance(e, ResponseReasoningTextDoneEvent)
1676 ]
1677 assert len(reasoning_done) == 1
1678 assert reasoning_done[0].text == "thinking... end"
1679
1680 # Content deltas should be emitted for both the mixed delta's
1681 # content and the pure content delta
1682 text_deltas = [e for e in events if isinstance(e, ResponseTextDeltaEvent)]
1683 assert len(text_deltas) == 2
1684 assert text_deltas[0].delta == "hello"
1685 assert text_deltas[1].delta == " world"
1686
1687 @pytest.mark.asyncio
1688 async def test_transition_without_mixed_delta_no_extra_reasoning_event(
1689 self, monkeypatch
1690 ):
1691 """When the transition from reasoning to content is clean (no mixed
1692 delta), no extra reasoning delta event should be emitted."""
1693 monkeypatch.setattr(envs, "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", False)
1694 serving = _make_serving_instance_with_reasoning()
1695
1696 delta_sequence = [
1697 DeltaMessage(reasoning="thinking"),
1698 DeltaMessage(content="answer"),
1699 ]
1700 response_parser = _mock_parser_with_reasoning(serving, delta_sequence)
1701
1702 contexts = [
1703 _make_simple_context_with_output("chunk1", [10], response_parser),
1704 _make_simple_context_with_output("chunk2", [20], response_parser),
1705 ]
1706
1707 async def result_generator():
1708 for ctx in contexts:
1709 yield ctx
1710
1711 request = ResponsesRequest(input="hi", tools=[], stream=True)
1712 sampling_params = SamplingParams(max_tokens=64)
1713 metadata = RequestResponseMetadata(request_id="req")
1714 _identity_increment._counter = 0 # type: ignore
1715
1716 events = []
1717 async for event in serving._process_simple_streaming_events(
1718 request=request,
1719 sampling_params=sampling_params,
1720 result_generator=result_generator(),
1721 context=SimpleContext(response_parser=response_parser),
1722 model_name="test-model",
1723 tokenizer=MagicMock(),
1724 request_metadata=metadata,
1725 created_time=0,
1726 _increment_sequence_number_and_return=_identity_increment,
1727 ):
1728 events.append(event)
1729
1730 # Exactly one reasoning delta
1731 reasoning_deltas = [
1732 e for e in events if isinstance(e, ResponseReasoningTextDeltaEvent)
1733 ]
1734 assert len(reasoning_deltas) == 1
1735 assert reasoning_deltas[0].delta == "thinking"
1736
1737 # Done event has just "thinking"
1738 reasoning_done = [
1739 e for e in events if isinstance(e, ResponseReasoningTextDoneEvent)
1740 ]
1741 assert len(reasoning_done) == 1
1742 assert reasoning_done[0].text == "thinking"
1743
1744 # One content delta
1745 text_deltas = [e for e in events if isinstance(e, ResponseTextDeltaEvent)]
1746 assert len(text_deltas) == 1
1747 assert text_deltas[0].delta == "answer"
1748
1749 @pytest.mark.asyncio
1750 async def test_reasoning_only_stream_no_content(self, monkeypatch):
1751 """When the stream has only reasoning deltas and no content, the
1752 reasoning done event should be emitted at finalization with the
1753 full accumulated text, and no text delta events should appear."""
1754 monkeypatch.setattr(envs, "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", False)
1755 serving = _make_serving_instance_with_reasoning()
1756
1757 delta_sequence = [
1758 DeltaMessage(reasoning="step 1"),
1759 DeltaMessage(reasoning=" step 2"),
1760 ]
1761 response_parser = _mock_parser_with_reasoning(serving, delta_sequence)
1762
1763 contexts = [
1764 _make_simple_context_with_output("chunk1", [10], response_parser),
1765 _make_simple_context_with_output("chunk2", [20], response_parser),
1766 ]
1767
1768 async def result_generator():
1769 for ctx in contexts:
1770 yield ctx
1771
1772 request = ResponsesRequest(input="hi", tools=[], stream=True)
1773 sampling_params = SamplingParams(max_tokens=64)
1774 metadata = RequestResponseMetadata(request_id="req")
1775 _identity_increment._counter = 0 # type: ignore
1776
1777 events = []
1778 async for event in serving._process_simple_streaming_events(
1779 request=request,
1780 sampling_params=sampling_params,
1781 result_generator=result_generator(),
1782 context=SimpleContext(response_parser=response_parser),
1783 model_name="test-model",
1784 tokenizer=MagicMock(),
1785 request_metadata=metadata,
1786 created_time=0,
1787 _increment_sequence_number_and_return=_identity_increment,
1788 ):
1789 events.append(event)
1790
1791 # Two reasoning deltas
1792 reasoning_deltas = [
1793 e for e in events if isinstance(e, ResponseReasoningTextDeltaEvent)
1794 ]
1795 assert len(reasoning_deltas) == 2
1796 assert reasoning_deltas[0].delta == "step 1"
1797 assert reasoning_deltas[1].delta == " step 2"
1798
1799 # Done event at finalization with accumulated text
1800 reasoning_done = [
1801 e for e in events if isinstance(e, ResponseReasoningTextDoneEvent)
1802 ]
1803 assert len(reasoning_done) == 1
1804 assert reasoning_done[0].text == "step 1 step 2"
1805
1806 # No content text deltas
1807 text_deltas = [e for e in events if isinstance(e, ResponseTextDeltaEvent)]
1808 assert len(text_deltas) == 0
1809
1810 # Final item should be a reasoning item
1811 item_done_events = [
1812 e for e in events if isinstance(e, ResponseOutputItemDoneEvent)
1813 ]
1814 assert len(item_done_events) == 1
1815 assert isinstance(item_done_events[0].item, ResponseReasoningItem)
1816
1817
1818 class TestAutoToolStreaming:
1819 @staticmethod
1820 async def _collect_events(delta_sequence: list[DeltaMessage]):
1821 serving = _make_serving_instance_with_reasoning()
1822 response_parser = _mock_parser_with_reasoning(serving, delta_sequence)
1823
1824 contexts = [
1825 _make_simple_context_with_output("chunk", [i], response_parser)
1826 for i in range(len(delta_sequence))
1827 ]
1828
1829 async def result_generator():
1830 for ctx in contexts:
1831 yield ctx
1832
1833 request = ResponsesRequest(
1834 input="hi",
1835 tools=[
1836 {
1837 "type": "function",
1838 "name": "get_weather",
1839 "description": "Get weather.",
1840 "parameters": {
1841 "type": "object",
1842 "properties": {"location": {"type": "string"}},
1843 "required": ["location"],
1844 "additionalProperties": False,
1845 },
1846 }
1847 ],
1848 tool_choice="auto",
1849 stream=True,
1850 )
1851 sampling_params = SamplingParams(max_tokens=64)
1852 metadata = RequestResponseMetadata(request_id="req")
1853 _identity_increment._counter = 0 # type: ignore
1854
1855 events = []
1856 async for event in serving._process_simple_streaming_events(
1857 request=request,
1858 sampling_params=sampling_params,
1859 result_generator=result_generator(),
1860 context=SimpleContext(response_parser=response_parser),
1861 model_name="test-model",
1862 tokenizer=MagicMock(),
1863 request_metadata=metadata,
1864 created_time=0,
1865 _increment_sequence_number_and_return=_identity_increment,
1866 ):
1867 events.append(event)
1868 return events
1869
1870 @pytest.mark.skip_global_cleanup
1871 @pytest.mark.asyncio
1872 async def test_auto_multi_tool_streaming_opens_one_item_per_tool(self, monkeypatch):
1873 monkeypatch.setattr(envs, "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", False)
1874
1875 delta_sequence = [
1876 DeltaMessage(
1877 tool_calls=[
1878 DeltaToolCall(
1879 id="call_vienna",
1880 type="function",
1881 index=0,
1882 function=DeltaFunctionCall(
1883 name="get_weather",
1884 arguments="",
1885 ),
1886 )
1887 ]
1888 ),
1889 DeltaMessage(
1890 tool_calls=[
1891 DeltaToolCall(
1892 index=0,
1893 function=DeltaFunctionCall(
1894 arguments='{"location":"Vienna"}',
1895 ),
1896 )
1897 ]
1898 ),
1899 DeltaMessage(
1900 tool_calls=[
1901 DeltaToolCall(
1902 id="call_berlin",
1903 type="function",
1904 index=1,
1905 function=DeltaFunctionCall(
1906 name="get_weather",
1907 arguments='{"location":"Berlin"}',
1908 ),
1909 )
1910 ]
1911 ),
1912 ]
1913 events = await self._collect_events(delta_sequence)
1914
1915 function_items = [
1916 event
1917 for event in events
1918 if event.type == "response.output_item.added"
1919 and getattr(event.item, "type", None) == "function_call"
1920 ]
1921 assert len(function_items) == 2
1922 assert [event.item.name for event in function_items] == [
1923 "get_weather",
1924 "get_weather",
1925 ]
1926 assert [event.output_index for event in function_items] == [0, 1]
1927
1928 argument_deltas = [
1929 event.delta
1930 for event in events
1931 if event.type == "response.function_call_arguments.delta"
1932 ]
1933 assert argument_deltas == [
1934 '{"location":"Vienna"}',
1935 '{"location":"Berlin"}',
1936 ]
1937
1938 argument_done = [
1939 event
1940 for event in events
1941 if event.type == "response.function_call_arguments.done"
1942 ]
1943 assert [event.arguments for event in argument_done] == [
1944 '{"location":"Vienna"}',
1945 '{"location":"Berlin"}',
1946 ]
1947 assert [event.output_index for event in argument_done] == [0, 1]
1948
1949 function_done = [
1950 event
1951 for event in events
1952 if event.type == "response.output_item.done"
1953 and getattr(event.item, "type", None) == "function_call"
1954 ]
1955 assert [event.item.arguments for event in function_done] == [
1956 '{"location":"Vienna"}',
1957 '{"location":"Berlin"}',
1958 ]
1959 assert [event.output_index for event in function_done] == [0, 1]
1960
1961 @pytest.mark.skip_global_cleanup
1962 @pytest.mark.asyncio
1963 async def test_auto_tool_choice_first_delta_tool_call_does_not_duplicate_item(
1964 self, monkeypatch
1965 ):
1966 monkeypatch.setattr(envs, "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", False)
1967
1968 delta_sequence = [
1969 DeltaMessage(
1970 tool_calls=[
1971 DeltaToolCall(
1972 id="call_test",
1973 type="function",
1974 index=0,
1975 function=DeltaFunctionCall(
1976 name="get_weather",
1977 arguments="",
1978 ),
1979 )
1980 ]
1981 ),
1982 DeltaMessage(
1983 tool_calls=[
1984 DeltaToolCall(
1985 index=0,
1986 function=DeltaFunctionCall(
1987 arguments='{"location":"Berlin"}',
1988 ),
1989 )
1990 ]
1991 ),
1992 ]
1993 events = await self._collect_events(delta_sequence)
1994
1995 function_items = [
1996 event
1997 for event in events
1998 if event.type == "response.output_item.added"
1999 and getattr(event.item, "type", None) == "function_call"
2000 ]
2001 assert len(function_items) == 1
2002 assert function_items[0].item.name == "get_weather"
2003
2004 argument_deltas = [
2005 event.delta
2006 for event in events
2007 if event.type == "response.function_call_arguments.delta"
2008 ]
2009 assert "".join(argument_deltas) == '{"location":"Berlin"}'
2010
2011 @pytest.mark.skip_global_cleanup
2012 @pytest.mark.asyncio
2013 async def test_compound_content_and_tool_name_args_same_delta(self, monkeypatch):
2014 monkeypatch.setattr(envs, "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", False)
2015
2016 tool_args = '{"location":"Berlin"}'
2017
2018 delta_sequence = [
2019 DeltaMessage(
2020 content="Let me check.",
2021 tool_calls=[
2022 DeltaToolCall(
2023 id="call_weather",
2024 type="function",
2025 index=0,
2026 function=DeltaFunctionCall(name="get_weather"),
2027 ),
2028 DeltaToolCall(
2029 index=0,
2030 function=DeltaFunctionCall(arguments=tool_args),
2031 ),
2032 ],
2033 )
2034 ]
2035
2036 events = await self._collect_events(delta_sequence)
2037
2038 text_deltas = [
2039 event.delta
2040 for event in events
2041 if event.type == "response.output_text.delta"
2042 ]
2043 assert text_deltas == ["Let me check."]
2044
2045 argument_deltas = [
2046 event.delta
2047 for event in events
2048 if event.type == "response.function_call_arguments.delta"
2049 ]
2050 assert argument_deltas == [tool_args]
2051
2052 types = [event.type for event in events]
2053 assert types.index("response.output_text.delta") < types.index(
2054 "response.function_call_arguments.delta"
2055 )
2056
2057 argument_done = [
2058 event
2059 for event in events
2060 if event.type == "response.function_call_arguments.done"
2061 ]
2062 assert [event.arguments for event in argument_done] == [tool_args]
2063
2064 function_done = [
2065 event
2066 for event in events
2067 if event.type == "response.output_item.done"
2068 and getattr(event.item, "type", None) == "function_call"
2069 ]
2070 assert len(function_done) == 1
2071 assert function_done[0].item.name == "get_weather"
2072 assert function_done[0].item.arguments == tool_args
2073
2074
2075 @pytest.mark.skip_global_cleanup
2076 @pytest.mark.asyncio
2077 async def test_stream_completed_response_reuses_streamed_items(monkeypatch):
2078 """response.completed must carry the streamed items (same ids,
2079 call_id, logprobs) rather than a reparse of the full output."""
2080 monkeypatch.setattr(envs, "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", False)
2081 serving = _make_serving_instance_with_reasoning()
2082 call = DeltaToolCall(
2083 id="chatcmpl-tool-parser-id",
2084 index=0,
2085 function=DeltaFunctionCall(name="get_weather", arguments="{}"),
2086 )
2087 response_parser = _mock_parser_with_reasoning(
2088 serving,
2089 [
2090 DeltaMessage(reasoning="think"),
2091 DeltaMessage(content="Hi"),
2092 DeltaMessage(tool_calls=[call]),
2093 ],
2094 )
2095 response_parser.count_reasoning_tokens = MagicMock(return_value=1)
2096 context = SimpleContext(response_parser=response_parser)
2097
2098 async def result_generator():
2099 for token_id, text in [(10, "think"), (20, "Hi"), (30, "call")]:
2100 output = _make_request_output(text, [token_id])
2101 output.outputs[0].logprobs = [
2102 {token_id: SampleLogprob(logprob=-0.5, decoded_token=text)}
2103 ]
2104 context.append_output(output)
2105 yield context
2106
2107 events = [
2108 event
2109 async for event in serving.responses_stream_generator(
2110 request=ResponsesRequest(
2111 input="hi",
2112 include=["message.output_text.logprobs"],
2113 stream=True,
2114 store=False,
2115 ),
2116 sampling_params=SamplingParams(max_tokens=16),
2117 result_generator=result_generator(),
2118 context=context,
2119 model_name="test-model",
2120 tokenizer=MagicMock(),
2121 request_metadata=RequestResponseMetadata(request_id="req"),
2122 )
2123 ]
2124
2125 streamed = [e.item for e in events if e.type == "response.output_item.done"]
2126 assert events[-1].response.output == streamed
2127 _, message, function_call = streamed
2128 assert [lp.token for lp in message.content[0].logprobs] == ["Hi"]
2129 assert function_call.call_id == "chatcmpl-tool-parser-id"
2130
2131
2132 @pytest.mark.asyncio
2133 @pytest.mark.parametrize(("parallel_tool_calls", "num_calls"), [(False, 1), (None, 3)])
2134 async def test_streaming_parallel_tool_calls(
2135 monkeypatch, parallel_tool_calls, num_calls
2136 ):
2137 """parallel_tool_calls=False keeps only the first call even when the grammar
2138 does not limit the model; None keeps every call."""
2139 monkeypatch.setattr(envs, "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", False)
2140 serving = _make_serving_instance(tool_parser="hermes")
2141 request = ResponsesRequest(
2142 input="hi",
2143 tools=[{"type": "function", "name": "get_weather", "parameters": {}}],
2144 parallel_tool_calls=parallel_tool_calls,
2145 stream=True,
2146 )
2147 context = SimpleContext(
2148 response_parser=serving._make_response_parser(request, MagicMock(), {})
2149 )
2150 args = [f'{{"city": "{city}"}}' for city in ("Paris", "Berlin", "Tokyo")]
2151
2152 async def result_generator():
2153 for i, arg in enumerate(args):
2154 text = f'<tool_call>\n{{"name": "get_weather", "arguments": {arg}}}'
2155 context.append_output(_make_request_output(f"{text}\n</tool_call>\n", [i]))
2156 yield context
2157
2158 events = [
2159 event
2160 async for event in serving.responses_stream_generator(
2161 request=request,
2162 sampling_params=SamplingParams(max_tokens=64),
2163 result_generator=result_generator(),
2164 context=context,
2165 model_name="test-model",
2166 tokenizer=MagicMock(),
2167 request_metadata=RequestResponseMetadata(request_id="req"),
2168 )
2169 ]
2170
2171 assert [
2172 event.output_index
2173 for event in events
2174 if event.type == "response.output_item.added"
2175 ] == list(range(num_calls))
2176 assert all(getattr(event, "output_index", 0) < num_calls for event in events)
2177 assert [
2178 event.item.arguments
2179 for event in events
2180 if event.type == "response.output_item.done"
2181 ] == args[:num_calls]
2182 assert [item.arguments for item in events[-1].response.output] == args[:num_calls]