main
py 1,410 lines 53.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import asyncio
5 import time
6 from collections import deque
7 from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping, Sequence
8 from contextlib import AsyncExitStack
9 from http import HTTPStatus
10 from typing import Any, Final, cast
11
12 from fastapi import Request
13 from openai.types.responses import (
14 ResponseOutputItem,
15 ResponseOutputMessage,
16 ResponseOutputText,
17 ResponseStatus,
18 )
19 from openai.types.responses.response_output_text import Logprob, LogprobTopLogprob
20 from pydantic import TypeAdapter
21
22 from vllm import envs
23 from vllm.config.utils import replace
24 from vllm.engine.protocol import EngineClient
25 from vllm.entrypoints.chat_utils import (
26 ChatTemplateContentFormatOption,
27 )
28 from vllm.entrypoints.generate.base.protocol import (
29 DeltaMessage,
30 RequestResponseMetadata,
31 )
32 from vllm.entrypoints.generate.base.serving import (
33 GenerateBaseServing,
34 build_per_request_timing_metrics,
35 )
36 from vllm.entrypoints.mcp.tool_server import ToolServer
37 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
38 from vllm.entrypoints.openai.responses.context import (
39 ConversationContext,
40 HarmonyContext,
41 ParsableContext,
42 SimpleContext,
43 )
44 from vllm.entrypoints.openai.responses.harmony import harmony_to_response_output
45 from vllm.entrypoints.openai.responses.protocol import (
46 InputTokensDetails,
47 OutputTokensDetails,
48 ResponseCompletedEvent,
49 ResponseCreatedEvent,
50 ResponseInProgressEvent,
51 ResponseInputOutputItem,
52 ResponseInputOutputMessage,
53 ResponsesRequest,
54 ResponsesResponse,
55 ResponseUsage,
56 StreamingResponsesResponse,
57 )
58 from vllm.entrypoints.openai.responses.streaming_events import (
59 SimpleStreamingEventProcessor,
60 StreamingState,
61 _StateType,
62 emit_content_delta_events,
63 emit_previous_item_done_events,
64 emit_tool_action_events,
65 split_delta,
66 )
67 from vllm.entrypoints.openai.responses.utils import (
68 build_response_output_items,
69 extract_function_tool_names,
70 extract_tool_types,
71 )
72 from vllm.entrypoints.serve.engine.protocol import ErrorResponse
73 from vllm.entrypoints.serve.utils.api_utils import get_max_tokens
74 from vllm.entrypoints.serve.utils.request_logger import RequestLogger
75 from vllm.entrypoints.serve.utils.tool_calls_utils import (
76 maybe_filter_parallel_tool_calls,
77 )
78 from vllm.exceptions import GenerationError, VLLMValidationError
79 from vllm.inputs import EngineInput
80 from vllm.logger import init_logger
81 from vllm.logprobs import Logprob as SampleLogprob
82 from vllm.logprobs import SampleLogprobs
83 from vllm.lora.request import LoRARequest
84 from vllm.outputs import CompletionOutput
85 from vllm.parser import Parser, ParserManager
86 from vllm.renderers import TokenizeParams
87 from vllm.renderers.online_renderer import (
88 OnlineRenderer,
89 ResponsesPreviousMessages,
90 ResponsesRenderResult,
91 )
92 from vllm.sampling_params import SamplingParams, StructuredOutputsParams
93 from vllm.tokenizers import TokenizerLike
94 from vllm.utils import random_uuid
95 from vllm.utils.collection_utils import as_list
96
97 logger = init_logger(__name__)
98
99
100 class OpenAIServingResponses(GenerateBaseServing):
101 def __init__(
102 self,
103 engine_client: EngineClient,
104 models: OpenAIServingModels,
105 online_renderer: OnlineRenderer,
106 *,
107 request_logger: RequestLogger | None,
108 chat_template: str | None,
109 chat_template_content_format: ChatTemplateContentFormatOption,
110 return_tokens_as_token_ids: bool = False,
111 reasoning_parser: str = "",
112 enable_auto_tools: bool = False,
113 tool_parser: str | None = None,
114 tool_strict_level: str = "auto",
115 tool_server: ToolServer | None = None,
116 enable_prompt_tokens_details: bool = False,
117 enable_force_include_usage: bool = False,
118 enable_per_request_metrics: bool = False,
119 enable_log_outputs: bool = False,
120 default_chat_template_kwargs: dict[str, Any] | None = None,
121 ) -> None:
122 super().__init__(
123 engine_client=engine_client,
124 models=models,
125 request_logger=request_logger,
126 return_tokens_as_token_ids=return_tokens_as_token_ids,
127 )
128
129 self.online_renderer = online_renderer
130 self.chat_template = chat_template
131 self.chat_template_content_format: Final = chat_template_content_format
132 self.chat_template_kwargs = default_chat_template_kwargs or {}
133 self.enable_log_outputs = enable_log_outputs
134
135 # Set up the unified parser - either a unified parser or fall back to
136 # separate parsers accessed through the parser interface
137 self.parser = ParserManager.get_parser(
138 tool_parser_name=tool_parser,
139 reasoning_parser_name=reasoning_parser,
140 enable_auto_tools=enable_auto_tools,
141 tool_strict_level=tool_strict_level,
142 model_name=self.model_config.model,
143 is_harmony=self.model_config.hf_config.model_type == "gpt_oss",
144 tokenizer=self.renderer.tokenizer,
145 )
146 self.enable_prompt_tokens_details = enable_prompt_tokens_details
147 self.enable_force_include_usage = enable_force_include_usage
148 self.enable_per_request_metrics = enable_per_request_metrics
149
150 self.default_sampling_params = self.model_config.get_diff_sampling_param()
151 mc = self.model_config
152 self.override_max_tokens = (
153 self.default_sampling_params.get("max_tokens")
154 if mc.generation_config not in ("auto", "vllm")
155 else getattr(mc, "override_generation_config", {}).get("max_new_tokens")
156 )
157
158 # If False (default), the "store" option is (silently) ignored and the
159 # response is not stored. If True, the response is stored in memory.
160 # NOTE(woosuk): This may not be intuitive for users, as the default
161 # behavior in OpenAI's Responses API is to store the response, but
162 # vLLM's default behavior is not.
163 self.enable_store = envs.VLLM_ENABLE_RESPONSES_API_STORE
164 if self.enable_store:
165 logger.warning_once(
166 "`VLLM_ENABLE_RESPONSES_API_STORE` is enabled. This may "
167 "cause a memory leak since we never remove responses from "
168 "the store."
169 )
170
171 self.use_harmony = self.model_config.hf_config.model_type == "gpt_oss"
172 if self.use_harmony:
173 logger.warning(
174 "For gpt-oss, we ignore --enable-auto-tool-choice "
175 "and always enable tool use."
176 )
177 self.enable_auto_tools = enable_auto_tools
178 # HACK(woosuk): This is a hack. We should use a better store.
179 # FIXME: If enable_store=True, this may cause a memory leak since we
180 # never remove responses from the store.
181 self.response_store: dict[str, ResponsesResponse] = {}
182 self.response_store_lock = asyncio.Lock()
183
184 # HACK(woosuk): This is a hack. We should use a better store.
185 # FIXME: If enable_store=True, this may cause a memory leak since we
186 # never remove messages from the store.
187 self.msg_store: dict[str, ResponsesPreviousMessages] = {}
188
189 # HACK(wuhang): This is a hack. We should use a better store.
190 # FIXME: If enable_store=True, this may cause a memory leak since we
191 # never remove events from the store.
192 self.event_store: dict[
193 str, tuple[deque[StreamingResponsesResponse], asyncio.Event]
194 ] = {}
195
196 self.background_tasks: dict[str, asyncio.Task] = {}
197
198 self.tool_server = tool_server
199
200 def _effective_chat_template_kwargs(
201 self, request: ResponsesRequest
202 ) -> dict[str, Any]:
203 return (
204 request.build_chat_params(
205 self.chat_template,
206 self.chat_template_content_format,
207 )
208 .with_defaults(self.chat_template_kwargs)
209 .chat_template_kwargs
210 )
211
212 def _make_response_parser(
213 self,
214 request: ResponsesRequest,
215 tokenizer: TokenizerLike,
216 chat_template_kwargs: dict[str, Any],
217 ) -> Parser | None:
218 if self.parser is None:
219 return None
220 return self.parser(
221 tokenizer,
222 request.tools,
223 chat_template_kwargs=chat_template_kwargs,
224 model_config=self.model_config,
225 )
226
227 def _validate_generator_input(
228 self,
229 engine_input: EngineInput,
230 ) -> ErrorResponse | None:
231 """Add validations to the input to the generator here."""
232 prompt_len = self._extract_prompt_len(engine_input)
233 max_model_len = self.model_config.max_model_len
234
235 if prompt_len >= max_model_len:
236 error_message = (
237 f"The engine prompt length {prompt_len} "
238 f"exceeds the max_model_len {max_model_len}. "
239 "Please reduce prompt."
240 )
241 return self.create_error_response(
242 err_type="invalid_request_error",
243 message=error_message,
244 status_code=HTTPStatus.BAD_REQUEST,
245 param="input",
246 )
247
248 return None
249
250 def _validate_create_responses_input(
251 self, request: ResponsesRequest
252 ) -> ErrorResponse | None:
253 if self.use_harmony and request.is_include_output_logprobs():
254 return self.create_error_response(
255 err_type="invalid_request_error",
256 message="logprobs are not supported with gpt-oss models",
257 status_code=HTTPStatus.BAD_REQUEST,
258 param="logprobs",
259 )
260 if request.store and not self.enable_store and request.background:
261 return self.create_error_response(
262 err_type="invalid_request_error",
263 message=(
264 "This vLLM engine does not support `store=True` and "
265 "therefore does not support the background mode. To "
266 "enable these features, set the environment variable "
267 "`VLLM_ENABLE_RESPONSES_API_STORE=1` when launching "
268 "the vLLM server."
269 ),
270 status_code=HTTPStatus.BAD_REQUEST,
271 param="background",
272 )
273 if request.previous_input_messages and request.previous_response_id:
274 return self.create_error_response(
275 err_type="invalid_request_error",
276 message="Only one of `previous_input_messages` and "
277 "`previous_response_id` can be set.",
278 status_code=HTTPStatus.BAD_REQUEST,
279 param="previous_response_id",
280 )
281 return None
282
283 async def _render_resolved_response_inputs(
284 self,
285 request: ResponsesRequest,
286 prev_response: ResponsesResponse | None,
287 *,
288 skip_mm_cache: bool = False,
289 ) -> ResponsesRenderResult | ErrorResponse:
290 previous_messages: ResponsesPreviousMessages | None = None
291 previous_response_outputs: list[ResponseOutputItem] | None = None
292 if prev_response is not None:
293 stored_messages = (
294 self.msg_store[prev_response.id]
295 if self.use_harmony
296 else self.msg_store.get(prev_response.id)
297 )
298 if stored_messages is not None:
299 previous_messages = stored_messages.copy()
300 previous_response_outputs = list(prev_response.output)
301
302 return await self.online_renderer.render_responses(
303 request,
304 previous_messages=previous_messages,
305 previous_response_outputs=previous_response_outputs,
306 tool_server=self.tool_server,
307 skip_mm_cache=skip_mm_cache,
308 )
309
310 async def create_responses(
311 self,
312 request: ResponsesRequest,
313 raw_request: Request | None = None,
314 ) -> (
315 AsyncGenerator[StreamingResponsesResponse, None]
316 | ResponsesResponse
317 | ErrorResponse
318 ):
319 return await self._with_kv_transfer_rejection_cleanup(
320 self._create_responses(request, raw_request), request, raw_request
321 )
322
323 async def _create_responses(
324 self, request: ResponsesRequest, raw_request: Request | None = None
325 ) -> (
326 AsyncGenerator[StreamingResponsesResponse, None]
327 | ResponsesResponse
328 | ErrorResponse
329 ):
330 error_check_ret = await self._check_model(request)
331 if error_check_ret is not None:
332 logger.error("Error with model %s", error_check_ret)
333 return error_check_ret
334 maybe_validation_error = self._validate_create_responses_input(request)
335 if maybe_validation_error is not None:
336 return maybe_validation_error
337
338 maybe_template_error = self.online_renderer.validate_chat_template(
339 request_chat_template=None,
340 chat_template_kwargs=request.chat_template_kwargs,
341 trust_request_chat_template=(
342 self.online_renderer.trust_request_chat_template
343 ),
344 )
345 if maybe_template_error is not None:
346 return maybe_template_error
347
348 self._preflight()
349
350 if request.store and not self.enable_store:
351 # Disable the store option.
352 # NOTE(woosuk): Although returning an error is possible, we opted
353 # to implicitly disable store and process the request anyway, as
354 # we assume most users do not intend to actually store the response
355 # (i.e., their request's `store=True` just because it's the default
356 # value).
357 request.store = False
358
359 # Handle the previous response ID.
360 prev_response_id = request.previous_response_id
361 if prev_response_id is not None:
362 async with self.response_store_lock:
363 prev_response = self.response_store.get(prev_response_id)
364 if prev_response is None:
365 return self._make_not_found_error(prev_response_id)
366 else:
367 prev_response = None
368
369 lora_request = self._maybe_get_adapters(request)
370 model_name = self.models.model_name(lora_request)
371
372 render_result = await self._render_resolved_response_inputs(
373 request,
374 prev_response,
375 )
376 if isinstance(render_result, ErrorResponse):
377 return render_result
378 messages = render_result.messages
379 engine_inputs = [render_result.engine_input]
380
381 request_metadata = RequestResponseMetadata(request_id=request.request_id)
382 if raw_request:
383 raw_request.state.request_metadata = request_metadata
384
385 # Schedule the request and get the result generator.
386 max_model_len = self.model_config.max_model_len
387 generators: list[AsyncGenerator[ConversationContext, None]] = []
388
389 # Only include builtin tools that the request actually asked for.
390 # Without this filter, tools registered on the server (e.g. via
391 # --tool-server demo) would be available for execution even when
392 # the request didn't enable them.
393 requested_tool_types = extract_tool_types(request.tools)
394 builtin_tool_list: list[str] = []
395 if self.tool_server is not None:
396 if (
397 self.tool_server.has_tool("browser")
398 and "web_search_preview" in requested_tool_types
399 ):
400 builtin_tool_list.append("browser")
401 if (
402 self.tool_server.has_tool("python")
403 and "code_interpreter" in requested_tool_types
404 ):
405 builtin_tool_list.append("python")
406 if (
407 self.tool_server.has_tool("container")
408 and "container" in requested_tool_types
409 ):
410 builtin_tool_list.append("container")
411
412 if self.tool_server is not None:
413 available_tools = builtin_tool_list
414 else:
415 assert len(builtin_tool_list) == 0
416 available_tools = []
417 tokenizer = self.renderer.get_tokenizer()
418
419 for engine_input in engine_inputs:
420 maybe_error = self._validate_generator_input(engine_input)
421 if maybe_error is not None:
422 return maybe_error
423
424 default_max_tokens = get_max_tokens(
425 max_model_len,
426 request.max_output_tokens,
427 self._extract_prompt_len(engine_input),
428 self.default_sampling_params,
429 self.override_max_tokens,
430 truncate_prompt_tokens=(
431 -1 if request.truncation != "disabled" else None
432 ),
433 )
434
435 sampling_params = request.to_sampling_params(
436 default_max_tokens, self.default_sampling_params
437 )
438
439 trace_headers = (
440 None
441 if raw_request is None
442 else await self._get_trace_headers(raw_request.headers)
443 )
444 session_id = self._get_session_id(request, raw_request)
445
446 chat_template_kwargs = self._effective_chat_template_kwargs(request)
447 response_parser = self._make_response_parser(
448 request, tokenizer, chat_template_kwargs
449 )
450 if response_parser is not None:
451 prompt_token_ids = self._extract_prompt_components(
452 engine_input
453 ).token_ids
454 if prompt_token_ids is not None:
455 response_parser.set_prompt_token_ids(prompt_token_ids)
456
457 context: ConversationContext
458 function_tool_names = extract_function_tool_names(request.tools)
459 if self.use_harmony:
460 context = HarmonyContext(
461 messages,
462 available_tools,
463 function_tool_names,
464 response_parser=response_parser,
465 request=request,
466 )
467 else:
468 if envs.VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT:
469 # This is a feature in development for parsing
470 # tokens during generation instead of at the end
471 context = ParsableContext(
472 response_messages=messages,
473 tokenizer=tokenizer,
474 parser_cls=self.parser,
475 request=request,
476 response_parser=response_parser,
477 available_tools=available_tools,
478 chat_template=self.chat_template,
479 chat_template_content_format=self.chat_template_content_format,
480 enable_auto_tools=self.enable_auto_tools,
481 )
482 else:
483 context = SimpleContext(
484 response_parser=response_parser,
485 )
486
487 reasoning_parser_kwargs = None
488 if (
489 context.response_parser is not None
490 and context.response_parser.reasoning_parser is not None
491 ):
492 reasoning_parser_kwargs = {
493 "chat_template_kwargs": chat_template_kwargs,
494 }
495 if (
496 isinstance(
497 struct_out := sampling_params.structured_outputs,
498 StructuredOutputsParams,
499 )
500 and struct_out.all_non_structural_tag_constraints_none()
501 ):
502 sampling_params.structured_outputs = replace(
503 struct_out,
504 structural_tag=(
505 context.response_parser.reasoning_parser.prepare_structured_tag(
506 struct_out.structural_tag, self.tool_server
507 )
508 ),
509 )
510 generator = self._generate_with_builtin_tools(
511 request_id=request.request_id,
512 engine_input=engine_input,
513 sampling_params=sampling_params,
514 context=context,
515 tok_params=(
516 request.build_tok_params(self.model_config)
517 if isinstance(context, HarmonyContext)
518 else None
519 ),
520 lora_request=lora_request,
521 priority=self._get_priority(request, raw_request),
522 trace_headers=trace_headers,
523 session_id=session_id,
524 reasoning_parser_kwargs=reasoning_parser_kwargs,
525 )
526 generators.append(generator)
527
528 assert len(generators) == 1
529 (result_generator,) = generators
530
531 # Store the input messages.
532 if request.store:
533 self.msg_store[request.request_id] = messages
534
535 if request.background:
536 created_time = int(time.time())
537 response = ResponsesResponse.from_request(
538 request,
539 sampling_params,
540 model_name=model_name,
541 created_time=created_time,
542 output=[],
543 status="queued",
544 usage=None,
545 )
546 async with self.response_store_lock:
547 self.response_store[response.id] = response
548
549 # Run the request in the background.
550 if request.stream:
551 task = asyncio.create_task(
552 self._run_background_request_stream(
553 request,
554 sampling_params,
555 result_generator,
556 context,
557 model_name,
558 tokenizer,
559 request_metadata,
560 created_time,
561 ),
562 name=f"create_{request.request_id}",
563 )
564 else:
565 task = asyncio.create_task(
566 self._run_background_request(
567 request,
568 sampling_params,
569 result_generator,
570 context,
571 model_name,
572 tokenizer,
573 request_metadata,
574 created_time,
575 ),
576 name=f"create_{response.id}",
577 )
578
579 # For cleanup.
580 response_id = response.id
581 self.background_tasks[response_id] = task
582 task.add_done_callback(
583 lambda _: self.background_tasks.pop(response_id, None)
584 )
585
586 if request.stream:
587 return self.responses_background_stream_generator(request.request_id)
588 return response
589
590 if request.stream:
591 return self.responses_stream_generator(
592 request,
593 sampling_params,
594 result_generator,
595 context,
596 model_name,
597 tokenizer,
598 request_metadata,
599 )
600
601 return await self.responses_full_generator(
602 request,
603 sampling_params,
604 result_generator,
605 context,
606 model_name,
607 tokenizer,
608 request_metadata,
609 )
610
611 async def _render_next_turn(
612 self,
613 request: ResponsesRequest,
614 messages: list[ResponseInputOutputItem],
615 ) -> list[EngineInput]:
616 render_request = request.model_copy(
617 update={
618 "input": list(messages),
619 "instructions": None,
620 "previous_input_messages": None,
621 "previous_response_id": None,
622 }
623 )
624 render_result = await self.online_renderer.render_responses(
625 render_request,
626 previous_messages=None,
627 previous_response_outputs=None,
628 tool_server=self.tool_server,
629 )
630 if isinstance(render_result, ErrorResponse):
631 raise VLLMValidationError(render_result.error.message)
632 return [render_result.engine_input]
633
634 async def _generate_with_builtin_tools(
635 self,
636 request_id: str,
637 engine_input: EngineInput,
638 sampling_params: SamplingParams,
639 context: ConversationContext,
640 tok_params: TokenizeParams | None = None,
641 lora_request: LoRARequest | None = None,
642 priority: int = 0,
643 trace_headers: Mapping[str, str] | None = None,
644 session_id: str | None = None,
645 reasoning_parser_kwargs: dict[str, Any] | None = None,
646 ):
647 max_model_len = self.model_config.max_model_len
648 cache_salt = cast(str | None, engine_input.get("cache_salt"))
649
650 orig_priority = priority
651 sub_request = 0
652 while True:
653 # Ensure that each sub-request has a unique request id.
654 sub_request_id = f"{request_id}_{sub_request}"
655
656 self._log_inputs(
657 sub_request_id,
658 engine_input,
659 params=sampling_params,
660 lora_request=lora_request,
661 )
662
663 generator = self.engine_client.generate(
664 engine_input,
665 sampling_params,
666 sub_request_id,
667 lora_request=lora_request,
668 trace_headers=trace_headers,
669 priority=priority,
670 session_id=session_id,
671 reasoning_parser_kwargs=reasoning_parser_kwargs,
672 )
673
674 async for res in generator:
675 context.request_metrics = res.metrics
676 context.append_output(res)
677 # NOTE(woosuk): The stop condition is handled by the engine.
678 yield context
679
680 if not context.need_builtin_tool_call():
681 # The model did not ask for a tool call, so we're done.
682 break
683
684 # Call the tool and update the context with the result.
685 context.request_metrics_cover_all_generation_turns = False
686 tool_output = await context.call_tool()
687 context.append_tool_output(tool_output)
688
689 # TODO: uncomment this and enable tool output streaming
690 # yield context
691
692 # Create inputs for the next turn.
693 # Render the next prompt token ids and update sampling_params.
694 if isinstance(context, HarmonyContext):
695 engine_input = self.online_renderer.render_responses_harmony_messages(
696 context.messages,
697 cache_salt=cache_salt,
698 tok_params=tok_params,
699 )
700
701 sampling_params.max_tokens = get_max_tokens(
702 max_model_len,
703 context.request.max_output_tokens if context.request else None,
704 self._extract_prompt_len(engine_input),
705 self.default_sampling_params,
706 self.override_max_tokens,
707 )
708 elif isinstance(context, ParsableContext):
709 (engine_input,) = await self._render_next_turn(
710 context.request,
711 context.response_messages,
712 )
713 if context.response_parser is not None:
714 prompt_token_ids = self._extract_prompt_components(
715 engine_input
716 ).token_ids
717 if prompt_token_ids is not None:
718 context.response_parser.set_prompt_token_ids(prompt_token_ids)
719
720 sampling_params.max_tokens = get_max_tokens(
721 max_model_len,
722 context.request.max_output_tokens,
723 self._extract_prompt_len(engine_input),
724 self.default_sampling_params, # type: ignore
725 self.override_max_tokens, # type: ignore
726 truncate_prompt_tokens=(
727 -1 if context.request.truncation != "disabled" else None
728 ),
729 )
730
731 # OPTIMIZATION
732 priority = orig_priority - 1
733 sub_request += 1
734
735 async def _initialize_tool_sessions(
736 self,
737 request: ResponsesRequest,
738 context: ConversationContext,
739 exit_stack: AsyncExitStack,
740 ):
741 # we should only initialize the tool session if the request needs tools
742 if len(request.tools) == 0:
743 return
744 mcp_tools = {
745 tool.server_label: tool for tool in request.tools if tool.type == "mcp"
746 }
747 await context.init_tool_sessions(
748 self.tool_server, exit_stack, request.request_id, mcp_tools
749 )
750
751 async def responses_full_generator(
752 self,
753 request: ResponsesRequest,
754 sampling_params: SamplingParams,
755 result_generator: AsyncIterator[ConversationContext],
756 context: ConversationContext,
757 model_name: str,
758 tokenizer: TokenizerLike,
759 request_metadata: RequestResponseMetadata,
760 created_time: int | None = None,
761 ) -> ErrorResponse | ResponsesResponse:
762 if created_time is None:
763 created_time = int(time.time())
764
765 async with AsyncExitStack() as exit_stack:
766 try:
767 await self._initialize_tool_sessions(request, context, exit_stack)
768 async for _ in result_generator:
769 pass
770 except asyncio.CancelledError:
771 return self.create_error_response("Client disconnected")
772
773 # NOTE: Implementation of status is still WIP, but for now
774 # we guarantee that if the status is not "completed", it is accurate.
775 # "completed" is implemented as the "catch-all" for now.
776 status: ResponseStatus = "completed"
777
778 input_messages: ResponseInputOutputMessage | None = None
779 output_messages: ResponseInputOutputMessage | None = None
780 if self.use_harmony:
781 assert isinstance(context, HarmonyContext)
782 output = []
783 harmony_msgs = context.messages[context.num_init_messages :]
784 if harmony_msgs:
785 fn_names = context.function_tool_names
786 for msg in harmony_msgs[:-1]:
787 output.extend(harmony_to_response_output(msg, fn_names))
788 output.extend(
789 harmony_to_response_output(
790 harmony_msgs[-1],
791 fn_names,
792 incomplete=context.last_append_flush_status,
793 )
794 )
795
796 if request.enable_response_messages:
797 input_messages = context.messages[: context.num_init_messages]
798 output_messages = context.messages[context.num_init_messages :]
799 num_tool_output_tokens = context.num_tool_output_tokens
800 if len(output) > 0:
801 if context.finish_reason == "length":
802 status = "incomplete"
803 elif context.finish_reason == "abort":
804 status = "cancelled"
805 else:
806 self._raise_if_error(context.finish_reason, request.request_id)
807 else:
808 status = "incomplete"
809 elif isinstance(context, ParsableContext):
810 output = context.make_response_output_items()
811
812 if request.enable_response_messages:
813 input_messages = context.input_messages
814 output_messages = context.output_messages
815
816 # TODO: Calculate usage.
817 # assert final_res.prompt_token_ids is not None
818 num_tool_output_tokens = 0
819
820 # Check finish reason from the parser
821 if context.finish_reason == "length":
822 status = "incomplete"
823 else:
824 assert isinstance(context, SimpleContext)
825 # Use final_output which has accumulated text/token_ids/logprobs
826 final_res = context.final_output
827 assert final_res is not None
828 assert len(final_res.outputs) == 1
829 final_output = final_res.outputs[0]
830
831 # finish_reason='error' indicates retryable internal error
832 self._raise_if_error(final_output.finish_reason, request.request_id)
833
834 # Check if generation was stopped due to max_tokens
835 if final_output.finish_reason == "length":
836 status = "incomplete"
837
838 streamed_items = context.streamed_output_items
839 self._log_final_output(
840 request, final_output, is_streaming=streamed_items is not None
841 )
842 if streamed_items is not None:
843 output = streamed_items
844 else:
845 output = self._make_response_output_items(
846 request,
847 final_output,
848 tokenizer,
849 parser=context.response_parser,
850 )
851
852 if request.enable_response_messages:
853 input_messages = context.input_messages
854 output_messages = context.output_messages
855
856 # Calculate usage.
857 assert final_res.prompt_token_ids is not None
858 num_tool_output_tokens = 0
859
860 assert isinstance(context, (SimpleContext, HarmonyContext, ParsableContext))
861 num_prompt_tokens = context.num_prompt_tokens
862 num_generated_tokens = context.num_output_tokens
863 num_cached_tokens = context.num_cached_tokens
864 num_reasoning_tokens = context.num_reasoning_tokens
865 # HarmonyContext and ParsableContext count reasoning tokens as each
866 # round is appended. SimpleContext is single-round but accumulates
867 # streaming deltas, so count its full output once here.
868 if isinstance(context, SimpleContext) and context.response_parser is not None:
869 num_reasoning_tokens = context.response_parser.count_reasoning_tokens(
870 context._accumulated_token_ids
871 )
872
873 usage = ResponseUsage(
874 input_tokens=num_prompt_tokens,
875 output_tokens=num_generated_tokens,
876 total_tokens=num_prompt_tokens + num_generated_tokens,
877 input_tokens_details=InputTokensDetails(
878 cache_write_tokens=getattr(context, "num_cache_creation_tokens", 0),
879 cached_tokens=num_cached_tokens,
880 input_tokens_per_turn=[
881 turn.input_tokens for turn in context.all_turn_metrics
882 ],
883 cached_tokens_per_turn=[
884 turn.cached_input_tokens for turn in context.all_turn_metrics
885 ],
886 ),
887 output_tokens_details=OutputTokensDetails(
888 reasoning_tokens=num_reasoning_tokens,
889 tool_output_tokens=num_tool_output_tokens,
890 output_tokens_per_turn=[
891 turn.output_tokens for turn in context.all_turn_metrics
892 ],
893 tool_output_tokens_per_turn=[
894 turn.tool_output_tokens for turn in context.all_turn_metrics
895 ],
896 ),
897 )
898 per_request_metrics = None
899 if (
900 self.enable_per_request_metrics
901 and context.request_metrics_cover_all_generation_turns
902 ):
903 per_request_metrics = build_per_request_timing_metrics(
904 context.request_metrics, num_generated_tokens
905 )
906 response = ResponsesResponse.from_request(
907 request,
908 sampling_params,
909 input_messages=input_messages,
910 output_messages=output_messages,
911 model_name=model_name,
912 created_time=created_time,
913 output=output,
914 status=status,
915 usage=usage,
916 metrics=per_request_metrics,
917 kv_transfer_params=context.kv_transfer_params,
918 ec_transfer_params=context.ec_transfer_params,
919 )
920
921 if request.store:
922 async with self.response_store_lock:
923 stored_response = self.response_store.get(response.id)
924 # If the response is already cancelled, don't update it.
925 if stored_response is None or stored_response.status != "cancelled":
926 self.response_store[response.id] = response
927 return response
928
929 def _topk_logprobs(
930 self,
931 logprobs: dict[int, SampleLogprob],
932 top_logprobs: int,
933 tokenizer: TokenizerLike,
934 ) -> list[LogprobTopLogprob]:
935 """Returns the top-k logprobs from the logprobs dictionary."""
936 out = []
937 for i, (token_id, _logprob) in enumerate(logprobs.items()):
938 if i >= top_logprobs:
939 break
940 text = self._get_decoded_token(
941 logprob=_logprob,
942 token_id=token_id,
943 tokenizer=tokenizer,
944 return_as_token_id=self.return_tokens_as_token_ids,
945 )
946 out.append(
947 LogprobTopLogprob(
948 token=text,
949 logprob=max(_logprob.logprob, -9999.0),
950 bytes=list(text.encode("utf-8", errors="replace")),
951 )
952 )
953 return out
954
955 def _create_response_logprobs(
956 self,
957 token_ids: Sequence[int],
958 logprobs: SampleLogprobs | None,
959 tokenizer: TokenizerLike,
960 top_logprobs: int | None = None,
961 ) -> list[Logprob]:
962 assert logprobs is not None, "logprobs must be provided"
963 assert len(token_ids) == len(logprobs), (
964 "token_ids and logprobs.token_ids must have the same length"
965 )
966 out = []
967 for i, token_id in enumerate(token_ids):
968 logprob = logprobs[i]
969 token_logprob = logprob[token_id]
970 text = self._get_decoded_token(
971 logprob=token_logprob,
972 token_id=token_id,
973 tokenizer=tokenizer,
974 return_as_token_id=self.return_tokens_as_token_ids,
975 )
976 out.append(
977 Logprob(
978 token=text,
979 logprob=max(token_logprob.logprob, -9999.0),
980 bytes=list(text.encode("utf-8", errors="replace")),
981 top_logprobs=(
982 self._topk_logprobs(
983 logprob, top_logprobs=top_logprobs, tokenizer=tokenizer
984 )
985 if top_logprobs
986 else []
987 ),
988 )
989 )
990 return out
991
992 def _log_final_output(
993 self,
994 request: ResponsesRequest,
995 final_output: CompletionOutput,
996 is_streaming: bool,
997 ) -> None:
998 if self.enable_log_outputs and self.request_logger:
999 self.request_logger.log_outputs(
1000 request_id=request.request_id,
1001 outputs=final_output.text,
1002 output_token_ids=final_output.token_ids,
1003 finish_reason=final_output.finish_reason,
1004 is_streaming=is_streaming,
1005 delta=False,
1006 )
1007
1008 def _make_response_output_items(
1009 self,
1010 request: ResponsesRequest,
1011 final_output: CompletionOutput,
1012 tokenizer: TokenizerLike,
1013 parser: Parser | None = None,
1014 ) -> list[ResponseOutputItem]:
1015 # Compute logprobs if requested
1016 logprobs = None
1017 if request.is_include_output_logprobs() and final_output.logprobs:
1018 logprobs = self._create_response_logprobs(
1019 token_ids=final_output.token_ids,
1020 logprobs=final_output.logprobs,
1021 tokenizer=tokenizer,
1022 top_logprobs=request.top_logprobs,
1023 )
1024
1025 # Use parser to extract reasoning, content, and tool calls
1026 if parser:
1027 reasoning, content, tool_calls = parser.parse(
1028 final_output.text,
1029 request,
1030 enable_auto_tools=self.enable_auto_tools,
1031 model_output_token_ids=final_output.token_ids,
1032 )
1033 if not request.include_reasoning:
1034 reasoning = None
1035 logprobs = None
1036 return build_response_output_items(
1037 reasoning=reasoning,
1038 content=content,
1039 tool_calls=maybe_filter_parallel_tool_calls(tool_calls or [], request),
1040 logprobs=logprobs,
1041 tools=request.tools,
1042 )
1043
1044 # Fallback when no parser is configured
1045 return [
1046 ResponseOutputMessage(
1047 id=f"msg_{random_uuid()}",
1048 content=[
1049 ResponseOutputText(
1050 text=final_output.text,
1051 annotations=[],
1052 type="output_text",
1053 logprobs=logprobs,
1054 )
1055 ]
1056 if final_output.text
1057 else [],
1058 role="assistant",
1059 status="completed",
1060 type="message",
1061 )
1062 ]
1063
1064 async def _run_background_request_stream(
1065 self,
1066 request: ResponsesRequest,
1067 *args,
1068 **kwargs,
1069 ):
1070 event_deque: deque[StreamingResponsesResponse] = deque()
1071 new_event_signal = asyncio.Event()
1072 self.event_store[request.request_id] = (event_deque, new_event_signal)
1073 generator = self.responses_stream_generator(request, *args, **kwargs)
1074 try:
1075 async for event in generator:
1076 event_deque.append(event)
1077 new_event_signal.set() # Signal new event available
1078 finally:
1079 new_event_signal.set()
1080
1081 async def _run_background_request(
1082 self,
1083 request: ResponsesRequest,
1084 *args,
1085 **kwargs,
1086 ):
1087 response = await self.responses_full_generator(request, *args, **kwargs)
1088
1089 if isinstance(response, ErrorResponse):
1090 # If the request has failed, update the status to "failed".
1091 response_id = request.request_id
1092 async with self.response_store_lock:
1093 stored_response = self.response_store.get(response_id)
1094 assert stored_response is not None
1095 if stored_response.status not in ("completed", "cancelled"):
1096 stored_response.status = "failed"
1097
1098 async def responses_background_stream_generator(
1099 self,
1100 response_id: str,
1101 starting_after: int | None = None,
1102 ) -> AsyncGenerator[StreamingResponsesResponse, None]:
1103 if response_id not in self.event_store:
1104 raise VLLMValidationError(
1105 f"Unknown response_id: {response_id}",
1106 parameter="response_id",
1107 value=response_id,
1108 )
1109
1110 event_deque, new_event_signal = self.event_store[response_id]
1111 start_index = 0 if starting_after is None else starting_after + 1
1112 current_index = start_index
1113
1114 while True:
1115 new_event_signal.clear()
1116
1117 # Yield existing events from start_index
1118 while current_index < len(event_deque):
1119 event = event_deque[current_index]
1120 yield event
1121 if getattr(event, "type", "unknown") == "response.completed":
1122 return
1123 current_index += 1
1124
1125 await new_event_signal.wait()
1126
1127 async def retrieve_responses(
1128 self,
1129 response_id: str,
1130 starting_after: int | None,
1131 stream: bool | None,
1132 ) -> (
1133 ErrorResponse
1134 | ResponsesResponse
1135 | AsyncGenerator[StreamingResponsesResponse, None]
1136 ):
1137 async with self.response_store_lock:
1138 response = self.response_store.get(response_id)
1139
1140 if response is None:
1141 return self._make_not_found_error(response_id)
1142
1143 if stream:
1144 return self.responses_background_stream_generator(
1145 response_id,
1146 starting_after,
1147 )
1148 return response
1149
1150 async def cancel_responses(
1151 self,
1152 response_id: str,
1153 ) -> ErrorResponse | ResponsesResponse:
1154 async with self.response_store_lock:
1155 response = self.response_store.get(response_id)
1156 if response is None:
1157 return self._make_not_found_error(response_id)
1158
1159 prev_status = response.status
1160 if prev_status not in ("queued", "in_progress"):
1161 return self.create_error_response(
1162 err_type="invalid_request_error",
1163 message="Cannot cancel a synchronous response.",
1164 param="response_id",
1165 )
1166
1167 # Update the status to "cancelled".
1168 response.status = "cancelled"
1169
1170 # Abort the request.
1171 if task := self.background_tasks.get(response_id):
1172 task.cancel()
1173 try:
1174 await task
1175 except asyncio.CancelledError:
1176 logger.exception("Background task for %s was cancelled", response_id)
1177 return response
1178
1179 def _make_not_found_error(self, response_id: str) -> ErrorResponse:
1180 return self.create_error_response(
1181 err_type="invalid_request_error",
1182 message=f"Response with id '{response_id}' not found.",
1183 status_code=HTTPStatus.NOT_FOUND,
1184 param="response_id",
1185 )
1186
1187 async def _process_simple_streaming_events(
1188 self,
1189 request: ResponsesRequest,
1190 sampling_params: SamplingParams,
1191 result_generator: AsyncIterator[ConversationContext | None],
1192 context: ConversationContext,
1193 model_name: str,
1194 tokenizer: TokenizerLike,
1195 request_metadata: RequestResponseMetadata,
1196 created_time: int,
1197 _increment_sequence_number_and_return: Callable[
1198 [StreamingResponsesResponse], StreamingResponsesResponse
1199 ],
1200 ) -> AsyncGenerator[StreamingResponsesResponse, None]:
1201 processor = SimpleStreamingEventProcessor(tools=request.tools)
1202
1203 hide_stream_metadata = (
1204 not request.include_reasoning and context.response_parser is not None
1205 )
1206
1207 def _get_logprobs(output: CompletionOutput) -> list[Logprob]:
1208 if not request.is_include_output_logprobs():
1209 return []
1210 if hide_stream_metadata:
1211 return []
1212 return self._create_response_logprobs(
1213 token_ids=output.token_ids,
1214 logprobs=output.logprobs,
1215 tokenizer=tokenizer,
1216 top_logprobs=request.top_logprobs,
1217 )
1218
1219 async for ctx in result_generator:
1220 assert isinstance(ctx, SimpleContext)
1221 if ctx.last_output is None or not ctx.last_output.outputs:
1222 continue
1223
1224 output = ctx.last_output.outputs[0]
1225 self._raise_if_error(output.finish_reason, request.request_id)
1226 delta_text = output.text
1227 delta_token_ids = as_list(output.token_ids)
1228
1229 if ctx.response_parser:
1230 delta_message = ctx.response_parser.parse_delta(
1231 delta_text=delta_text,
1232 delta_token_ids=delta_token_ids,
1233 request=request,
1234 prompt_token_ids=ctx.last_output.prompt_token_ids,
1235 finished=output.finish_reason is not None,
1236 )
1237 else:
1238 delta_message = DeltaMessage(content=output.text)
1239
1240 if not delta_message:
1241 continue
1242 delta_message = maybe_filter_parallel_tool_calls(delta_message, request)
1243
1244 for dm in split_delta(delta_message):
1245 target_state, tool_call = processor.resolve_target_state(dm)
1246 if target_state == _StateType.NONE:
1247 continue
1248
1249 if processor.needs_transition(target_state, tool_call):
1250 for event in processor.close_current():
1251 yield _increment_sequence_number_and_return(event)
1252 for event in processor.open(target_state, tool_call):
1253 yield _increment_sequence_number_and_return(event)
1254
1255 for event in processor.emit_delta(dm, output, _get_logprobs):
1256 yield _increment_sequence_number_and_return(event)
1257
1258 for event in processor.close_current():
1259 yield _increment_sequence_number_and_return(event)
1260
1261 assert isinstance(context, SimpleContext)
1262 context.streamed_output_items = processor.output_items
1263
1264 async def _process_harmony_streaming_events(
1265 self,
1266 request: ResponsesRequest,
1267 sampling_params: SamplingParams,
1268 result_generator: AsyncIterator[ConversationContext | None],
1269 context: ConversationContext,
1270 model_name: str,
1271 tokenizer: TokenizerLike,
1272 request_metadata: RequestResponseMetadata,
1273 created_time: int,
1274 _increment_sequence_number_and_return: Callable[
1275 [StreamingResponsesResponse], StreamingResponsesResponse
1276 ],
1277 ) -> AsyncGenerator[StreamingResponsesResponse, None]:
1278 state = StreamingState()
1279
1280 async for ctx in result_generator:
1281 assert isinstance(ctx, HarmonyContext)
1282
1283 # finish_reason='error' indicates a retryable error
1284 self._raise_if_error(ctx.finish_reason, request.request_id)
1285
1286 for segment in ctx.last_append_segments:
1287 if segment.delta:
1288 for event in emit_content_delta_events(
1289 segment, state, ctx.function_tool_names
1290 ):
1291 yield _increment_sequence_number_and_return(event)
1292
1293 elif completed_message := segment.completed_message:
1294 # TODO: Fix browser emitted as MCP calls
1295 for event in emit_previous_item_done_events(
1296 completed_message, state, ctx.function_tool_names
1297 ):
1298 yield _increment_sequence_number_and_return(event)
1299
1300 for event in emit_tool_action_events(
1301 completed_message, state, self.tool_server
1302 ):
1303 yield _increment_sequence_number_and_return(event)
1304 state.reset_for_new_item()
1305
1306 async def responses_stream_generator(
1307 self,
1308 request: ResponsesRequest,
1309 sampling_params: SamplingParams,
1310 result_generator: AsyncIterator[ConversationContext | None],
1311 context: ConversationContext,
1312 model_name: str,
1313 tokenizer: TokenizerLike,
1314 request_metadata: RequestResponseMetadata,
1315 created_time: int | None = None,
1316 ) -> AsyncGenerator[StreamingResponsesResponse, None]:
1317 # TODO:
1318 # 1. Handle disconnect
1319
1320 created_time = created_time or int(time.time())
1321
1322 sequence_number = 0
1323
1324 def _increment_sequence_number_and_return(
1325 event: StreamingResponsesResponse,
1326 ) -> StreamingResponsesResponse:
1327 nonlocal sequence_number
1328 # Set sequence_number if the event has this attribute
1329 if hasattr(event, "sequence_number"):
1330 event.sequence_number = sequence_number
1331 sequence_number += 1
1332 return event
1333
1334 async with AsyncExitStack() as exit_stack:
1335 if self.use_harmony:
1336 # TODO: in streaming, we noticed this bug:
1337 # https://github.com/vllm-project/vllm/issues/25697
1338 await self._initialize_tool_sessions(request, context, exit_stack)
1339 processor = self._process_harmony_streaming_events
1340 else:
1341 processor = self._process_simple_streaming_events
1342 # TODO Hanchen make sampling params to include the structural tag
1343
1344 initial_response = ResponsesResponse.from_request(
1345 request,
1346 sampling_params,
1347 model_name=model_name,
1348 created_time=created_time,
1349 output=[],
1350 status="in_progress",
1351 usage=None,
1352 ).model_dump(mode="json", by_alias=True)
1353 yield _increment_sequence_number_and_return(
1354 ResponseCreatedEvent(
1355 type="response.created",
1356 sequence_number=-1,
1357 response=initial_response,
1358 )
1359 )
1360 yield _increment_sequence_number_and_return(
1361 ResponseInProgressEvent(
1362 type="response.in_progress",
1363 sequence_number=-1,
1364 response=initial_response,
1365 )
1366 )
1367
1368 try:
1369 async for event_data in processor(
1370 request,
1371 sampling_params,
1372 result_generator,
1373 context,
1374 model_name,
1375 tokenizer,
1376 request_metadata,
1377 created_time,
1378 _increment_sequence_number_and_return,
1379 ):
1380 yield event_data
1381 except GenerationError as e:
1382 error_json = self._convert_generation_error_to_streaming_response(e)
1383 yield _increment_sequence_number_and_return(
1384 TypeAdapter(StreamingResponsesResponse).validate_json(error_json)
1385 )
1386 return
1387
1388 async def empty_async_generator():
1389 # A hack to trick Python to think this is a generator but
1390 # in fact it immediately returns.
1391 if False:
1392 yield
1393
1394 final_response = await self.responses_full_generator(
1395 request,
1396 sampling_params,
1397 empty_async_generator(),
1398 context,
1399 model_name,
1400 tokenizer,
1401 request_metadata,
1402 created_time=created_time,
1403 )
1404 yield _increment_sequence_number_and_return(
1405 ResponseCompletedEvent(
1406 type="response.completed",
1407 sequence_number=-1,
1408 response=final_response,
1409 )
1410 )