613 lines
22 KiB
Python
613 lines
22 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||
import contextlib
|
||
import json
|
||
import time
|
||
from collections.abc import Awaitable, Mapping
|
||
from dataclasses import dataclass, field
|
||
from http import HTTPStatus
|
||
from typing import Any, ClassVar, Generic, Protocol, TypeAlias, TypeVar
|
||
|
||
from fastapi import Request
|
||
from openai.types.responses import ToolChoiceFunction
|
||
from pydantic import ConfigDict, TypeAdapter, ValidationError
|
||
from starlette.datastructures import Headers
|
||
|
||
import vllm.envs as envs
|
||
from vllm.config import ModelConfig
|
||
from vllm.engine.protocol import EngineClient
|
||
from vllm.entrypoints.chat_utils import ChatTemplateContentFormatOption
|
||
from vllm.entrypoints.generate.beam_search.online import BeamSearchOnlineMixin
|
||
from vllm.entrypoints.logger import RequestLogger
|
||
from vllm.entrypoints.openai.chat_completion.protocol import (
|
||
BatchChatCompletionRequest,
|
||
ChatCompletionNamedToolChoiceParam,
|
||
ChatCompletionRequest,
|
||
ChatCompletionResponse,
|
||
)
|
||
from vllm.entrypoints.openai.completion.protocol import (
|
||
CompletionRequest,
|
||
CompletionResponse,
|
||
)
|
||
from vllm.entrypoints.openai.engine.protocol import (
|
||
ErrorResponse,
|
||
FunctionCall,
|
||
FunctionDefinition,
|
||
GenerationError,
|
||
)
|
||
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
|
||
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
|
||
from vllm.entrypoints.serve.disagg.protocol import GenerateRequest, GenerateResponse
|
||
from vllm.entrypoints.serve.tokenize.protocol import (
|
||
DetokenizeRequest,
|
||
TokenizeChatRequest,
|
||
TokenizeCompletionRequest,
|
||
TokenizeResponse,
|
||
)
|
||
from vllm.entrypoints.speech_to_text.transcription.protocol import (
|
||
TranscriptionRequest,
|
||
TranscriptionResponse,
|
||
)
|
||
from vllm.entrypoints.speech_to_text.translation.protocol import TranslationRequest
|
||
from vllm.entrypoints.utils import create_error_response
|
||
from vllm.inputs import EngineInput, PromptType
|
||
from vllm.logger import init_logger
|
||
from vllm.logprobs import Logprob, PromptLogprobs
|
||
from vllm.lora.request import LoRARequest
|
||
from vllm.renderers import ChatParams, TokenizeParams
|
||
from vllm.renderers.inputs.preprocess import (
|
||
extract_prompt_components,
|
||
extract_prompt_len,
|
||
)
|
||
from vllm.sampling_params import BeamSearchParams, SamplingParams
|
||
from vllm.tokenizers import TokenizerLike
|
||
from vllm.tool_parsers import ToolParser
|
||
from vllm.tracing import (
|
||
contains_trace_headers,
|
||
extract_trace_headers,
|
||
log_tracing_disabled_warning,
|
||
)
|
||
from vllm.utils import random_uuid
|
||
from vllm.utils.mistral import is_mistral_tool_parser
|
||
|
||
logger = init_logger(__name__)
|
||
|
||
|
||
class RendererRequest(Protocol):
|
||
def build_tok_params(self, model_config: ModelConfig) -> TokenizeParams:
|
||
raise NotImplementedError
|
||
|
||
|
||
class RendererChatRequest(RendererRequest, Protocol):
|
||
def build_chat_params(
|
||
self,
|
||
default_template: str | None,
|
||
default_template_content_format: ChatTemplateContentFormatOption,
|
||
) -> ChatParams:
|
||
raise NotImplementedError
|
||
|
||
|
||
CompletionLikeRequest: TypeAlias = (
|
||
CompletionRequest | TokenizeCompletionRequest | DetokenizeRequest
|
||
)
|
||
|
||
ChatLikeRequest: TypeAlias = (
|
||
ChatCompletionRequest | BatchChatCompletionRequest | TokenizeChatRequest
|
||
)
|
||
|
||
SpeechToTextRequest: TypeAlias = TranscriptionRequest | TranslationRequest
|
||
|
||
AnyRequest: TypeAlias = (
|
||
CompletionLikeRequest
|
||
| ChatLikeRequest
|
||
| SpeechToTextRequest
|
||
| ResponsesRequest
|
||
| GenerateRequest
|
||
)
|
||
|
||
AnyResponse: TypeAlias = (
|
||
CompletionResponse
|
||
| ChatCompletionResponse
|
||
| TranscriptionResponse
|
||
| TokenizeResponse
|
||
| GenerateResponse
|
||
)
|
||
|
||
RequestT = TypeVar("RequestT", bound=AnyRequest)
|
||
_T = TypeVar("_T")
|
||
|
||
|
||
@dataclass(kw_only=True)
|
||
class ServeContext(Generic[RequestT]):
|
||
request: RequestT
|
||
raw_request: Request | None = None
|
||
model_name: str
|
||
request_id: str
|
||
created_time: int = field(default_factory=lambda: int(time.time()))
|
||
lora_request: LoRARequest | None = None
|
||
engine_inputs: list[EngineInput] | None = None
|
||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||
|
||
|
||
class OpenAIServing(BeamSearchOnlineMixin):
|
||
request_id_prefix: ClassVar[str] = """
|
||
A short string prepended to every request’s ID.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
engine_client: EngineClient,
|
||
models: OpenAIServingModels,
|
||
*,
|
||
request_logger: RequestLogger | None,
|
||
return_tokens_as_token_ids: bool = False,
|
||
):
|
||
super().__init__()
|
||
|
||
self.engine_client = engine_client
|
||
self.models = models
|
||
|
||
self.request_logger = request_logger
|
||
self.return_tokens_as_token_ids = return_tokens_as_token_ids
|
||
|
||
self.model_config = engine_client.model_config
|
||
self.renderer = engine_client.renderer
|
||
self.input_processor = engine_client.input_processor
|
||
vllm_config = getattr(engine_client, "vllm_config", None)
|
||
kv_transfer_config = getattr(vllm_config, "kv_transfer_config", None)
|
||
self.has_kv_connector = kv_transfer_config is not None
|
||
|
||
# Computed once at startup (cached by ``vllm_config`` identity) and
|
||
# stamped on non-streaming responses. Streaming chunks deliberately
|
||
# omit it to avoid per-chunk overhead.
|
||
from vllm.entrypoints.openai.fingerprint import get_system_fingerprint
|
||
|
||
try:
|
||
self.system_fingerprint: str | None = get_system_fingerprint(
|
||
engine_client.vllm_config
|
||
)
|
||
except Exception:
|
||
# Never fail server startup over the fingerprint.
|
||
self.system_fingerprint = None
|
||
|
||
@staticmethod
|
||
def create_error_response(
|
||
message: str | Exception,
|
||
err_type: str = "BadRequestError",
|
||
status_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
|
||
param: str | None = None,
|
||
) -> ErrorResponse:
|
||
return create_error_response(message, err_type, status_code, param)
|
||
|
||
def create_streaming_error_response(
|
||
self,
|
||
message: str | Exception,
|
||
err_type: str = "BadRequestError",
|
||
status_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
|
||
param: str | None = None,
|
||
) -> str:
|
||
json_str = json.dumps(
|
||
self.create_error_response(
|
||
message=message,
|
||
err_type=err_type,
|
||
status_code=status_code,
|
||
param=param,
|
||
).model_dump()
|
||
)
|
||
return json_str
|
||
|
||
def _raise_if_error(self, finish_reason: str | None, request_id: str) -> None:
|
||
"""Raise GenerationError if finish_reason indicates an error."""
|
||
if finish_reason == "error":
|
||
logger.error(
|
||
"Request %s failed with an internal error during generation",
|
||
request_id,
|
||
)
|
||
raise GenerationError("Internal server error")
|
||
|
||
def _convert_generation_error_to_streaming_response(
|
||
self, e: GenerationError
|
||
) -> str:
|
||
"""Convert GenerationError to streaming error response."""
|
||
return self.create_streaming_error_response(
|
||
str(e),
|
||
err_type="InternalServerError",
|
||
status_code=e.status_code,
|
||
)
|
||
|
||
async def _check_model(
|
||
self,
|
||
request: AnyRequest,
|
||
) -> ErrorResponse | None:
|
||
error_response = None
|
||
|
||
if self._is_model_supported(request.model):
|
||
return None
|
||
if request.model in self.models.lora_requests:
|
||
return None
|
||
if (
|
||
envs.VLLM_ALLOW_RUNTIME_LORA_UPDATING
|
||
and request.model
|
||
and (load_result := await self.models.resolve_lora(request.model))
|
||
):
|
||
if isinstance(load_result, LoRARequest):
|
||
return None
|
||
if (
|
||
isinstance(load_result, ErrorResponse)
|
||
and load_result.error.code == HTTPStatus.BAD_REQUEST.value
|
||
):
|
||
error_response = load_result
|
||
|
||
return error_response or self.create_error_response(
|
||
message=f"The model `{request.model}` does not exist.",
|
||
err_type="NotFoundError",
|
||
status_code=HTTPStatus.NOT_FOUND,
|
||
param="model",
|
||
)
|
||
|
||
def _get_active_default_mm_loras(self, request: AnyRequest) -> LoRARequest | None:
|
||
"""Determine if there are any active default multimodal loras."""
|
||
# TODO: Currently this is only enabled for chat completions
|
||
# to be better aligned with only being enabled for .generate
|
||
# when run offline. It would be nice to support additional
|
||
# tasks types in the future.
|
||
message_types = self._get_message_types(request)
|
||
default_mm_loras = set()
|
||
|
||
for lora in self.models.lora_requests.values():
|
||
# Best effort match for default multimodal lora adapters;
|
||
# There is probably a better way to do this, but currently
|
||
# this matches against the set of 'types' in any content lists
|
||
# up until '_', e.g., to match audio_url -> audio
|
||
if lora.lora_name in message_types:
|
||
default_mm_loras.add(lora)
|
||
|
||
# Currently only support default modality specific loras if
|
||
# we have exactly one lora matched on the request.
|
||
if len(default_mm_loras) == 1:
|
||
return default_mm_loras.pop()
|
||
return None
|
||
|
||
def _maybe_get_adapters(
|
||
self,
|
||
request: AnyRequest,
|
||
supports_default_mm_loras: bool = False,
|
||
) -> LoRARequest | None:
|
||
if request.model in self.models.lora_requests:
|
||
return self.models.lora_requests[request.model]
|
||
|
||
# Currently only support default modality specific loras
|
||
# if we have exactly one lora matched on the request.
|
||
if supports_default_mm_loras:
|
||
default_mm_lora = self._get_active_default_mm_loras(request)
|
||
if default_mm_lora is not None:
|
||
return default_mm_lora
|
||
|
||
if self._is_model_supported(request.model):
|
||
return None
|
||
|
||
# if _check_model has been called earlier, this will be unreachable
|
||
raise ValueError(f"The model `{request.model}` does not exist.")
|
||
|
||
def _get_message_types(self, request: AnyRequest) -> set[str]:
|
||
"""Retrieve the set of types from message content dicts up
|
||
until `_`; we use this to match potential multimodal data
|
||
with default per modality loras.
|
||
"""
|
||
message_types: set[str] = set()
|
||
|
||
if not hasattr(request, "messages"):
|
||
return message_types
|
||
|
||
messages = request.messages
|
||
if messages is None or isinstance(messages, (str, bytes)):
|
||
return message_types
|
||
|
||
for message in messages:
|
||
if (
|
||
isinstance(message, dict)
|
||
and "content" in message
|
||
and isinstance(message["content"], list)
|
||
):
|
||
for content_dict in message["content"]:
|
||
if "type" in content_dict:
|
||
message_types.add(content_dict["type"].split("_")[0])
|
||
return message_types
|
||
|
||
def _validate_chat_template(
|
||
self,
|
||
request_chat_template: str | None,
|
||
chat_template_kwargs: dict[str, Any] | None,
|
||
trust_request_chat_template: bool,
|
||
) -> ErrorResponse | None:
|
||
if not trust_request_chat_template and (
|
||
request_chat_template is not None
|
||
or (
|
||
chat_template_kwargs
|
||
and chat_template_kwargs.get("chat_template") is not None
|
||
)
|
||
):
|
||
return self.create_error_response(
|
||
"Chat template is passed with request, but "
|
||
"--trust-request-chat-template is not set. "
|
||
"Refused request with untrusted chat template."
|
||
)
|
||
return None
|
||
|
||
@staticmethod
|
||
def _prepare_extra_chat_template_kwargs(
|
||
request_chat_template_kwargs: dict[str, Any] | None = None,
|
||
default_chat_template_kwargs: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Helper to merge server-default and request-specific chat template kwargs."""
|
||
request_chat_template_kwargs = request_chat_template_kwargs or {}
|
||
if default_chat_template_kwargs is None:
|
||
return request_chat_template_kwargs
|
||
# Apply server defaults first, then request kwargs override.
|
||
return default_chat_template_kwargs | request_chat_template_kwargs
|
||
|
||
def _extract_prompt_components(self, prompt: PromptType | EngineInput):
|
||
return extract_prompt_components(self.model_config, prompt)
|
||
|
||
def _extract_prompt_text(self, prompt: PromptType | EngineInput):
|
||
return self._extract_prompt_components(prompt).text
|
||
|
||
def _extract_prompt_len(self, prompt: EngineInput):
|
||
return extract_prompt_len(self.model_config, prompt)
|
||
|
||
def _log_inputs(
|
||
self,
|
||
request_id: str,
|
||
inputs: PromptType | EngineInput,
|
||
params: SamplingParams | BeamSearchParams | None,
|
||
lora_request: LoRARequest | None,
|
||
) -> None:
|
||
if self.request_logger is None:
|
||
return
|
||
|
||
components = self._extract_prompt_components(inputs)
|
||
|
||
self.request_logger.log_inputs(
|
||
request_id,
|
||
components.text,
|
||
components.token_ids,
|
||
components.embeds,
|
||
params=params,
|
||
lora_request=lora_request,
|
||
)
|
||
|
||
async def _get_trace_headers(
|
||
self,
|
||
headers: Headers,
|
||
) -> Mapping[str, str] | None:
|
||
is_tracing_enabled = await self.engine_client.is_tracing_enabled()
|
||
|
||
if is_tracing_enabled:
|
||
return extract_trace_headers(headers)
|
||
|
||
if contains_trace_headers(headers):
|
||
log_tracing_disabled_warning()
|
||
|
||
return None
|
||
|
||
@staticmethod
|
||
def _base_request_id(
|
||
raw_request: Request | None, default: str | None = None
|
||
) -> str | None:
|
||
"""Pulls the request id to use from a header, if provided"""
|
||
if raw_request is not None and (
|
||
(req_id := raw_request.headers.get("X-Request-Id")) is not None
|
||
):
|
||
return req_id
|
||
|
||
return random_uuid() if default is None else default
|
||
|
||
@staticmethod
|
||
def _get_data_parallel_rank(raw_request: Request | None) -> int | None:
|
||
"""Pulls the data parallel rank from a header, if provided"""
|
||
if raw_request is None:
|
||
return None
|
||
|
||
rank_str = raw_request.headers.get("X-data-parallel-rank")
|
||
if rank_str is None:
|
||
return None
|
||
|
||
try:
|
||
return int(rank_str)
|
||
except ValueError:
|
||
return None
|
||
|
||
async def _with_kv_transfer_rejection_cleanup(
|
||
self,
|
||
awaitable: Awaitable[_T],
|
||
request: ChatCompletionRequest | CompletionRequest | ResponsesRequest,
|
||
raw_request: Request | None,
|
||
) -> _T:
|
||
"""Wrap a `create_*` coroutine so that, if it raises or returns an
|
||
ErrorResponse (i.e. the request never reached the engine), the KV
|
||
connector is notified to free any pinned remote-prefill blocks."""
|
||
kv_transfer_params = self.has_kv_connector and request.kv_transfer_params
|
||
if not kv_transfer_params or not kv_transfer_params.get("do_remote_prefill"):
|
||
return await awaitable
|
||
|
||
notify = True
|
||
try:
|
||
result = await awaitable
|
||
if not isinstance(result, ErrorResponse):
|
||
notify = False
|
||
return result
|
||
finally:
|
||
if notify:
|
||
try:
|
||
await self.engine_client.notify_kv_transfer_request_rejected(
|
||
request.request_id,
|
||
kv_transfer_params,
|
||
data_parallel_rank=self._get_data_parallel_rank(raw_request),
|
||
)
|
||
except Exception:
|
||
logger.warning(
|
||
"Failed to notify KV connector about rejected request %s",
|
||
request.request_id,
|
||
exc_info=True,
|
||
)
|
||
|
||
@staticmethod
|
||
def _parse_tool_calls_from_content(
|
||
request: ResponsesRequest | ChatCompletionRequest,
|
||
tokenizer: TokenizerLike | None,
|
||
enable_auto_tools: bool,
|
||
tool_parser_cls: type[ToolParser] | None,
|
||
content: str | None = None,
|
||
) -> tuple[list[FunctionCall] | None, str | None]:
|
||
# When the Mistral grammar factory injected structured outputs,
|
||
# let the parser handle the output.
|
||
use_mistral_tool_parser = (
|
||
isinstance(request, ChatCompletionRequest)
|
||
and is_mistral_tool_parser(tool_parser_cls)
|
||
and request._grammar_from_tool_parser
|
||
)
|
||
|
||
function_calls = list[FunctionCall]()
|
||
if (
|
||
not use_mistral_tool_parser
|
||
and request.tool_choice
|
||
and isinstance(request.tool_choice, ToolChoiceFunction)
|
||
):
|
||
# Forced Function Call (Responses API)
|
||
if content is None:
|
||
return [], None
|
||
function_calls.append(
|
||
FunctionCall(name=request.tool_choice.name, arguments=content)
|
||
)
|
||
content = None # Clear content since tool is called.
|
||
elif (
|
||
not use_mistral_tool_parser
|
||
and request.tool_choice
|
||
and isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam)
|
||
and (tool_parser_cls is None or tool_parser_cls.supports_required_and_named)
|
||
):
|
||
# Named function with standard JSON-based parsing
|
||
if content is None:
|
||
return [], None
|
||
function_calls.append(
|
||
FunctionCall(name=request.tool_choice.function.name, arguments=content)
|
||
)
|
||
content = None # Clear content since tool is called.
|
||
elif (
|
||
not use_mistral_tool_parser
|
||
and request.tool_choice == "required"
|
||
and (tool_parser_cls is None or tool_parser_cls.supports_required_and_named)
|
||
):
|
||
# "required" with standard JSON-based parsing
|
||
tool_calls = []
|
||
with contextlib.suppress(ValidationError):
|
||
content = content or ""
|
||
tool_calls = TypeAdapter(list[FunctionDefinition]).validate_json(
|
||
content
|
||
)
|
||
for tool_call in tool_calls:
|
||
function_calls.append(
|
||
FunctionCall(
|
||
name=tool_call.name,
|
||
arguments=json.dumps(tool_call.parameters, ensure_ascii=False),
|
||
)
|
||
)
|
||
content = None # Clear content since tool is called.
|
||
elif tool_parser_cls and (
|
||
use_mistral_tool_parser
|
||
or (
|
||
enable_auto_tools
|
||
and (
|
||
request.tool_choice == "auto"
|
||
or request.tool_choice is None
|
||
or (
|
||
not tool_parser_cls.supports_required_and_named
|
||
and request.tools
|
||
and (
|
||
request.tool_choice == "required"
|
||
or isinstance(
|
||
request.tool_choice,
|
||
ChatCompletionNamedToolChoiceParam,
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
):
|
||
# Automatic Tool Call Parsing (also used as fallback for
|
||
# required/named when supports_required_and_named=False)
|
||
if tokenizer is None:
|
||
raise ValueError(
|
||
"Tokenizer not available when `skip_tokenizer_init=True`"
|
||
)
|
||
|
||
try:
|
||
tool_parser = tool_parser_cls(tokenizer, request.tools)
|
||
except RuntimeError as e:
|
||
logger.exception("Error in tool parser creation.")
|
||
raise e
|
||
tool_call_info = tool_parser.extract_tool_calls(
|
||
content if content is not None else "",
|
||
request=request, # type: ignore
|
||
)
|
||
if tool_call_info is not None and tool_call_info.tools_called:
|
||
# extract_tool_calls() returns a list of tool calls.
|
||
function_calls.extend(
|
||
FunctionCall(
|
||
id=tool_call.id,
|
||
name=tool_call.function.name,
|
||
arguments=tool_call.function.arguments,
|
||
)
|
||
for tool_call in tool_call_info.tool_calls
|
||
)
|
||
content = tool_call_info.content
|
||
if content and content.strip() == "":
|
||
content = None
|
||
else:
|
||
# No tool calls.
|
||
return None, content
|
||
|
||
return function_calls, content
|
||
|
||
@staticmethod
|
||
def _get_decoded_token(
|
||
logprob: Logprob,
|
||
token_id: int,
|
||
tokenizer: TokenizerLike | None,
|
||
return_as_token_id: bool = False,
|
||
) -> str:
|
||
if return_as_token_id:
|
||
return f"token_id:{token_id}"
|
||
|
||
if logprob.decoded_token is not None:
|
||
return logprob.decoded_token
|
||
|
||
if tokenizer is None:
|
||
raise ValueError(
|
||
"Unable to get tokenizer because `skip_tokenizer_init=True`"
|
||
)
|
||
|
||
return tokenizer.decode([token_id])
|
||
|
||
def _is_model_supported(self, model_name: str | None) -> bool:
|
||
if not model_name:
|
||
return True
|
||
if envs.VLLM_SKIP_MODEL_NAME_VALIDATION:
|
||
return True
|
||
return self.models.is_base_model(model_name)
|
||
|
||
|
||
def clamp_prompt_logprobs(
|
||
prompt_logprobs: PromptLogprobs | None,
|
||
) -> PromptLogprobs | None:
|
||
if prompt_logprobs is None:
|
||
return prompt_logprobs
|
||
|
||
for logprob_dict in prompt_logprobs:
|
||
if logprob_dict is None:
|
||
continue
|
||
for logprob_values in logprob_dict.values():
|
||
if logprob_values.logprob == float("-inf"):
|
||
logprob_values.logprob = -9999.0
|
||
return prompt_logprobs
|