152 lines
6.1 KiB
Python
152 lines
6.1 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
import asyncio
|
|
import time
|
|
|
|
from fastapi import Request
|
|
from pydantic import ValidationError
|
|
from vllm.entrypoints.openai.chat_completion.protocol import (
|
|
BatchChatCompletionRequest,
|
|
ChatCompletionRequest,
|
|
ChatCompletionResponse,
|
|
ChatCompletionResponseChoice,
|
|
)
|
|
from vllm.entrypoints.openai.engine.protocol import (
|
|
ErrorResponse,
|
|
UsageInfo,
|
|
)
|
|
from vllm.logger import init_logger
|
|
|
|
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
class OmniOpenAIServingChatBatch(OmniOpenAIServingChat):
|
|
@staticmethod
|
|
def _maybe_collapse_choices(choices: list[ChatCompletionResponseChoice]):
|
|
"""Collapse content + audio choices into one choice.
|
|
|
|
NOTE: This is a hack for models that produce separate text and
|
|
audio choices to ensure we have 1:1 mapping between number of
|
|
inputs in the batch and responses.
|
|
"""
|
|
content_choice, audio_choice = None, None
|
|
if len(choices) == 1:
|
|
return choices[0]
|
|
if len(choices) != 2:
|
|
raise ValueError(f"Expected 1 or 2 choices to collapse, got {len(choices)}. ")
|
|
|
|
for choice in choices:
|
|
if choice.message.audio:
|
|
if audio_choice is not None:
|
|
raise ValueError("Multiple audio choices cannot be set in one completion message")
|
|
audio_choice = choice
|
|
else:
|
|
if content_choice is not None:
|
|
raise ValueError("Multiple content choices cannot be set in one completion message")
|
|
content_choice = choice
|
|
|
|
if content_choice is None or audio_choice is None:
|
|
raise ValueError(
|
|
"Expected one content and one audio choice, but got: "
|
|
f"content={content_choice is not None}, audio={audio_choice is not None}"
|
|
)
|
|
content_choice.message.audio = audio_choice.message.audio
|
|
return content_choice
|
|
|
|
@staticmethod
|
|
def _get_subrequest_ids(base_id, request: BatchChatCompletionRequest) -> list[str]:
|
|
"""Get the request ID for each entry in the batch request to be resubmitted to chat completions."""
|
|
return [f"{base_id}-idx-{idx}" for idx in range(len(request.messages))]
|
|
|
|
async def create_batch_chat_completion(
|
|
self,
|
|
request: BatchChatCompletionRequest,
|
|
raw_request: Request,
|
|
) -> ChatCompletionResponse | ErrorResponse:
|
|
"""Given a request, submit each request to chat completions & collect the results."""
|
|
model = ""
|
|
enabled_streaming = False
|
|
chat_requests: list[ChatCompletionRequest] = []
|
|
choices: list[ChatCompletionResponseChoice] = []
|
|
total_prompt_tokens = 0
|
|
total_completion_tokens = 0
|
|
|
|
# Get the base request from the raw_request before maybe mutating the header
|
|
base_id = OmniOpenAIServingChatBatch._base_request_id(
|
|
raw_request,
|
|
request.request_id,
|
|
)
|
|
batch_req_id = f"chatcmpl-batch-{base_id}"
|
|
sub_req_ids = self._get_subrequest_ids(base_id, request)
|
|
|
|
# Remove the raw request header if it exists now that we have subreq IDs,
|
|
# since this takes priority over the object request_ids
|
|
if raw_request.headers.get("X-Request-Id"):
|
|
copy_headers = raw_request.headers.mutablecopy()
|
|
del copy_headers["X-Request-Id"]
|
|
raw_request._headers = copy_headers
|
|
|
|
for idx, msg in enumerate(request.messages):
|
|
# Update the current request ID to avoid subrequest collisions
|
|
request.request_id = sub_req_ids[idx]
|
|
try:
|
|
chat_cmp_request = request.to_chat_completion_request(msg)
|
|
except ValidationError as e:
|
|
return self._create_error_response(
|
|
f"Message {idx} could not be converted to a chat completion request: {e}",
|
|
)
|
|
# Streaming isn't supported for batch chat completions,
|
|
# so always set to False & warn if it was requested.
|
|
enabled_streaming |= chat_cmp_request.stream
|
|
chat_cmp_request.stream = False
|
|
chat_requests.append(chat_cmp_request)
|
|
|
|
if enabled_streaming:
|
|
logger.warning("Streaming is not supported for batched chat completions; ignoring stream=True.")
|
|
|
|
# Submit each chat completion request as a task, then gather results.
|
|
# TODO (Alex): optimize this
|
|
tasks = [asyncio.create_task(self.create_chat_completion(c, raw_request)) for c in chat_requests]
|
|
try:
|
|
results = await asyncio.gather(*tasks)
|
|
finally:
|
|
# Ensure we cancel remaining tasks if needed, e.g., early exit due to bad behavior
|
|
for t in tasks:
|
|
if not t.done():
|
|
t.cancel()
|
|
|
|
for i, resp in enumerate(results):
|
|
if isinstance(resp, ErrorResponse):
|
|
return resp
|
|
completion: ChatCompletionResponse = resp
|
|
model = completion.model
|
|
# FIXME (Alex): We should probably handle this in chat completions,
|
|
# not here, but we need to ensure streaming is properly handled
|
|
try:
|
|
collapsed_choice = self._maybe_collapse_choices(completion.choices)
|
|
except ValueError as e:
|
|
return self._create_error_response(
|
|
f"Failed to collapse choices with error: {e}",
|
|
)
|
|
collapsed_choice.index = i
|
|
choices.append(collapsed_choice)
|
|
if completion.usage:
|
|
total_prompt_tokens += completion.usage.prompt_tokens
|
|
total_completion_tokens += completion.usage.completion_tokens
|
|
|
|
usage = UsageInfo(
|
|
prompt_tokens=total_prompt_tokens,
|
|
completion_tokens=total_completion_tokens,
|
|
total_tokens=total_prompt_tokens + total_completion_tokens,
|
|
)
|
|
|
|
return ChatCompletionResponse(
|
|
id=batch_req_id,
|
|
created=int(time.time()),
|
|
model=model,
|
|
choices=choices,
|
|
usage=usage,
|
|
)
|