Files
vllm-project--vllm/vllm/entrypoints/openai/responses/utils.py
T
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

390 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Iterable
from typing import Any
from openai.types.chat import (
ChatCompletionAssistantMessageParam,
ChatCompletionMessageToolCallParam,
ChatCompletionToolMessageParam,
)
from openai.types.chat.chat_completion_message_tool_call_param import (
Function as FunctionCallTool,
)
from openai.types.responses import (
ResponseFunctionToolCall,
ResponseOutputItem,
ResponseOutputMessage,
ResponseOutputText,
ResponseReasoningItem,
)
from openai.types.responses.response import ToolChoice
from openai.types.responses.response_function_tool_call_output_item import (
ResponseFunctionToolCallOutputItem,
)
from openai.types.responses.response_output_text import Logprob
from openai.types.responses.response_reasoning_item import (
Content as ResponseReasoningTextContent,
)
from openai.types.responses.tool import Tool
from vllm import envs
from vllm.entrypoints.chat_utils import make_tool_call_id
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionMessageParam
from vllm.entrypoints.openai.engine.protocol import FunctionCall
from vllm.entrypoints.openai.responses.protocol import ResponseInputOutputItem
from vllm.logger import init_logger
from vllm.tool_parsers.utils import (
build_responses_tool_call_name_map,
flat_namespace_tool_name,
iter_response_function_tool_dicts,
resolve_responses_tool_call_name,
)
from vllm.utils import random_uuid
logger = init_logger(__name__)
def build_response_output_items(
reasoning: str | None,
content: str | None,
tool_calls: list[FunctionCall] | None,
logprobs: list[Logprob] | None = None,
tools: list[Tool] | None = None,
) -> list[ResponseOutputItem]:
outputs: list[ResponseOutputItem] = []
tool_call_name_map = build_responses_tool_call_name_map(tools)
if reasoning:
outputs.append(
ResponseReasoningItem(
id=f"rs_{random_uuid()}",
summary=[],
type="reasoning",
content=[
ResponseReasoningTextContent(text=reasoning, type="reasoning_text")
],
status=None,
)
)
if content:
outputs.append(
ResponseOutputMessage(
id=f"msg_{random_uuid()}",
content=[
ResponseOutputText(
text=content,
annotations=[],
type="output_text",
logprobs=logprobs,
)
],
role="assistant",
status="completed",
type="message",
)
)
if tool_calls:
for idx, tool_call in enumerate(tool_calls):
call_name = resolve_responses_tool_call_name(
tool_call.name, tool_call_name_map=tool_call_name_map
)
outputs.append(
ResponseFunctionToolCall(
id=f"fc_{random_uuid()}",
call_id=tool_call.id
or make_tool_call_id(func_name=tool_call.name, idx=idx),
type="function_call",
status="completed",
name=call_name.name,
namespace=call_name.namespace,
arguments=tool_call.arguments,
)
)
return outputs
def should_continue_final_message(
request_input: str | list[ResponseInputOutputItem],
) -> bool:
"""
Determine if the last input message is a partial assistant message
that should be continued rather than starting a new generation.
This enables partial message completion similar to Anthropic's Messages API,
where users can provide an incomplete assistant message and have the model
continue from where it left off.
A message is considered partial if:
1. It's a ResponseOutputMessage or ResponseReasoningItem
2. Its status is "in_progress" or "incomplete"
Args:
request_input: The input to the Responses API request
Returns:
True if the final message should be continued, False otherwise
"""
if isinstance(request_input, str):
# Simple string input is always a user message
return False
if not request_input:
return False
last_item = request_input[-1]
# Check if the last item is a partial assistant message
if isinstance(last_item, ResponseOutputMessage):
return last_item.status in ("in_progress", "incomplete")
# Check if the last item is a partial reasoning item
if isinstance(last_item, ResponseReasoningItem):
return last_item.status in ("in_progress", "incomplete")
if isinstance(last_item, dict):
# only support partial completion for messages for now
if last_item.get("type", "message") not in ("message", "reasoning"):
return False
return last_item.get("status") in ("in_progress", "incomplete")
return False
def construct_input_messages(
*,
request_instructions: str | None = None,
request_input: str | list[ResponseInputOutputItem],
prev_msg: list[ChatCompletionMessageParam] | None = None,
prev_response_output: list[ResponseOutputItem] | None = None,
):
messages: list[ChatCompletionMessageParam] = []
if request_instructions:
messages.append(
{
"role": "system",
"content": request_instructions,
}
)
# Prepend the conversation history.
if prev_msg is not None:
# Filter out system messages from previous conversation -- per the
# OpenAI spec, instructions should NOT carry over across responses.
# The current request's instructions (if any) were already added above.
messages.extend(m for m in prev_msg if m.get("role") != "system")
if prev_response_output is not None:
# Add the previous output.
for output_item in prev_response_output:
# NOTE: We skip the reasoning output.
if isinstance(output_item, ResponseOutputMessage):
for content in output_item.content:
messages.append(
{
"role": "assistant",
"content": content.text,
}
)
# Append the new input.
# Responses API supports simple text inputs without chat format.
if isinstance(request_input, str):
messages.append({"role": "user", "content": request_input})
else:
input_messages = construct_chat_messages_with_tool_call(request_input)
messages.extend(input_messages)
return messages
def construct_chat_messages_with_tool_call(
input_messages: list[ResponseInputOutputItem],
) -> list[ChatCompletionMessageParam]:
"""Build chat messages from response items.
Some chat messages span multiple response items (e.g., reasoning + tool calls).
"""
messages: list[ChatCompletionMessageParam] = []
for item in input_messages:
message = _construct_message_from_response_item(
item, prev_msg=messages[-1] if messages else None
)
if message is not None:
messages.append(message)
return messages
def _construct_message_from_response_item(
item: ResponseInputOutputItem,
prev_msg: ChatCompletionMessageParam | None = None,
) -> ChatCompletionMessageParam | None:
"""
Returns a new message or None. If `None`, `prev_msg` might be updated.
If `prev_msg` is `None`, a new message is always returned.
"""
prev_assistant_msg = (
prev_msg if prev_msg and prev_msg.get("role") == "assistant" else None
)
if isinstance(item, ResponseFunctionToolCall):
tool_name = item.name
if item.namespace:
tool_name = flat_namespace_tool_name(item.namespace, item.name)
tool_call = ChatCompletionMessageToolCallParam(
id=item.call_id,
function=FunctionCallTool(
name=tool_name,
arguments=item.arguments,
),
type="function",
)
if prev_assistant_msg:
tool_calls = prev_assistant_msg.get("tool_calls")
if tool_calls is None:
prev_assistant_msg["tool_calls"] = [tool_call]
return None
if isinstance(tool_calls, list):
tool_calls.append(tool_call)
return None
if isinstance(tool_calls, Iterable) and not isinstance(
tool_calls, (dict, str)
):
tool_calls = list(tool_calls)
tool_calls.append(tool_call)
prev_assistant_msg["tool_calls"] = tool_calls
return None
logger.warning(
"Previous assistant message has unknown tool_calls format. "
"Tool call merging is skipped and a new assistant message is created. "
"Item %s",
item.id,
)
return ChatCompletionAssistantMessageParam(
role="assistant",
tool_calls=[tool_call],
)
elif isinstance(item, ResponseReasoningItem):
reasoning = ""
if item.encrypted_content:
raise ValueError("Encrypted content is not supported.")
elif item.content and len(item.content) >= 1:
reasoning = item.content[0].text
elif len(item.summary) >= 1:
reasoning = item.summary[0].text
logger.warning(
"Using summary text as reasoning content for item %s. "
"Please use content instead of summary for "
"reasoning items.",
item.id,
)
if prev_assistant_msg:
previous_reasoning = prev_assistant_msg.get("reasoning")
if previous_reasoning is None:
prev_assistant_msg["reasoning"] = reasoning
return None
return {
"role": "assistant",
"reasoning": reasoning,
}
elif isinstance(item, ResponseOutputMessage):
output_text = item.content[0].text
if prev_assistant_msg:
previous_content = prev_assistant_msg.get("content")
if previous_content is None:
prev_assistant_msg["content"] = output_text
return None
return {
"role": "assistant",
"content": output_text,
}
elif isinstance(item, ResponseFunctionToolCallOutputItem):
return ChatCompletionToolMessageParam(
role="tool",
content=item.output,
tool_call_id=item.call_id,
)
elif isinstance(item, dict) and item.get("type") == "function_call_output":
# Append the function call output as a tool message.
return ChatCompletionToolMessageParam(
role="tool",
content=item.get("output"),
tool_call_id=item.get("call_id"),
)
elif isinstance(item, dict) and item.get("role") == "assistant":
content = item.get("content")
text: str | None = None
if isinstance(content, str):
text = content
elif isinstance(content, list) and content:
text = content[0].get("text")
if text is not None:
if prev_assistant_msg:
previous_content = prev_assistant_msg.get("content")
if previous_content is None:
prev_assistant_msg["content"] = text
return None
return {"role": "assistant", "content": text}
return item # type: ignore[arg-type]
def extract_function_tool_names(tools: list[Tool]) -> frozenset[str]:
names = []
for tool in tools:
if tool.type == "function":
names.append(tool.name)
elif tool.type == "namespace":
names.extend(
flat_namespace_tool_name(tool.name, namespaced_tool.name)
for namespaced_tool in tool.tools
if namespaced_tool.type == "function"
)
return frozenset(names)
def extract_tool_types(tools: list[Tool]) -> set[str]:
"""
Extracts the tool types from the given tools.
"""
tool_types: set[str] = set()
for tool in tools:
if tool.type == "mcp":
# Allow the MCP Tool type to enable built in tools if the
# server_label is allowlisted in
# envs.VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS
if tool.server_label in envs.VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS:
tool_types.add(tool.server_label)
else:
tool_types.add(tool.type)
return tool_types
def convert_tool_responses_to_completions_format(tool: dict) -> dict:
"""
Convert a flat tool schema:
{"type": "function", "name": "...", "description": "...", "parameters": {...}}
into:
{"type": "function", "function": {...}}
"""
return {
"type": "function",
"function": tool,
}
def construct_tool_dicts(
tools: list[Tool], tool_choice: ToolChoice
) -> list[dict[str, Any]] | None:
if not tools or (tool_choice == "none"):
tool_dicts = None
else:
tool_dicts = [
convert_tool_responses_to_completions_format(tool)
for tool in iter_response_function_tool_dicts(tools)
]
return tool_dicts