1553 lines
59 KiB
Python
1553 lines
59 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
import asyncio
|
|
import time
|
|
from collections import deque
|
|
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping, Sequence
|
|
from contextlib import AsyncExitStack
|
|
from copy import copy
|
|
from http import HTTPStatus
|
|
from typing import Any, Final
|
|
|
|
from fastapi import Request
|
|
from openai.types.responses import (
|
|
ResponseFunctionToolCall,
|
|
ResponseOutputItem,
|
|
ResponseOutputMessage,
|
|
ResponseOutputText,
|
|
ResponseStatus,
|
|
response_text_delta_event,
|
|
)
|
|
from openai.types.responses.response_output_text import Logprob, LogprobTopLogprob
|
|
from openai.types.responses.tool import Mcp, Tool
|
|
from openai_harmony import Message as OpenAIHarmonyMessage
|
|
from pydantic import TypeAdapter
|
|
|
|
from vllm import envs
|
|
from vllm.config.utils import replace
|
|
from vllm.engine.protocol import EngineClient
|
|
from vllm.entrypoints.chat_utils import (
|
|
ChatCompletionMessageParam,
|
|
ChatTemplateContentFormatOption,
|
|
)
|
|
from vllm.entrypoints.generate.base.serving import (
|
|
GenerateBaseServing,
|
|
GenerationError,
|
|
)
|
|
from vllm.entrypoints.mcp.tool_server import ToolServer
|
|
from vllm.entrypoints.openai.engine.protocol import (
|
|
DeltaMessage,
|
|
ErrorResponse,
|
|
RequestResponseMetadata,
|
|
)
|
|
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
|
|
from vllm.entrypoints.openai.parser.harmony_utils import (
|
|
build_harmony_preamble,
|
|
extract_instructions_from_messages,
|
|
get_user_message,
|
|
has_custom_tools,
|
|
render_for_completion,
|
|
)
|
|
from vllm.entrypoints.openai.responses.context import (
|
|
ConversationContext,
|
|
HarmonyContext,
|
|
ParsableContext,
|
|
SimpleContext,
|
|
)
|
|
from vllm.entrypoints.openai.responses.harmony import (
|
|
construct_harmony_previous_input_messages,
|
|
harmony_to_response_output,
|
|
response_input_to_harmony,
|
|
)
|
|
from vllm.entrypoints.openai.responses.protocol import (
|
|
InputTokensDetails,
|
|
OutputTokensDetails,
|
|
ResponseCompletedEvent,
|
|
ResponseCreatedEvent,
|
|
ResponseInProgressEvent,
|
|
ResponseInputOutputItem,
|
|
ResponseInputOutputMessage,
|
|
ResponsesRequest,
|
|
ResponsesResponse,
|
|
ResponseUsage,
|
|
StreamingResponsesResponse,
|
|
)
|
|
from vllm.entrypoints.openai.responses.streaming_events import (
|
|
SimpleStreamingEventProcessor,
|
|
StreamingState,
|
|
_StateType,
|
|
emit_content_delta_events,
|
|
emit_previous_item_done_events,
|
|
emit_tool_action_events,
|
|
split_delta,
|
|
)
|
|
from vllm.entrypoints.openai.responses.utils import (
|
|
build_response_output_items,
|
|
construct_input_messages,
|
|
construct_tool_dicts,
|
|
extract_function_tool_names,
|
|
extract_tool_types,
|
|
)
|
|
from vllm.entrypoints.serve.utils.api_utils import get_max_tokens
|
|
from vllm.entrypoints.serve.utils.request_logger import RequestLogger
|
|
from vllm.exceptions import VLLMValidationError
|
|
from vllm.inputs import EngineInput, tokens_input
|
|
from vllm.logger import init_logger
|
|
from vllm.logprobs import Logprob as SampleLogprob
|
|
from vllm.logprobs import SampleLogprobs
|
|
from vllm.lora.request import LoRARequest
|
|
from vllm.outputs import CompletionOutput
|
|
from vllm.parser import Parser, ParserManager
|
|
from vllm.renderers.online_renderer import OnlineRenderer
|
|
from vllm.sampling_params import SamplingParams, StructuredOutputsParams
|
|
from vllm.tokenizers import TokenizerLike
|
|
from vllm.utils import random_uuid
|
|
from vllm.utils.collection_utils import as_list
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
def _extract_allowed_tools_from_mcp_requests(
|
|
tools: list[Tool],
|
|
) -> dict[str, list[str] | None]:
|
|
"""
|
|
Extract allowed_tools mapping from MCP tool requests.
|
|
|
|
Returns a dictionary mapping server_label to allowed_tools list.
|
|
Handles both list format and McpAllowedToolsMcpToolFilter object format.
|
|
|
|
Special handling:
|
|
- If allowed_tools is None, returns None (allows all tools)
|
|
- If allowed_tools contains "*", returns None (allows all tools)
|
|
- Otherwise, returns the list of specific tool names
|
|
|
|
This function can be reused for both harmony and non-harmony MCP calls.
|
|
"""
|
|
allowed_tools_map: dict[str, list[str] | None] = {}
|
|
for tool in tools:
|
|
if not isinstance(tool, Mcp):
|
|
continue
|
|
|
|
# allowed_tools can be a list or an object with tool_names
|
|
# Extract the actual list of tool names
|
|
allowed_tools_val = None
|
|
if tool.allowed_tools is not None:
|
|
if isinstance(tool.allowed_tools, list):
|
|
allowed_tools_val = tool.allowed_tools
|
|
elif hasattr(tool.allowed_tools, "tool_names"):
|
|
# It's an McpAllowedToolsMcpToolFilter object
|
|
allowed_tools_val = tool.allowed_tools.tool_names
|
|
|
|
# Normalize "*" to None (both mean "allow all tools")
|
|
if allowed_tools_val is not None and "*" in allowed_tools_val:
|
|
allowed_tools_val = None
|
|
|
|
allowed_tools_map[tool.server_label] = allowed_tools_val
|
|
return allowed_tools_map
|
|
|
|
|
|
class OpenAIServingResponses(GenerateBaseServing):
|
|
def __init__(
|
|
self,
|
|
engine_client: EngineClient,
|
|
models: OpenAIServingModels,
|
|
online_renderer: OnlineRenderer,
|
|
*,
|
|
request_logger: RequestLogger | None,
|
|
chat_template: str | None,
|
|
chat_template_content_format: ChatTemplateContentFormatOption,
|
|
return_tokens_as_token_ids: bool = False,
|
|
reasoning_parser: str = "",
|
|
enable_auto_tools: bool = False,
|
|
tool_parser: str | None = None,
|
|
tool_server: ToolServer | None = None,
|
|
enable_prompt_tokens_details: bool = False,
|
|
enable_force_include_usage: bool = False,
|
|
enable_log_outputs: bool = False,
|
|
default_chat_template_kwargs: dict[str, Any] | None = None,
|
|
) -> None:
|
|
super().__init__(
|
|
engine_client=engine_client,
|
|
models=models,
|
|
request_logger=request_logger,
|
|
return_tokens_as_token_ids=return_tokens_as_token_ids,
|
|
)
|
|
|
|
self.online_renderer = online_renderer
|
|
self.chat_template = chat_template
|
|
self.chat_template_content_format: Final = chat_template_content_format
|
|
self.chat_template_kwargs = default_chat_template_kwargs or {}
|
|
self.enable_log_outputs = enable_log_outputs
|
|
|
|
# Set up the unified parser - either a unified parser or fall back to
|
|
# separate parsers accessed through the parser interface
|
|
self.parser = ParserManager.get_parser(
|
|
tool_parser_name=tool_parser,
|
|
reasoning_parser_name=reasoning_parser,
|
|
enable_auto_tools=enable_auto_tools,
|
|
model_name=self.model_config.model,
|
|
is_harmony=self.model_config.hf_config.model_type == "gpt_oss",
|
|
)
|
|
self.enable_prompt_tokens_details = enable_prompt_tokens_details
|
|
self.enable_force_include_usage = enable_force_include_usage
|
|
|
|
self.default_sampling_params = self.model_config.get_diff_sampling_param()
|
|
mc = self.model_config
|
|
self.override_max_tokens = (
|
|
self.default_sampling_params.get("max_tokens")
|
|
if mc.generation_config not in ("auto", "vllm")
|
|
else getattr(mc, "override_generation_config", {}).get("max_new_tokens")
|
|
)
|
|
|
|
# If False (default), the "store" option is (silently) ignored and the
|
|
# response is not stored. If True, the response is stored in memory.
|
|
# NOTE(woosuk): This may not be intuitive for users, as the default
|
|
# behavior in OpenAI's Responses API is to store the response, but
|
|
# vLLM's default behavior is not.
|
|
self.enable_store = envs.VLLM_ENABLE_RESPONSES_API_STORE
|
|
if self.enable_store:
|
|
logger.warning_once(
|
|
"`VLLM_ENABLE_RESPONSES_API_STORE` is enabled. This may "
|
|
"cause a memory leak since we never remove responses from "
|
|
"the store."
|
|
)
|
|
|
|
self.use_harmony = self.model_config.hf_config.model_type == "gpt_oss"
|
|
if self.use_harmony:
|
|
logger.warning(
|
|
"For gpt-oss, we ignore --enable-auto-tool-choice "
|
|
"and always enable tool use."
|
|
)
|
|
self.enable_auto_tools = enable_auto_tools
|
|
# HACK(woosuk): This is a hack. We should use a better store.
|
|
# FIXME: If enable_store=True, this may cause a memory leak since we
|
|
# never remove responses from the store.
|
|
self.response_store: dict[str, ResponsesResponse] = {}
|
|
self.response_store_lock = asyncio.Lock()
|
|
|
|
# HACK(woosuk): This is a hack. We should use a better store.
|
|
# FIXME: If enable_store=True, this may cause a memory leak since we
|
|
# never remove messages from the store.
|
|
self.msg_store: dict[str, list[ChatCompletionMessageParam]] = {}
|
|
|
|
# HACK(wuhang): This is a hack. We should use a better store.
|
|
# FIXME: If enable_store=True, this may cause a memory leak since we
|
|
# never remove events from the store.
|
|
self.event_store: dict[
|
|
str, tuple[deque[StreamingResponsesResponse], asyncio.Event]
|
|
] = {}
|
|
|
|
self.background_tasks: dict[str, asyncio.Task] = {}
|
|
|
|
self.tool_server = tool_server
|
|
|
|
def _effective_chat_template_kwargs(
|
|
self, request: ResponsesRequest
|
|
) -> dict[str, Any]:
|
|
return (
|
|
request.build_chat_params(
|
|
self.chat_template,
|
|
self.chat_template_content_format,
|
|
)
|
|
.with_defaults(self.chat_template_kwargs)
|
|
.chat_template_kwargs
|
|
)
|
|
|
|
def _make_response_parser(
|
|
self,
|
|
request: ResponsesRequest,
|
|
tokenizer: TokenizerLike,
|
|
chat_template_kwargs: dict[str, Any],
|
|
) -> Parser | None:
|
|
if self.parser is None:
|
|
return None
|
|
return self.parser(
|
|
tokenizer,
|
|
request.tools,
|
|
chat_template_kwargs=chat_template_kwargs,
|
|
model_config=self.model_config,
|
|
)
|
|
|
|
def _validate_generator_input(
|
|
self,
|
|
engine_input: EngineInput,
|
|
) -> ErrorResponse | None:
|
|
"""Add validations to the input to the generator here."""
|
|
prompt_len = self._extract_prompt_len(engine_input)
|
|
max_model_len = self.model_config.max_model_len
|
|
|
|
if prompt_len >= max_model_len:
|
|
error_message = (
|
|
f"The engine prompt length {prompt_len} "
|
|
f"exceeds the max_model_len {max_model_len}. "
|
|
"Please reduce prompt."
|
|
)
|
|
return self.create_error_response(
|
|
err_type="invalid_request_error",
|
|
message=error_message,
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
param="input",
|
|
)
|
|
|
|
return None
|
|
|
|
def _validate_create_responses_input(
|
|
self, request: ResponsesRequest
|
|
) -> ErrorResponse | None:
|
|
if self.use_harmony and request.is_include_output_logprobs():
|
|
return self.create_error_response(
|
|
err_type="invalid_request_error",
|
|
message="logprobs are not supported with gpt-oss models",
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
param="logprobs",
|
|
)
|
|
if request.store and not self.enable_store and request.background:
|
|
return self.create_error_response(
|
|
err_type="invalid_request_error",
|
|
message=(
|
|
"This vLLM engine does not support `store=True` and "
|
|
"therefore does not support the background mode. To "
|
|
"enable these features, set the environment variable "
|
|
"`VLLM_ENABLE_RESPONSES_API_STORE=1` when launching "
|
|
"the vLLM server."
|
|
),
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
param="background",
|
|
)
|
|
if request.previous_input_messages and request.previous_response_id:
|
|
return self.create_error_response(
|
|
err_type="invalid_request_error",
|
|
message="Only one of `previous_input_messages` and "
|
|
"`previous_response_id` can be set.",
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
param="previous_response_id",
|
|
)
|
|
return None
|
|
|
|
async def create_responses(
|
|
self,
|
|
request: ResponsesRequest,
|
|
raw_request: Request | None = None,
|
|
) -> (
|
|
AsyncGenerator[StreamingResponsesResponse, None]
|
|
| ResponsesResponse
|
|
| ErrorResponse
|
|
):
|
|
return await self._with_kv_transfer_rejection_cleanup(
|
|
self._create_responses(request, raw_request), request, raw_request
|
|
)
|
|
|
|
async def _create_responses(
|
|
self, request: ResponsesRequest, raw_request: Request | None = None
|
|
) -> (
|
|
AsyncGenerator[StreamingResponsesResponse, None]
|
|
| ResponsesResponse
|
|
| ErrorResponse
|
|
):
|
|
error_check_ret = await self._check_model(request)
|
|
if error_check_ret is not None:
|
|
logger.error("Error with model %s", error_check_ret)
|
|
return error_check_ret
|
|
maybe_validation_error = self._validate_create_responses_input(request)
|
|
if maybe_validation_error is not None:
|
|
return maybe_validation_error
|
|
|
|
# If the engine is dead, raise the engine's DEAD_ERROR.
|
|
# This is required for the streaming case, where we return a
|
|
# success status before we actually start generating text :).
|
|
if self.engine_client.errored:
|
|
raise self.engine_client.dead_error
|
|
|
|
if request.store and not self.enable_store:
|
|
# Disable the store option.
|
|
# NOTE(woosuk): Although returning an error is possible, we opted
|
|
# to implicitly disable store and process the request anyway, as
|
|
# we assume most users do not intend to actually store the response
|
|
# (i.e., their request's `store=True` just because it's the default
|
|
# value).
|
|
request.store = False
|
|
|
|
# Handle the previous response ID.
|
|
prev_response_id = request.previous_response_id
|
|
if prev_response_id is not None:
|
|
async with self.response_store_lock:
|
|
prev_response = self.response_store.get(prev_response_id)
|
|
if prev_response is None:
|
|
return self._make_not_found_error(prev_response_id)
|
|
else:
|
|
prev_response = None
|
|
|
|
lora_request = self._maybe_get_adapters(request)
|
|
model_name = self.models.model_name(lora_request)
|
|
|
|
if self.use_harmony:
|
|
messages, engine_inputs = self._make_request_with_harmony(
|
|
request, prev_response
|
|
)
|
|
else:
|
|
messages, engine_inputs = await self._make_request(request, prev_response)
|
|
|
|
request_metadata = RequestResponseMetadata(request_id=request.request_id)
|
|
if raw_request:
|
|
raw_request.state.request_metadata = request_metadata
|
|
|
|
# Schedule the request and get the result generator.
|
|
max_model_len = self.model_config.max_model_len
|
|
generators: list[AsyncGenerator[ConversationContext, None]] = []
|
|
|
|
# Only include builtin tools that the request actually asked for.
|
|
# Without this filter, tools registered on the server (e.g. via
|
|
# --tool-server demo) would be available for execution even when
|
|
# the request didn't enable them.
|
|
requested_tool_types = extract_tool_types(request.tools)
|
|
builtin_tool_list: list[str] = []
|
|
if self.tool_server is not None:
|
|
if (
|
|
self.tool_server.has_tool("browser")
|
|
and "web_search_preview" in requested_tool_types
|
|
):
|
|
builtin_tool_list.append("browser")
|
|
if (
|
|
self.tool_server.has_tool("python")
|
|
and "code_interpreter" in requested_tool_types
|
|
):
|
|
builtin_tool_list.append("python")
|
|
if (
|
|
self.tool_server.has_tool("container")
|
|
and "container" in requested_tool_types
|
|
):
|
|
builtin_tool_list.append("container")
|
|
|
|
if self.tool_server is not None:
|
|
available_tools = builtin_tool_list
|
|
else:
|
|
assert len(builtin_tool_list) == 0
|
|
available_tools = []
|
|
tokenizer = self.renderer.get_tokenizer()
|
|
|
|
for engine_input in engine_inputs:
|
|
maybe_error = self._validate_generator_input(engine_input)
|
|
if maybe_error is not None:
|
|
return maybe_error
|
|
|
|
default_max_tokens = get_max_tokens(
|
|
max_model_len,
|
|
request.max_output_tokens,
|
|
self._extract_prompt_len(engine_input),
|
|
self.default_sampling_params,
|
|
self.override_max_tokens,
|
|
truncate_prompt_tokens=(
|
|
-1 if request.truncation != "disabled" else None
|
|
),
|
|
)
|
|
|
|
sampling_params = request.to_sampling_params(
|
|
default_max_tokens, self.default_sampling_params
|
|
)
|
|
|
|
trace_headers = (
|
|
None
|
|
if raw_request is None
|
|
else await self._get_trace_headers(raw_request.headers)
|
|
)
|
|
|
|
chat_template_kwargs = self._effective_chat_template_kwargs(request)
|
|
response_parser = self._make_response_parser(
|
|
request, tokenizer, chat_template_kwargs
|
|
)
|
|
|
|
context: ConversationContext
|
|
function_tool_names = extract_function_tool_names(request.tools)
|
|
if self.use_harmony:
|
|
context = HarmonyContext(
|
|
messages,
|
|
available_tools,
|
|
function_tool_names,
|
|
response_parser=response_parser,
|
|
)
|
|
else:
|
|
if envs.VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT:
|
|
# This is a feature in development for parsing
|
|
# tokens during generation instead of at the end
|
|
context = ParsableContext(
|
|
response_messages=messages,
|
|
tokenizer=tokenizer,
|
|
parser_cls=self.parser,
|
|
request=request,
|
|
response_parser=response_parser,
|
|
available_tools=available_tools,
|
|
chat_template=self.chat_template,
|
|
chat_template_content_format=self.chat_template_content_format,
|
|
enable_auto_tools=self.enable_auto_tools,
|
|
)
|
|
else:
|
|
context = SimpleContext(
|
|
response_parser=response_parser,
|
|
)
|
|
|
|
if (
|
|
context.response_parser is not None
|
|
and context.response_parser.reasoning_parser is not None
|
|
):
|
|
reasoning_parser_kwargs = {
|
|
"chat_template_kwargs": chat_template_kwargs,
|
|
}
|
|
if (
|
|
isinstance(
|
|
struct_out := sampling_params.structured_outputs,
|
|
StructuredOutputsParams,
|
|
)
|
|
and struct_out.all_non_structural_tag_constraints_none()
|
|
):
|
|
sampling_params.structured_outputs = replace(
|
|
struct_out,
|
|
structural_tag=(
|
|
context.response_parser.reasoning_parser.prepare_structured_tag(
|
|
struct_out.structural_tag, self.tool_server
|
|
)
|
|
),
|
|
)
|
|
generator = self._generate_with_builtin_tools(
|
|
request_id=request.request_id,
|
|
engine_input=engine_input,
|
|
sampling_params=sampling_params,
|
|
context=context,
|
|
lora_request=lora_request,
|
|
priority=request.priority,
|
|
trace_headers=trace_headers,
|
|
reasoning_parser_kwargs=reasoning_parser_kwargs
|
|
if self.parser and self.parser.reasoning_parser_cls is not None
|
|
else None,
|
|
)
|
|
generators.append(generator)
|
|
|
|
assert len(generators) == 1
|
|
(result_generator,) = generators
|
|
|
|
# Store the input messages.
|
|
if request.store:
|
|
self.msg_store[request.request_id] = messages
|
|
|
|
if request.background:
|
|
created_time = int(time.time())
|
|
response = ResponsesResponse.from_request(
|
|
request,
|
|
sampling_params,
|
|
model_name=model_name,
|
|
created_time=created_time,
|
|
output=[],
|
|
status="queued",
|
|
usage=None,
|
|
)
|
|
async with self.response_store_lock:
|
|
self.response_store[response.id] = response
|
|
|
|
# Run the request in the background.
|
|
if request.stream:
|
|
task = asyncio.create_task(
|
|
self._run_background_request_stream(
|
|
request,
|
|
sampling_params,
|
|
result_generator,
|
|
context,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
created_time,
|
|
),
|
|
name=f"create_{request.request_id}",
|
|
)
|
|
else:
|
|
task = asyncio.create_task(
|
|
self._run_background_request(
|
|
request,
|
|
sampling_params,
|
|
result_generator,
|
|
context,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
created_time,
|
|
),
|
|
name=f"create_{response.id}",
|
|
)
|
|
|
|
# For cleanup.
|
|
response_id = response.id
|
|
self.background_tasks[response_id] = task
|
|
task.add_done_callback(
|
|
lambda _: self.background_tasks.pop(response_id, None)
|
|
)
|
|
|
|
if request.stream:
|
|
return self.responses_background_stream_generator(request.request_id)
|
|
return response
|
|
|
|
if request.stream:
|
|
return self.responses_stream_generator(
|
|
request,
|
|
sampling_params,
|
|
result_generator,
|
|
context,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
)
|
|
|
|
return await self.responses_full_generator(
|
|
request,
|
|
sampling_params,
|
|
result_generator,
|
|
context,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
)
|
|
|
|
async def _make_request(
|
|
self,
|
|
request: ResponsesRequest,
|
|
prev_response: ResponsesResponse | None,
|
|
):
|
|
tool_dicts = construct_tool_dicts(request.tools, request.tool_choice)
|
|
# Construct the input messages.
|
|
messages = construct_input_messages(
|
|
request_instructions=request.instructions,
|
|
request_input=request.input,
|
|
prev_msg=self.msg_store.get(prev_response.id) if prev_response else None,
|
|
prev_response_output=prev_response.output if prev_response else None,
|
|
)
|
|
chat_template_kwargs = self._effective_chat_template_kwargs(request)
|
|
_, engine_inputs = await self.online_renderer.preprocess_chat(
|
|
request,
|
|
messages,
|
|
default_template=self.chat_template,
|
|
default_template_content_format=self.chat_template_content_format,
|
|
default_template_kwargs=chat_template_kwargs,
|
|
tool_dicts=tool_dicts,
|
|
parser=self.parser,
|
|
)
|
|
return messages, engine_inputs
|
|
|
|
async def _render_next_turn(
|
|
self,
|
|
request: ResponsesRequest,
|
|
messages: list[ResponseInputOutputItem],
|
|
tool_dicts: list[dict[str, Any]] | None,
|
|
parser: type[Parser] | None,
|
|
chat_template: str | None,
|
|
chat_template_content_format: ChatTemplateContentFormatOption,
|
|
):
|
|
new_messages = construct_input_messages(
|
|
request_input=messages,
|
|
)
|
|
chat_template_kwargs = self._effective_chat_template_kwargs(request)
|
|
_, engine_inputs = await self.online_renderer.preprocess_chat(
|
|
request,
|
|
new_messages,
|
|
default_template=chat_template,
|
|
default_template_content_format=chat_template_content_format,
|
|
default_template_kwargs=chat_template_kwargs,
|
|
tool_dicts=tool_dicts,
|
|
parser=parser,
|
|
)
|
|
return engine_inputs
|
|
|
|
async def _generate_with_builtin_tools(
|
|
self,
|
|
request_id: str,
|
|
engine_input: EngineInput,
|
|
sampling_params: SamplingParams,
|
|
context: ConversationContext,
|
|
lora_request: LoRARequest | None = None,
|
|
priority: int = 0,
|
|
trace_headers: Mapping[str, str] | None = None,
|
|
reasoning_parser_kwargs: dict[str, Any] | None = None,
|
|
):
|
|
max_model_len = self.model_config.max_model_len
|
|
|
|
orig_priority = priority
|
|
sub_request = 0
|
|
while True:
|
|
# Ensure that each sub-request has a unique request id.
|
|
sub_request_id = f"{request_id}_{sub_request}"
|
|
|
|
self._log_inputs(
|
|
sub_request_id,
|
|
engine_input,
|
|
params=sampling_params,
|
|
lora_request=lora_request,
|
|
)
|
|
|
|
generator = self.engine_client.generate(
|
|
engine_input,
|
|
sampling_params,
|
|
sub_request_id,
|
|
lora_request=lora_request,
|
|
trace_headers=trace_headers,
|
|
priority=priority,
|
|
reasoning_parser_kwargs=reasoning_parser_kwargs,
|
|
)
|
|
|
|
async for res in generator:
|
|
context.append_output(res)
|
|
# NOTE(woosuk): The stop condition is handled by the engine.
|
|
yield context
|
|
|
|
if not context.need_builtin_tool_call():
|
|
# The model did not ask for a tool call, so we're done.
|
|
break
|
|
|
|
# Call the tool and update the context with the result.
|
|
tool_output = await context.call_tool()
|
|
context.append_tool_output(tool_output)
|
|
|
|
# TODO: uncomment this and enable tool output streaming
|
|
# yield context
|
|
|
|
# Create inputs for the next turn.
|
|
# Render the next prompt token ids and update sampling_params.
|
|
if isinstance(context, HarmonyContext):
|
|
token_ids = context.render_for_completion()
|
|
engine_input = tokens_input(token_ids)
|
|
|
|
sampling_params.max_tokens = max_model_len - len(token_ids)
|
|
elif isinstance(context, ParsableContext):
|
|
(engine_input,) = await self._render_next_turn(
|
|
context.request,
|
|
context.response_messages,
|
|
context.tool_dicts,
|
|
context.parser_cls,
|
|
context.chat_template,
|
|
context.chat_template_content_format,
|
|
)
|
|
|
|
sampling_params.max_tokens = get_max_tokens(
|
|
max_model_len,
|
|
context.request.max_output_tokens,
|
|
self._extract_prompt_len(engine_input),
|
|
self.default_sampling_params, # type: ignore
|
|
self.override_max_tokens, # type: ignore
|
|
truncate_prompt_tokens=(
|
|
-1 if context.request.truncation != "disabled" else None
|
|
),
|
|
)
|
|
|
|
# OPTIMIZATION
|
|
priority = orig_priority - 1
|
|
sub_request += 1
|
|
|
|
def _make_request_with_harmony(
|
|
self,
|
|
request: ResponsesRequest,
|
|
prev_response: ResponsesResponse | None,
|
|
):
|
|
if request.tool_choice not in ("auto", "none"):
|
|
raise NotImplementedError(
|
|
"Only 'auto' or 'none' tool_choice is supported "
|
|
"in response API with Harmony"
|
|
)
|
|
|
|
arrival_time = time.time()
|
|
messages = self._construct_input_messages_with_harmony(request, prev_response)
|
|
prompt_token_ids = render_for_completion(messages)
|
|
engine_input = tokens_input(prompt_token_ids, cache_salt=request.cache_salt)
|
|
engine_input["arrival_time"] = arrival_time
|
|
|
|
return messages, [engine_input]
|
|
|
|
async def _initialize_tool_sessions(
|
|
self,
|
|
request: ResponsesRequest,
|
|
context: ConversationContext,
|
|
exit_stack: AsyncExitStack,
|
|
):
|
|
# we should only initialize the tool session if the request needs tools
|
|
if len(request.tools) == 0:
|
|
return
|
|
mcp_tools = {
|
|
tool.server_label: tool for tool in request.tools if tool.type == "mcp"
|
|
}
|
|
await context.init_tool_sessions(
|
|
self.tool_server, exit_stack, request.request_id, mcp_tools
|
|
)
|
|
|
|
async def responses_full_generator(
|
|
self,
|
|
request: ResponsesRequest,
|
|
sampling_params: SamplingParams,
|
|
result_generator: AsyncIterator[ConversationContext],
|
|
context: ConversationContext,
|
|
model_name: str,
|
|
tokenizer: TokenizerLike,
|
|
request_metadata: RequestResponseMetadata,
|
|
created_time: int | None = None,
|
|
) -> ErrorResponse | ResponsesResponse:
|
|
if created_time is None:
|
|
created_time = int(time.time())
|
|
|
|
async with AsyncExitStack() as exit_stack:
|
|
try:
|
|
await self._initialize_tool_sessions(request, context, exit_stack)
|
|
async for _ in result_generator:
|
|
pass
|
|
except asyncio.CancelledError:
|
|
return self.create_error_response("Client disconnected")
|
|
|
|
# NOTE: Implementation of status is still WIP, but for now
|
|
# we guarantee that if the status is not "completed", it is accurate.
|
|
# "completed" is implemented as the "catch-all" for now.
|
|
status: ResponseStatus = "completed"
|
|
|
|
input_messages: ResponseInputOutputMessage | None = None
|
|
output_messages: ResponseInputOutputMessage | None = None
|
|
if self.use_harmony:
|
|
assert isinstance(context, HarmonyContext)
|
|
output = []
|
|
harmony_msgs = context.messages[context.num_init_messages :]
|
|
if harmony_msgs:
|
|
fn_names = context.function_tool_names
|
|
for msg in harmony_msgs[:-1]:
|
|
output.extend(harmony_to_response_output(msg, fn_names))
|
|
output.extend(
|
|
harmony_to_response_output(
|
|
harmony_msgs[-1],
|
|
fn_names,
|
|
incomplete=context.last_append_flush_status,
|
|
)
|
|
)
|
|
|
|
if request.enable_response_messages:
|
|
input_messages = context.messages[: context.num_init_messages]
|
|
output_messages = context.messages[context.num_init_messages :]
|
|
num_tool_output_tokens = context.num_tool_output_tokens
|
|
if len(output) > 0:
|
|
if context.finish_reason == "length":
|
|
status = "incomplete"
|
|
elif context.finish_reason == "abort":
|
|
status = "cancelled"
|
|
else:
|
|
self._raise_if_error(context.finish_reason, request.request_id)
|
|
else:
|
|
status = "incomplete"
|
|
elif isinstance(context, ParsableContext):
|
|
output = context.make_response_output_items()
|
|
|
|
if request.enable_response_messages:
|
|
input_messages = context.input_messages
|
|
output_messages = context.output_messages
|
|
|
|
# TODO: Calculate usage.
|
|
# assert final_res.prompt_token_ids is not None
|
|
num_tool_output_tokens = 0
|
|
|
|
# Check finish reason from the parser
|
|
if context.finish_reason == "length":
|
|
status = "incomplete"
|
|
else:
|
|
assert isinstance(context, SimpleContext)
|
|
# Use final_output which has accumulated text/token_ids/logprobs
|
|
final_res = context.final_output
|
|
assert final_res is not None
|
|
assert len(final_res.outputs) == 1
|
|
final_output = final_res.outputs[0]
|
|
|
|
# finish_reason='error' indicates retryable internal error
|
|
self._raise_if_error(final_output.finish_reason, request.request_id)
|
|
|
|
# Check if generation was stopped due to max_tokens
|
|
if final_output.finish_reason == "length":
|
|
status = "incomplete"
|
|
|
|
output = self._make_response_output_items(
|
|
request,
|
|
final_output,
|
|
tokenizer,
|
|
parser=context.response_parser,
|
|
)
|
|
|
|
if request.enable_response_messages:
|
|
input_messages = context.input_messages
|
|
output_messages = context.output_messages
|
|
|
|
# Calculate usage.
|
|
assert final_res.prompt_token_ids is not None
|
|
num_tool_output_tokens = 0
|
|
|
|
assert isinstance(context, (SimpleContext, HarmonyContext, ParsableContext))
|
|
num_prompt_tokens = context.num_prompt_tokens
|
|
num_generated_tokens = context.num_output_tokens
|
|
num_cached_tokens = context.num_cached_tokens
|
|
num_reasoning_tokens = context.num_reasoning_tokens
|
|
# For text-based reasoning parsers (e.g., <think>...</think>),
|
|
# HarmonyContext already counts reasoning tokens via channels.
|
|
# For Simple/Parsable contexts, derive reasoning_tokens from
|
|
# accumulated output token IDs using the parser if not already set.
|
|
if (
|
|
num_reasoning_tokens == 0
|
|
and isinstance(context, (SimpleContext, ParsableContext))
|
|
and context.response_parser is not None
|
|
and context.response_parser.reasoning_parser is not None
|
|
):
|
|
accumulated = getattr(context, "_accumulated_token_ids", []) or []
|
|
num_reasoning_tokens = (
|
|
context.response_parser.reasoning_parser.count_reasoning_tokens(
|
|
accumulated
|
|
)
|
|
)
|
|
|
|
usage = ResponseUsage(
|
|
input_tokens=num_prompt_tokens,
|
|
output_tokens=num_generated_tokens,
|
|
total_tokens=num_prompt_tokens + num_generated_tokens,
|
|
input_tokens_details=InputTokensDetails(
|
|
cached_tokens=num_cached_tokens,
|
|
input_tokens_per_turn=[
|
|
turn.input_tokens for turn in context.all_turn_metrics
|
|
],
|
|
cached_tokens_per_turn=[
|
|
turn.cached_input_tokens for turn in context.all_turn_metrics
|
|
],
|
|
),
|
|
output_tokens_details=OutputTokensDetails(
|
|
reasoning_tokens=num_reasoning_tokens,
|
|
tool_output_tokens=num_tool_output_tokens,
|
|
output_tokens_per_turn=[
|
|
turn.output_tokens for turn in context.all_turn_metrics
|
|
],
|
|
tool_output_tokens_per_turn=[
|
|
turn.tool_output_tokens for turn in context.all_turn_metrics
|
|
],
|
|
),
|
|
)
|
|
response = ResponsesResponse.from_request(
|
|
request,
|
|
sampling_params,
|
|
input_messages=input_messages,
|
|
output_messages=output_messages,
|
|
model_name=model_name,
|
|
created_time=created_time,
|
|
output=output,
|
|
status=status,
|
|
usage=usage,
|
|
kv_transfer_params=context.kv_transfer_params,
|
|
ec_transfer_params=context.ec_transfer_params,
|
|
)
|
|
|
|
if request.store:
|
|
async with self.response_store_lock:
|
|
stored_response = self.response_store.get(response.id)
|
|
# If the response is already cancelled, don't update it.
|
|
if stored_response is None or stored_response.status != "cancelled":
|
|
self.response_store[response.id] = response
|
|
return response
|
|
|
|
def _topk_logprobs(
|
|
self,
|
|
logprobs: dict[int, SampleLogprob],
|
|
top_logprobs: int,
|
|
tokenizer: TokenizerLike,
|
|
) -> list[LogprobTopLogprob]:
|
|
"""Returns the top-k logprobs from the logprobs dictionary."""
|
|
out = []
|
|
for i, (token_id, _logprob) in enumerate(logprobs.items()):
|
|
if i >= top_logprobs:
|
|
break
|
|
text = self._get_decoded_token(
|
|
logprob=_logprob,
|
|
token_id=token_id,
|
|
tokenizer=tokenizer,
|
|
return_as_token_id=self.return_tokens_as_token_ids,
|
|
)
|
|
out.append(
|
|
LogprobTopLogprob(
|
|
token=text,
|
|
logprob=max(_logprob.logprob, -9999.0),
|
|
bytes=list(text.encode("utf-8", errors="replace")),
|
|
)
|
|
)
|
|
return out
|
|
|
|
def _create_response_logprobs(
|
|
self,
|
|
token_ids: Sequence[int],
|
|
logprobs: SampleLogprobs | None,
|
|
tokenizer: TokenizerLike,
|
|
top_logprobs: int | None = None,
|
|
) -> list[Logprob]:
|
|
assert logprobs is not None, "logprobs must be provided"
|
|
assert len(token_ids) == len(logprobs), (
|
|
"token_ids and logprobs.token_ids must have the same length"
|
|
)
|
|
out = []
|
|
for i, token_id in enumerate(token_ids):
|
|
logprob = logprobs[i]
|
|
token_logprob = logprob[token_id]
|
|
text = self._get_decoded_token(
|
|
logprob=token_logprob,
|
|
token_id=token_id,
|
|
tokenizer=tokenizer,
|
|
return_as_token_id=self.return_tokens_as_token_ids,
|
|
)
|
|
out.append(
|
|
Logprob(
|
|
token=text,
|
|
logprob=max(token_logprob.logprob, -9999.0),
|
|
bytes=list(text.encode("utf-8", errors="replace")),
|
|
top_logprobs=(
|
|
self._topk_logprobs(
|
|
logprob, top_logprobs=top_logprobs, tokenizer=tokenizer
|
|
)
|
|
if top_logprobs
|
|
else []
|
|
),
|
|
)
|
|
)
|
|
return out
|
|
|
|
def _create_stream_response_logprobs(
|
|
self,
|
|
token_ids: Sequence[int],
|
|
logprobs: SampleLogprobs | None,
|
|
tokenizer: TokenizerLike,
|
|
top_logprobs: int | None = None,
|
|
) -> list[response_text_delta_event.Logprob]:
|
|
lgs = self._create_response_logprobs(
|
|
token_ids=token_ids,
|
|
logprobs=logprobs,
|
|
tokenizer=tokenizer,
|
|
top_logprobs=top_logprobs,
|
|
)
|
|
return [
|
|
response_text_delta_event.Logprob(
|
|
token=lg.token,
|
|
logprob=lg.logprob,
|
|
top_logprobs=[
|
|
response_text_delta_event.LogprobTopLogprob(
|
|
token=tl.token, logprob=tl.logprob
|
|
)
|
|
for tl in lg.top_logprobs
|
|
],
|
|
)
|
|
for lg in lgs
|
|
]
|
|
|
|
def _make_response_output_items(
|
|
self,
|
|
request: ResponsesRequest,
|
|
final_output: CompletionOutput,
|
|
tokenizer: TokenizerLike,
|
|
parser: Parser | None = None,
|
|
) -> list[ResponseOutputItem]:
|
|
# Log complete response if output logging is enabled
|
|
if self.enable_log_outputs and self.request_logger:
|
|
self.request_logger.log_outputs(
|
|
request_id=request.request_id,
|
|
outputs=final_output.text,
|
|
output_token_ids=final_output.token_ids,
|
|
finish_reason=final_output.finish_reason,
|
|
is_streaming=False,
|
|
delta=False,
|
|
)
|
|
|
|
# Compute logprobs if requested
|
|
logprobs = None
|
|
if request.is_include_output_logprobs() and final_output.logprobs:
|
|
logprobs = self._create_response_logprobs(
|
|
token_ids=final_output.token_ids,
|
|
logprobs=final_output.logprobs,
|
|
tokenizer=tokenizer,
|
|
top_logprobs=request.top_logprobs,
|
|
)
|
|
|
|
# Use parser to extract reasoning, content, and tool calls
|
|
if parser:
|
|
reasoning, content, tool_calls = parser.parse(
|
|
final_output.text,
|
|
request,
|
|
enable_auto_tools=self.enable_auto_tools,
|
|
model_output_token_ids=final_output.token_ids,
|
|
)
|
|
if not request.include_reasoning:
|
|
reasoning = None
|
|
logprobs = None
|
|
return build_response_output_items(
|
|
reasoning=reasoning,
|
|
content=content,
|
|
tool_calls=tool_calls,
|
|
logprobs=logprobs,
|
|
tools=request.tools,
|
|
)
|
|
|
|
# Fallback when no parser is configured
|
|
return [
|
|
ResponseOutputMessage(
|
|
id=f"msg_{random_uuid()}",
|
|
content=[
|
|
ResponseOutputText(
|
|
text=final_output.text,
|
|
annotations=[],
|
|
type="output_text",
|
|
logprobs=logprobs,
|
|
)
|
|
]
|
|
if final_output.text
|
|
else [],
|
|
role="assistant",
|
|
status="completed",
|
|
type="message",
|
|
)
|
|
]
|
|
|
|
def _get_harmony_builtin_tool_descriptions(
|
|
self, request: ResponsesRequest, tool_types: set[str]
|
|
) -> dict[str, str | None]:
|
|
# Extract allowed_tools from MCP tool requests
|
|
allowed_tools_map = _extract_allowed_tools_from_mcp_requests(request.tools)
|
|
|
|
# Get filtered tool descriptions first.
|
|
# If get_tool_description returns None (due to filtering), the tool is disabled.
|
|
browser_description = (
|
|
self.tool_server.get_tool_description(
|
|
"browser", allowed_tools_map.get("web_search_preview")
|
|
)
|
|
if "web_search_preview" in tool_types
|
|
and self.tool_server is not None
|
|
and self.tool_server.has_tool("browser")
|
|
else None
|
|
)
|
|
python_description = (
|
|
self.tool_server.get_tool_description(
|
|
"python", allowed_tools_map.get("code_interpreter")
|
|
)
|
|
if "code_interpreter" in tool_types
|
|
and self.tool_server is not None
|
|
and self.tool_server.has_tool("python")
|
|
else None
|
|
)
|
|
container_description = (
|
|
self.tool_server.get_tool_description(
|
|
"container", allowed_tools_map.get("container")
|
|
)
|
|
if "container" in tool_types
|
|
and self.tool_server is not None
|
|
and self.tool_server.has_tool("container")
|
|
else None
|
|
)
|
|
return {
|
|
"browser_description": browser_description,
|
|
"python_description": python_description,
|
|
"container_description": container_description,
|
|
}
|
|
|
|
def _construct_input_messages_with_harmony(
|
|
self,
|
|
request: ResponsesRequest,
|
|
prev_response: ResponsesResponse | None,
|
|
) -> list[OpenAIHarmonyMessage]:
|
|
messages: list[OpenAIHarmonyMessage] = []
|
|
request_input = request.input
|
|
if prev_response is None:
|
|
# New conversation.
|
|
tool_types = extract_tool_types(request.tools)
|
|
with_custom_tools = has_custom_tools(tool_types)
|
|
instructions = request.instructions
|
|
if instructions is None and isinstance(request_input, list):
|
|
instructions, request_input = extract_instructions_from_messages(
|
|
request_input
|
|
)
|
|
tool_descriptions = self._get_harmony_builtin_tool_descriptions(
|
|
request, tool_types
|
|
)
|
|
tools = request.tools if with_custom_tools else None
|
|
messages.extend(
|
|
build_harmony_preamble(
|
|
instructions=instructions,
|
|
tools=tools,
|
|
reasoning_effort=(
|
|
request.reasoning.effort if request.reasoning else None
|
|
),
|
|
with_custom_tools=with_custom_tools,
|
|
**tool_descriptions,
|
|
)
|
|
)
|
|
messages += construct_harmony_previous_input_messages(request)
|
|
|
|
else:
|
|
# Continue the previous conversation.
|
|
# FIXME(woosuk): Currently, request params like reasoning and
|
|
# instructions are ignored.
|
|
prev_msgs = self.msg_store[prev_response.id]
|
|
|
|
messages.extend(prev_msgs)
|
|
# Append the new input.
|
|
# Responses API supports simple text inputs without chat format.
|
|
if isinstance(request_input, str):
|
|
# Skip empty string input when previous_input_messages supplies
|
|
# the full conversation history --- an empty trailing user message
|
|
# confuses the model into thinking nothing was sent.
|
|
if request_input or not request.previous_input_messages:
|
|
messages.append(get_user_message(request_input))
|
|
else:
|
|
if prev_response is not None:
|
|
prev_outputs = copy(prev_response.output)
|
|
else:
|
|
prev_outputs = []
|
|
for response_msg in request_input:
|
|
new_msg = response_input_to_harmony(response_msg, prev_outputs)
|
|
if new_msg is not None:
|
|
messages.append(new_msg)
|
|
|
|
# User passes in a tool call request and its output. We need
|
|
# to add the tool call request to prev_outputs so that
|
|
# response_input_to_harmony can find the tool call request when
|
|
# parsing the tool call output.
|
|
if isinstance(response_msg, ResponseFunctionToolCall):
|
|
prev_outputs.append(response_msg)
|
|
return messages
|
|
|
|
async def _run_background_request_stream(
|
|
self,
|
|
request: ResponsesRequest,
|
|
*args,
|
|
**kwargs,
|
|
):
|
|
event_deque: deque[StreamingResponsesResponse] = deque()
|
|
new_event_signal = asyncio.Event()
|
|
self.event_store[request.request_id] = (event_deque, new_event_signal)
|
|
generator = self.responses_stream_generator(request, *args, **kwargs)
|
|
try:
|
|
async for event in generator:
|
|
event_deque.append(event)
|
|
new_event_signal.set() # Signal new event available
|
|
finally:
|
|
new_event_signal.set()
|
|
|
|
async def _run_background_request(
|
|
self,
|
|
request: ResponsesRequest,
|
|
*args,
|
|
**kwargs,
|
|
):
|
|
response = await self.responses_full_generator(request, *args, **kwargs)
|
|
|
|
if isinstance(response, ErrorResponse):
|
|
# If the request has failed, update the status to "failed".
|
|
response_id = request.request_id
|
|
async with self.response_store_lock:
|
|
stored_response = self.response_store.get(response_id)
|
|
assert stored_response is not None
|
|
if stored_response.status not in ("completed", "cancelled"):
|
|
stored_response.status = "failed"
|
|
|
|
async def responses_background_stream_generator(
|
|
self,
|
|
response_id: str,
|
|
starting_after: int | None = None,
|
|
) -> AsyncGenerator[StreamingResponsesResponse, None]:
|
|
if response_id not in self.event_store:
|
|
raise VLLMValidationError(
|
|
f"Unknown response_id: {response_id}",
|
|
parameter="response_id",
|
|
value=response_id,
|
|
)
|
|
|
|
event_deque, new_event_signal = self.event_store[response_id]
|
|
start_index = 0 if starting_after is None else starting_after + 1
|
|
current_index = start_index
|
|
|
|
while True:
|
|
new_event_signal.clear()
|
|
|
|
# Yield existing events from start_index
|
|
while current_index < len(event_deque):
|
|
event = event_deque[current_index]
|
|
yield event
|
|
if getattr(event, "type", "unknown") == "response.completed":
|
|
return
|
|
current_index += 1
|
|
|
|
await new_event_signal.wait()
|
|
|
|
async def retrieve_responses(
|
|
self,
|
|
response_id: str,
|
|
starting_after: int | None,
|
|
stream: bool | None,
|
|
) -> (
|
|
ErrorResponse
|
|
| ResponsesResponse
|
|
| AsyncGenerator[StreamingResponsesResponse, None]
|
|
):
|
|
async with self.response_store_lock:
|
|
response = self.response_store.get(response_id)
|
|
|
|
if response is None:
|
|
return self._make_not_found_error(response_id)
|
|
|
|
if stream:
|
|
return self.responses_background_stream_generator(
|
|
response_id,
|
|
starting_after,
|
|
)
|
|
return response
|
|
|
|
async def cancel_responses(
|
|
self,
|
|
response_id: str,
|
|
) -> ErrorResponse | ResponsesResponse:
|
|
async with self.response_store_lock:
|
|
response = self.response_store.get(response_id)
|
|
if response is None:
|
|
return self._make_not_found_error(response_id)
|
|
|
|
prev_status = response.status
|
|
if prev_status not in ("queued", "in_progress"):
|
|
return self.create_error_response(
|
|
err_type="invalid_request_error",
|
|
message="Cannot cancel a synchronous response.",
|
|
param="response_id",
|
|
)
|
|
|
|
# Update the status to "cancelled".
|
|
response.status = "cancelled"
|
|
|
|
# Abort the request.
|
|
if task := self.background_tasks.get(response_id):
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
logger.exception("Background task for %s was cancelled", response_id)
|
|
return response
|
|
|
|
def _make_not_found_error(self, response_id: str) -> ErrorResponse:
|
|
return self.create_error_response(
|
|
err_type="invalid_request_error",
|
|
message=f"Response with id '{response_id}' not found.",
|
|
status_code=HTTPStatus.NOT_FOUND,
|
|
param="response_id",
|
|
)
|
|
|
|
async def _process_simple_streaming_events(
|
|
self,
|
|
request: ResponsesRequest,
|
|
sampling_params: SamplingParams,
|
|
result_generator: AsyncIterator[ConversationContext | None],
|
|
context: ConversationContext,
|
|
model_name: str,
|
|
tokenizer: TokenizerLike,
|
|
request_metadata: RequestResponseMetadata,
|
|
created_time: int,
|
|
_increment_sequence_number_and_return: Callable[
|
|
[StreamingResponsesResponse], StreamingResponsesResponse
|
|
],
|
|
) -> AsyncGenerator[StreamingResponsesResponse, None]:
|
|
processor = SimpleStreamingEventProcessor(tools=request.tools)
|
|
|
|
hide_stream_metadata = not request.include_reasoning and self.parser is not None
|
|
|
|
def _get_logprobs(
|
|
output: CompletionOutput,
|
|
) -> list[response_text_delta_event.Logprob]:
|
|
if not request.is_include_output_logprobs():
|
|
return []
|
|
if hide_stream_metadata:
|
|
return []
|
|
return self._create_stream_response_logprobs(
|
|
token_ids=output.token_ids,
|
|
logprobs=output.logprobs,
|
|
tokenizer=tokenizer,
|
|
top_logprobs=request.top_logprobs,
|
|
)
|
|
|
|
async for ctx in result_generator:
|
|
assert isinstance(ctx, SimpleContext)
|
|
if ctx.last_output is None or not ctx.last_output.outputs:
|
|
continue
|
|
|
|
output = ctx.last_output.outputs[0]
|
|
self._raise_if_error(output.finish_reason, request.request_id)
|
|
delta_text = output.text
|
|
delta_token_ids = as_list(output.token_ids)
|
|
|
|
if ctx.response_parser:
|
|
delta_message = ctx.response_parser.parse_delta(
|
|
delta_text=delta_text,
|
|
delta_token_ids=delta_token_ids,
|
|
request=request,
|
|
prompt_token_ids=ctx.last_output.prompt_token_ids,
|
|
finished=output.finish_reason is not None,
|
|
)
|
|
else:
|
|
delta_message = DeltaMessage(content=output.text)
|
|
|
|
if not delta_message:
|
|
continue
|
|
|
|
for dm in split_delta(delta_message):
|
|
target_state, tool_call = processor.resolve_target_state(dm)
|
|
if target_state == _StateType.NONE:
|
|
continue
|
|
|
|
if processor.needs_transition(target_state, tool_call):
|
|
for event in processor.close_current():
|
|
yield _increment_sequence_number_and_return(event)
|
|
for event in processor.open(target_state, tool_call):
|
|
yield _increment_sequence_number_and_return(event)
|
|
|
|
for event in processor.emit_delta(dm, output, _get_logprobs):
|
|
yield _increment_sequence_number_and_return(event)
|
|
|
|
for event in processor.close_current():
|
|
yield _increment_sequence_number_and_return(event)
|
|
|
|
async def _process_harmony_streaming_events(
|
|
self,
|
|
request: ResponsesRequest,
|
|
sampling_params: SamplingParams,
|
|
result_generator: AsyncIterator[ConversationContext | None],
|
|
context: ConversationContext,
|
|
model_name: str,
|
|
tokenizer: TokenizerLike,
|
|
request_metadata: RequestResponseMetadata,
|
|
created_time: int,
|
|
_increment_sequence_number_and_return: Callable[
|
|
[StreamingResponsesResponse], StreamingResponsesResponse
|
|
],
|
|
) -> AsyncGenerator[StreamingResponsesResponse, None]:
|
|
state = StreamingState()
|
|
|
|
async for ctx in result_generator:
|
|
assert isinstance(ctx, HarmonyContext)
|
|
|
|
# finish_reason='error' indicates a retryable error
|
|
self._raise_if_error(ctx.finish_reason, request.request_id)
|
|
|
|
for segment in ctx.last_append_segments:
|
|
if segment.delta:
|
|
for event in emit_content_delta_events(
|
|
segment, state, ctx.function_tool_names
|
|
):
|
|
yield _increment_sequence_number_and_return(event)
|
|
|
|
elif completed_message := segment.completed_message:
|
|
# TODO: Fix browser emitted as MCP calls
|
|
for event in emit_previous_item_done_events(
|
|
completed_message, state, ctx.function_tool_names
|
|
):
|
|
yield _increment_sequence_number_and_return(event)
|
|
|
|
for event in emit_tool_action_events(
|
|
completed_message, state, self.tool_server
|
|
):
|
|
yield _increment_sequence_number_and_return(event)
|
|
state.reset_for_new_item()
|
|
|
|
async def responses_stream_generator(
|
|
self,
|
|
request: ResponsesRequest,
|
|
sampling_params: SamplingParams,
|
|
result_generator: AsyncIterator[ConversationContext | None],
|
|
context: ConversationContext,
|
|
model_name: str,
|
|
tokenizer: TokenizerLike,
|
|
request_metadata: RequestResponseMetadata,
|
|
created_time: int | None = None,
|
|
) -> AsyncGenerator[StreamingResponsesResponse, None]:
|
|
# TODO:
|
|
# 1. Handle disconnect
|
|
|
|
created_time = created_time or int(time.time())
|
|
|
|
sequence_number = 0
|
|
|
|
def _increment_sequence_number_and_return(
|
|
event: StreamingResponsesResponse,
|
|
) -> StreamingResponsesResponse:
|
|
nonlocal sequence_number
|
|
# Set sequence_number if the event has this attribute
|
|
if hasattr(event, "sequence_number"):
|
|
event.sequence_number = sequence_number
|
|
sequence_number += 1
|
|
return event
|
|
|
|
async with AsyncExitStack() as exit_stack:
|
|
if self.use_harmony:
|
|
# TODO: in streaming, we noticed this bug:
|
|
# https://github.com/vllm-project/vllm/issues/25697
|
|
await self._initialize_tool_sessions(request, context, exit_stack)
|
|
processor = self._process_harmony_streaming_events
|
|
else:
|
|
processor = self._process_simple_streaming_events
|
|
# TODO Hanchen make sampling params to include the structural tag
|
|
|
|
initial_response = ResponsesResponse.from_request(
|
|
request,
|
|
sampling_params,
|
|
model_name=model_name,
|
|
created_time=created_time,
|
|
output=[],
|
|
status="in_progress",
|
|
usage=None,
|
|
).model_dump(mode="json", by_alias=True)
|
|
yield _increment_sequence_number_and_return(
|
|
ResponseCreatedEvent(
|
|
type="response.created",
|
|
sequence_number=-1,
|
|
response=initial_response,
|
|
)
|
|
)
|
|
yield _increment_sequence_number_and_return(
|
|
ResponseInProgressEvent(
|
|
type="response.in_progress",
|
|
sequence_number=-1,
|
|
response=initial_response,
|
|
)
|
|
)
|
|
|
|
try:
|
|
async for event_data in processor(
|
|
request,
|
|
sampling_params,
|
|
result_generator,
|
|
context,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
created_time,
|
|
_increment_sequence_number_and_return,
|
|
):
|
|
yield event_data
|
|
except GenerationError as e:
|
|
error_json = self._convert_generation_error_to_streaming_response(e)
|
|
yield _increment_sequence_number_and_return(
|
|
TypeAdapter(StreamingResponsesResponse).validate_json(error_json)
|
|
)
|
|
return
|
|
|
|
async def empty_async_generator():
|
|
# A hack to trick Python to think this is a generator but
|
|
# in fact it immediately returns.
|
|
if False:
|
|
yield
|
|
|
|
final_response = await self.responses_full_generator(
|
|
request,
|
|
sampling_params,
|
|
empty_async_generator(),
|
|
context,
|
|
model_name,
|
|
tokenizer,
|
|
request_metadata,
|
|
created_time=created_time,
|
|
)
|
|
yield _increment_sequence_number_and_return(
|
|
ResponseCompletedEvent(
|
|
type="response.completed",
|
|
sequence_number=-1,
|
|
response=final_response,
|
|
)
|
|
)
|