Files
vllm-omni/vllm_omni/entrypoints/openai/api_server.py
T

2147 lines
83 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
import base64
import io
import json
import multiprocessing
import multiprocessing.forkserver as forkserver
import os
# Image generation API imports
import random
import time
import uuid
from argparse import Namespace
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from http import HTTPStatus
from typing import Annotated, Any, Literal, cast
import httpx
import vllm.envs as envs
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, WebSocket
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from PIL import Image
from pydantic import BaseModel, Field
from starlette.datastructures import State
from starlette.routing import Route
from vllm import SamplingParams
from vllm.engine.protocol import EngineClient
from vllm.entrypoints.anthropic.serving import AnthropicServingMessages
from vllm.entrypoints.chat_utils import load_chat_template
from vllm.entrypoints.launcher import serve_http
from vllm.entrypoints.logger import RequestLogger
from vllm.entrypoints.mcp.tool_server import DemoToolServer, MCPToolServer, ToolServer
from vllm.entrypoints.openai.api_server import build_app as build_openai_app
from vllm.entrypoints.openai.api_server import setup_server as setup_openai_server
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
ChatCompletionResponse,
)
# yapf conflicts with isort for this block
# yapf: disable
# yapf: enable
from vllm.entrypoints.openai.completion.serving import OpenAIServingCompletion
from vllm.entrypoints.openai.engine.protocol import (
ErrorResponse,
ModelCard,
ModelList,
ModelPermission,
)
from vllm.entrypoints.openai.models.protocol import BaseModelPath
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
from vllm.entrypoints.openai.orca_metrics import metrics_header
from vllm.entrypoints.openai.responses.serving import OpenAIServingResponses
from vllm.entrypoints.openai.server_utils import get_uvicorn_log_config
from vllm.entrypoints.openai.speech_to_text.serving import (
OpenAIServingTranscription,
OpenAIServingTranslation,
)
from vllm.entrypoints.openai.utils import validate_json_request
from vllm.entrypoints.pooling.classify.serving import ServingClassification
from vllm.entrypoints.pooling.embed.serving import ServingEmbedding as OpenAIServingEmbedding
from vllm.entrypoints.pooling.pooling.serving import OpenAIServingPooling
from vllm.entrypoints.pooling.score.serving import ServingScores
from vllm.entrypoints.serve.disagg.serving import ServingTokens
# vLLM moved `base` from openai.basic.api_router to serve.instrumentator.basic.
# Keep a fallback for older/newer upstream layouts during rebase windows.
from vllm.entrypoints.serve.instrumentator.basic import base
from vllm.entrypoints.serve.render.serving import OpenAIServingRender
from vllm.entrypoints.serve.tokenize.serving import OpenAIServingTokenization
from vllm.entrypoints.utils import (
load_aware_call,
process_lora_modules,
with_cancellation,
)
from vllm.logger import init_logger
from vllm.tasks import POOLING_TASKS
from vllm.tool_parsers import ToolParserManager
from vllm.utils.system_utils import decorate_logs
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError
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.protocol.videos import (
SecondStr,
SizeStr,
VideoDeleteResponse,
VideoError,
VideoGenerationRequest,
VideoGenerationStatus,
VideoListResponse,
VideoResponse,
)
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler
from vllm_omni.entrypoints.openai.serving_video import OmniOpenAIServingVideo, ReferenceImage
from vllm_omni.entrypoints.openai.storage import STORAGE_MANAGER
from vllm_omni.entrypoints.openai.stores import VIDEO_STORE, VIDEO_TASKS
from vllm_omni.entrypoints.openai.utils import get_stage_type, parse_lora_request
from vllm_omni.entrypoints.openai.video_api_utils import decode_input_reference
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniSamplingParams, OmniTextPrompt
logger = init_logger(__name__)
router = APIRouter()
profiler_router = APIRouter()
def _should_enable_profiler_endpoints(args: Namespace) -> bool:
# Check upstream vLLM's profiler_config
profiler_config = getattr(args, "profiler_config", None)
if profiler_config is not None:
# profiler_config exists, check if profiler is set
profiler = getattr(profiler_config, "profiler", None)
if profiler is not None:
return True
# TODO: remove this env after refactoring torch profiler to CLI args
env_value = os.environ.get("VLLM_TORCH_PROFILER_DIR")
return env_value is not None
class ProfileRequest(BaseModel):
"""Request model for profiling endpoints."""
stages: list[int] | None = Field(
default=None,
description="List of stage IDs to profile. If None, profiles all stages.",
)
def _remove_route_from_router(
router: APIRouter,
path: str,
methods: set[str] | None = None,
) -> None:
methods_set = {method.upper() for method in methods} if methods else None
for route in list(router.routes):
if getattr(route, "path", None) != path:
continue
if methods_set is not None:
route_methods = {method.upper() for method in (getattr(route, "methods", None) or set())}
if not (route_methods & methods_set):
continue
router.routes.remove(route)
ENDPOINT_LOAD_METRICS_FORMAT_HEADER_LABEL = "endpoint-load-metrics-format"
async def _get_vllm_config(engine_client: EngineClient) -> Any:
if hasattr(engine_client, "get_vllm_config"):
return await engine_client.get_vllm_config()
return getattr(engine_client, "vllm_config", None)
def _remove_route_from_app(app, path: str, methods: set[str] | None = None):
"""Remove a route from the app by path and optionally by methods.
OMNI: used to override upstream /v1/chat/completions with omni behavior.
"""
routes_to_remove = []
for route in app.routes:
if isinstance(route, Route) and route.path == path:
if methods is None or (hasattr(route, "methods") and route.methods & methods):
routes_to_remove.append(route)
for route in routes_to_remove:
app.routes.remove(route)
class _DiffusionServingModels:
"""Minimal OpenAIServingModels implementation for diffusion-only servers.
vLLM's /v1/models route expects `app.state.openai_serving_models` to expose
`show_available_models()`. In pure diffusion mode we don't initialize the
full OpenAIServingModels (it depends on LLM-specific processors), so we
provide a lightweight fallback.
"""
class _NullModelConfig:
def __getattr__(self, name):
return None
class _Unsupported:
def __init__(self, name: str):
self.name = name
def __call__(self, *args, **kwargs):
raise NotImplementedError(f"{self.name} is not supported in diffusion mode")
def __getattr__(self, attr):
raise NotImplementedError(f"{self.name}.{attr} is not supported in diffusion mode")
def __init__(self, base_model_paths: list[BaseModelPath]) -> None:
self._base_model_paths = base_model_paths
self.model_config = self._NullModelConfig()
@property
def base_model_paths(self) -> list[BaseModelPath]:
return self._base_model_paths
def __getattr__(self, name):
return self._Unsupported(name)
async def show_available_models(self) -> ModelList:
return ModelList(
data=[
ModelCard(
id=base_model.name,
root=base_model.model_path,
permission=[ModelPermission()],
)
for base_model in self._base_model_paths
]
)
# 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_openai_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)
if args.reasoning_parser_plugin and len(args.reasoning_parser_plugin) > 3:
from vllm.reasoning import ReasoningParserManager
ReasoningParserManager.import_reasoning_parser(args.reasoning_parser_plugin)
# Load logging config for uvicorn if specified
log_config = get_uvicorn_log_config(args)
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:
supported_tasks: tuple[str, ...]
if hasattr(engine_client, "get_supported_tasks"):
supported_tasks = tuple(await engine_client.get_supported_tasks())
else:
supported_tasks = ("generate",)
if not supported_tasks:
# Only default to "generate" when get_supported_tasks is not implemented;
# TTS-only models intentionally return an empty set.
if not hasattr(engine_client, "get_supported_tasks"):
supported_tasks = ("generate",)
# OMNI: Pass supported_tasks to build_app (required by upstream vLLM)
app = build_openai_app(args, supported_tasks)
# OMNI: Remove upstream routes that we override with omni-specific handlers
_remove_route_from_app(app, "/v1/chat/completions", {"POST"})
_remove_route_from_app(app, "/v1/models", {"GET"}) # Remove upstream /v1/models to use omni's handler
app.include_router(router)
await omni_init_app_state(engine_client, app.state, args)
# Conditionally register profiler endpoints based on config or env var
if _should_enable_profiler_endpoints(args):
logger.warning("Profiler endpoints are enabled. This should ONLY be used for local development!")
app.include_router(profiler_router)
vllm_config = await _get_vllm_config(engine_client)
# 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,
ssl_ciphers=args.ssl_ciphers,
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
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
for compatibility with existing CLI options
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.
"""
if disable_frontend_multiprocessing:
logger.warning("Ignoring --disable-frontend-multiprocessing for AsyncOmni runtime.")
async_omni: EngineClient | None = None
try:
kwargs = vars(args).copy()
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,
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)
state: FastAPI application state object to initialize
args: Parsed command-line arguments
"""
# Get vllm_config from engine_client (following 0.14.0 pattern)
vllm_config = await _get_vllm_config(engine_client)
# 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 = get_stage_type(stage_configs[0])
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
state.args = args
# 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
state.openai_serving_models = _DiffusionServingModels(base_model_paths)
# OMNI: tokenization endpoints are not supported in pure diffusion mode.
state.openai_serving_tokenization = None
# 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,
)
diffusion_stage_configs = engine_client.stage_configs if hasattr(engine_client, "stage_configs") else None
state.openai_serving_video = OmniOpenAIServingVideo.for_diffusion(
diffusion_engine=engine_client, # type: ignore
model_name=model_name,
stage_configs=diffusion_stage_configs,
)
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 _get_vllm_config(engine_client)
if vllm_config is None:
logger.warning("vllm_config is None, some features may not work correctly")
state.vllm_config = vllm_config
# Get supported tasks
supported_tasks: set[str] = {"generate"}
if hasattr(engine_client, "get_supported_tasks"):
supported_tasks = set(await engine_client.get_supported_tasks())
logger.info("Supported tasks: %s", supported_tasks)
resolved_chat_template = load_chat_template(args.chat_template)
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 = (
vllm_config.lora_config.default_mm_loras
if vllm_config is not None and vllm_config.lora_config is not None
else {}
)
lora_modules = process_lora_modules(args.lora_modules, default_mm_loras)
# 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.v1.engine.input_processor import InputProcessor
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 = InputProcessor(
vllm_config=vllm_config,
)
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
renderer = getattr(engine_client, "renderer", None)
if renderer is None:
from vllm.renderers import renderer_from_config
renderer = renderer_from_config(vllm_config)
engine_client.renderer = renderer
engine_client.io_processor = get_io_processor(vllm_config, renderer, 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_render = OpenAIServingRender(
model_config=engine_client.model_config,
renderer=engine_client.renderer,
io_processor=engine_client.io_processor,
model_registry=state.openai_serving_models.registry,
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,
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,
default_chat_template_kwargs=args.default_chat_template_kwargs,
log_error_stack=args.log_error_stack,
)
state.openai_serving_responses = (
OpenAIServingResponses(
engine_client,
state.openai_serving_models,
request_logger=request_logger,
chat_template=resolved_chat_template,
chat_template_content_format=args.chat_template_content_format,
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
enable_auto_tools=args.enable_auto_tool_choice,
tool_parser=args.tool_call_parser,
tool_server=tool_server,
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,
)
if "generate" in supported_tasks
else None
)
state.openai_serving_chat = (
OmniOpenAIServingChat(
engine_client,
state.openai_serving_models,
args.response_role,
openai_serving_render=state.openai_serving_render,
request_logger=request_logger,
chat_template=resolved_chat_template,
chat_template_content_format=args.chat_template_content_format,
default_chat_template_kwargs=args.default_chat_template_kwargs,
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,
enable_log_deltas=args.enable_log_deltas,
)
if "generate" in supported_tasks
else None
)
# Warm up chat template processing to avoid first-request latency
if state.openai_serving_chat is not None:
state.openai_serving_chat.warmup()
state.openai_serving_completion = (
OpenAIServingCompletion(
engine_client,
state.openai_serving_models,
openai_serving_render=state.openai_serving_render,
request_logger=request_logger,
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
enable_prompt_tokens_details=args.enable_prompt_tokens_details,
enable_force_include_usage=args.enable_force_include_usage,
)
if "generate" in supported_tasks
else None
)
state.openai_serving_pooling = (
OpenAIServingPooling(
engine_client,
state.openai_serving_models,
supported_tasks=supported_tasks,
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,
)
if any(task in POOLING_TASKS for task in supported_tasks)
else None
)
state.openai_serving_embedding = (
OpenAIServingEmbedding(
engine_client,
state.openai_serving_models,
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,
)
if "embed" in supported_tasks
else None
)
state.openai_serving_classification = (
ServingClassification(
engine_client,
state.openai_serving_models,
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,
)
if "classify" in supported_tasks
else None
)
state.openai_serving_scores = (
ServingScores(
engine_client,
state.openai_serving_models,
request_logger=request_logger,
score_template=resolved_chat_template,
log_error_stack=args.log_error_stack,
)
if any(t in supported_tasks for t in ("embed", "score", "token_embed"))
else None
)
state.openai_serving_tokenization = OpenAIServingTokenization(
engine_client,
state.openai_serving_models,
request_logger=request_logger,
chat_template=resolved_chat_template,
chat_template_content_format=args.chat_template_content_format,
default_chat_template_kwargs=args.default_chat_template_kwargs,
trust_request_chat_template=args.trust_request_chat_template,
)
state.openai_serving_transcription = (
OpenAIServingTranscription(
engine_client,
state.openai_serving_models,
request_logger=request_logger,
enable_force_include_usage=args.enable_force_include_usage,
)
if "transcription" in supported_tasks
else None
)
state.openai_serving_translation = (
OpenAIServingTranslation(
engine_client,
state.openai_serving_models,
request_logger=request_logger,
enable_force_include_usage=args.enable_force_include_usage,
)
if "transcription" in supported_tasks
else None
)
state.anthropic_serving_messages = (
AnthropicServingMessages(
engine_client,
state.openai_serving_models,
args.response_role,
openai_serving_render=state.openai_serving_render,
request_logger=request_logger,
chat_template=resolved_chat_template,
chat_template_content_format=args.chat_template_content_format,
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
enable_auto_tools=args.enable_auto_tool_choice,
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,
)
if "generate" in supported_tasks
else None
)
state.serving_tokens = (
ServingTokens(
engine_client,
state.openai_serving_models,
request_logger=request_logger,
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
enable_prompt_tokens_details=args.enable_prompt_tokens_details,
enable_log_outputs=args.enable_log_outputs,
force_no_detokenize=args.tokens_only,
)
if "generate" in supported_tasks
else None
)
state.openai_serving_speech = OmniOpenAIServingSpeech(
engine_client, state.openai_serving_models, request_logger=request_logger
)
state.openai_streaming_speech = OmniStreamingSpeechHandler(
speech_service=state.openai_serving_speech,
)
state.openai_serving_video = OmniOpenAIServingVideo(
engine_client,
model_name=served_model_names[0] if served_model_names else None,
stage_configs=state.stage_configs,
)
state.enable_server_load_tracking = args.enable_server_load_tracking
state.server_load_metrics = 0
def Omnivideo(request: Request) -> OmniOpenAIServingVideo | None:
return request.app.state.openai_serving_video
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):
metrics_header_format = raw_request.headers.get(ENDPOINT_LOAD_METRICS_FORMAT_HEADER_LABEL, "")
handler = Omnichat(raw_request)
if handler is None:
base_server = getattr(raw_request.app.state, "openai_serving_tokenization", None)
if base_server is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND.value,
detail="The model does not support Chat Completions API",
)
return base_server.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.error.code if generator.error 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,
headers=metrics_header(metrics_header_format),
)
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,
headers=metrics_header(metrics_header_format),
)
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"),
headers=metrics_header(metrics_header_format),
)
return StreamingResponse(content=generator, media_type="text/event-stream")
_remove_route_from_router(router, "/v1/audio/speech", {"POST"})
@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):
"""Generate speech audio from text using the loaded TTS model.
Args:
request: Speech synthesis request in OpenAI-compatible format.
raw_request: Raw FastAPI request for accessing app state.
Returns:
The generated audio response, or an OpenAI-style error payload when
the request cannot be fulfilled.
Raises:
HTTPException: If the server does not support speech generation or the
synthesis request fails unexpectedly.
"""
handler = Omnispeech(raw_request)
if handler is None:
base_server = getattr(raw_request.app.state, "openai_serving_tokenization", None)
if base_server is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND.value,
detail="The model does not support Speech API",
)
return base_server.create_error_response(message="The model does not support Speech API")
try:
result = await handler.create_speech(request, raw_request)
if isinstance(result, ErrorResponse):
return JSONResponse(
content=result.model_dump(),
status_code=result.error.code if result.error else 400,
)
return result
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=str(e)) from e
@router.get(
"/v1/audio/voices",
responses={
HTTPStatus.OK.value: {"model": dict},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def list_voices(raw_request: Request):
"""List available TTS voices exposed by the loaded speech model.
Args:
raw_request: Raw FastAPI request for accessing app state.
Returns:
A JSON payload containing the sorted set of supported speaker names, or
an OpenAI-style error response when the current server configuration
does not support the Speech API.
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
# Get all speakers (both model built-in and uploaded)
speakers = sorted(handler.supported_speakers) if handler.supported_speakers else []
# Get uploaded speakers details
uploaded_speakers = []
if hasattr(handler, "uploaded_speakers"):
for voice_name, info in handler.uploaded_speakers.items():
uploaded_speakers.append(
{
"name": info.get("name", voice_name),
"consent": info.get("consent", ""),
"created_at": info.get("created_at", 0),
"file_size": info.get("file_size", 0),
"mime_type": info.get("mime_type", ""),
}
)
return JSONResponse(content={"voices": speakers, "uploaded_voices": uploaded_speakers})
@router.post(
"/v1/audio/voices",
responses={
HTTPStatus.OK.value: {"model": dict},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def upload_voice(
raw_request: Request,
audio_sample: UploadFile = File(...),
consent: str = Form(...),
name: str = Form(...),
):
"""Upload a new voice sample for voice cloning.
Uploads an audio file that can be used as a reference for voice cloning
in Base task TTS requests. The voice can then be referenced by name
in subsequent TTS requests.
Args:
audio_sample: Audio file (max 10MB)
consent: Consent recording ID
name: Name for the new voice
raw_request: Raw FastAPI request
Returns:
JSON response with voice information
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
try:
# Upload the voice
result = await handler.upload_voice(audio_sample, consent, name)
return JSONResponse(content={"success": True, "voice": result})
except ValueError as e:
return base(raw_request).create_error_response(message=str(e))
except Exception as e:
logger.exception(f"Failed to upload voice: {e}")
return base(raw_request).create_error_response(message=f"Failed to upload voice: {str(e)}")
@router.delete(
"/v1/audio/voices/{name}",
responses={
HTTPStatus.OK.value: {"model": dict},
HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def delete_voice(name: str, raw_request: Request):
"""Delete an uploaded voice.
Deletes the voice sample and associated metadata. Also removes any
cached voice clone prompts for this voice.
Args:
name: Name of the voice to delete
raw_request: Raw FastAPI request
Returns:
JSON response indicating success or failure
"""
handler = Omnispeech(raw_request)
if handler is None:
return base(raw_request).create_error_response(message="The model does not support Speech API")
try:
# Delete the voice
success = await handler.delete_voice(name)
if not success:
return JSONResponse(
content={"success": False, "error": f"Voice '{name}' not found"},
status_code=HTTPStatus.NOT_FOUND.value,
)
return JSONResponse(content={"success": True, "message": f"Voice '{name}' deleted successfully"})
except ValueError as e:
return base(raw_request).create_error_response(message=str(e))
except Exception as e:
logger.exception(f"Failed to delete voice '{name}': {e}")
return base(raw_request).create_error_response(message=f"Failed to delete voice: {str(e)}")
@router.websocket("/v1/audio/speech/stream")
async def streaming_speech(websocket: WebSocket):
"""WebSocket endpoint for streaming text input TTS.
Accepts text incrementally, splits at sentence boundaries, and
returns audio per sentence. See serving_speech_stream.py for protocol.
"""
handler = getattr(websocket.app.state, "openai_streaming_speech", None)
if handler is None:
await websocket.accept()
await websocket.send_json(
{
"type": "error",
"message": "Streaming speech is not available",
}
)
await websocket.close()
return
await handler.handle_session(websocket)
# Health and Model endpoints for diffusion mode
# Remove existing health endpoint if present (from vllm imports)
# to ensure our handler takes precedence
_remove_route_from_router(router, "/health")
@router.get("/health")
async def health(raw_request: Request) -> JSONResponse:
"""Health check endpoint that works for both LLM and diffusion modes.
Returns 200 OK if the server is healthy.
For LLM mode: delegates to engine_client health check
For diffusion mode: checks if diffusion_engine is running
"""
# Check if we're in diffusion mode
diffusion_engine = getattr(raw_request.app.state, "diffusion_engine", None)
if diffusion_engine is not None:
# Diffusion mode health check
if hasattr(diffusion_engine, "is_running") and diffusion_engine.is_running:
return JSONResponse(content={"status": "healthy"})
return JSONResponse(
content={"status": "unhealthy", "reason": "Diffusion engine is not running"},
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
)
# LLM mode - delegate to engine_client
engine_client = getattr(raw_request.app.state, "engine_client", None)
if engine_client is not None:
await engine_client.check_health()
return JSONResponse(content={"status": "healthy"})
return JSONResponse(
content={"status": "unhealthy", "reason": "No engine initialized"},
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
)
# Remove existing models endpoint if present (from vllm imports)
# to ensure our handler takes precedence
_remove_route_from_router(router, "/v1/models")
@router.get("/v1/models")
async def show_available_models(raw_request: Request) -> JSONResponse:
"""Show available models endpoint that works for both LLM and diffusion modes.
Returns model information in OpenAI-compatible format.
"""
# Check if we're in diffusion mode
diffusion_model_name = getattr(raw_request.app.state, "diffusion_model_name", None)
if diffusion_model_name is not None:
# Diffusion mode - return the loaded model
return JSONResponse(
content={
"object": "list",
"data": [
{
"id": diffusion_model_name,
"object": "model",
"created": 0,
"owned_by": "vllm-omni",
"permission": [],
}
],
}
)
# LLM mode - delegate to openai_serving_models
openai_serving_models = getattr(raw_request.app.state, "openai_serving_models", None)
if openai_serving_models is not None:
models = await openai_serving_models.show_available_models()
return JSONResponse(content=models.model_dump())
return JSONResponse(
content={"object": "list", "data": []},
)
# 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, model_name, stage_configs = _get_engine_and_model(raw_request)
# 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
prompt: OmniTextPrompt = {"prompt": request.prompt}
if request.negative_prompt is not None:
prompt["negative_prompt"] = request.negative_prompt
gen_params = OmniDiffusionSamplingParams(num_outputs_per_prompt=request.n)
# Parse per-request LoRA (compatible with chat's extra_body.lora shape).
lora_request, lora_scale = _parse_lora_request(request.lora)
_update_if_not_none(gen_params, "lora_request", lora_request)
_update_if_not_none(gen_params, "lora_scale", lora_scale)
# Parse and add size if provided
width, height = None, None
if request.size:
width, height = parse_size(request.size)
size_str = f"{width}x{height}"
else:
size_str = "model default"
_update_if_not_none(gen_params, "width", width)
_update_if_not_none(gen_params, "height", height)
# 3.3 Add optional parameters ONLY if provided
_update_if_not_none(gen_params, "num_inference_steps", request.num_inference_steps)
_update_if_not_none(gen_params, "guidance_scale", request.guidance_scale)
_update_if_not_none(gen_params, "true_cfg_scale", request.true_cfg_scale)
# If seed is not provided, generate a random one to ensure
# a proper generator is initialized in the backend.
# This fixes issues where using the default global generator
# might produce blurry images in some environments.
_update_if_not_none(
gen_params, "seed", request.seed if request.seed is not None else random.randint(0, 2**32 - 1)
)
_update_if_not_none(gen_params, "generator_device", request.generator_device)
request_id = f"img_gen_{uuid.uuid4().hex}"
logger.info(f"Generating {request.n} image(s) {size_str}")
# Generate images using AsyncOmni (multi-stage mode)
result = await _generate_with_async_omni(
engine_client=engine_client,
gen_params=gen_params,
stage_configs=stage_configs,
prompt=prompt,
request_id=request_id,
)
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 = _extract_images_from_result(result)
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)}"
)
@router.post(
"/v1/images/edits",
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 edit_images(
raw_request: Request,
image: list[UploadFile] | None = File(None),
image_array: list[UploadFile] | None = File(None, alias="image[]"),
url: list[str] | None = Form(None),
url_array: list[str] | None = Form(None, alias="url[]"),
prompt: str = Form(...),
model: str = Form(None),
n: int = Form(1),
size: str = Form("auto"),
response_format: str = Form("b64_json"),
output_format: str | None = Form("png"),
background: str | None = Form("auto"),
output_compression: Annotated[int, Form(ge=0, le=100)] = 100,
user: str | None = Form(None), # unused now
# vllm-omni extensions for diffusion control
negative_prompt: str | None = Form(None),
num_inference_steps: int | None = Form(None),
guidance_scale: float | None = Form(None),
true_cfg_scale: float | None = Form(None),
seed: int | None = Form(None),
generator_device: str | None = Form(None),
# vllm-omni extension for per-request LoRA.
lora: str | None = Form(None), # Json string
) -> ImageGenerationResponse:
"""
OpenAI-compatible image edit endpoint.
"""
# 1. get engine and model
engine_client, model_name, stage_configs = _get_engine_and_model(raw_request)
if model is not None and model != model_name:
logger.warning(
f"Model mismatch: request specifies '{model}' but server is running '{model_name}'. Using server model."
)
# 2. get output format & compression
output_format = _choose_output_format(output_format, background)
if response_format != "b64_json":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail="Only response_format 'b64_json' is supported now.",
)
try:
# 2. Build prompt & images params
prompt: OmniTextPrompt = {"prompt": prompt}
if negative_prompt is not None:
prompt["negative_prompt"] = negative_prompt
input_images_list = []
images = image or image_array
urls = url or url_array
if images:
input_images_list.extend(images)
if urls:
input_images_list.extend(urls)
if not input_images_list:
raise HTTPException(status_code=422, detail="Field 'image' or 'url' is required")
pil_images = await _load_input_images(input_images_list)
prompt["multi_modal_data"] = {}
prompt["multi_modal_data"]["image"] = pil_images
# 3 Build sample params
gen_params = OmniDiffusionSamplingParams()
# 3.0 Init with system default values
app_state_args = getattr(raw_request.app.state, "args", None)
default_sample_param = getattr(app_state_args, "default_sampling_params", None)
# Currently only have one diffusion stage.
diffusion_stage_ids = [i for i, cfg in enumerate(stage_configs) if get_stage_type(cfg) == "diffusion"]
if not diffusion_stage_ids:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
detail="No diffusion stage found in multi-stage pipeline.",
)
diffusion_stage_id = diffusion_stage_ids[0]
apply_stage_default_sampling_params(
default_sample_param,
gen_params,
str(diffusion_stage_id),
)
_update_if_not_none(gen_params, "num_outputs_per_prompt", n)
# 3.1 Parse per-request LoRA (compatible with chat's extra_body.lora shape).
lora_dict = _get_lora_from_json_str(lora)
lora_request, lora_scale = _parse_lora_request(lora_dict)
_update_if_not_none(gen_params, "lora_request", lora_request)
_update_if_not_none(gen_params, "lora_scale", lora_scale)
# 3.2 Parse and add size if provided
max_generated_image_size = getattr(app_state_args, "max_generated_image_size", None)
width, height = None, None
if size.lower() == "auto":
width, height = pil_images[0].size # Use first image size
else:
width, height = parse_size(size)
if max_generated_image_size is not None and (width * height > max_generated_image_size):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail=f"Requested image size {width}x{height} exceeds the maximum allowed "
f"size of {max_generated_image_size} pixels.",
)
size_str = f"{width}x{height}"
_update_if_not_none(gen_params, "width", width)
_update_if_not_none(gen_params, "height", height)
# 3.3 Add optional parameters ONLY if provided
_update_if_not_none(gen_params, "num_inference_steps", num_inference_steps)
_update_if_not_none(gen_params, "guidance_scale", guidance_scale)
_update_if_not_none(gen_params, "true_cfg_scale", true_cfg_scale)
# If seed is not provided, generate a random one to ensure
# a proper generator is initialized in the backend.
# This fixes issues where using the default global generator
# might produce blurry images in some environments.
_update_if_not_none(gen_params, "seed", seed if seed is not None else random.randint(0, 2**32 - 1))
_update_if_not_none(gen_params, "generator_device", generator_device)
# 4. Generate images using AsyncOmni (multi-stage mode)
request_id = f"img_edit_{int(time.time())}"
logger.info(f"Generating {n} image(s) {size_str}")
result = await _generate_with_async_omni(
engine_client=engine_client,
gen_params=gen_params,
stage_configs=stage_configs,
prompt=prompt,
request_id=request_id,
)
# 5. Extract images from result
images = _extract_images_from_result(result)
logger.info(f"Successfully generated {len(images)} image(s)")
# Encode images to base64
image_data = [
ImageData(
b64_json=_encode_image_base64_with_compression(
img, format=output_format, output_compression=output_compression
),
revised_prompt=None,
)
for img in images
]
return ImageGenerationResponse(
created=int(time.time()),
data=image_data,
output_format=output_format,
size=size_str,
)
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 edit failed: {e}")
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Image edit failed: {str(e)}")
def _get_engine_and_model(raw_request: Request):
# Get engine client (AsyncOmni) from app state
engine_client: EngineClient | AsyncOmni | None = getattr(raw_request.app.state, "engine_client", None)
if engine_client is None:
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.
# Prefer app state (compat layer populated at startup), then fall back to
# the engine client's stage configs for refactored AsyncOmni paths.
stage_configs = getattr(raw_request.app.state, "stage_configs", None)
if not stage_configs:
stage_configs = getattr(engine_client, "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.",
)
normalized_stage_configs = list(stage_configs)
has_diffusion_stage = any(get_stage_type(stage_cfg) == "diffusion" for stage_cfg in normalized_stage_configs)
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)
base_model_paths = getattr(serving_models, "base_model_paths", None) if serving_models else None
if base_model_paths:
model_name = base_model_paths[0].name
else:
model_name = "unknown"
return engine_client, model_name, normalized_stage_configs
def _get_lora_from_json_str(lora_body):
if lora_body is None:
return None
try:
lora_dict = json.loads(lora_body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid LoRA JSON string")
if not isinstance(lora_dict, dict):
raise HTTPException(status_code=400, detail="LoRA must be a JSON object")
return lora_dict
def _parse_lora_request(lora_body: dict[str, Any]):
try:
return parse_lora_request(lora_body)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail=str(e),
) from e
async def _generate_with_async_omni(
engine_client: AsyncOmni | Any,
gen_params: Any,
stage_configs: list[Any],
**kwargs,
):
engine_client = cast(AsyncOmni, engine_client)
result = None
normalized_stage_configs = list(stage_configs)
if not normalized_stage_configs:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
detail="Stage configs not found. Start server with a multi-stage omni model.",
)
default_params_list: list[OmniSamplingParams] | None = getattr(
engine_client,
"default_sampling_params_list",
None,
)
if not isinstance(default_params_list, list):
default_params_list = []
else:
default_params_list = list(default_params_list)
sampling_params_list: list[OmniSamplingParams] = []
for idx, stage_cfg in enumerate(normalized_stage_configs):
stage_type = get_stage_type(stage_cfg)
if stage_type == "diffusion":
sampling_params_list.append(gen_params)
continue
if idx < len(default_params_list):
default_stage_params = default_params_list[idx]
else:
default_stage_params = SamplingParams()
if hasattr(default_stage_params, "clone"):
try:
default_stage_params = default_stage_params.clone()
except Exception:
pass
sampling_params_list.append(default_stage_params)
async for output in engine_client.generate(
sampling_params_list=sampling_params_list,
**kwargs,
):
result = output
if result is None:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
detail="No output generated from multi-stage pipeline.",
)
return result
def _update_if_not_none(object: Any, key: str, val: Any) -> None:
if val is not None:
setattr(object, key, val)
def _extract_images_from_result(result: Any) -> list[Any]:
images = []
if hasattr(result, "images") and result.images:
images = result.images
elif hasattr(result, "request_output"):
request_output = result.request_output
if isinstance(request_output, dict) and request_output.get("images"):
images = request_output["images"]
elif hasattr(request_output, "images") and request_output.images:
images = request_output.images
return images
async def _load_input_images(
inputs: list[str],
) -> list[Image.Image]:
"""
convert to PIL.Image.Image list
"""
if isinstance(inputs, str):
inputs = [inputs]
images: list[Image.Image] = []
for inp in inputs:
# 1. URL + base64
if isinstance(inp, str) and inp.startswith("data:image"):
try:
_, b64_data = inp.split(",", 1)
image_bytes = base64.b64decode(b64_data)
img = Image.open(io.BytesIO(image_bytes))
images.append(img)
except Exception as e:
raise ValueError(f"Invalid base64 image: {e}")
# 2. URL
elif isinstance(inp, str) and inp.startswith("http"):
async with httpx.AsyncClient(timeout=60) as client:
try:
resp = await client.get(inp)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content))
images.append(img)
except Exception as e:
raise ValueError(f"Failed to download image from URL {inp}: {e}")
# 3. UploadFile
elif hasattr(inp, "file"):
try:
img_data = await inp.read()
img = Image.open(io.BytesIO(img_data))
images.append(img)
except Exception as e:
raise ValueError(f"Failed to open uploaded file: {e}")
else:
raise ValueError(f"Unsupported input: {inp}")
if not images:
raise ValueError("No valid input images found")
return images
def _choose_output_format(output_format: str | None, background: str | None) -> str:
# Normalize and choose extension
fmt = (output_format or "").lower()
if fmt in {"jpg", "png", "webp", "jpeg"}:
return fmt
# If transparency requested, prefer png
if (background or "auto").lower() == "transparent":
return "png"
# Default
return "jpeg"
def _encode_image_base64_with_compression(
image: Image.Image, format: str = "png", output_compression: int = 100
) -> str:
"""Encode PIL Image to base64 PNG string.
Args:
image: PIL Image object
format: Output image format (e.g., "PNG", "JPEG", "WEBP")
output_compression: Compression level (0-100%), 100 for best quality
Returns:
Base64-encoded image as string
"""
buffer = io.BytesIO()
save_kwargs = {}
if format in ("jpg", "jpeg", "webp"):
save_kwargs["quality"] = output_compression
elif format == "png":
save_kwargs["compress_level"] = max(0, min(9, 9 - output_compression // 11)) # Map 0-100 to 9-0
image.save(buffer, format=format, **save_kwargs)
buffer.seek(0)
return base64.b64encode(buffer.read()).decode("utf-8")
def apply_stage_default_sampling_params(
default_params_json: str | None,
sampling_params: Any,
stage_key: str,
) -> None:
"""
Update a stage's sampling parameters with vLLM-Omni defaults.
Args:
default_params_json: JSON string of stage-keyed default parameters
sampling_params: The sampling parameters object to update
stage_key: The stage ID/key in the pipeline
"""
if default_params_json is not None:
default_params_dict = json.loads(default_params_json)
if stage_key in default_params_dict:
stage_defaults = default_params_dict[stage_key]
for param_name, param_value in stage_defaults.items():
if hasattr(sampling_params, param_name):
setattr(sampling_params, param_name, param_value)
def _resolve_video_runtime_context(raw_request: Request) -> tuple[str | None, list[Any] | None]:
app_model_name = None
serving_models = getattr(raw_request.app.state, "openai_serving_models", None)
if serving_models and getattr(serving_models, "base_model_paths", None):
base_paths = serving_models.base_model_paths
if base_paths:
app_model_name = base_paths[0].name
app_stage_configs = getattr(raw_request.app.state, "stage_configs", None)
return app_model_name, app_stage_configs
def _parse_form_json(value: str | None) -> Any:
if value is None or value == "":
return None
try:
return json.loads(value)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail="Invalid JSON in form field.",
) from exc
def video_response_from_request(model_name: str, req: VideoGenerationRequest) -> VideoResponse:
resp = VideoResponse(
model=model_name,
status=VideoGenerationStatus.QUEUED,
size=req.size,
prompt=req.prompt,
)
resp.seconds = str(req.seconds or resp.seconds)
return resp
async def decode_and_save_video_output(output: Any, file_name: str) -> str:
if not output.b64_json:
raise RuntimeError(f"Video output for {file_name} did not include b64_json content.")
try:
video_bytes = base64.b64decode(output.b64_json)
except Exception as decode_exc:
raise RuntimeError(f"Failed to decode generated video payload for {file_name}") from decode_exc
return await STORAGE_MANAGER.save(video_bytes, file_name)
def _cleanup_video(video_id: str, output_path: str | None):
try:
if output_path is not None:
os.remove(output_path)
except OSError:
logger.warning("Failed to cleanup partial video file '%s' for id=%s", output_path, video_id)
async def _run_video_generation_job(
handler: OmniOpenAIServingVideo,
request: VideoGenerationRequest,
video_id: str,
reference_image: ReferenceImage | None = None,
) -> None:
job = await VIDEO_STORE.get(video_id)
if job is None:
logger.warning("Video job %s missing before generation task started; skipping", video_id)
return
await VIDEO_STORE.update_fields(video_id, {"status": VideoGenerationStatus.IN_PROGRESS})
started_at = time.perf_counter()
output_path = None
try:
response = await handler.generate_videos(request, video_id, reference_image=reference_image)
if not response.data:
raise RuntimeError("Video generation completed but returned no outputs.")
if (video_count := len(response.data)) > 1:
logger.warning("Video request %s generated %s outputs but we only expected one.", video_id, video_count)
file_name = f"{video_id}.{job.file_extension}"
output_path = await decode_and_save_video_output(response.data[0], file_name)
logger.info("Video request %s persisted %s output file.", video_id, output_path)
await VIDEO_STORE.update_fields(
video_id,
{
"status": VideoGenerationStatus.COMPLETED,
"progress": 100,
"file_name": file_name,
"completed_at": int(time.time()),
"inference_time_s": time.perf_counter() - started_at,
},
)
except Exception as exc:
logger.exception("Video generation failed for id=%s", video_id)
_cleanup_video(video_id, output_path)
# TODO: It would be better to have a finite collection of errors to return rather than the exception name
await VIDEO_STORE.update_fields(
video_id,
{
"status": VideoGenerationStatus.FAILED,
"completed_at": int(time.time()),
"error": VideoError(code=type(exc).__name__, message=str(exc)),
"inference_time_s": time.perf_counter() - started_at,
},
)
except asyncio.CancelledError:
_cleanup_video(video_id, output_path)
await VIDEO_STORE.pop(video_id)
raise
@router.post(
"/v1/videos",
responses={
HTTPStatus.OK.value: {"model": VideoResponse},
HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse},
HTTPStatus.SERVICE_UNAVAILABLE.value: {"model": ErrorResponse},
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse},
},
)
async def create_video(
raw_request: Request,
prompt: str = Form(...),
input_reference: UploadFile | None = File(default=None),
image_reference: str | None = Form(default=None),
model: str | None = Form(default=None),
seconds: SecondStr | None = Form(default=None),
size: SizeStr | None = Form(default=None),
user: str | None = Form(default=None),
width: int | None = Form(default=None),
height: int | None = Form(default=None),
num_frames: int | None = Form(default=None),
fps: int | None = Form(default=None),
num_inference_steps: int | None = Form(default=None),
guidance_scale: float | None = Form(default=None),
guidance_scale_2: float | None = Form(default=None),
boundary_ratio: float | None = Form(default=None),
flow_shift: float | None = Form(default=None),
true_cfg_scale: float | None = Form(default=None),
seed: int | None = Form(default=None),
negative_prompt: str | None = Form(default=None),
lora: str | None = Form(default=None),
) -> VideoResponse:
"""Create an asynchronous video generation job.
This OpenAI-style endpoint accepts multipart form-data, validates the
request payload, persists a queued job record, and starts generation in the
background. The response contains metadata for polling job status rather
than the generated video bytes.
Args:
raw_request: Raw FastAPI request for accessing app state.
prompt: Text prompt describing the requested video.
input_reference: Optional uploaded reference image file.
image_reference: Optional JSON-encoded reference image descriptor.
model: Optional model name supplied by the client.
seconds: Optional target duration string accepted by the video API.
size: Optional output size string such as ``1280x720``.
user: Optional user identifier forwarded in the stored request.
width: Optional explicit output width override.
height: Optional explicit output height override.
num_frames: Optional explicit frame count override.
fps: Optional explicit frame rate override.
num_inference_steps: Optional inference step override.
guidance_scale: Optional primary guidance scale override.
guidance_scale_2: Optional secondary guidance scale override.
boundary_ratio: Optional boundary ratio override.
flow_shift: Optional flow shift override.
true_cfg_scale: Optional true CFG scale override.
seed: Optional random seed override.
negative_prompt: Optional negative prompt.
lora: Optional JSON-encoded per-request LoRA configuration.
Returns:
A queued ``VideoResponse`` that includes the generated job identifier
and initial metadata for later retrieval.
Raises:
HTTPException: If the request is invalid, the video handler is
unavailable, or job initialization fails.
"""
input_reference_bytes = await input_reference.read() if input_reference is not None else None
parsed_image_reference = _parse_form_json(image_reference)
if parsed_image_reference is not None and input_reference_bytes is not None:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST.value,
detail="Provide either input_reference or image_reference, not both.",
)
request_data: dict[str, Any] = {
"prompt": prompt,
"model": model,
"seconds": seconds,
"size": size,
"image_reference": parsed_image_reference,
"user": user,
"width": width,
"height": height,
"num_frames": num_frames,
"fps": fps,
"num_inference_steps": num_inference_steps,
"guidance_scale": guidance_scale,
"guidance_scale_2": guidance_scale_2,
"boundary_ratio": boundary_ratio,
"flow_shift": flow_shift,
"true_cfg_scale": true_cfg_scale,
"seed": seed,
"negative_prompt": negative_prompt,
"lora": _parse_form_json(lora),
}
request_data = {k: v for k, v in request_data.items() if v is not None}
request = VideoGenerationRequest(**request_data)
handler = Omnivideo(raw_request)
if handler is None:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE.value,
detail="Video generation handler not initialized.",
)
logger.info("Video generation handler: %s", type(handler).__name__)
try:
app_model_name, app_stage_configs = _resolve_video_runtime_context(raw_request)
effective_model_name = handler.model_name or app_model_name or request.model or "unknown"
if request.model is not None and effective_model_name is not None and request.model != effective_model_name:
logger.warning(
"Model mismatch: request specifies '%s' but server is running '%s'. Using server model.",
request.model,
effective_model_name,
)
handler.set_stage_configs_if_missing(app_stage_configs)
except HTTPException:
raise
except Exception as e:
logger.exception("Video generation failed: %s", e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
detail=f"Video generation failed: {str(e)}",
)
ref = video_response_from_request(effective_model_name, request)
try:
image_data = await decode_input_reference(request.image_reference, input_reference_bytes)
except InvalidInputReferenceError as exc:
raise HTTPException(400, detail=str(exc) or "Invalid input reference.") from exc
reference_image = ReferenceImage(data=image_data) if image_data is not None else image_data
await VIDEO_STORE.upsert(ref.id, ref)
task = asyncio.create_task(_run_video_generation_job(handler, request, ref.id, reference_image))
await VIDEO_TASKS.upsert(ref.id, task)
return ref
@router.get("/v1/videos", response_model=VideoListResponse)
async def list_videos(
after: str | None = None,
limit: int | None = Query(None, ge=0, le=100),
order: Annotated[Literal["asc", "desc"], Query()] = "desc",
):
"""List stored video generation jobs.
Args:
after: Optional cursor indicating the last seen video ID.
limit: Optional maximum number of jobs to return.
order: Sort order for the returned jobs by creation time.
Returns:
A ``VideoListResponse`` containing paginated job metadata and cursor
information.
"""
jobs = await VIDEO_STORE.list_values()
jobs.sort(key=lambda j: j.created_at, reverse=order == "desc")
if after is not None:
idx = next((i for i, job in enumerate(jobs) if job.id == after), None)
jobs = [] if idx is None else jobs[idx + 1 :]
has_more = False
if limit is not None:
has_more = len(jobs) > limit
jobs = jobs[:limit]
first_id, last_id = None, None
if len(jobs) > 0:
first_id = jobs[0].id
last_id = jobs[-1].id
return VideoListResponse(data=jobs, has_more=has_more, first_id=first_id, last_id=last_id)
@router.get("/v1/videos/{video_id}")
async def retrieve_video(video_id: str) -> VideoResponse:
"""Retrieve metadata for a previously created video job.
Args:
video_id: Identifier returned by ``POST /v1/videos``.
Returns:
The stored ``VideoResponse`` for the requested job.
Raises:
HTTPException: If the video job does not exist.
"""
job = await VIDEO_STORE.get(video_id)
if job is None:
raise HTTPException(status_code=404, detail="Video not found")
return job
@router.delete("/v1/videos/{video_id}")
async def delete_video(video_id: str) -> VideoDeleteResponse:
"""Delete a stored video job and any generated output.
If the job is still queued or running, this endpoint first attempts to
cancel the in-flight generation task before removing the stored metadata.
Args:
video_id: Identifier of the video job to delete.
Returns:
A ``VideoDeleteResponse`` confirming the job was removed.
Raises:
HTTPException: If the video job does not exist, cancellation is still
in progress, or output is not yet ready for a completed job.
"""
job = await VIDEO_STORE.get(video_id)
if job is None:
raise HTTPException(status_code=404, detail="Video not found")
if job.status in (VideoGenerationStatus.QUEUED, VideoGenerationStatus.IN_PROGRESS):
task = await VIDEO_TASKS.get(video_id)
if task is not None:
task.cancel()
try:
await asyncio.wait_for(task, timeout=2.0)
except asyncio.TimeoutError:
raise HTTPException(status_code=409, detail="Cancellation in progress. Please try again later.")
except asyncio.CancelledError:
pass
await VIDEO_STORE.pop(video_id)
return VideoDeleteResponse(id=job.id, deleted=True)
elif job.status is VideoGenerationStatus.FAILED:
if job.file_name is not None:
try:
await STORAGE_MANAGER.delete(job.file_name)
except Exception:
logger.warning("Failed to delete stored artifact for failed video job %s", video_id, exc_info=True)
await VIDEO_STORE.pop(video_id)
return VideoDeleteResponse(id=job.id, deleted=True)
if job.file_name is None:
raise HTTPException(status_code=409, detail="Video output not yet available. Please try again later.")
await STORAGE_MANAGER.delete(job.file_name)
await VIDEO_STORE.pop(video_id)
return VideoDeleteResponse(id=job.id, deleted=True)
@router.get("/v1/videos/{video_id}/content")
async def download_video(video_id: str) -> FileResponse:
"""Download the generated file for a completed video job.
Args:
video_id: Identifier of the video job whose output should be returned.
Returns:
A ``FileResponse`` streaming the generated video file from local
storage.
Raises:
HTTPException: If the job does not exist, is still in progress, or the
generated file is missing from disk.
"""
job = await VIDEO_STORE.get(video_id)
if job is None:
raise HTTPException(status_code=404, detail="Video not found")
if job.status == VideoGenerationStatus.FAILED:
raise HTTPException(status_code=422, detail="Video generation failed. Check job status for error details.")
if not job.file_name:
raise HTTPException(status_code=404, detail="Generation is still in-progress")
full_path = STORAGE_MANAGER.get_full_file_path(job.file_name)
if not os.path.exists(full_path):
raise HTTPException(status_code=404, detail="Generated video file not found on disk")
return FileResponse(path=full_path, media_type=job.media_type, filename=job.file_name)
@profiler_router.post("/start_profile")
async def start_profile(raw_request: Request, request: ProfileRequest | None = None):
"""Start profiling for the engine.
Args:
request: Optional request body with stages to profile.
- stages: List of stage IDs to profile. If None, profiles all stages.
Example:
POST /start_profile
{"stages": [0, 1]} # Profile only stages 0 and 1
"""
try:
stages = request.stages if request else None
logger.info("Starting profiler for stages: %s", stages if stages else "all")
engine_client = raw_request.app.state.engine_client
result = await engine_client.start_profile(stages=stages)
logger.info("Profiler started.")
return JSONResponse(content=result)
except Exception as e:
logger.exception("Failed to start profiler: %s", e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Failed to start profiler: {str(e)}"
)
@profiler_router.post("/stop_profile")
async def stop_profile(raw_request: Request, request: ProfileRequest | None = None):
"""Stop profiling for the engine.
Args:
request: Optional request body with stages to stop profiling.
- stages: List of stage IDs to stop profiling. If None, stops all stages.
Example:
POST /stop_profile
{"stages": [0, 1]} # Stop profiling only stages 0 and 1
"""
try:
stages = request.stages if request else None
logger.info("Stopping profiler for stages: %s", stages if stages else "all")
engine_client = raw_request.app.state.engine_client
result = await engine_client.stop_profile(stages=stages)
logger.info("Profiler stopped.")
return JSONResponse(content=result)
except Exception as e:
logger.exception("Failed to stop profiler: %s", e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Failed to stop profiler: {str(e)}"
)