Files
vllm-omni/vllm_omni/entrypoints/openai/api_server.py
T
2025-12-27 13:56:51 +08:00

682 lines
27 KiB
Python

import multiprocessing
import multiprocessing.forkserver as forkserver
import os
# Image generation API imports
import time
from argparse import Namespace
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from http import HTTPStatus
from typing import Any
import vllm.envs as envs
from fastapi import Depends, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from starlette.datastructures import State
from vllm.config import VllmConfig
from vllm.engine.protocol import EngineClient
from vllm.entrypoints.chat_utils import load_chat_template, resolve_hf_chat_template, resolve_mistral_chat_template
from vllm.entrypoints.launcher import serve_http
from vllm.entrypoints.logger import RequestLogger
from vllm.entrypoints.openai.api_server import (
base,
build_app,
load_log_config,
maybe_register_tokenizer_info_endpoint,
router,
setup_server,
validate_json_request,
)
from vllm.entrypoints.openai.protocol import ChatCompletionRequest, ChatCompletionResponse, ErrorResponse
from vllm.entrypoints.openai.serving_models import BaseModelPath, LoRAModulePath, OpenAIServingModels
from vllm.entrypoints.openai.tool_parsers import ToolParserManager
# yapf conflicts with isort for this block
# yapf: disable
# yapf: enable
from vllm.entrypoints.tool_server import DemoToolServer, MCPToolServer, ToolServer
from vllm.entrypoints.utils import load_aware_call, with_cancellation
from vllm.logger import init_logger
from vllm.tokenizers import MistralTokenizer
from vllm.utils.system_utils import decorate_logs
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.openai.image_api_utils import (
encode_image_base64,
parse_size,
)
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
from vllm_omni.entrypoints.openai.protocol.images import (
ImageData,
ImageGenerationRequest,
ImageGenerationResponse,
)
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
logger = init_logger(__name__)
# Server entry points
async def omni_run_server(args, **uvicorn_kwargs) -> None:
"""Run a single-worker API server.
Unified entry point that automatically handles both LLM and Diffusion models
through AsyncOmni, which manages multi-stage pipelines.
"""
# Suppress Pydantic serialization warnings globally for multimodal content
# (e.g., when ChatMessage.content is a list instead of str)
import warnings as warnings_module
warnings_module.filterwarnings("ignore", message=".*Pydantic.*serialization.*", category=UserWarning)
warnings_module.filterwarnings("ignore", message=".*PydanticSerializationUnexpectedValue.*", category=UserWarning)
# Add process-specific prefix to stdout and stderr.
decorate_logs("APIServer")
listen_address, sock = setup_server(args)
# Unified use of omni_run_server_worker, AsyncOmni automatically handles LLM and Diffusion models
await omni_run_server_worker(listen_address, sock, args, **uvicorn_kwargs)
async def omni_run_server_worker(listen_address, sock, args, client_config=None, **uvicorn_kwargs) -> None:
"""Run a single API server worker."""
if args.tool_parser_plugin and len(args.tool_parser_plugin) > 3:
ToolParserManager.import_tool_parser(args.tool_parser_plugin)
# Load logging config for uvicorn if specified
log_config = load_log_config(args.log_config_file)
if log_config is not None:
uvicorn_kwargs["log_config"] = log_config
async with build_async_omni(
args,
client_config=client_config,
) as engine_client:
maybe_register_tokenizer_info_endpoint(args)
app = build_app(args)
vllm_config = await engine_client.get_vllm_config()
await omni_init_app_state(engine_client, vllm_config, app.state, args)
# Check if pure diffusion mode (vllm_config will be None)
is_pure_diffusion = vllm_config is None
if is_pure_diffusion:
logger.info(
"Starting vLLM API server (pure diffusion mode) on %s",
listen_address,
)
else:
logger.info(
"Starting vLLM API server %d on %s",
vllm_config.parallel_config._api_process_rank,
listen_address,
)
shutdown_task = await serve_http(
app,
sock=sock,
enable_ssl_refresh=args.enable_ssl_refresh,
host=args.host,
port=args.port,
log_level=args.uvicorn_log_level,
# NOTE: When the 'disable_uvicorn_access_log' value is True,
# no access log will be output.
access_log=not args.disable_uvicorn_access_log,
timeout_keep_alive=envs.VLLM_HTTP_TIMEOUT_KEEP_ALIVE,
ssl_keyfile=args.ssl_keyfile,
ssl_certfile=args.ssl_certfile,
ssl_ca_certs=args.ssl_ca_certs,
ssl_cert_reqs=args.ssl_cert_reqs,
h11_max_incomplete_event_size=args.h11_max_incomplete_event_size,
h11_max_header_count=args.h11_max_header_count,
**uvicorn_kwargs,
)
# NB: Await server shutdown only after the backend context is exited
try:
await shutdown_task
finally:
sock.close()
@asynccontextmanager
async def build_async_omni(
args: Namespace,
*,
disable_frontend_multiprocessing: bool | None = None,
client_config: dict[str, Any] | None = None,
) -> AsyncIterator[EngineClient]:
"""Build an AsyncOmni instance from command-line arguments.
Creates an async context manager that yields an AsyncOmni instance
configured from the provided arguments. Handles forkserver setup if
needed and ensures proper cleanup on exit.
Args:
args: Parsed command-line arguments containing model and configuration
disable_frontend_multiprocessing: Optional flag to disable frontend
multiprocessing (deprecated in V1)
client_config: Optional client configuration dictionary
Yields:
EngineClient instance (AsyncOmni) ready for use
"""
if os.getenv("VLLM_WORKER_MULTIPROC_METHOD") == "forkserver":
# The executor is expected to be mp.
# Pre-import heavy modules in the forkserver process
logger.debug("Setup forkserver with pre-imports")
multiprocessing.set_start_method("forkserver")
multiprocessing.set_forkserver_preload(["vllm.v1.engine.async_llm"])
forkserver.ensure_running()
logger.debug("Forkserver setup complete!")
# Context manager to handle async_omni lifecycle
# Ensures everything is shutdown and cleaned up on error/exit
async with build_async_omni_from_stage_config(
args,
disable_frontend_multiprocessing=disable_frontend_multiprocessing,
) as async_omni:
yield async_omni
@asynccontextmanager
async def build_async_omni_from_stage_config(
args: Namespace,
*,
disable_frontend_multiprocessing: bool = False,
) -> AsyncIterator[EngineClient]:
"""Create AsyncOmni from stage configuration.
Creates an AsyncOmni instance either in-process or using multiprocess
RPC. Loads stage configurations from the model or from a specified path.
Args:
args: Parsed command-line arguments containing model and stage configs
disable_frontend_multiprocessing: Flag to disable frontend multiprocessing
(deprecated in V1)
client_config: Optional client configuration dictionary
Yields:
EngineClient instance (AsyncOmni) ready for use
Note:
Stage configurations are loaded from args.stage_configs_path if provided,
otherwise from the model's default configuration.
"""
# V1 AsyncLLM.
if disable_frontend_multiprocessing:
logger.warning("V1 is enabled, but got --disable-frontend-multiprocessing.")
async_omni: EngineClient | None = None
try:
# Convert args Namespace to kwargs dict for AsyncOmni to use
kwargs = vars(args).copy()
# Remove model as it will be passed separately
kwargs.pop("model", None)
async_omni = AsyncOmni(model=args.model, **kwargs)
# # Don't keep the dummy data in memory
# await async_llm.reset_mm_cache()
yield async_omni
finally:
if async_omni:
async_omni.shutdown()
async def omni_init_app_state(
engine_client: EngineClient,
vllm_config: VllmConfig | None,
state: State,
args: Namespace,
) -> None:
"""Initialize the FastAPI application state for omni API server.
Sets up the application state with model information, request logger,
and other server configuration needed for handling API requests.
Automatically detects pure diffusion mode (single diffusion stage) and
handles it appropriately.
Args:
engine_client: Engine client instance (AsyncOmni)
vllm_config: vLLM configuration object (may be None for pure diffusion)
state: FastAPI application state object to initialize
args: Parsed command-line arguments
"""
# Detect if it's pure Diffusion mode (single stage and is Diffusion)
is_pure_diffusion = False
if hasattr(engine_client, "stage_configs") and engine_client.stage_configs:
stage_configs = engine_client.stage_configs
if len(stage_configs) == 1:
stage_type = stage_configs[0].get("stage_type", "llm")
if stage_type == "diffusion":
is_pure_diffusion = True
logger.info("Detected pure diffusion mode (single diffusion stage)")
if args.served_model_name is not None:
served_model_names = args.served_model_name
else:
served_model_names = [args.model]
if args.enable_log_requests:
request_logger = RequestLogger(max_log_len=args.max_log_len)
else:
request_logger = None
base_model_paths = [BaseModelPath(name=name, model_path=args.model) for name in served_model_names]
state.engine_client = engine_client
state.log_stats = not args.disable_log_stats
# For omni models
state.stage_configs = engine_client.stage_configs if hasattr(engine_client, "stage_configs") else None
# Pure Diffusion mode: use simplified initialization logic
if is_pure_diffusion:
model_name = served_model_names[0] if served_model_names else args.model
state.vllm_config = None
state.diffusion_engine = engine_client
# Use for_diffusion method to create chat handler
state.openai_serving_chat = OmniOpenAIServingChat.for_diffusion(
diffusion_engine=engine_client, # type: ignore
model_name=model_name,
)
state.enable_server_load_tracking = getattr(args, "enable_server_load_tracking", False)
state.server_load_metrics = 0
logger.info("Pure diffusion API server initialized for model: %s", model_name)
return
# LLM or multi-stage mode: use standard initialization logic
if vllm_config is None:
# Try to get vllm_config from engine_client
vllm_config = await engine_client.get_vllm_config()
if vllm_config is None:
logger.warning("vllm_config is None, some features may not work correctly")
state.vllm_config = vllm_config
if vllm_config is not None:
_model_config = vllm_config.model_config
resolved_chat_template = load_chat_template(args.chat_template)
if resolved_chat_template is not None and vllm_config is not None:
# Get the tokenizer to check official template
tokenizer = await engine_client.get_tokenizer()
if tokenizer is not None:
if isinstance(tokenizer, MistralTokenizer):
# The warning is logged in resolve_mistral_chat_template.
resolved_chat_template = resolve_mistral_chat_template(chat_template=resolved_chat_template)
else:
hf_chat_template = resolve_hf_chat_template(
tokenizer=tokenizer,
chat_template=None,
tools=None,
model_config=vllm_config.model_config,
)
if hf_chat_template != resolved_chat_template:
logger.warning(
"Using supplied chat template: %s\nIt is different from official chat template '%s'. This discrepancy may lead to performance degradation.", # noqa: E501
resolved_chat_template,
args.model,
)
if args.tool_server == "demo":
tool_server: ToolServer | None = DemoToolServer()
assert isinstance(tool_server, DemoToolServer)
await tool_server.init_and_validate()
elif args.tool_server:
tool_server = MCPToolServer()
await tool_server.add_tool_server(args.tool_server)
else:
tool_server = None
# Merge default_mm_loras into the static lora_modules
default_mm_loras = {}
if vllm_config is not None and vllm_config.lora_config is not None:
default_mm_loras = vllm_config.lora_config.default_mm_loras
lora_modules = args.lora_modules
if default_mm_loras:
default_mm_lora_paths = [
LoRAModulePath(
name=modality,
path=lora_path,
)
for modality, lora_path in default_mm_loras.items()
]
if args.lora_modules is None:
lora_modules = default_mm_lora_paths
else:
lora_modules += default_mm_lora_paths
# Ensure input_processor, io_processor, and model_config exist for OpenAIServingModels compatibility
if (
not hasattr(engine_client, "input_processor")
or engine_client.input_processor is None
or not hasattr(engine_client, "io_processor")
or engine_client.io_processor is None
or not hasattr(engine_client, "model_config")
or engine_client.model_config is None
):
if vllm_config is not None:
# Try to initialize processors if vllm_config is available
try:
from vllm.plugins.io_processors import get_io_processor
from vllm_omni.engine.input_processor import OmniInputProcessor
tokenizer = await engine_client.get_tokenizer()
if tokenizer is not None:
# Initialize input_processor
if not hasattr(engine_client, "input_processor") or engine_client.input_processor is None:
engine_client.input_processor = OmniInputProcessor(
vllm_config=vllm_config,
tokenizer=tokenizer,
)
logger.info("Initialized input_processor for AsyncOmni")
# Initialize model_config
if not hasattr(engine_client, "model_config") or engine_client.model_config is None:
engine_client.model_config = vllm_config.model_config
logger.info("Initialized model_config for AsyncOmni")
# Initialize io_processor
if not hasattr(engine_client, "io_processor") or engine_client.io_processor is None:
model_config = (
engine_client.model_config
if hasattr(engine_client, "model_config")
else vllm_config.model_config
)
io_processor_plugin = model_config.io_processor_plugin
engine_client.io_processor = get_io_processor(vllm_config, io_processor_plugin)
logger.info("Initialized io_processor for AsyncOmni")
else:
logger.warning("Cannot initialize processors: tokenizer is None. OpenAIServingModels may fail.")
except Exception as e:
logger.warning(
"Failed to initialize processors for AsyncOmni: %s. OpenAIServingModels may fail.",
e,
)
else:
logger.warning("Cannot initialize processors: vllm_config is None. OpenAIServingModels may fail.")
state.openai_serving_models = OpenAIServingModels(
engine_client=engine_client,
base_model_paths=base_model_paths,
lora_modules=lora_modules,
)
await state.openai_serving_models.init_static_loras()
state.openai_serving_chat = OmniOpenAIServingChat(
engine_client,
state.openai_serving_models,
args.response_role,
request_logger=request_logger,
chat_template=resolved_chat_template,
chat_template_content_format=args.chat_template_content_format,
trust_request_chat_template=args.trust_request_chat_template,
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
enable_auto_tools=args.enable_auto_tool_choice,
exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
tool_parser=args.tool_call_parser,
reasoning_parser=args.structured_outputs_config.reasoning_parser,
enable_prompt_tokens_details=args.enable_prompt_tokens_details,
enable_force_include_usage=args.enable_force_include_usage,
enable_log_outputs=args.enable_log_outputs,
log_error_stack=args.log_error_stack,
)
state.openai_serving_speech = OmniOpenAIServingSpeech(
engine_client, state.openai_serving_models, request_logger=request_logger
)
state.enable_server_load_tracking = args.enable_server_load_tracking
state.server_load_metrics = 0
def Omnichat(request: Request) -> OmniOpenAIServingChat | None:
return request.app.state.openai_serving_chat
def Omnispeech(request: Request) -> OmniOpenAIServingSpeech | None:
return request.app.state.openai_serving_speech
@router.post(
"/v1/chat/completions",
dependencies=[Depends(validate_json_request)],
responses={
HTTPStatus.OK.value: {"content": {"text/event-stream": {}}},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
@with_cancellation
@load_aware_call
async def create_chat_completion(request: ChatCompletionRequest, raw_request: Request):
handler = Omnichat(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Chat Completions API")
try:
generator = await handler.create_chat_completion(request, raw_request)
except Exception as e:
logger.exception("Chat completion failed: %s", e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=str(e)) from e
if isinstance(generator, ErrorResponse):
return JSONResponse(
content=generator.model_dump(), status_code=generator.code if hasattr(generator, "code") else 400
)
elif isinstance(generator, ChatCompletionResponse):
# Completely bypass Pydantic serialization warnings for multimodal content
# by converting to dict first, then serializing with warnings suppressed
import json as json_lib
import warnings as warnings_module
# Temporarily suppress ALL Pydantic UserWarnings during serialization
with warnings_module.catch_warnings():
warnings_module.filterwarnings("ignore", category=UserWarning)
warnings_module.filterwarnings("ignore", message=".*Pydantic.*", category=UserWarning)
try:
# Use serialize_as_any=True to bypass type checking
response_dict = generator.model_dump(mode="json", serialize_as_any=True, warnings="none")
return JSONResponse(content=response_dict)
except Exception:
# Fallback: convert to JSON string and parse back to avoid any serialization issues
try:
response_json = generator.model_dump_json(warnings="none", serialize_as_any=True)
response_dict = json_lib.loads(response_json)
return JSONResponse(content=response_dict)
except Exception:
# Last resort: regular dump with warnings suppressed
with warnings_module.catch_warnings():
warnings_module.filterwarnings("ignore", category=UserWarning)
return JSONResponse(content=generator.model_dump(mode="json", warnings="none"))
return StreamingResponse(content=generator, media_type="text/event-stream")
@router.post(
"/v1/audio/speech",
dependencies=[Depends(validate_json_request)],
responses={
HTTPStatus.OK.value: {"content": {"audio/*": {}}},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
@with_cancellation
@load_aware_call
async def create_speech(request: OpenAICreateSpeechRequest, raw_request: Request):
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
try:
return await handler.create_speech(request, raw_request)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=str(e)) from e
# Image generation API endpoints
@router.post(
"/v1/images/generations",
dependencies=[Depends(validate_json_request)],
responses={
HTTPStatus.OK.value: {"model": ImageGenerationResponse},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.SERVICE_UNAVAILABLE.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def generate_images(request: ImageGenerationRequest, raw_request: Request) -> ImageGenerationResponse:
"""Generate images from text prompts using diffusion models.
OpenAI DALL-E compatible endpoint for text-to-image generation.
Only supports multi-stage omni mode with diffusion stages.
Args:
request: Image generation request with prompt and parameters
raw_request: Raw FastAPI request for accessing app state
Returns:
ImageGenerationResponse with generated images as base64 PNG
Raises:
HTTPException: For validation errors, missing engine, or generation failures
"""
# Get engine client (AsyncOmni) from app state
engine_client: EngineClient | None = getattr(raw_request.app.state, "engine_client", None)
if engine_client is None or not hasattr(engine_client, "stage_list"):
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
detail="Multi-stage engine not initialized. Start server with a multi-stage omni model.",
)
# Check if there's a diffusion stage
stage_configs = getattr(raw_request.app.state, "stage_configs", None)
if not stage_configs:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
detail="Stage configs not found. Start server with a multi-stage omni model.",
)
# Check for diffusion stage
has_diffusion_stage = False
for stage in stage_configs:
# Handle both dict and OmegaConf objects
stage_type = None
if isinstance(stage, dict):
stage_type = stage.get("stage_type", "llm")
elif hasattr(stage, "get"):
stage_type = stage.get("stage_type", "llm")
elif hasattr(stage, "stage_type"):
stage_type = stage.stage_type
else:
# Fallback: try to access as dict-like
try:
stage_type = stage["stage_type"] if "stage_type" in stage else "llm"
except (TypeError, KeyError):
stage_type = "llm"
if stage_type == "diffusion":
has_diffusion_stage = True
break
if not has_diffusion_stage:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
detail="No diffusion stage found in multi-stage pipeline.",
)
# Get server's loaded model name
serving_models = getattr(raw_request.app.state, "openai_serving_models", None)
if serving_models and hasattr(serving_models, "base_model_paths") and serving_models.base_model_paths:
model_name = serving_models.base_model_paths[0].name
else:
model_name = "unknown"
# Validate model field (warn if mismatch, don't error)
if request.model is not None and request.model != model_name:
logger.warning(
f"Model mismatch: request specifies '{request.model}' but "
f"server is running '{model_name}'. Using server model."
)
try:
# Build params - pass through user values directly
gen_params = {
"prompt": request.prompt,
"num_outputs_per_prompt": request.n,
}
# Parse and add size if provided
if request.size:
width, height = parse_size(request.size)
gen_params["height"] = height
gen_params["width"] = width
size_str = f"{width}x{height}"
else:
size_str = "model default"
# Add optional parameters ONLY if provided
if request.num_inference_steps is not None:
gen_params["num_inference_steps"] = request.num_inference_steps
if request.negative_prompt is not None:
gen_params["negative_prompt"] = request.negative_prompt
if request.guidance_scale is not None:
gen_params["guidance_scale"] = request.guidance_scale
if request.true_cfg_scale is not None:
gen_params["true_cfg_scale"] = request.true_cfg_scale
if request.seed is not None:
gen_params["seed"] = request.seed
gen_params["request_id"] = f"img_gen_{int(time.time())}"
logger.info(f"Generating {request.n} image(s) {size_str}")
# Generate images using AsyncOmni (multi-stage mode)
result = await engine_client.generate(**gen_params)
if result is None:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
detail="No output generated from multi-stage pipeline.",
)
# Extract images from result
images = result.images if hasattr(result, "images") and result.images else []
logger.info(f"Successfully generated {len(images)} image(s)")
# Encode images to base64
image_data = [ImageData(b64_json=encode_image_base64(img), revised_prompt=None) for img in images]
return ImageGenerationResponse(
created=int(time.time()),
data=image_data,
)
except HTTPException:
# Re-raise HTTPExceptions as-is
raise
except ValueError as e:
logger.error(f"Validation error: {e}")
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST.value, detail=str(e))
except Exception as e:
logger.exception(f"Image generation failed: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Image generation failed: {str(e)}"
)