chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,4 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
from sglang.multimodal_gen.runtime.utils.logging_utils import globally_suppress_loggers
globally_suppress_loggers()
@@ -0,0 +1 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
@@ -0,0 +1,30 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# adapted from vllm: https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/entrypoints/cli/types.py
import argparse
from sglang.multimodal_gen.utils import FlexibleArgumentParser
class CLISubcommand:
"""Base class for CLI subcommands"""
name: str
def cmd(
self, args: argparse.Namespace, unknown_args: list[str] | None = None
) -> None:
"""Execute the command with the given arguments"""
raise NotImplementedError
def validate(self, args: argparse.Namespace) -> None:
"""Validate the arguments for this command"""
pass
def subparser_init(
self, subparsers: argparse._SubParsersAction
) -> FlexibleArgumentParser:
"""Initialize the subparser for this command"""
raise NotImplementedError
@@ -0,0 +1,248 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# adapted from vllm: https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/entrypoints/cli/serve.py
import argparse
import dataclasses
import json
import os
from typing import cast
from sglang.multimodal_gen import DiffGenerator
from sglang.multimodal_gen.configs.sample.sampling_params import (
SamplingParams,
generate_request_id,
)
from sglang.multimodal_gen.runtime.entrypoints.cli.cli_types import CLISubcommand
from sglang.multimodal_gen.runtime.entrypoints.cli.utils import (
RaiseNotImplementedAction,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import GenerationResult
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.runtime.utils.perf_logger import (
MemorySnapshot,
PerformanceLogger,
RequestMetrics,
)
from sglang.multimodal_gen.utils import FlexibleArgumentParser
logger = init_logger(__name__)
def _resolve_cli_sampling_params_cls(server_args: ServerArgs) -> type[SamplingParams]:
pipeline_class_name = getattr(server_args, "pipeline_class_name", None)
if pipeline_class_name:
from sglang.multimodal_gen.registry import get_pipeline_config_classes
config_classes = get_pipeline_config_classes(pipeline_class_name)
if config_classes is not None:
_, sampling_params_cls = config_classes
return sampling_params_cls
try:
from sglang.multimodal_gen.registry import get_model_info
model_info = get_model_info(
server_args.model_path,
backend=server_args.backend,
model_id=server_args.model_id,
)
if model_info is not None:
return model_info.sampling_param_cls
except Exception as exc:
logger.debug("Falling back to base SamplingParams for CLI parsing: %s", exc)
return SamplingParams
def add_multimodal_gen_generate_args(parser: argparse.ArgumentParser):
"""Add the arguments for the generate command."""
parser.add_argument(
"--config",
type=str,
default="",
required=False,
help="Read CLI options from a config JSON or YAML file. If provided, --model-path and --prompt are optional.",
)
parser.add_argument(
"--perf-dump-path",
type=str,
default=None,
required=False,
help="Path to dump the performance metrics (JSON) for the run.",
)
parser.add_argument(
"--output-file-path",
type=str,
default=None,
required=False,
help="Convenience alias that sets both --output-path and --output-file-name.",
)
parser = ServerArgs.add_cli_args(parser)
parser = SamplingParams.add_cli_args(parser)
parser.add_argument(
"--text-encoder-configs",
action=RaiseNotImplementedAction,
help="JSON array of text encoder configurations (NOT YET IMPLEMENTED)",
)
return parser
def _apply_output_file_path_override(
args: argparse.Namespace, sampling_params_kwargs: dict
):
output_file_path = args.output_file_path
if not output_file_path:
return
output_path = os.path.dirname(output_file_path) or "."
sampling_params_kwargs["output_path"] = output_path
sampling_params_kwargs["output_file_name"] = os.path.basename(output_file_path)
def maybe_dump_performance(
args: argparse.Namespace,
server_args,
prompt: str,
results: GenerationResult | list[GenerationResult] | None,
):
"""dump performance if necessary"""
if not (args.perf_dump_path and results):
return
if isinstance(results, list):
result = results[0] if results else None
else:
result = results
metrics_dict = result.metrics
if not (args.perf_dump_path and metrics_dict):
return
metrics = RequestMetrics(request_id=metrics_dict.get("request_id"))
metrics.stages = metrics_dict.get("stages", {})
metrics.steps = metrics_dict.get("steps", [])
metrics.total_duration_ms = metrics_dict.get("total_duration_ms", 0)
# restore memory snapshots from serialized dict
memory_snapshots_dict = metrics_dict.get("memory_snapshots", {})
for checkpoint_name, snapshot_dict in memory_snapshots_dict.items():
snapshot = MemorySnapshot(
allocated_mb=snapshot_dict.get("allocated_mb", 0.0),
reserved_mb=snapshot_dict.get("reserved_mb", 0.0),
peak_allocated_mb=snapshot_dict.get("peak_allocated_mb", 0.0),
peak_reserved_mb=snapshot_dict.get("peak_reserved_mb", 0.0),
)
metrics.memory_snapshots[checkpoint_name] = snapshot
PerformanceLogger.dump_benchmark_report(
file_path=args.perf_dump_path,
metrics=metrics,
meta={
"prompt": prompt,
"model": server_args.model_path,
},
tag="cli_generate",
)
def generate_cmd(args: argparse.Namespace, unknown_args: list[str] | None = None):
"""The entry point for the generate command."""
args.request_id = "mocked_fake_id_for_offline_generate"
server_args = ServerArgs.from_cli_args(args, unknown_args)
sampling_params_cls = _resolve_cli_sampling_params_cls(server_args)
sampling_params_kwargs = {}
config_file = getattr(args, "config", None)
# respect config file by overriding args with args parsed from it
if config_file:
config_args = ServerArgs.load_config_file(config_file) or {}
sampling_param_fields = {
field.name for field in dataclasses.fields(sampling_params_cls)
}
sampling_params_kwargs.update(
{
key: value
for key, value in config_args.items()
if key in sampling_param_fields and value is not None
}
)
sampling_params_kwargs.update(sampling_params_cls.get_cli_args(args))
_apply_output_file_path_override(args, sampling_params_kwargs)
sampling_params_kwargs["request_id"] = generate_request_id()
# Handle diffusers-specific kwargs passed via CLI
if hasattr(args, "diffusers_kwargs") and args.diffusers_kwargs:
try:
sampling_params_kwargs["diffusers_kwargs"] = json.loads(
args.diffusers_kwargs
)
logger.info(
"Parsed diffusers_kwargs: %s",
sampling_params_kwargs["diffusers_kwargs"],
)
except json.JSONDecodeError as e:
logger.error("Failed to parse --diffusers-kwargs as JSON: %s", e)
raise ValueError(
f"--diffusers-kwargs must be valid JSON. Got: {args.diffusers_kwargs}"
) from e
generator = DiffGenerator.from_pretrained(
model_path=server_args.model_path, server_args=server_args, local_mode=True
)
results = generator.generate(sampling_params_kwargs=sampling_params_kwargs)
prompt = sampling_params_kwargs.get("prompt")
maybe_dump_performance(args, server_args, prompt, results)
class GenerateSubcommand(CLISubcommand):
"""The `generate` subcommand for the sglang-diffusion CLI"""
def __init__(self) -> None:
self.name = "generate"
super().__init__()
self.init_arg_names = self._get_init_arg_names()
self.generation_arg_names = self._get_generation_arg_names()
def _get_init_arg_names(self) -> list[str]:
"""Get names of arguments for DiffGenerator initialization"""
return ["num_gpus", "tp_size", "sp_size", "model_path"]
def _get_generation_arg_names(self) -> list[str]:
"""Get names of arguments for generate_video method"""
return [field.name for field in dataclasses.fields(SamplingParams)]
def cmd(
self, args: argparse.Namespace, unknown_args: list[str] | None = None
) -> None:
generate_cmd(args, unknown_args)
def validate(self, args: argparse.Namespace) -> None:
"""Validate the arguments for this command"""
if args.num_gpus is not None and args.num_gpus <= 0:
raise ValueError("Number of gpus must be positive")
if args.config and not os.path.exists(args.config):
raise ValueError(f"Config file not found: {args.config}")
def subparser_init(
self, subparsers: argparse._SubParsersAction
) -> FlexibleArgumentParser:
generate_parser = subparsers.add_parser(
"generate",
help="Run inference on a model",
usage="sglang generate (--model-path MODEL_PATH_OR_ID --prompt PROMPT) | --config CONFIG_FILE [OPTIONS]",
)
generate_parser = add_multimodal_gen_generate_args(generate_parser)
return cast(FlexibleArgumentParser, generate_parser)
@@ -0,0 +1,44 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
# adapted from vllm: https://github.com/vllm-project/vllm/blob/v0.7.3/vllm/entrypoints/cli/main.py
from sglang.multimodal_gen.runtime.entrypoints.cli.cli_types import CLISubcommand
from sglang.multimodal_gen.runtime.entrypoints.cli.generate import GenerateSubcommand
from sglang.multimodal_gen.runtime.entrypoints.cli.serve import ServeSubcommand
from sglang.multimodal_gen.utils import FlexibleArgumentParser
def generate_cmd_init() -> list[CLISubcommand]:
return [GenerateSubcommand(), ServeSubcommand()]
def cmd_init() -> list[CLISubcommand]:
"""Initialize all commands from separate modules"""
commands = []
commands.extend(generate_cmd_init())
return commands
def main() -> None:
parser = FlexibleArgumentParser(description="sglang-diffusion CLI")
parser.add_argument("-v", "--version", action="version", version="0.1.0")
subparsers = parser.add_subparsers(required=False, dest="subparser")
cmds = {}
for cmd in cmd_init():
cmd.subparser_init(subparsers).set_defaults(dispatch_function=cmd.cmd)
cmds[cmd.name] = cmd
args, unknown_args = parser.parse_known_args()
if args.subparser in cmds:
cmds[args.subparser].validate(args)
if hasattr(args, "dispatch_function"):
args.dispatch_function(args, unknown_args=unknown_args)
else:
parser.print_help()
if __name__ == "__main__":
main()
@@ -0,0 +1,75 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
import argparse
import os
from typing import cast
from sglang.multimodal_gen.apps.webui import run_sgl_diffusion_webui
from sglang.multimodal_gen.runtime.entrypoints.cli.cli_types import CLISubcommand
from sglang.multimodal_gen.runtime.launch_server import (
dispatch_launch,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.utils import FlexibleArgumentParser
def add_multimodal_gen_serve_args(parser: argparse.ArgumentParser):
"""Add the arguments for the serve command."""
parser.add_argument(
"--config",
type=str,
default="",
required=False,
help="Read CLI options from a config JSON or YAML file.",
)
return ServerArgs.add_cli_args(parser)
def execute_serve_cmd(args: argparse.Namespace, unknown_args: list[str] | None = None):
"""The entry point for the serve command."""
# use server-based warmup for production
server_args = ServerArgs.from_cli_args(
args, unknown_args, default_args={"warmup_mode": "server"}
)
dispatch_launch(server_args)
if server_args.webui:
run_sgl_diffusion_webui(server_args)
class ServeSubcommand(CLISubcommand):
"""The `serve` subcommand for the sglang-diffusion CLI"""
def __init__(self) -> None:
self.name = "serve"
super().__init__()
def cmd(
self, args: argparse.Namespace, unknown_args: list[str] | None = None
) -> None:
execute_serve_cmd(args, unknown_args)
def validate(self, args: argparse.Namespace) -> None:
"""Validate the arguments for this command"""
if args.config and not os.path.exists(args.config):
raise ValueError(f"Config file not found: {args.config}")
def subparser_init(
self, subparsers: argparse._SubParsersAction
) -> FlexibleArgumentParser:
serve_parser = subparsers.add_parser(
"serve",
help="Launch the server and start FastAPI listener.",
usage="sglang serve --model-path MODEL_PATH_OR_ID [OPTIONS]",
)
serve_parser = add_multimodal_gen_serve_args(serve_parser)
return cast(FlexibleArgumentParser, serve_parser)
def cmd_init() -> list[CLISubcommand]:
return [ServeSubcommand()]
@@ -0,0 +1,75 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
import argparse
import os
import shlex
import subprocess
import sys
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
class RaiseNotImplementedAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
raise NotImplementedError(f"The {option_string} option is not yet implemented")
def launch_distributed(
num_gpus: int, args: list[str], master_port: int | None = None
) -> int:
"""
Launch a distributed job with the given arguments
Args:
num_gpus: Number of GPUs to use
args: Arguments to pass to v1_sgl_diffusion_inference.py (defaults to sys.argv[1:])
master_port: Port for the master process (default: random)
"""
current_env = os.environ.copy()
python_executable = sys.executable
project_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "../../../..")
)
main_script = os.path.join(
project_root, "sgl_diffusion/sample/v1_sgl_diffusion_inference.py"
)
cmd = [
python_executable,
"-m",
"torch.distributed.run",
f"--nproc_per_node={num_gpus}",
]
if master_port is not None:
cmd.append(f"--master_port={master_port}")
cmd.append(main_script)
cmd.extend(args)
logger.info("Running inference with %d GPU(s)", num_gpus)
logger.info("Launching command: %s", shlex.join(cmd))
current_env["PYTHONIOENCODING"] = "utf-8"
process = subprocess.Popen(
cmd,
env=current_env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1,
encoding="utf-8",
errors="replace",
)
if process.stdout:
for line in iter(process.stdout.readline, ""):
print(line.strip())
return process.wait()
@@ -0,0 +1,713 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
"""
DiffGenerator module for sglang-diffusion.
This module provides a consolidated interface for generating images/videos using
diffusion models.
"""
import dataclasses
import multiprocessing as mp
import os
import time
from contextlib import ExitStack
from typing import Any, List, Union
from sglang.multimodal_gen.configs.sample.sampling_params import (
DataType,
SamplingParams,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
GenerationResult,
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
ShutdownReq,
UnmergeLoraWeightsReq,
expand_request_outputs,
format_lora_message,
prepare_request,
save_outputs,
)
from sglang.multimodal_gen.runtime.launch_server import launch_server
from sglang.multimodal_gen.runtime.pipelines_core import Req
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.scheduler_client import sync_scheduler_client
from sglang.multimodal_gen.runtime.server_args import PortArgs, ServerArgs
from sglang.multimodal_gen.runtime.server_warmup import (
run_sync_client_warmup,
should_run_explicit_client_warmup,
)
from sglang.multimodal_gen.runtime.utils.logging_utils import (
GREEN,
RESET,
init_logger,
log_batch_completion,
log_generation_timer,
)
from sglang.multimodal_gen.runtime.utils.trace_wrapper import (
init_diffusion_tracing,
trace_req,
)
logger = init_logger(__name__)
try:
# Set the start method to 'spawn' to avoid CUDA errors in forked processes.
# This must be done at the top level of the module, before any CUDA context
# or other processes are initialized.
mp.set_start_method("spawn", force=True)
except RuntimeError:
# The start method can only be set once per program execution.
pass
class DiffGenerator:
"""
A unified class for generating images/videos using diffusion models.
This class provides a simple interface for image/video generation with rich
customization options, similar to popular frameworks like HF Diffusers.
"""
def __init__(
self,
server_args: ServerArgs,
):
"""
Initialize the generator.
Args:
server_args: The inference arguments
"""
self.server_args = server_args
self.port_args = PortArgs.from_server_args(server_args)
# The executor is now a client to the Scheduler service
self.local_scheduler_process: list[mp.Process] | None = None
self.owns_scheduler_client: bool = False
@classmethod
def from_pretrained(
cls,
local_mode: bool = True,
**kwargs,
) -> "DiffGenerator":
"""
Create a DiffGenerator from a pretrained model.
Priority level: Default pipeline config < User's pipeline config < User's kwargs
"""
# If users also provide some kwargs, it will override the ServerArgs and PipelineConfig.
if (server_args := kwargs.get("server_args", None)) is not None:
if isinstance(server_args, ServerArgs):
pass
elif isinstance(server_args, dict):
server_args = ServerArgs.from_kwargs(**server_args)
else:
server_args = ServerArgs.from_kwargs(**kwargs)
return cls.from_server_args(server_args, local_mode=local_mode)
@classmethod
def from_server_args(
cls, server_args: ServerArgs, local_mode: bool = True
) -> "DiffGenerator":
"""
Create a DiffGenerator with the specified arguments.
Args:
server_args: The inference arguments
Returns:
The created DiffGenerator
"""
instance = cls(
server_args=server_args,
)
init_diffusion_tracing(server_args, "DiffGenerator")
logger.info(f"Local mode: {local_mode}")
if local_mode:
instance.local_scheduler_process = instance._start_local_server_if_needed()
instance.owns_scheduler_client = True
instance._run_client_warmup_if_needed()
else:
# In remote mode, we just need to connect and check.
sync_scheduler_client.initialize(server_args)
instance._check_remote_scheduler()
instance.owns_scheduler_client = True
return instance
def _start_local_server_if_needed(
self,
) -> list[mp.Process]:
"""Check if a local server is running; if not, start it and return the process handles."""
# First, we need a client to test the server. Initialize it temporarily.
sync_scheduler_client.initialize(self.server_args)
processes = launch_server(self.server_args, launch_http_server=False)
return processes
def _run_client_warmup_if_needed(self) -> None:
if not should_run_explicit_client_warmup(self.server_args):
return
run_sync_client_warmup(self.server_args, sync_scheduler_client.forward)
def _check_remote_scheduler(self):
"""Check if the remote scheduler is accessible."""
if not sync_scheduler_client.ping():
raise ConnectionError(
f"Could not connect to remote scheduler at "
f"{self.server_args.scheduler_endpoint} with `local mode` as False. "
"Please ensure the server is running."
)
logger.info(
f"Successfully connected to remote scheduler at "
f"{self.server_args.scheduler_endpoint}."
)
@staticmethod
def _resolve_image_paths_per_prompt(
prompts: list[str], image_paths: str | list[str] | None
) -> list[str | list[str] | None]:
if len(prompts) <= 1:
return [image_paths]
if not isinstance(image_paths, list) or len(image_paths) <= 1:
return [image_paths for _ in prompts]
if len(image_paths) != len(prompts):
raise ValueError(
"When using multiple prompts with multiple input images, "
"provide either one shared image or exactly one image per prompt."
)
return [[image_path] for image_path in image_paths]
def generate(
self,
sampling_params_kwargs: dict | None = None,
external_trace_header: dict[str, str] | None = None,
) -> GenerationResult | list[GenerationResult] | None:
"""Generate image(s)/video(s) based on the given prompt(s).
Returns a single GenerationResult for a single prompt, a list for
multiple prompts, or None when every request failed.
"""
# 1. prepare requests
prompts = self._resolve_prompts(
sampling_params_kwargs.get("prompt"),
sampling_params_kwargs.get("prompt_path"),
)
user_output_file_name = sampling_params_kwargs.get("output_file_name")
if len(prompts) > 1 and user_output_file_name is not None:
raise ValueError(
"Cannot use multiple prompts with a fixed output_file_name. "
"Either remove --output-file-name or use a single prompt."
)
sampling_params_orig = SamplingParams.from_user_sampling_params_args(
self.server_args.model_path,
server_args=self.server_args,
**sampling_params_kwargs,
)
request_groups: list[list[Req]] = []
image_paths_per_prompt = self._resolve_image_paths_per_prompt(
prompts, sampling_params_orig.image_path
)
for i, p in enumerate(prompts):
sampling_params = dataclasses.replace(
sampling_params_orig,
prompt=p,
output_file_name=user_output_file_name,
image_path=image_paths_per_prompt[i],
)
# `dataclasses.replace` drops non-field attrs; restore
# `_explicit_fields` so InputValidationStage honors user-supplied
# width/height, and mark the keys overridden above as explicit.
sampling_params._explicit_fields = getattr(
sampling_params_orig, "_explicit_fields", set()
) | {"prompt", "output_file_name", "image_path"}
sampling_params._set_output_file_name()
req = prepare_request(
server_args=self.server_args,
sampling_params=sampling_params,
external_trace_header=external_trace_header,
)
request_groups.append(
expand_request_outputs(
req,
num_prompts=len(prompts),
prompt_index=i,
)
)
results: list[GenerationResult] = []
total_start_time = time.perf_counter()
global_output_index = 0
for requests in request_groups:
try:
timer_prompt = [req.prompt for req in requests]
logger.info("Processing %d grouped request(s)", len(requests))
with ExitStack() as stack:
for req in requests:
stack.enter_context(trace_req(req.trace_ctx))
timer = stack.enter_context(
log_generation_timer(logger, timer_prompt)
)
output_batch = self._send_to_scheduler_and_wait_for_response(
requests
)
if output_batch.error:
raise Exception(f"{output_batch.error}")
if (
output_batch.output is None
and output_batch.output_file_paths is None
):
logger.error("Received empty output from scheduler")
continue
if requests[0].save_output and requests[0].return_file_paths_only:
output_file_paths = output_batch.output_file_paths or []
self._validate_output_count(
len(output_file_paths), len(requests)
)
for idx, path in enumerate(output_file_paths):
req = requests[idx]
results.append(
GenerationResult(
**self._result_common(
req, output_batch, timer.duration, idx
),
prompt_index=global_output_index + idx,
output_file_path=path,
)
)
elif requests[0].data_type == DataType.MESH:
output_file_paths = output_batch.output_file_paths or []
self._validate_output_count(
len(output_file_paths), len(requests)
)
for idx, sample in enumerate(output_file_paths):
req = requests[idx]
results.append(
GenerationResult(
**self._result_common(
req, output_batch, timer.duration, idx
),
prompt_index=global_output_index + idx,
output_file_path=sample,
)
)
else:
self._validate_output_count(
len(output_batch.output), len(requests)
)
samples_out: list[Any] = []
audios_out: list[Any] = []
frames_out: list[Any] = []
save_outputs(
output_batch.output,
requests[0].data_type,
requests[0].fps,
requests[0].save_output,
lambda idx: requests[idx].output_file_path(1, 0),
audio=output_batch.audio,
audio_sample_rate=output_batch.audio_sample_rate,
samples_out=samples_out,
audios_out=audios_out,
frames_out=frames_out,
output_compression=requests[0].output_compression,
enable_frame_interpolation=requests[
0
].enable_frame_interpolation,
frame_interpolation_exp=requests[0].frame_interpolation_exp,
frame_interpolation_scale=requests[
0
].frame_interpolation_scale,
frame_interpolation_model_path=requests[
0
].frame_interpolation_model_path,
enable_upscaling=requests[0].enable_upscaling,
upscaling_model_path=requests[0].upscaling_model_path,
upscaling_scale=requests[0].upscaling_scale,
)
for idx in range(len(samples_out)):
req = requests[idx]
results.append(
GenerationResult(
**self._result_common(
req, output_batch, timer.duration, idx
),
samples=samples_out[idx],
frames=frames_out[idx],
audio=audios_out[idx],
prompt_index=global_output_index + idx,
output_file_path=req.output_file_path(1, 0),
)
)
except Exception as e:
logger.error("Generation failed: %s", e, exc_info=True)
finally:
global_output_index += len(requests)
total_gen_time = time.perf_counter() - total_start_time
if self.server_args.batching_max_size > 1:
log_batch_completion(
logger,
len(results),
total_gen_time,
)
self._log_summary(results)
if not results:
return None
return results[0] if len(results) == 1 else results
def generate_action(
self,
sampling_params_kwargs: dict | None = None,
external_trace_header: dict[str, str] | None = None,
) -> dict[str, Any]:
sampling_params_kwargs = sampling_params_kwargs or {}
sampling_params = SamplingParams.from_user_sampling_params_args(
self.server_args.model_path,
server_args=self.server_args,
**sampling_params_kwargs,
)
if sampling_params.data_type != DataType.ACTION:
raise ValueError(
f"generate_action requires an ACTION pipeline, got {sampling_params.data_type}"
)
req = prepare_request(
server_args=self.server_args,
sampling_params=sampling_params,
external_trace_header=external_trace_header,
)
output_batch = self._send_to_scheduler_and_wait_for_response(req)
if output_batch.error:
raise RuntimeError(output_batch.error)
if output_batch.output is None:
raise RuntimeError("action policy returned no output")
return output_batch.output[0]
def _resolve_prompts(
self,
prompt: str | list[str] | None,
prompt_path: str | None = None,
) -> list[str]:
"""Collect prompts from the argument or from a prompt file."""
path = prompt_path or self.server_args.prompt_file_path
if path is not None:
if not os.path.exists(path):
raise FileNotFoundError(f"Prompt text file not found: {path}")
with open(path, encoding="utf-8") as f:
prompts = [line.strip() for line in f if line.strip()]
if not prompts:
raise ValueError(f"No prompts found in file: {path}")
logger.info("Found %d prompts in %s", len(prompts), path)
return prompts
if prompt is None:
return [" "]
if isinstance(prompt, str):
return [prompt]
return list(prompt)
def _log_summary(self, results: list[GenerationResult]) -> None:
if not results:
return
if self.server_args.warmup:
total_duration_ms = results[0].metrics.get("total_duration_ms", 0)
logger.info(
f"Warmed-up request processed in {GREEN}%.2f{RESET} seconds (with warmup excluded)",
total_duration_ms / 1000.0,
)
peak_memories = [r.peak_memory_mb for r in results if r.peak_memory_mb]
if peak_memories:
logger.info(
f"Memory usage - Max peak: {max(peak_memories):.2f} MB, "
f"Avg peak: {sum(peak_memories) / len(peak_memories):.2f} MB"
)
@staticmethod
def _result_common(
req: Req,
output_batch: OutputBatch,
generation_time: float,
output_index: int | None = None,
) -> dict[str, Any]:
metrics = output_batch.metrics
if (
output_index is not None
and output_batch.metrics_list is not None
and output_index < len(output_batch.metrics_list)
):
metrics = output_batch.metrics_list[output_index]
if req.data_type == DataType.ACTION:
size = ("action",)
else:
size = (req.height, req.width, req.num_frames)
return dict(
prompt=req.prompt,
size=size,
generation_time=generation_time,
peak_memory_mb=output_batch.peak_memory_mb,
metrics=metrics.to_dict() if metrics else {},
action=output_batch.action_pred,
trajectory_latents=output_batch.trajectory_latents,
trajectory_timesteps=output_batch.trajectory_timesteps,
rollout_trajectory_data=output_batch.rollout_trajectory_data,
trajectory_decoded=output_batch.trajectory_decoded,
)
@staticmethod
def _validate_output_count(output_count: int, request_count: int) -> None:
if output_count != request_count:
raise RuntimeError(
f"Expected {request_count} outputs, got {output_count} from scheduler"
)
def _send_to_scheduler_and_wait_for_response(self, batch: list[Req]) -> OutputBatch:
"""
Sends a request to the scheduler and waits for a response.
"""
return sync_scheduler_client.forward(batch)
# LoRA
def _send_lora_request(self, req: Any, success_msg: str, failure_msg: str):
response = sync_scheduler_client.forward(req)
if response.error is None:
logger.info(success_msg)
return response
else:
error_msg = response.error
raise RuntimeError(f"{failure_msg}: {error_msg}")
def set_lora(
self,
lora_nickname: Union[str, List[str]],
lora_path: Union[str, None, List[Union[str, None]]] = None,
target: Union[str, List[str]] = "all",
strength: Union[float, List[float]] = 1.0,
merge_mode: str | None = None,
) -> None:
"""
Set LoRA adapter(s) for the specified transformer(s).
Supports both single LoRA (backward compatible) and multiple LoRA adapters.
Args:
lora_nickname: The nickname(s) of the adapter(s). Can be a string or a list of strings.
lora_path: Path(s) to the LoRA adapter(s). Can be a string, None, or a list of strings/None.
target: Which transformer(s) to apply the LoRA to. Can be a string or a list of strings.
Valid values:
- "all": Apply to all transformers (default)
- "transformer": Apply only to the primary transformer (high noise for Wan2.2)
- "transformer_2": Apply only to transformer_2 (low noise for Wan2.2)
- "critic": Apply only to the critic model
strength: LoRA strength(s) for merge, default 1.0. Can be a float or a list of floats.
merge_mode: Optional LoRA merge mode: "auto", "merge", or "dynamic".
"""
req = SetLoraReq(
lora_nickname=lora_nickname,
lora_path=lora_path,
target=target,
strength=strength,
merge_mode=merge_mode,
)
nickname_str, target_str, strength_str = format_lora_message(
lora_nickname, target, strength
)
self._send_lora_request(
req,
f"Successfully set LoRA adapter(s): {nickname_str} (target: {target_str}, strength: {strength_str})",
"Failed to set LoRA adapter",
)
def unmerge_lora_weights(self, target: str = "all") -> None:
"""
Unmerge LoRA weights from the base model.
Args:
target: Which transformer(s) to unmerge.
"""
req = UnmergeLoraWeightsReq(target=target)
self._send_lora_request(
req,
f"Successfully unmerged LoRA weights (target: {target})",
"Failed to unmerge LoRA weights",
)
def merge_lora_weights(self, target: str = "all", strength: float = 1.0) -> None:
"""
Merge LoRA weights into the base model.
Args:
target: Which transformer(s) to merge.
strength: LoRA strength for merge, default 1.0.
"""
req = MergeLoraWeightsReq(target=target, strength=strength)
self._send_lora_request(
req,
f"Successfully merged LoRA weights (target: {target}, strength: {strength})",
"Failed to merge LoRA weights",
)
def list_loras(self) -> dict:
"""List loaded LoRA adapters and current application status per module."""
output = self._send_lora_request(
req=ListLorasReq(),
success_msg="Successfully listed LoRA adapters",
failure_msg="Failed to list LoRA adapters",
)
# _send_lora_request already raises on error, so output.error is always None here
return output.output or {}
def _ensure_lora_state(
self,
lora_path: str | None,
lora_nickname: str | None = None,
merge_lora: bool = True,
) -> None:
"""
Ensure the LoRA state matches the desired configuration.
Note: This method does not cache client-side state. The server handles
idempotent operations, so redundant calls are safe but may have minor overhead.
"""
if lora_path is None:
# Unmerge all LoRA weights when no lora_path is provided
self.unmerge_lora_weights()
return
lora_nickname = lora_nickname or self.server_args.lora_nickname
# Set the LoRA adapter (server handles idempotent logic)
self.set_lora(lora_nickname, lora_path)
# Merge or unmerge based on the merge_lora flag
if merge_lora:
self.merge_lora_weights()
else:
self.unmerge_lora_weights()
def generate_with_lora(
self,
prompt: str | list[str] | None = None,
sampling_params: SamplingParams | None = None,
*,
lora_path: str | None = None,
lora_nickname: str | None = None,
merge_lora: bool = True,
**kwargs,
):
self._ensure_lora_state(
lora_path=lora_path, lora_nickname=lora_nickname, merge_lora=merge_lora
)
return self.generate(
sampling_params_kwargs=dict(
prompt=prompt,
sampling_params=sampling_params,
**kwargs,
)
)
def shutdown(self):
"""
Shutdown the generator.
If in local mode, it also shuts down the scheduler server.
"""
# sends the shutdown command to the server
if self.local_scheduler_process and self.owns_scheduler_client:
try:
sync_scheduler_client.forward(ShutdownReq(), timeout_ms=5000)
except Exception:
pass
if self.local_scheduler_process:
for process in self.local_scheduler_process:
process.join(timeout=10)
if process.is_alive():
logger.warning(
f"Local worker {process.name} did not terminate gracefully, forcing."
)
process.terminate()
process.join(timeout=1)
if process.is_alive():
process.kill()
process.join(timeout=1)
self.local_scheduler_process = None
if self.owns_scheduler_client:
sync_scheduler_client.close()
self.owns_scheduler_client = False
def _force_shutdown_local_processes(self) -> None:
local_scheduler_process = getattr(self, "local_scheduler_process", None)
log = globals().get("logger")
if local_scheduler_process:
for process in local_scheduler_process:
if process.is_alive():
if log is not None:
log.warning(
f"Local worker {process.name} did not terminate gracefully, forcing."
)
process.terminate()
for process in local_scheduler_process:
process.join(timeout=1)
if process.is_alive():
if log is not None:
log.warning(
f"Local worker {process.name} did not terminate after terminate(), killing."
)
process.kill()
process.join(timeout=1)
self.local_scheduler_process = None
if getattr(self, "owns_scheduler_client", False):
try:
client = globals().get("sync_scheduler_client")
if client is not None:
client.close()
finally:
self.owns_scheduler_client = False
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.shutdown()
def __del__(self):
owns_scheduler_client = bool(getattr(self, "owns_scheduler_client", False))
local_scheduler_process = getattr(self, "local_scheduler_process", None)
log = globals().get("logger")
if owns_scheduler_client:
if log is not None:
log.warning(
"Generator was garbage collected without being shut down. "
"Forcing local server and client cleanup."
)
self._force_shutdown_local_processes()
elif local_scheduler_process:
if log is not None:
log.warning(
"Generator was garbage collected without being shut down. "
"Forcing local server cleanup."
)
self._force_shutdown_local_processes()
@@ -0,0 +1,413 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
import asyncio
import base64
import os
import signal
import uuid
from contextlib import asynccontextmanager, suppress
from typing import TYPE_CHECKING
import httpx
import torch
from fastapi import APIRouter, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.entrypoints.openai import image_api, video_api
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
VertexGenerateReqInput,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime import (
realtime_video_api,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import build_sampling_params
from sglang.multimodal_gen.runtime.entrypoints.post_training import (
rollout_api,
weights_api,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
prepare_request,
save_outputs,
)
from sglang.multimodal_gen.runtime.entrypoints.vla import api as vla_api
from sglang.multimodal_gen.runtime.entrypoints.vla import openpi
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import ServerArgs, get_global_server_args
from sglang.multimodal_gen.runtime.server_warmup import (
run_async_client_warmup,
should_run_synthetic_server_warmup,
)
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.srt.utils.json_response import orjson_response
from sglang.version import __version__
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
logger = init_logger(__name__)
VERTEX_ROUTE = os.environ.get("AIP_PREDICT_ROUTE", "/vertex_generate")
SERVER_WARMUP_BYPASS_PATHS = (
"/health",
"/health_generate",
"/model_info",
"/server_info",
)
async def _wait_until_http_ready(server_args: ServerArgs) -> None:
"""for server warmup"""
health_url = f"{server_args.url()}/health"
# Probe the local server directly: a loopback readiness check must never be
# routed through an HTTP proxy. trust_env=False also avoids crashing startup
# on a malformed proxy env var, since httpx parses *_PROXY/NO_PROXY when the
# client is constructed (raising httpx.InvalidURL before any request). See #28493.
async with httpx.AsyncClient(trust_env=False) as client:
for _ in range(120):
try:
response = await client.get(health_url, timeout=5.0)
if response.status_code == 200:
return
except httpx.HTTPError:
pass
await asyncio.sleep(1.0)
raise RuntimeError(f"HTTP server did not become ready at {health_url}")
async def _run_server_warmup_after_http_ready(
server_args: ServerArgs, warmup_done: asyncio.Event
) -> None:
try:
if not should_run_synthetic_server_warmup(server_args):
warmup_done.set()
return
await _wait_until_http_ready(server_args)
await run_async_client_warmup(
server_args,
async_scheduler_client.forward,
fail_open=server_args.warmup_resolutions is None,
)
logger.info("The server is fired up and ready to roll!")
warmup_done.set()
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("Server warmup failed; aborting startup: %s", e, exc_info=True)
os.kill(os.getpid(), signal.SIGTERM)
@asynccontextmanager
async def lifespan(app: FastAPI):
from sglang.multimodal_gen.runtime.scheduler_client import (
async_scheduler_client,
run_zeromq_broker,
)
# 1. Initialize the singleton client that connects to the backend Scheduler
server_args = app.state.server_args
async_scheduler_client.initialize(server_args)
warmup_done = asyncio.Event()
app.state.server_warmup_done = warmup_done
# 2. Start the ZMQ Broker in the background to handle offline requests
broker_task = asyncio.create_task(run_zeromq_broker(server_args))
warmup_task = None
if server_args.server_warmup:
warmup_task = asyncio.create_task(
_run_server_warmup_after_http_ready(server_args, warmup_done)
)
else:
warmup_done.set()
try:
yield
finally:
if warmup_task is not None and not warmup_task.done():
warmup_task.cancel()
with suppress(asyncio.CancelledError):
await warmup_task
# On shutdown
logger.info("FastAPI app is shutting down...")
broker_task.cancel()
async_scheduler_client.close()
# Health router
health_router = APIRouter()
@health_router.get("/health")
async def health():
return {"status": "ok"}
@health_router.get("/models", deprecated=True)
async def get_models(request: Request):
"""
Get information about the model served by this server.
.. deprecated::
Use /v1/models instead for OpenAI-compatible model discovery.
This endpoint will be removed in a future version.
"""
from sglang.multimodal_gen.registry import get_model_info
server_args: ServerArgs = request.app.state.server_args
model_info = get_model_info(server_args.model_path, model_id=server_args.model_id)
response = {
"model_path": server_args.model_path,
"num_gpus": server_args.num_gpus,
"task_type": server_args.pipeline_config.task_type.name,
"dit_precision": server_args.pipeline_config.dit_precision,
"vae_precision": server_args.pipeline_config.vae_precision,
}
if model_info:
response["pipeline_name"] = model_info.pipeline_cls.pipeline_name
response["pipeline_class"] = model_info.pipeline_cls.__name__
return response
@health_router.get("/server_info")
async def server_info_endpoint(request: Request):
"""Get server information.
Returns fields compatible with the LLM engine's /server_info so that
the model gateway can discover diffusion workers.
"""
server_args: ServerArgs = request.app.state.server_args
return {
"model_path": server_args.model_path,
"served_model_name": server_args.model_id or server_args.model_path,
"tp_size": server_args.tp_size,
"dp_size": server_args.dp_size,
"version": __version__,
}
@health_router.get("/model_info")
async def model_info_endpoint(request: Request):
"""Get model information.
Returns fields compatible with the LLM engine's /model_info so that
the model gateway can detect capabilities for diffusion workers.
"""
from sglang.multimodal_gen.registry import get_model_info
server_args: ServerArgs = request.app.state.server_args
task_type = server_args.pipeline_config.task_type
try:
registry_info = get_model_info(
server_args.model_path,
backend=server_args.backend,
model_id=server_args.model_id,
)
except Exception:
logger.warning("Failed to resolve model info from registry", exc_info=True)
registry_info = None
return {
# Fields consumed by the model gateway for worker discovery
"model_path": server_args.model_path,
"is_generation": True,
"model_type": "diffusion",
"architectures": (
[registry_info.pipeline_cls.__name__] if registry_info else None
),
# Fields matching the LLM engine's /model_info shape
"has_image_understanding": task_type.accepts_image_input(),
"has_audio_understanding": False,
# Diffusion-specific fields
"task_type": task_type.name,
"is_image_gen": task_type.is_image_gen(),
}
@health_router.get("/health_generate")
async def health_generate():
# TODO : health generate endpoint
return {"status": "ok"}
@health_router.get("/stats")
async def stats_endpoint(request: Request):
"""Get runtime statistics including disagg pipeline metrics.
Returns queue depth, request counts, latency, throughput, etc.
Sends a GetDisaggStatsReq to the scheduler via ZMQ and returns the result.
"""
from sglang.multimodal_gen.runtime.entrypoints.utils import GetDisaggStatsReq
server_args: ServerArgs = request.app.state.server_args
response: dict = {
"status": "ok",
"model_path": server_args.model_path,
}
# Query the scheduler for disagg metrics
try:
stats_response = await async_scheduler_client.forward(GetDisaggStatsReq())
if hasattr(stats_response, "output") and stats_response.output is not None:
response["disagg"] = stats_response.output
except Exception as e:
response["disagg"] = {"error": str(e)}
return response
def make_serializable(obj):
"""Recursively converts Tensors to None for JSON serialization."""
if isinstance(obj, torch.Tensor):
return None
if isinstance(obj, dict):
return {k: make_serializable(v) for k, v in obj.items()}
if isinstance(obj, list):
return [make_serializable(v) for v in obj]
return obj
def encode_video_to_base64(file_path: str):
if not os.path.exists(file_path):
return None
with open(file_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
async def forward_to_scheduler(
req_obj: "Req",
sp: SamplingParams,
):
"""Forwards request to scheduler and processes the result."""
try:
response = await async_scheduler_client.forward(req_obj)
if response.output is None and response.output_file_paths is None:
raise RuntimeError("Model generation returned no output.")
if response.output_file_paths:
output_file_path = response.output_file_paths[0]
else:
output_file_path = sp.output_file_path()
save_outputs(
[response.output[0]],
sp.data_type,
sp.fps,
True,
lambda _idx: output_file_path,
audio=response.audio,
audio_sample_rate=response.audio_sample_rate,
enable_frame_interpolation=sp.enable_frame_interpolation,
frame_interpolation_exp=sp.frame_interpolation_exp,
frame_interpolation_scale=sp.frame_interpolation_scale,
frame_interpolation_model_path=sp.frame_interpolation_model_path,
enable_upscaling=sp.enable_upscaling,
upscaling_model_path=sp.upscaling_model_path,
upscaling_scale=sp.upscaling_scale,
)
if hasattr(response, "model_dump"):
data = response.model_dump()
else:
data = response if isinstance(response, dict) else vars(response)
if output_file_path:
logger.info("Processing output file: %s", output_file_path)
b64_video = encode_video_to_base64(output_file_path)
if b64_video:
data["output"] = b64_video
data.pop("video_data", None)
data.pop("video_tensor", None)
return make_serializable(data)
except Exception as e:
logger.error("Error during generation: %s", e, exc_info=True)
return {"error": str(e)}
vertex_router = APIRouter()
@vertex_router.post(VERTEX_ROUTE)
async def vertex_generate(vertex_req: VertexGenerateReqInput):
if not vertex_req.instances:
return orjson_response({"predictions": []})
server_args = get_global_server_args()
params = vertex_req.parameters or {}
futures = []
for inst in vertex_req.instances:
rid = f"vertex_{uuid.uuid4()}"
sp = build_sampling_params(
rid,
prompt=inst.get("prompt") or inst.get("text"),
image_path=inst.get("image") or inst.get("image_url"),
num_frames=params.get("num_frames"),
fps=params.get("fps"),
width=params.get("width"),
height=params.get("height"),
guidance_scale=params.get("guidance_scale"),
save_output=params.get("save_output"),
)
backend_req = prepare_request(server_args, sampling_params=sp)
futures.append(forward_to_scheduler(backend_req, sp))
results = await asyncio.gather(*futures)
return orjson_response({"predictions": results})
def create_app(server_args: ServerArgs):
"""
Create and configure the FastAPI application instance.
"""
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def wait_for_server_warmup(request: Request, call_next):
warmup_done = getattr(request.app.state, "server_warmup_done", None)
if (
warmup_done is not None
and not warmup_done.is_set()
and request.url.path not in SERVER_WARMUP_BYPASS_PATHS
):
await warmup_done.wait()
return await call_next(request)
app.include_router(health_router)
app.include_router(vertex_router)
from sglang.multimodal_gen.runtime.entrypoints.openai import common_api, mesh_api
app.include_router(common_api.router)
app.include_router(image_api.router)
app.include_router(video_api.router)
app.include_router(realtime_video_api.router)
if server_args.pipeline_config.task_type.is_action_gen():
app.include_router(vla_api.router)
app.include_router(openpi.router)
app.include_router(mesh_api.router)
app.include_router(weights_api.router)
app.include_router(rollout_api.router)
app.state.server_args = server_args
return app
@@ -0,0 +1,252 @@
import time
from typing import Any, List, Optional, Union
from fastapi import APIRouter, Body, HTTPException
from pydantic import BaseModel, Field
from sglang.multimodal_gen.registry import get_model_info
from sglang.multimodal_gen.runtime.entrypoints.utils import (
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
UnmergeLoraWeightsReq,
format_lora_message,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.srt.utils.json_response import orjson_response
router = APIRouter(prefix="/v1")
logger = init_logger(__name__)
class ModelCard(BaseModel):
"""Model cards."""
id: str
object: str = "model"
created: int = Field(default_factory=lambda: int(time.time()))
owned_by: str = "sglang"
root: Optional[str] = None
parent: Optional[str] = None
max_model_len: Optional[int] = None
class DiffusionModelCard(ModelCard):
"""Extended ModelCard with diffusion-specific fields."""
num_gpus: Optional[int] = None
task_type: Optional[str] = None
dit_precision: Optional[str] = None
vae_precision: Optional[str] = None
pipeline_name: Optional[str] = None
pipeline_class: Optional[str] = None
async def _handle_lora_request(req: Any, success_msg: str, failure_msg: str):
try:
output: OutputBatch = await async_scheduler_client.forward(req)
if output.error is None:
return {"status": "ok", "message": success_msg}
else:
error_msg = output.error
raise HTTPException(status_code=500, detail=f"{failure_msg}: {error_msg}")
except Exception as e:
if isinstance(e, HTTPException):
raise
logger.error(f"Error during '{failure_msg}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/set_lora")
async def set_lora(
lora_nickname: Union[str, List[str]] = Body(..., embed=True),
lora_path: Optional[Union[str, List[Optional[str]]]] = Body(None, embed=True),
target: Union[str, List[str]] = Body("all", embed=True),
strength: Union[float, List[float]] = Body(1.0, embed=True),
merge_mode: Optional[str] = Body(None, embed=True),
):
"""
Set LoRA adapter(s) for the specified transformer(s).
Supports both single LoRA (backward compatible) and multiple LoRA adapters.
Args:
lora_nickname: The nickname(s) of the adapter(s). Can be a string or a list of strings.
lora_path: Path(s) to the LoRA adapter(s) (local path or HF repo id).
Can be a string, None, or a list of strings/None. Must match the length of lora_nickname.
target: Which transformer(s) to apply the LoRA to. Can be a string or a list of strings.
If a list, must match the length of lora_nickname. Valid values:
- "all": Apply to all transformers (default)
- "transformer": Apply only to the primary transformer (high noise for Wan2.2)
- "transformer_2": Apply only to transformer_2 (low noise for Wan2.2)
- "critic": Apply only to the critic model
strength: LoRA strength(s) for merge, default 1.0. Can be a float or a list of floats.
If a list, must match the length of lora_nickname. Values < 1.0 reduce the effect,
values > 1.0 amplify the effect.
merge_mode: Optional LoRA merge mode: "auto", "merge", or "dynamic".
"""
req = SetLoraReq(
lora_nickname=lora_nickname,
lora_path=lora_path,
target=target,
strength=strength,
merge_mode=merge_mode,
)
nickname_str, target_str, strength_str = format_lora_message(
lora_nickname, target, strength
)
return await _handle_lora_request(
req,
f"Successfully set LoRA adapter(s): {nickname_str} (target: {target_str}, strength: {strength_str})",
"Failed to set LoRA adapter",
)
@router.post("/merge_lora_weights")
async def merge_lora_weights(
target: str = Body("all", embed=True),
strength: float = Body(1.0, embed=True),
):
"""
Merge LoRA weights into the base model.
Args:
target: Which transformer(s) to merge. One of "all", "transformer",
"transformer_2", "critic".
strength: LoRA strength for merge, default 1.0. Values < 1.0 reduce the effect,
values > 1.0 amplify the effect.
"""
req = MergeLoraWeightsReq(target=target, strength=strength)
return await _handle_lora_request(
req,
f"Successfully merged LoRA weights (target: {target}, strength: {strength})",
"Failed to merge LoRA weights",
)
@router.post("/unmerge_lora_weights")
async def unmerge_lora_weights(
target: str = Body("all", embed=True),
):
"""
Unmerge LoRA weights from the base model.
Args:
target: Which transformer(s) to unmerge. One of "all", "transformer",
"transformer_2", "critic".
"""
req = UnmergeLoraWeightsReq(target=target)
return await _handle_lora_request(
req,
f"Successfully unmerged LoRA weights (target: {target})",
"Failed to unmerge LoRA weights",
)
@router.get("/model_info")
async def model_info():
"""Get the model information."""
server_args = get_global_server_args()
if not server_args:
raise HTTPException(status_code=500, detail="Server args not initialized")
result = {
"model_path": server_args.model_path,
}
return result
@router.get("/list_loras")
async def list_loras():
"""List loaded LoRA adapters and current application status per module."""
try:
req = ListLorasReq()
output: OutputBatch = await async_scheduler_client.forward(req)
if output.error is None:
return output.output or {}
else:
raise HTTPException(status_code=500, detail=output.error)
except Exception as e:
if isinstance(e, HTTPException):
raise
logger.error(f"Error during 'list_loras': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/models")
async def available_models():
"""Show available models. OpenAI-compatible endpoint with extended diffusion info."""
server_args = get_global_server_args()
if not server_args:
raise HTTPException(status_code=500, detail="Server args not initialized")
model_info = get_model_info(
server_args.model_path,
backend=server_args.backend,
model_id=server_args.model_id,
)
card_kwargs = {
"id": server_args.model_path,
"root": server_args.model_path,
# Extended diffusion-specific fields
"num_gpus": server_args.num_gpus,
"task_type": server_args.pipeline_config.task_type.name,
"dit_precision": server_args.pipeline_config.dit_precision,
"vae_precision": server_args.pipeline_config.vae_precision,
}
if model_info:
card_kwargs["pipeline_name"] = model_info.pipeline_cls.pipeline_name
card_kwargs["pipeline_class"] = model_info.pipeline_cls.__name__
model_card = DiffusionModelCard(**card_kwargs)
# Return dict directly to preserve extended fields (ModelList strips them)
return {"object": "list", "data": [model_card.model_dump()]}
@router.get("/models/{model:path}")
async def retrieve_model(model: str):
"""Retrieve a model instance. OpenAI-compatible endpoint with extended diffusion info."""
server_args = get_global_server_args()
if not server_args:
raise HTTPException(status_code=500, detail="Server args not initialized")
if model != server_args.model_path:
return orjson_response(
{
"error": {
"message": f"The model '{model}' does not exist",
"type": "invalid_request_error",
"param": "model",
"code": "model_not_found",
}
},
status_code=404,
)
model_info = get_model_info(
server_args.model_path,
backend=server_args.backend,
model_id=server_args.model_id,
)
card_kwargs = {
"id": model,
"root": model,
"num_gpus": server_args.num_gpus,
"task_type": server_args.pipeline_config.task_type.name,
"dit_precision": server_args.pipeline_config.dit_precision,
"vae_precision": server_args.pipeline_config.vae_precision,
}
if model_info:
card_kwargs["pipeline_name"] = model_info.pipeline_cls.pipeline_name
card_kwargs["pipeline_class"] = model_info.pipeline_cls.__name__
# Return dict to preserve extended fields
return DiffusionModelCard(**card_kwargs).model_dump()
@@ -0,0 +1,447 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
import base64
import contextlib
import json
import os
import time
from typing import Any, List, Optional
from fastapi import (
APIRouter,
File,
Form,
HTTPException,
Path,
Query,
Request,
UploadFile,
)
from fastapi.responses import FileResponse
from sglang.multimodal_gen.configs.sample.sampling_params import generate_request_id
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
ImageGenerationsRequest,
ImageResponse,
ImageResponseData,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.storage import cloud_storage
from sglang.multimodal_gen.runtime.entrypoints.openai.stores import IMAGE_STORE
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
add_common_data_to_response,
build_sampling_params,
choose_output_image_ext,
flatten_extra_params,
merge_image_input_list,
process_generation_batch,
save_image_to_path,
temp_dir_if_disabled,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.srt.observability.trace import extract_trace_headers
router = APIRouter(prefix="/v1/images", tags=["images"])
def _get_extra_field(request, field_name):
"""Get a field from model_extra, with fallback to nested extra_body dict."""
extra = request.model_extra or {}
value = extra.get(field_name)
if value is not None:
return value
if field_name == "use_guardrails" and extra.get("guardrails") is not None:
return extra["guardrails"]
for container_name in ("extra_body", "extra_json", "extra_args", "extra_params"):
value = _parse_extra_container(extra.get(container_name)).get(field_name)
if value is not None:
return value
return value
def _parse_extra_container(value: Any) -> dict[str, Any]:
if isinstance(value, str):
try:
value = json.loads(value)
except Exception:
return {}
if isinstance(value, dict):
return flatten_extra_params(dict(value))
return {}
def _read_b64_for_paths(paths: list[str]) -> list[str]:
"""Read and base64-encode each file. Must be called before cloud upload deletes them."""
result = []
for path in paths:
with open(path, "rb") as f:
result.append(base64.b64encode(f.read()).decode("utf-8"))
return result
def _build_image_response_kwargs(
save_file_path_list: list[str],
resp_format: str,
prompt: str,
request_id: str,
result: OutputBatch,
*,
b64_list: list[str] | None = None,
cloud_url: str | None = None,
fallback_url: str | None = None,
is_persistent: bool = True,
) -> dict:
"""Build ImageResponse data list.
For b64_json: uses pre-read b64_list (call _read_b64_for_paths first).
For url: uses cloud_url or fallback_url.
file_path is omitted when is_persistent=False to avoid exposing stale temp paths.
"""
ret = None
if resp_format == "b64_json":
if not b64_list:
raise ValueError("b64_list required for b64_json response_format")
data = [
ImageResponseData(
b64_json=b64,
revised_prompt=prompt,
file_path=os.path.abspath(path) if is_persistent else None,
)
for b64, path in zip(b64_list, save_file_path_list)
]
ret = {"data": data}
elif resp_format == "url":
url = cloud_url or fallback_url
if not url:
raise HTTPException(
status_code=400,
detail="response_format='url' requires cloud storage to be configured.",
)
ret = {
"data": [
ImageResponseData(
url=url,
revised_prompt=prompt,
file_path=(
os.path.abspath(save_file_path_list[0])
if is_persistent
else None
),
)
],
}
else:
raise HTTPException(
status_code=400, detail=f"response_format={resp_format} is not supported"
)
ret = add_common_data_to_response(ret, request_id=request_id, result=result)
return ret
@router.post("/generations", response_model=ImageResponse)
async def generations(
request: ImageGenerationsRequest,
raw_request: Request,
):
request_id = generate_request_id()
server_args = get_global_server_args()
is_cosmos3 = "cosmos3" in (server_args.model_path or "").lower()
ext = (
"png"
if is_cosmos3 and request.output_format is None
else choose_output_image_ext(request.output_format, request.background)
)
with temp_dir_if_disabled(server_args.output_path) as output_dir:
sampling = build_sampling_params(
request_id,
prompt=request.prompt,
size=request.size,
width=request.width,
height=request.height,
num_outputs_per_prompt=max(1, min(int(request.n or 1), 10)),
output_file_name=f"{request_id}.{ext}",
output_path=output_dir,
num_frames=1,
seed=request.seed,
generator_device=request.generator_device,
num_inference_steps=request.num_inference_steps,
guidance_scale=request.guidance_scale,
true_cfg_scale=request.true_cfg_scale,
negative_prompt=request.negative_prompt,
max_sequence_length=(
request.max_sequence_length
if request.max_sequence_length is not None
else _get_extra_field(request, "max_sequence_length")
),
flow_shift=(
request.flow_shift
if request.flow_shift is not None
else _get_extra_field(request, "flow_shift")
),
use_duration_template=_get_extra_field(request, "use_duration_template"),
use_resolution_template=_get_extra_field(
request, "use_resolution_template"
),
use_system_prompt=_get_extra_field(request, "use_system_prompt"),
use_guardrails=_get_extra_field(request, "use_guardrails"),
enable_teacache=request.enable_teacache,
output_compression=request.output_compression,
output_quality=request.output_quality,
diffusers_kwargs=request.diffusers_kwargs,
enable_upscaling=request.enable_upscaling,
upscaling_model_path=request.upscaling_model_path,
upscaling_scale=request.upscaling_scale,
perf_dump_path=request.perf_dump_path,
use_pe=_get_extra_field(request, "use_pe"),
preset=_get_extra_field(request, "preset"),
progressive_mode=(
request.progressive_mode
if request.progressive_mode is not None
else _get_extra_field(request, "progressive_mode")
),
progressive_levels=(
request.progressive_levels
if request.progressive_levels is not None
else _get_extra_field(request, "progressive_levels")
),
progressive_delta=(
request.progressive_delta
if request.progressive_delta is not None
else _get_extra_field(request, "progressive_delta")
),
)
trace_headers = extract_trace_headers(raw_request.headers)
batch = prepare_request(
server_args=server_args,
sampling_params=sampling,
external_trace_header=trace_headers,
)
# Add diffusers_kwargs if provided
if request.diffusers_kwargs:
batch.extra["diffusers_kwargs"] = request.diffusers_kwargs
save_file_path_list, result = await process_generation_batch(
async_scheduler_client, batch
)
save_file_path = save_file_path_list[0]
resp_format = (request.response_format or "b64_json").lower()
if (
is_cosmos3
and "response_format" not in request.model_fields_set
and request.response_format == "url"
):
resp_format = "b64_json"
# read b64 before cloud upload may delete the local file
b64_list = (
_read_b64_for_paths(save_file_path_list)
if resp_format == "b64_json"
else None
)
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
is_persistent = server_args.output_path is not None
await IMAGE_STORE.upsert(
request_id,
{
"id": request_id,
"created_at": int(time.time()),
"file_path": None if cloud_url or not is_persistent else save_file_path,
"url": cloud_url,
},
)
response_kwargs = _build_image_response_kwargs(
save_file_path_list,
resp_format,
request.prompt,
request_id,
result,
b64_list=b64_list,
cloud_url=cloud_url,
fallback_url=f"/v1/images/{request_id}/content" if is_persistent else None,
is_persistent=is_persistent,
)
return ImageResponse(**response_kwargs)
@router.post("/edits", response_model=ImageResponse)
async def edits(
raw_request: Request,
image: Optional[List[UploadFile]] = File(None),
image_array: Optional[List[UploadFile]] = File(None, alias="image[]"),
url: Optional[List[str]] = Form(None),
url_array: Optional[List[str]] = Form(None, alias="url[]"),
prompt: str = Form(...),
mask: Optional[UploadFile] = File(None),
model: Optional[str] = Form(None),
n: Optional[int] = Form(1),
response_format: Optional[str] = Form(None),
size: Optional[str] = Form(None),
output_format: Optional[str] = Form(None),
background: Optional[str] = Form("auto"),
seed: Optional[int] = Form(None),
generator_device: Optional[str] = Form("cuda"),
user: Optional[str] = Form(None),
negative_prompt: Optional[str] = Form(None),
guidance_scale: Optional[float] = Form(None),
true_cfg_scale: Optional[float] = Form(None),
num_inference_steps: Optional[int] = Form(None),
output_quality: Optional[str] = Form("default"),
output_compression: Optional[int] = Form(None),
enable_teacache: Optional[bool] = Form(False),
enable_upscaling: Optional[bool] = Form(False),
upscaling_model_path: Optional[str] = Form(None),
upscaling_scale: Optional[int] = Form(4),
num_frames: int = Form(1),
):
request_id = generate_request_id()
server_args = get_global_server_args()
# Resolve images from either `image` or `image[]` (OpenAI SDK sends `image[]` when list is provided)
images = image or image_array
urls = url or url_array
if (not images or len(images) == 0) and (not urls or len(urls) == 0):
raise HTTPException(
status_code=422, detail="Field 'image' or 'url' is required"
)
image_list = merge_image_input_list(images, urls)
with contextlib.ExitStack() as stack:
uploads_dir = stack.enter_context(
temp_dir_if_disabled(server_args.input_save_path)
)
output_dir = stack.enter_context(temp_dir_if_disabled(server_args.output_path))
input_paths = []
try:
for idx, img in enumerate(image_list):
filename = img.filename if hasattr(img, "filename") else f"image_{idx}"
input_path = await save_image_to_path(
img,
os.path.join(uploads_dir, f"{request_id}_{idx}_{filename}"),
prefer_remote_source=server_args.input_save_path is None,
)
input_paths.append(input_path)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Failed to process image source: {str(e)}",
)
ext = choose_output_image_ext(output_format, background)
sampling = build_sampling_params(
request_id,
prompt=prompt,
size=size,
num_outputs_per_prompt=max(1, min(int(n or 1), 10)),
output_file_name=f"{request_id}.{ext}",
output_path=output_dir,
image_path=input_paths,
seed=seed,
generator_device=generator_device,
negative_prompt=negative_prompt,
guidance_scale=guidance_scale,
true_cfg_scale=true_cfg_scale,
num_inference_steps=num_inference_steps,
enable_teacache=enable_teacache,
num_frames=num_frames,
output_compression=output_compression,
output_quality=output_quality,
enable_upscaling=enable_upscaling,
upscaling_model_path=upscaling_model_path,
upscaling_scale=upscaling_scale,
)
trace_headers = extract_trace_headers(raw_request.headers)
batch = prepare_request(
server_args=server_args,
sampling_params=sampling,
external_trace_header=trace_headers,
)
save_file_path_list, result = await process_generation_batch(
async_scheduler_client, batch
)
save_file_path = save_file_path_list[0]
resp_format = (response_format or "b64_json").lower()
# read b64 before cloud upload may delete the local file
b64_list = (
_read_b64_for_paths(save_file_path_list)
if resp_format == "b64_json"
else None
)
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
is_persistent = server_args.output_path is not None
is_input_persistent = server_args.input_save_path is not None
await IMAGE_STORE.upsert(
request_id,
{
"id": request_id,
"created_at": int(time.time()),
"file_path": None if cloud_url or not is_persistent else save_file_path,
"url": cloud_url,
"input_image_paths": input_paths if is_input_persistent else None,
"num_input_images": len(input_paths),
},
)
response_kwargs = _build_image_response_kwargs(
save_file_path_list,
resp_format,
prompt,
request_id,
result,
b64_list=b64_list,
cloud_url=cloud_url,
fallback_url=f"/v1/images/{request_id}/content" if is_persistent else None,
is_persistent=is_persistent,
)
return ImageResponse(**response_kwargs)
@router.get("/{image_id}/content")
async def download_image_content(
image_id: str = Path(...), variant: Optional[str] = Query(None)
):
item = await IMAGE_STORE.get(image_id)
if not item:
raise HTTPException(status_code=404, detail="Image not found")
if item.get("url"):
raise HTTPException(
status_code=400,
detail=f"Image has been uploaded to cloud storage. Please use the cloud URL: {item.get('url')}",
)
file_path = item.get("file_path")
if not file_path:
raise HTTPException(
status_code=404,
detail="Image was not persisted on disk (output_path is disabled). Use b64_json response_format or configure cloud storage.",
)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Image is still being generated")
ext = os.path.splitext(file_path)[1].lower()
media_type = "image/jpeg"
if ext == ".png":
media_type = "image/png"
elif ext == ".webp":
media_type = "image/webp"
return FileResponse(
path=file_path, media_type=media_type, filename=os.path.basename(file_path)
)
@@ -0,0 +1,296 @@
import asyncio
import os
import time
from typing import Any, Dict, List, Optional
from fastapi import (
APIRouter,
File,
Form,
HTTPException,
Path,
Query,
Request,
UploadFile,
)
from fastapi.responses import FileResponse
from sglang.multimodal_gen.configs.sample.sampling_params import (
SamplingParams,
generate_request_id,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
MeshGenerationsRequest,
MeshListResponse,
MeshResponse,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.storage import cloud_storage
from sglang.multimodal_gen.runtime.entrypoints.openai.stores import MESH_STORE
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
add_common_data_to_response,
merge_image_input_list,
process_generation_batch,
save_image_to_path,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
router = APIRouter(prefix="/v1/meshes", tags=["meshes"])
def _normalize_format(fmt: Optional[str]) -> str:
fmt = (fmt or "glb").lower()
return fmt if fmt in ("glb", "obj") else "glb"
def _build_sampling_params_from_request(
request_id: str, req: MeshGenerationsRequest, image_path: Optional[str] = None
) -> SamplingParams:
ext = _normalize_format(req.output_format)
server_args = get_global_server_args()
sampling_kwargs: Dict[str, Any] = {
"request_id": request_id,
"prompt": req.prompt,
"num_frames": 1,
"image_path": [image_path] if image_path else None,
"save_output": True,
"output_file_name": f"{request_id}.{ext}",
"seed": req.seed,
"generator_device": req.generator_device,
}
if req.num_inference_steps is not None:
sampling_kwargs["num_inference_steps"] = req.num_inference_steps
if req.guidance_scale is not None:
sampling_kwargs["guidance_scale"] = req.guidance_scale
if req.negative_prompt is not None:
sampling_kwargs["negative_prompt"] = req.negative_prompt
return SamplingParams.from_user_sampling_params_args(
model_path=server_args.model_path,
server_args=server_args,
**sampling_kwargs,
)
def _mesh_job_from_sampling(
request_id: str, req: MeshGenerationsRequest, sampling: SamplingParams
) -> Dict[str, Any]:
return {
"id": request_id,
"object": "mesh",
"model": req.model or "",
"status": "queued",
"progress": 0,
"created_at": int(time.time()),
"format": _normalize_format(req.output_format),
"file_path": os.path.abspath(sampling.output_file_path()),
}
async def _dispatch_job_async(job_id: str, batch: Req) -> None:
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
try:
save_file_path_list, result = await process_generation_batch(
async_scheduler_client, batch
)
save_file_path = save_file_path_list[0]
file_size = None
if os.path.exists(save_file_path):
file_size = os.path.getsize(save_file_path)
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
update_fields: Dict[str, Any] = {
"status": "completed",
"progress": 100,
"completed_at": int(time.time()),
"url": cloud_url,
"file_path": save_file_path if not cloud_url else None,
"file_size_bytes": file_size,
}
update_fields = add_common_data_to_response(
update_fields, request_id=job_id, result=result
)
await MESH_STORE.update_fields(job_id, update_fields)
except Exception as e:
logger.error(f"{e}")
await MESH_STORE.update_fields(
job_id, {"status": "failed", "error": {"message": str(e)}}
)
@router.post("", response_model=MeshResponse)
async def create_mesh(
request: Request,
image: Optional[List[UploadFile]] = File(None),
image_array: Optional[List[UploadFile]] = File(None, alias="image[]"),
url: Optional[List[str]] = Form(None),
url_array: Optional[List[str]] = Form(None, alias="url[]"),
prompt: Optional[str] = Form("generate 3d mesh"),
model: Optional[str] = Form(None),
seed: Optional[int] = Form(None),
generator_device: Optional[str] = Form("cuda"),
guidance_scale: Optional[float] = Form(None),
num_inference_steps: Optional[int] = Form(None),
negative_prompt: Optional[str] = Form(None),
output_format: Optional[str] = Form("glb"),
):
content_type = request.headers.get("content-type", "").lower()
request_id = generate_request_id()
server_args = get_global_server_args()
input_path = None
if "multipart/form-data" in content_type:
images = image or image_array
urls = url or url_array
image_list = merge_image_input_list(images, urls)
if not image_list:
raise HTTPException(
status_code=422,
detail="Field 'image' or 'url' is required for mesh generation",
)
uploads_dir = os.path.join("outputs", "uploads")
os.makedirs(uploads_dir, exist_ok=True)
img = image_list[0]
filename = img.filename if hasattr(img, "filename") else "input_image"
try:
input_path = await save_image_to_path(
img, os.path.join(uploads_dir, f"{request_id}_{filename}")
)
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Failed to process image source: {str(e)}"
)
req = MeshGenerationsRequest(
prompt=prompt or "generate 3d mesh",
model=model,
seed=seed,
generator_device=generator_device,
num_inference_steps=num_inference_steps,
negative_prompt=negative_prompt,
output_format=output_format,
**(
{"guidance_scale": guidance_scale} if guidance_scale is not None else {}
),
)
else:
try:
body = await request.json()
except Exception:
body = {}
try:
payload: Dict[str, Any] = dict(body or {})
if payload.get("input_image"):
img_src = payload.pop("input_image")
uploads_dir = os.path.join("outputs", "uploads")
os.makedirs(uploads_dir, exist_ok=True)
input_path = await save_image_to_path(
img_src,
os.path.join(uploads_dir, f"{request_id}_input_image"),
)
req = MeshGenerationsRequest(**payload)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid request body: {e}")
if not input_path:
raise HTTPException(
status_code=422,
detail="An input image is required for mesh generation",
)
sampling_params = _build_sampling_params_from_request(request_id, req, input_path)
job = _mesh_job_from_sampling(request_id, req, sampling_params)
await MESH_STORE.upsert(request_id, job)
batch = prepare_request(
server_args=server_args,
sampling_params=sampling_params,
)
asyncio.create_task(_dispatch_job_async(request_id, batch))
return MeshResponse(**job)
@router.get("", response_model=MeshListResponse)
async def list_meshes(
after: Optional[str] = Query(None),
limit: Optional[int] = Query(None, ge=1, le=100),
order: Optional[str] = Query("desc"),
):
order = (order or "desc").lower()
if order not in ("asc", "desc"):
order = "desc"
jobs = await MESH_STORE.list_values()
reverse = order != "asc"
jobs.sort(key=lambda j: j.get("created_at", 0), reverse=reverse)
if after is not None:
try:
idx = next(i for i, j in enumerate(jobs) if j["id"] == after)
jobs = jobs[idx + 1 :]
except StopIteration:
jobs = []
if limit is not None:
jobs = jobs[:limit]
items = [MeshResponse(**j) for j in jobs]
return MeshListResponse(data=items)
@router.get("/{mesh_id}", response_model=MeshResponse)
async def retrieve_mesh(mesh_id: str = Path(...)):
job = await MESH_STORE.get(mesh_id)
if not job:
raise HTTPException(status_code=404, detail="Mesh not found")
return MeshResponse(**job)
@router.delete("/{mesh_id}", response_model=MeshResponse)
async def delete_mesh(mesh_id: str = Path(...)):
job = await MESH_STORE.pop(mesh_id)
if not job:
raise HTTPException(status_code=404, detail="Mesh not found")
job["status"] = "deleted"
return MeshResponse(**job)
@router.get("/{mesh_id}/content")
async def download_mesh_content(
mesh_id: str = Path(...), variant: Optional[str] = Query(None)
):
job = await MESH_STORE.get(mesh_id)
if not job:
raise HTTPException(status_code=404, detail="Mesh not found")
if job.get("url"):
raise HTTPException(
status_code=400,
detail=f"Mesh has been uploaded to cloud storage. Please use the cloud URL: {job.get('url')}",
)
file_path = job.get("file_path")
if not file_path or not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Generation is still in-progress")
ext = os.path.splitext(file_path)[1].lower()
media_type = {
".glb": "model/gltf-binary",
".obj": "text/plain",
}.get(ext, "application/octet-stream")
return FileResponse(
path=file_path, media_type=media_type, filename=os.path.basename(file_path)
)
@@ -0,0 +1,227 @@
import time
import uuid
from abc import ABC
from dataclasses import dataclass, field
from typing import Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, ConfigDict, Field
# Image API protocol models
class ImageResponseData(BaseModel):
b64_json: Optional[str] = None
url: Optional[str] = None
revised_prompt: Optional[str] = None
file_path: Optional[str] = None
class ImageResponse(BaseModel):
id: str
created: int = Field(default_factory=lambda: int(time.time()))
data: List[ImageResponseData]
peak_memory_mb: Optional[float] = None
inference_time_s: Optional[float] = None
class ImageGenerationsRequest(BaseModel):
model_config = ConfigDict(extra="allow")
prompt: str
model: Optional[str] = None
n: Optional[int] = 1
quality: Optional[str] = "auto"
response_format: Optional[str] = "url" # url | b64_json
size: Optional[str] = "1024x1024" # e.g., 1024x1024
style: Optional[str] = "vivid"
background: Optional[str] = "auto" # transparent | opaque | auto
output_format: Optional[str] = None # png | jpeg | webp
user: Optional[str] = None
# SGLang extensions
width: Optional[int] = None
height: Optional[int] = None
num_inference_steps: Optional[int] = None
guidance_scale: Optional[float] = None
true_cfg_scale: Optional[float] = (
None # for CFG vs guidance distillation (e.g., QwenImage)
)
seed: Optional[Union[int, List[int]]] = None
generator_device: Optional[str] = "cuda"
negative_prompt: Optional[str] = None
output_quality: Optional[str] = "default"
output_compression: Optional[int] = None
enable_teacache: Optional[bool] = False
max_sequence_length: Optional[int] = None
flow_shift: Optional[float] = None
# Upscaling
enable_upscaling: Optional[bool] = False
upscaling_model_path: Optional[str] = None
upscaling_scale: Optional[int] = 4
diffusers_kwargs: Optional[Dict[str, Any]] = None # kwargs for diffusers backend
# Performance profiling
perf_dump_path: Optional[str] = None
# Progressive resolution generation
progressive_mode: Optional[str] = None
progressive_levels: Optional[int] = None
progressive_delta: Optional[float] = None
# Video API protocol models
class VideoResponse(BaseModel):
id: str
object: str = "video"
model: str = "sora-2"
status: str = "queued"
progress: int = 0
created_at: int = Field(default_factory=lambda: int(time.time()))
size: str = ""
seconds: str = "4"
quality: str = "standard"
url: Optional[str] = None
remixed_from_video_id: Optional[str] = None
completed_at: Optional[int] = None
expires_at: Optional[int] = None
error: Optional[Dict[str, Any]] = None
file_path: Optional[str] = None
file_paths: Optional[List[str]] = None
num_outputs: Optional[int] = None
peak_memory_mb: Optional[float] = None
inference_time_s: Optional[float] = None
action: Optional[Dict[str, Any]] = None
class VideoGenerationsRequest(BaseModel):
model_config = ConfigDict(extra="allow")
prompt: str
input_reference: Optional[str] = None
reference_url: Optional[str] = None
video_path: Optional[str] = None
video_url: Optional[str] = None
model: Optional[str] = None
n: Optional[int] = 1
num_outputs_per_prompt: Optional[int] = None
seconds: Optional[int] = 4
size: Optional[str] = ""
fps: Optional[int] = None
num_frames: Optional[int] = None
seed: Optional[Union[int, List[int]]] = None
generator_device: Optional[str] = "cuda"
# SGLang extensions
width: Optional[int] = None
height: Optional[int] = None
num_inference_steps: Optional[int] = None
guidance_scale: Optional[float] = None
guidance_scale_2: Optional[float] = None
true_cfg_scale: Optional[float] = (
None # for CFG vs guidance distillation (e.g., QwenImage)
)
negative_prompt: Optional[str] = None
max_sequence_length: Optional[int] = None
flow_shift: Optional[float] = None
enable_teacache: Optional[bool] = False
# Frame interpolation
enable_frame_interpolation: Optional[bool] = False
frame_interpolation_exp: Optional[int] = 1 # 1=2×, 2=4×
frame_interpolation_scale: Optional[float] = 1.0
frame_interpolation_model_path: Optional[str] = None
# Upscaling
enable_upscaling: Optional[bool] = False
upscaling_model_path: Optional[str] = None
upscaling_scale: Optional[int] = 4
output_quality: Optional[str] = "default"
output_compression: Optional[int] = None
output_path: Optional[str] = None
diffusers_kwargs: Optional[Dict[str, Any]] = None # kwargs for diffusers backend
# Performance profiling
perf_dump_path: Optional[str] = None
class VideoListResponse(BaseModel):
data: List[VideoResponse]
object: str = "list"
class VideoRemixRequest(BaseModel):
prompt: str
class RealtimeVideoGenerationsRequest(VideoGenerationsRequest):
type: Literal["init"]
# WebSocket does not support multipart/form-data image uploads
first_frame: Optional[bytes | str] = None
condition_inputs: Optional[Dict[str, Any]] = None
max_chunks: Optional[int] = Field(default=None, ge=1)
seed: Optional[int] = 42
guidance_scale: Optional[float] = 1.0
size: Optional[str] = "832x480"
profile: Optional[bool] = False
num_profiled_timesteps: Optional[int] = None
profile_all_stages: Optional[bool] = False
realtime_output_format: Optional[Literal["raw", "webp", "jpeg"]] = None
realtime_preview_max_width: Optional[int] = None
realtime_output_pacing: Optional[bool] = False
realtime_causal_sink_size: Optional[int] = None
realtime_causal_kv_cache_num_frames: Optional[int] = None
class RealtimeEvent(BaseModel):
type: Literal["event"]
kind: str
payload: Any = None
event_id: Optional[int] = None
# Mesh API protocol models
class MeshResponse(BaseModel):
id: str
object: str = "mesh"
model: str = ""
status: str = "queued"
progress: int = 0
created_at: int = Field(default_factory=lambda: int(time.time()))
format: str = "glb"
url: Optional[str] = None
completed_at: Optional[int] = None
expires_at: Optional[int] = None
error: Optional[Dict[str, Any]] = None
file_path: Optional[str] = None
file_size_bytes: Optional[int] = None
peak_memory_mb: Optional[float] = None
inference_time_s: Optional[float] = None
class MeshGenerationsRequest(BaseModel):
prompt: str = "generate 3d mesh"
input_image: Optional[str] = None
model: Optional[str] = None
seed: Optional[Union[int, List[int]]] = None
generator_device: Optional[str] = "cuda"
num_inference_steps: Optional[int] = None
guidance_scale: Optional[float] = None
negative_prompt: Optional[str] = None
output_format: Optional[str] = "glb"
class MeshListResponse(BaseModel):
data: List[MeshResponse]
object: str = "list"
@dataclass
class BaseReq(ABC):
rid: Optional[Union[str, List[str]]] = field(default=None, kw_only=True)
http_worker_ipc: Optional[str] = field(default=None, kw_only=True)
def regenerate_rid(self):
"""Generate a new request ID and return it."""
if isinstance(self.rid, list):
self.rid = [uuid.uuid4().hex for _ in range(len(self.rid))]
else:
self.rid = uuid.uuid4().hex
return self.rid
@dataclass
class VertexGenerateReqInput(BaseReq):
instances: List[dict]
parameters: Optional[dict] = None
@@ -0,0 +1 @@
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,352 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
RealtimeEvent,
RealtimeVideoGenerationsRequest,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_adapter import (
BaseRealtimeModelAdapter,
RealtimeChunkInputs,
build_realtime_sampling_params,
save_realtime_first_frame,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.lingbot_world.constants import (
LINGBOT_CAMERA_ACTIONS_CONDITION,
LINGBOT_PROMPT_UPDATED_CONDITION,
)
from sglang.multimodal_gen.runtime.realtime.control_signals import (
ControlSignalQueue,
ParsedControlEventPayload,
parse_control_event_payload,
)
from sglang.multimodal_gen.runtime.realtime.states import (
RealtimeCameraControlState,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.generate_session import (
GenerateSession,
RealtimeChunkContext,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
LINGBOT_REALTIME_DEFAULT_NUM_INFERENCE_STEPS = 4
LINGBOT_REALTIME_MIN_CONDITION_CHUNKS = 2
COMPOSITE_INPUT_EVENT_KIND = "composite_input"
class LingBotWorldRealtimeState(RealtimeCameraControlState):
def __init__(self):
super().__init__(
min_pulse_items=1,
script_maxlen=512,
max_transitions=512,
)
self.prompt_queue = ControlSignalQueue(max_events={"prompt": 1})
def clear(self) -> None:
super().clear()
self.prompt_queue.clear()
def receive_prompt(self, prompt: str, *, event_id: int | None = None) -> None:
self.prompt_queue.push("prompt", prompt, event_id=event_id)
def parse_camera_control_event_payload(
self,
payload: Any,
*,
event_id: int | None,
) -> ParsedControlEventPayload:
return parse_control_event_payload(
payload,
event_id=event_id,
kind="camera_actions",
normalize_state_payload=self._normalize_state_actions,
validate_script_payload=LingBotWorldRealtimeAdapter._validate_camera_actions,
)
def receive_parsed_camera_control_event_payload(
self,
parsed: ParsedControlEventPayload,
*,
event_id: int | None,
) -> str:
if parsed.mode == "state":
transitions = parsed.payload
self.receive_camera_state_transitions(transitions)
return f"kind=camera_actions, mode=state, transitions={len(transitions)}"
camera_actions = parsed.payload
self.receive_camera_action_script(camera_actions, event_id=event_id)
return f"kind=camera_actions, mode=script, frames={len(camera_actions)}"
def receive_camera_control_event_payload(
self,
payload: Any,
*,
event_id: int | None,
) -> str:
parsed = self.parse_camera_control_event_payload(payload, event_id=event_id)
return self.receive_parsed_camera_control_event_payload(
parsed, event_id=event_id
)
def sample_prompt(self) -> str:
prompt = self.prompt_queue.pop_latest("prompt")
if not isinstance(prompt, str):
raise ValueError("prompt event payload must be a string")
self.latest_sampled_event_id = self.prompt_queue.last_sampled_seq_id("prompt")
return prompt
def has_prompt(self) -> bool:
return self.prompt_queue.has_events("prompt")
class LingBotWorldRealtimeAdapter(BaseRealtimeModelAdapter):
def create_state(self) -> LingBotWorldRealtimeState:
return LingBotWorldRealtimeState()
def _state(self, session: GenerateSession) -> LingBotWorldRealtimeState:
state = session.adapter_state
if not isinstance(state, LingBotWorldRealtimeState):
raise TypeError("LingBot realtime adapter state is not initialized")
return state
async def on_init(
self,
session: GenerateSession,
request: RealtimeVideoGenerationsRequest,
) -> None:
condition_inputs = request.condition_inputs or {}
camera_actions = condition_inputs.get(LINGBOT_CAMERA_ACTIONS_CONDITION)
if camera_actions is not None:
state = self._state(session)
state.receive_camera_action_script(
self._validate_camera_actions(camera_actions)
)
await save_realtime_first_frame(session, request)
@staticmethod
def _validate_camera_actions(payload: Any) -> list[list[str]]:
if not isinstance(payload, list):
raise ValueError("camera_actions event payload must be list[list[str]]")
normalized = []
for frame_actions in payload:
if not isinstance(frame_actions, list):
raise ValueError("camera_actions event payload must be list[list[str]]")
normalized.append(list(frame_actions))
return normalized
def ingest_event(
self,
session: GenerateSession,
event: RealtimeEvent,
) -> str:
state = self._state(session)
if event.kind == "camera_actions":
return self._ingest_camera_actions(state, event.payload, event.event_id)
elif event.kind == "prompt":
return self._ingest_prompt(state, event.payload, event.event_id)
elif event.kind == COMPOSITE_INPUT_EVENT_KIND:
return self._ingest_composite_input(state, event.payload, event.event_id)
raise ValueError(f"unsupported event kind: {event.kind}")
def _ingest_camera_actions(
self,
state: LingBotWorldRealtimeState,
payload: Any,
event_id: int | None,
) -> str:
return state.receive_camera_control_event_payload(
payload,
event_id=event_id,
)
def _ingest_prompt(
self,
state: LingBotWorldRealtimeState,
payload: Any,
event_id: int | None,
) -> str:
prompt = self._validate_prompt_payload(payload)
state.receive_prompt(prompt, event_id=event_id)
return f"kind=prompt, prompt_len={len(prompt)}"
@staticmethod
def _validate_prompt_payload(payload: Any) -> str:
if not isinstance(payload, str) or not payload:
raise ValueError("prompt event payload must be a non-empty string")
return payload
def _ingest_composite_input(
self,
state: LingBotWorldRealtimeState,
payload: Any,
event_id: int | None,
) -> str:
if not isinstance(payload, dict):
raise ValueError("composite_input event payload must be a map")
input_types = payload.get("input_types")
if not isinstance(input_types, list) or not input_types:
raise ValueError(
"composite_input event payload requires non-empty input_types"
)
parsed_inputs = []
for input_type in input_types:
if not isinstance(input_type, str) or not input_type:
raise ValueError(
"composite_input input_types must contain non-empty strings"
)
if input_type not in payload:
raise ValueError(f"composite_input event payload requires {input_type}")
parsed_inputs.append(
(
input_type,
self._parse_composite_input_item(
state,
input_type,
payload[input_type],
event_id,
),
)
)
input_logs = []
for input_type, parsed_payload in parsed_inputs:
input_logs.append(
self._ingest_parsed_composite_input_item(
state,
input_type,
parsed_payload,
event_id,
)
)
return f"kind=composite_input, inputs={input_logs}"
def _parse_composite_input_item(
self,
state: LingBotWorldRealtimeState,
input_type: str,
payload: Any,
event_id: int | None,
) -> Any:
if input_type == "camera_actions":
return state.parse_camera_control_event_payload(
payload,
event_id=event_id,
)
if input_type == "prompt":
return self._validate_prompt_payload(payload)
raise ValueError(f"unsupported composite_input type: {input_type}")
def _ingest_parsed_composite_input_item(
self,
state: LingBotWorldRealtimeState,
input_type: str,
parsed_payload: Any,
event_id: int | None,
) -> str:
if input_type == "camera_actions":
return state.receive_parsed_camera_control_event_payload(
parsed_payload,
event_id=event_id,
)
if input_type == "prompt":
state.receive_prompt(parsed_payload, event_id=event_id)
return f"kind=prompt, prompt_len={len(parsed_payload)}"
raise ValueError(f"unsupported composite_input type: {input_type}")
def sample_chunk_inputs(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_size: int,
) -> RealtimeChunkInputs:
"""Samples user inputs (conditions) for the current RealtimeChunk from RealtimeStates"""
state = self._state(session)
request = session.request
if request is None:
raise ValueError("realtime request is not initialized")
prompt_updated = False
if chunk.index == 0:
prompt = request.prompt
elif state.has_prompt():
prompt = state.sample_prompt()
request.prompt = prompt
prompt_updated = True
else:
prompt = request.prompt
condition_inputs = {}
if prompt_updated:
condition_inputs[LINGBOT_PROMPT_UPDATED_CONDITION] = True
camera_actions = state.sample_camera_actions(chunk_size)
if camera_actions is not None:
condition_inputs[LINGBOT_CAMERA_ACTIONS_CONDITION] = camera_actions
return RealtimeChunkInputs(prompt=prompt, condition_inputs=condition_inputs)
def build_sampling_params(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_inputs: RealtimeChunkInputs,
chunk_size: int,
):
request = session.request
if request is None:
raise ValueError("realtime request is not initialized")
num_frames = self._condition_num_frames(
request=request,
server_args=server_args,
chunk_size=chunk_size,
)
return build_realtime_sampling_params(
chunk.request_id,
request=request,
chunk_inputs=chunk_inputs,
num_frames=num_frames,
num_inference_steps=(
request.num_inference_steps
or LINGBOT_REALTIME_DEFAULT_NUM_INFERENCE_STEPS
),
chunk_size=chunk_size,
)
@staticmethod
def _condition_num_frames(
*,
request: RealtimeVideoGenerationsRequest,
server_args: ServerArgs | None,
chunk_size: int,
) -> int:
if server_args is None:
return int(request.num_frames or 0)
# encode one extra blank condition chunk so repeat-last never reuses
# the first-frame image mask on later realtime chunks
temporal_ratio = int(
server_args.pipeline_config.vae_config.arch_config.temporal_compression_ratio
)
required_latent_frames = chunk_size * LINGBOT_REALTIME_MIN_CONDITION_CHUNKS
required_num_frames = (required_latent_frames - 1) * temporal_ratio + 1
return max(int(request.num_frames or 0), required_num_frames)
def get_realtime_event_id(self, session: GenerateSession) -> int | None:
return self._state(session).latest_sampled_event_id
def clear_state(self, session: GenerateSession) -> None:
state = session.adapter_state
if isinstance(state, LingBotWorldRealtimeState):
state.clear()
@@ -0,0 +1,264 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
RealtimeEvent,
RealtimeVideoGenerationsRequest,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_adapter import (
BaseRealtimeModelAdapter,
RealtimeChunkInputs,
build_realtime_sampling_params,
save_realtime_first_frame,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.sana_wm.base import (
normalize_sana_wm_camera_actions,
parse_sana_wm_action_string,
snap_sana_wm_num_frames,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.sana_wm.self_forcing import (
SanaWMSelfForcingSampler,
)
from sglang.multimodal_gen.runtime.realtime.states import (
RealtimeCameraControlState,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.generate_session import (
GenerateSession,
RealtimeChunkContext,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import (
OutputBatch,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
SANA_WM_DEFAULT_SIZE = "1280x704"
SANA_WM_DEFAULT_NUM_FRAMES = 1081
SANA_WM_DEFAULT_FPS = 16
SANA_WM_DEFAULT_STEPS = 4
SANA_WM_DEFAULT_GUIDANCE = 1.0
SANA_WM_CONTROL_PULSE_FRAMES = 8
def _normalize_sana_wm_state_actions(actions: list[Any]) -> list[str]:
return [str(action).lower() for action in actions]
class SanaWMRealtimeAdapterState(RealtimeCameraControlState):
def __init__(self):
super().__init__(
min_pulse_items=SANA_WM_CONTROL_PULSE_FRAMES,
script_maxlen=2048,
max_transitions=512,
normalize_state_actions=_normalize_sana_wm_state_actions,
)
self.base_condition_inputs: dict[str, Any] = {}
def clear(self) -> None:
super().clear()
self.base_condition_inputs.clear()
def receive_camera_control_event_payload(
self,
payload: Any,
*,
event_id: int | None,
) -> str:
return super().receive_camera_control_event_payload(
payload,
event_id=event_id,
validate_camera_actions=SanaWMRealtimeAdapter._validate_camera_actions,
)
class SanaWMRealtimeAdapter(BaseRealtimeModelAdapter):
def create_state(self) -> SanaWMRealtimeAdapterState:
return SanaWMRealtimeAdapterState()
def _state(self, session: GenerateSession) -> SanaWMRealtimeAdapterState:
state = session.adapter_state
if not isinstance(state, SanaWMRealtimeAdapterState):
raise TypeError("SANA-WM realtime adapter state is not initialized")
return state
@staticmethod
def _validate_camera_actions(payload: Any) -> list[list[str]]:
return normalize_sana_wm_camera_actions(
payload, error_label="camera_actions event payload"
)
@staticmethod
def _raw_frame_count(result: OutputBatch) -> int | None:
if result.raw_frame_batches is None:
return None
return sum(len(frames) for frames in result.raw_frame_batches)
async def on_init(
self,
session: GenerateSession,
request: RealtimeVideoGenerationsRequest,
) -> None:
request.size = request.size or SANA_WM_DEFAULT_SIZE
if request.num_frames is not None:
request.num_frames = int(request.num_frames)
else:
# Open-ended session: keep num_frames unset so prepare_next_request
# samples uniform action chunks (no front-loaded segmentation), and
# flag the stage explicitly via condition_inputs —
# build_sampling_params strips None fields, so the per-chunk batch
# would otherwise carry the SamplingParams default num_frames.
request.condition_inputs = {
**(request.condition_inputs or {}),
"sana_wm_open_ended": True,
}
request.fps = int(request.fps or SANA_WM_DEFAULT_FPS)
request.num_inference_steps = int(
request.num_inference_steps or SANA_WM_DEFAULT_STEPS
)
request.guidance_scale = float(
request.guidance_scale or SANA_WM_DEFAULT_GUIDANCE
)
if request.negative_prompt is None:
request.negative_prompt = ""
if request.generator_device is None:
request.generator_device = "cuda"
state = self._state(session)
condition_inputs = dict(request.condition_inputs or {})
camera_actions = condition_inputs.pop("camera_actions", None)
action = condition_inputs.pop("action", None)
if camera_actions is not None and action is not None:
raise ValueError("pass only one of camera_actions or action")
if camera_actions is not None:
state.receive_camera_control_event_payload(camera_actions, event_id=None)
if action is not None:
if not isinstance(action, str) or not action:
raise ValueError("action condition input must be a non-empty string")
state.receive_camera_action_script(
parse_sana_wm_action_string(action), event_id=None
)
state.base_condition_inputs = condition_inputs
await save_realtime_first_frame(
session,
request,
required_error="SANA-WM realtime requires first_frame",
cache_remote_urls=True,
)
def ingest_event(
self,
session: GenerateSession,
event: RealtimeEvent,
) -> str:
state = self._state(session)
if event.kind == "camera_actions":
return state.receive_camera_control_event_payload(
event.payload,
event_id=event.event_id,
)
if event.kind == "action":
if not isinstance(event.payload, str) or not event.payload:
raise ValueError("action event payload must be a non-empty string")
camera_actions = parse_sana_wm_action_string(event.payload)
state.receive_camera_action_script(camera_actions, event_id=event.event_id)
return f"kind=action, frames={len(camera_actions)}"
raise ValueError(f"unsupported event kind: {event.kind}")
def sample_chunk_inputs(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_size: int,
) -> RealtimeChunkInputs:
action_chunk_size = self._action_chunk_size(
session,
server_args,
chunk,
chunk_size,
)
state = self._state(session)
request = session.request
if request is None:
raise ValueError("realtime request is not initialized")
condition_inputs = dict(state.base_condition_inputs) if chunk.index == 0 else {}
camera_actions = state.sample_camera_actions(action_chunk_size)
if camera_actions is not None:
condition_inputs["camera_actions"] = camera_actions
return RealtimeChunkInputs(
prompt=request.prompt,
condition_inputs=condition_inputs,
)
def build_sampling_params(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_inputs: RealtimeChunkInputs,
chunk_size: int,
):
request = session.request
if request is None:
raise ValueError("realtime request is not initialized")
return build_realtime_sampling_params(
chunk.request_id,
request=request,
chunk_inputs=chunk_inputs,
num_frames=request.num_frames,
num_inference_steps=request.num_inference_steps,
chunk_size=chunk_size,
)
def _action_chunk_size(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_size: int,
) -> int:
temporal_compression = int(
server_args.pipeline_config.vae_config.arch_config.temporal_compression_ratio
)
# Match action sampling to the latent span used by the batch path. Chunk
# 0 may carry a front-loaded remainder, so a fixed nfpb*tc action count
# would read static-padded camera poses and drift from batch output.
action_chunk_size = chunk_size * temporal_compression
req_num_frames = (
session.request.num_frames if session.request is not None else None
)
if req_num_frames is not None:
snapped = snap_sana_wm_num_frames(
int(req_num_frames), stride=temporal_compression
)
latent_t = (snapped - 1) // temporal_compression + 1
segments = SanaWMSelfForcingSampler.create_autoregressive_segments(
latent_t, chunk_size
)
idx = int(chunk.index)
if 0 <= idx and idx + 1 < len(segments):
action_chunk_size = (
segments[idx + 1] - segments[idx]
) * temporal_compression
return action_chunk_size
def get_realtime_event_id(self, session: GenerateSession) -> int | None:
return self._state(session).latest_sampled_event_id
def on_chunk_complete(self, session: GenerateSession, result: OutputBatch) -> None:
if session.request is not None and self._raw_frame_count(result) == 0:
session.request.max_chunks = session.generate_chunk_cnt + 1
session.generate_chunk_completed()
def clear_state(self, session: GenerateSession) -> None:
state = session.adapter_state
if isinstance(state, SanaWMRealtimeAdapterState):
state.clear()
@@ -0,0 +1,84 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
RealtimeVideoGenerationsRequest,
)
from sglang.multimodal_gen.runtime.realtime.session import (
RealtimeSession,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_adapter import (
BaseRealtimeModelAdapter,
)
@dataclass(frozen=True, slots=True)
class RealtimeChunkContext:
session_id: str
index: int
request_id: str
class GenerateSession:
"""A realtime generation session"""
def __init__(self):
self.id = uuid4().hex
self.request: RealtimeVideoGenerationsRequest | None = None
self.input_temp_dir: str | None = None
self.generate_chunk_cnt = 0
self.current_chunk: RealtimeChunkContext | None = None
self.realtime_session = RealtimeSession()
self.adapter: BaseRealtimeModelAdapter | None = None
self.adapter_state: Any = None
self.output_pace_next_send_at: float | None = None
self.output_pace_last_event_id: int | None = None
def set_adapter(self, adapter: BaseRealtimeModelAdapter):
self.adapter = adapter
self.adapter_state = adapter.create_state()
def set_request(self, request: RealtimeVideoGenerationsRequest):
self.request = request
def dispose(self):
if self.adapter is not None:
self.adapter.dispose(self)
self.request = None
self.input_temp_dir = None
self.generate_chunk_cnt = 0
self.current_chunk = None
self.adapter = None
self.adapter_state = None
self.output_pace_next_send_at = None
self.output_pace_last_event_id = None
self.realtime_session.dispose()
def new_chunk(self) -> RealtimeChunkContext:
if self.current_chunk is not None:
raise RuntimeError("previous realtime chunk is still active")
chunk = RealtimeChunkContext(
session_id=self.id,
index=self.generate_chunk_cnt,
request_id=f"{self.id}_{uuid4().hex}",
)
self.current_chunk = chunk
return chunk
def generate_chunk_completed(self):
self.generate_chunk_cnt += 1
self.current_chunk = None
def reached_max_chunks(self) -> bool:
return (
self.request is not None
and self.request.max_chunks is not None
and self.generate_chunk_cnt >= self.request.max_chunks
)
@@ -0,0 +1,268 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import hashlib
import os
import tempfile
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from fastapi import WebSocket
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
RealtimeEvent,
RealtimeVideoGenerationsRequest,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_output_adapter import (
RawRGBRealtimeOutputAdapter,
RealtimeFrameSendStats,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
build_sampling_params,
save_image_to_path,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
prepare_request,
)
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.generate_session import (
GenerateSession,
RealtimeChunkContext,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import (
OutputBatch,
Req,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
@dataclass(slots=True)
class RealtimeChunkInputs:
"""Sampled from realtime control state, consumed by the Req"""
prompt: str
condition_inputs: dict[str, Any] = field(default_factory=dict)
async def save_realtime_first_frame(
session: GenerateSession,
request: RealtimeVideoGenerationsRequest,
*,
required_error: str | None = None,
cache_remote_urls: bool = False,
) -> None:
first_frame = request.first_frame
if first_frame is None:
if required_error is not None:
raise ValueError(required_error)
return
server_args = get_global_server_args()
if server_args.input_save_path is not None:
uploads_dir = server_args.input_save_path
os.makedirs(uploads_dir, exist_ok=True)
else:
if session.input_temp_dir is None:
session.input_temp_dir = tempfile.mkdtemp(prefix="sglang_input_")
uploads_dir = session.input_temp_dir
if (
cache_remote_urls
and isinstance(first_frame, str)
and first_frame.lower().startswith(("http://", "https://"))
):
suffix = os.path.splitext(first_frame.split("?", 1)[0])[1]
digest = hashlib.sha256(first_frame.encode("utf-8")).hexdigest()[:16]
target_path = os.path.join(uploads_dir, f"realtime_ref_{digest}{suffix}")
if os.path.exists(target_path):
request.first_frame = target_path
return
else:
target_path = os.path.join(uploads_dir, f"{session.id}_first_frame")
request.first_frame = await save_image_to_path(first_frame, target_path)
def build_realtime_sampling_params(
request_id: str,
*,
request: RealtimeVideoGenerationsRequest,
chunk_inputs: RealtimeChunkInputs,
num_frames: int | None,
num_inference_steps: int | None,
chunk_size: int,
):
return build_sampling_params(
request_id,
prompt=chunk_inputs.prompt,
size=request.size,
num_frames=num_frames,
fps=request.fps,
image_path=request.first_frame,
output_file_name=request_id,
save_output=False,
seed=request.seed,
generator_device=request.generator_device,
num_inference_steps=num_inference_steps,
guidance_scale=request.guidance_scale,
guidance_scale_2=request.guidance_scale_2,
negative_prompt=request.negative_prompt,
enable_teacache=request.enable_teacache,
enable_frame_interpolation=request.enable_frame_interpolation,
frame_interpolation_exp=request.frame_interpolation_exp,
frame_interpolation_scale=request.frame_interpolation_scale,
frame_interpolation_model_path=request.frame_interpolation_model_path,
enable_upscaling=request.enable_upscaling,
upscaling_model_path=request.upscaling_model_path,
upscaling_scale=request.upscaling_scale,
diffusers_kwargs=request.diffusers_kwargs,
profile=request.profile,
num_profiled_timesteps=request.num_profiled_timesteps,
profile_all_stages=request.profile_all_stages,
perf_dump_path=request.perf_dump_path,
output_path=request.output_path,
output_compression=request.output_compression,
output_quality=request.output_quality,
condition_inputs=chunk_inputs.condition_inputs,
realtime_chunk_size=chunk_size,
)
class BaseRealtimeModelAdapter:
def __init__(self):
self.output_adapter = RawRGBRealtimeOutputAdapter()
async def on_init(
self,
session: GenerateSession,
request: RealtimeVideoGenerationsRequest,
) -> None:
raise NotImplementedError
def create_state(self) -> Any:
"""create a state for managing runtime states"""
raise NotImplementedError
def ingest_event(
self,
session: GenerateSession,
event: RealtimeEvent,
) -> str:
"""
Ingest a realtime endpoint event and install it into the model's realtime control queues
"""
raise NotImplementedError
async def wait_for_next_chunk(self, session: GenerateSession) -> None:
del session
def get_chunk_size(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
) -> int:
del session, chunk
arch_config = server_args.pipeline_config.dit_config.arch_config
return int(getattr(arch_config, "num_frames_per_block", 3))
def sample_chunk_inputs(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_size: int,
) -> RealtimeChunkInputs:
raise NotImplementedError
def build_sampling_params(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
chunk_inputs: RealtimeChunkInputs,
chunk_size: int,
):
raise NotImplementedError
def get_realtime_event_id(self, session: GenerateSession) -> int | None:
del session
return None
def prepare_next_request(
self,
session: GenerateSession,
server_args: ServerArgs,
chunk: RealtimeChunkContext,
) -> Req:
chunk_size = self.get_chunk_size(session, server_args, chunk)
chunk_inputs = self.sample_chunk_inputs(
session,
server_args,
chunk,
chunk_size,
)
sampling_params = self.build_sampling_params(
session,
server_args,
chunk,
chunk_inputs,
chunk_size,
)
batch = prepare_request(
server_args=server_args,
sampling_params=sampling_params,
)
self.apply_realtime_request_fields(
batch,
session,
chunk,
event_id=self.get_realtime_event_id(session),
)
return batch
def apply_realtime_request_fields(
self,
batch: Req,
session: GenerateSession,
chunk: RealtimeChunkContext,
*,
event_id: int | None,
) -> None:
batch.realtime_session_id = session.id
batch.return_raw_frames = True
batch.block_idx = chunk.index
batch.realtime_event_id = event_id
if session.request is None:
return
batch.realtime_output_format = session.request.realtime_output_format
batch.realtime_preview_max_width = session.request.realtime_preview_max_width
batch.realtime_output_pacing = bool(session.request.realtime_output_pacing)
batch.realtime_causal_sink_size = session.request.realtime_causal_sink_size
batch.realtime_causal_kv_cache_num_frames = (
session.request.realtime_causal_kv_cache_num_frames
)
async def send_output(
self,
ws: WebSocket,
session: GenerateSession,
result: OutputBatch,
batch: Req,
) -> RealtimeFrameSendStats:
"""send the generate output (usually frames) back via websocket"""
return await self.output_adapter.send(ws, session, result, batch)
def on_chunk_complete(self, session: GenerateSession, result: OutputBatch) -> None:
del result
session.generate_chunk_completed()
def clear_state(self, session: GenerateSession) -> None:
del session
def dispose(self, session: GenerateSession) -> None:
self.clear_state(session)
self.output_adapter.reset()
@@ -0,0 +1,610 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import asyncio
import io
from dataclasses import dataclass
from typing import TYPE_CHECKING, TypedDict
import msgspec.msgpack
from fastapi import WebSocket
from PIL import Image
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.timer import (
RealtimeStageTimer,
)
from sglang.multimodal_gen.runtime.utils.realtime_video import (
JPEG_FRAME_CONTENT_TYPE,
RAW_RGB_CHANNELS,
RAW_RGB_CONTENT_TYPE,
WEBP_FRAME_CONTENT_TYPE,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.generate_session import (
GenerateSession,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import (
OutputBatch,
Req,
)
class RealtimeFrameBatchHeader(TypedDict, total=False):
type: str
request_id: str
chunk_index: int
content_type: str
num_frames: int
total_size: int
format: str
width: int
height: int
channels: int
bytes_per_frame: int
raw_size: int
encoding: str
delta_reference: str
payload_lengths: list[int]
event_id: int
frame_batch_index: int
num_frame_batches: int
is_final_frame_batch: bool
class RealtimeFrameBatchMessage(RealtimeFrameBatchHeader, total=False):
payload: bytes
class RealtimeFrameSendStats(TypedDict):
header_pack_ms: float
header_write_ms: float
raw_payload_build_ms: float
raw_write_ms: float
ws_write_ms: float
pace_wait_ms: float
raw_bytes: int
ws_payload_bytes: int
num_frames: int
num_batches: int
frame_shape: tuple[int, int, int] | None
content_type: str
def empty_frame_send_stats(content_type: str = "") -> RealtimeFrameSendStats:
return {
"header_pack_ms": 0.0,
"header_write_ms": 0.0,
"raw_payload_build_ms": 0.0,
"raw_write_ms": 0.0,
"ws_write_ms": 0.0,
"pace_wait_ms": 0.0,
"raw_bytes": 0,
"ws_payload_bytes": 0,
"num_frames": 0,
"num_batches": 0,
"frame_shape": None,
"content_type": content_type,
}
def _raw_rgb_frame_metadata(batch: Req) -> dict[str, int | str]:
frame_width = batch.width
frame_height = batch.height
if frame_width is None or frame_height is None:
return {}
frame_width = int(frame_width)
frame_height = int(frame_height)
if batch.enable_upscaling:
upscaling_scale = int(batch.upscaling_scale or 1)
frame_width *= upscaling_scale
frame_height *= upscaling_scale
return {
"format": "rgb24",
"width": frame_width,
"height": frame_height,
"channels": RAW_RGB_CHANNELS,
"bytes_per_frame": frame_width * frame_height * RAW_RGB_CHANNELS,
}
def _frame_shape_from_metadata(
metadata: dict[str, int | str] | None,
) -> tuple[int, int, int] | None:
if not metadata:
return None
return (
int(metadata["height"]),
int(metadata["width"]),
int(metadata["channels"]),
)
RAW_RGB_FRAMES_PER_WS_MESSAGE = 16
ENCODED_PREVIEW_FRAMES_PER_WS_MESSAGE = 6
FRAME_BATCH_PACK_OFFLOAD_BYTES = 64 * 1024
WEBP_DEFAULT_QUALITY = 90
JPEG_DEFAULT_QUALITY = 95
JPEG_SUBSAMPLING = 0
RAW_LOSSLESS_OUTPUT_FORMAT = "raw"
ENCODED_PREVIEW_FORMATS = {"webp", "jpeg"}
@dataclass(frozen=True)
class _TransportPayload:
content_type: str
payload: bytes
metadata: dict[str, int | str | bool | list[int]]
def _split_frame_batch(
frames: list[bytes],
frames_per_message: int = RAW_RGB_FRAMES_PER_WS_MESSAGE,
) -> list[list[bytes]]:
if not frames:
return [frames]
return [
frames[i : i + frames_per_message]
for i in range(0, len(frames), frames_per_message)
]
def _encode_rgb_frame_to_webp(
frame: bytes,
*,
width: int,
height: int,
quality: int,
preview_max_width: int | None,
) -> bytes:
buffer = io.BytesIO()
image = _resize_preview_image(
Image.frombytes("RGB", (width, height), frame),
preview_max_width=preview_max_width,
)
image.save(
buffer,
format="WEBP",
quality=quality,
method=0,
)
return buffer.getvalue()
def _encode_rgb_frame_to_jpeg(
frame: bytes,
*,
width: int,
height: int,
quality: int,
preview_max_width: int | None,
) -> bytes:
buffer = io.BytesIO()
image = _resize_preview_image(
Image.frombytes("RGB", (width, height), frame),
preview_max_width=preview_max_width,
)
image.save(
buffer,
format="JPEG",
quality=quality,
subsampling=JPEG_SUBSAMPLING,
)
return buffer.getvalue()
def _preview_dimensions(
*,
width: int,
height: int,
preview_max_width: int | None,
) -> tuple[int, int]:
if (
preview_max_width is None
or preview_max_width <= 0
or width <= preview_max_width
):
return width, height
preview_width = int(preview_max_width)
preview_height = max(1, round(height * preview_width / width))
return preview_width, preview_height
def _resize_preview_image(
image: Image.Image,
*,
preview_max_width: int | None,
) -> Image.Image:
width, height = image.size
preview_width, preview_height = _preview_dimensions(
width=width,
height=height,
preview_max_width=preview_max_width,
)
if (preview_width, preview_height) == image.size:
return image
return image.resize((preview_width, preview_height), Image.Resampling.BICUBIC)
def _pack_frame_batch_message(
header: RealtimeFrameBatchHeader,
payload: bytes,
) -> bytes:
message: RealtimeFrameBatchMessage = {
**header,
"type": "frame_batch",
"payload": payload,
}
return msgspec.msgpack.encode(message)
def _pack_frame_batch_header(header: RealtimeFrameBatchHeader) -> bytes:
return msgspec.msgpack.encode(header)
def _build_transport_payload(
transport_frames: list[bytes],
*,
content_type: str,
metadata: dict[str, int | str],
output_format: str | None,
transport_quality: int | None,
preview_max_width: int | None,
) -> _TransportPayload:
payload_content_type = content_type
payload_metadata: dict[str, int | str | bool | list[int]] = {}
raw_payload = b""
if (
output_format in ENCODED_PREVIEW_FORMATS
and content_type == RAW_RGB_CONTENT_TYPE
and transport_frames
):
if output_format == "webp":
encoded_frames = [
_encode_rgb_frame_to_webp(
frame,
width=int(metadata["width"]),
height=int(metadata["height"]),
quality=int(transport_quality or WEBP_DEFAULT_QUALITY),
preview_max_width=preview_max_width,
)
for frame in transport_frames
]
payload_content_type = WEBP_FRAME_CONTENT_TYPE
else:
encoded_frames = [
_encode_rgb_frame_to_jpeg(
frame,
width=int(metadata["width"]),
height=int(metadata["height"]),
quality=int(transport_quality or JPEG_DEFAULT_QUALITY),
preview_max_width=preview_max_width,
)
for frame in transport_frames
]
payload_content_type = JPEG_FRAME_CONTENT_TYPE
raw_payload = b"".join(encoded_frames)
preview_width, preview_height = _preview_dimensions(
width=int(metadata["width"]),
height=int(metadata["height"]),
preview_max_width=preview_max_width,
)
payload_metadata = {
"format": output_format,
"encoding": output_format,
"source_width": int(metadata["width"]),
"source_height": int(metadata["height"]),
"preview_width": preview_width,
"preview_height": preview_height,
"width": preview_width,
"height": preview_height,
"payload_lengths": [len(frame) for frame in encoded_frames],
}
elif content_type == RAW_RGB_CONTENT_TYPE and transport_frames:
raw_payload = b"".join(transport_frames)
payload_metadata = {
"raw_size": len(raw_payload),
"encoding": RAW_LOSSLESS_OUTPUT_FORMAT,
}
else:
raw_payload = b"".join(transport_frames)
return _TransportPayload(
content_type=payload_content_type,
payload=raw_payload,
metadata=payload_metadata,
)
def _should_build_payload_off_loop(
*,
content_type: str,
output_format: str | None,
transport_frames: list[bytes],
) -> bool:
if content_type != RAW_RGB_CONTENT_TYPE or not transport_frames:
return False
return output_format in ENCODED_PREVIEW_FORMATS or output_format is None
def _is_encoded_preview_transport(
*,
content_type: str,
output_format: str | None,
) -> bool:
return (
output_format in ENCODED_PREVIEW_FORMATS
and content_type == RAW_RGB_CONTENT_TYPE
)
async def _build_encoded_preview_payloads(
split_batches: list[list[bytes]],
*,
content_type: str,
metadata: dict[str, int | str],
output_format: str,
transport_quality: int | None,
preview_max_width: int | None,
event_id: int | None,
) -> list[_TransportPayload]:
return list(
await asyncio.gather(
*(
_build_encoded_preview_payload(
transport_frames,
metadata=metadata,
output_format=output_format,
transport_quality=transport_quality,
preview_max_width=preview_max_width,
)
for transport_frames in split_batches
)
)
)
async def _build_encoded_preview_payload(
transport_frames: list[bytes],
*,
metadata: dict[str, int | str],
output_format: str,
transport_quality: int | None,
preview_max_width: int | None,
) -> _TransportPayload:
width = int(metadata["width"])
height = int(metadata["height"])
if output_format == "webp":
encoded_frames = list(
await asyncio.gather(
*(
asyncio.to_thread(
_encode_rgb_frame_to_webp,
frame,
width=width,
height=height,
quality=int(transport_quality or WEBP_DEFAULT_QUALITY),
preview_max_width=preview_max_width,
)
for frame in transport_frames
)
)
)
payload_content_type = WEBP_FRAME_CONTENT_TYPE
else:
encoded_frames = list(
await asyncio.gather(
*(
asyncio.to_thread(
_encode_rgb_frame_to_jpeg,
frame,
width=width,
height=height,
quality=int(transport_quality or JPEG_DEFAULT_QUALITY),
preview_max_width=preview_max_width,
)
for frame in transport_frames
)
)
)
payload_content_type = JPEG_FRAME_CONTENT_TYPE
preview_width, preview_height = _preview_dimensions(
width=width,
height=height,
preview_max_width=preview_max_width,
)
return _TransportPayload(
content_type=payload_content_type,
payload=b"".join(encoded_frames),
metadata={
"format": output_format,
"encoding": output_format,
"source_width": width,
"source_height": height,
"preview_width": preview_width,
"preview_height": preview_height,
"width": preview_width,
"height": preview_height,
"payload_lengths": [len(frame) for frame in encoded_frames],
},
)
class RawRGBRealtimeOutputAdapter:
"""send raw RGB over WebSocket using lossless transport"""
def __init__(self) -> None:
pass
def reset(self) -> None:
pass
async def send(
self,
ws: WebSocket,
session: GenerateSession,
result: OutputBatch,
batch: Req,
) -> RealtimeFrameSendStats:
"""send frames through ws"""
content_type = result.raw_frame_content_type
if result.raw_frame_batches is None:
return empty_frame_send_stats(content_type)
if batch.block_idx == 0:
self.reset()
frame_metadata = (
result.raw_frame_metadata or _raw_rgb_frame_metadata(batch)
if content_type == RAW_RGB_CONTENT_TYPE
else {}
)
output_format = getattr(batch, "realtime_output_format", None)
preview_max_width = getattr(batch, "realtime_preview_max_width", None)
stats = await self._send_frame_batches(
ws,
result.raw_frame_batches,
content_type=content_type,
chunk_index_start=batch.block_idx,
request_id=batch.request_id,
event_id=getattr(batch, "realtime_event_id", None),
frame_metadata=frame_metadata,
output_format=output_format,
transport_quality=getattr(batch, "output_compression", None),
preview_max_width=preview_max_width,
)
stats["frame_shape"] = _frame_shape_from_metadata(frame_metadata)
return stats
async def _send_frame_batches(
self,
ws: WebSocket,
frame_batches: list[list[bytes]],
*,
content_type: str,
chunk_index_start: int,
request_id: str,
event_id: int | None = None,
frame_metadata: dict[str, int | str] | None = None,
output_format: str | None = None,
transport_quality: int | None = None,
preview_max_width: int | None = None,
) -> RealtimeFrameSendStats:
chunk_index = chunk_index_start
metadata = frame_metadata or {}
stats = empty_frame_send_stats(content_type)
for frames in frame_batches:
split_batches = (
_split_frame_batch(frames, ENCODED_PREVIEW_FRAMES_PER_WS_MESSAGE)
if _is_encoded_preview_transport(
content_type=content_type,
output_format=output_format,
)
else (
_split_frame_batch(frames)
if content_type == RAW_RGB_CONTENT_TYPE
else [frames]
)
)
num_frame_batches = len(split_batches)
encoded_preview_payloads: list[_TransportPayload] | None = None
if _is_encoded_preview_transport(
content_type=content_type,
output_format=output_format,
):
timer = RealtimeStageTimer()
encoded_preview_payloads = await _build_encoded_preview_payloads(
split_batches,
content_type=content_type,
metadata=metadata,
output_format=output_format,
transport_quality=transport_quality,
preview_max_width=preview_max_width,
event_id=event_id,
)
stats["raw_payload_build_ms"] += timer.mark_ms()
for frame_batch_index, transport_frames in enumerate(split_batches):
timer = RealtimeStageTimer()
transport_metadata = metadata
if encoded_preview_payloads is not None:
transport_payload = encoded_preview_payloads[frame_batch_index]
else:
if _should_build_payload_off_loop(
content_type=content_type,
output_format=output_format,
transport_frames=transport_frames,
):
transport_payload = await asyncio.to_thread(
_build_transport_payload,
transport_frames,
content_type=content_type,
metadata=metadata,
output_format=output_format,
transport_quality=transport_quality,
preview_max_width=preview_max_width,
)
else:
transport_payload = _build_transport_payload(
transport_frames,
content_type=content_type,
metadata=metadata,
output_format=output_format,
transport_quality=transport_quality,
preview_max_width=preview_max_width,
)
stats["raw_payload_build_ms"] += timer.mark_ms()
header: RealtimeFrameBatchHeader = {
"type": "frame_batch_header",
"request_id": request_id,
"chunk_index": chunk_index,
"content_type": transport_payload.content_type,
"num_frames": len(transport_frames),
"total_size": len(transport_payload.payload),
"frame_batch_index": frame_batch_index,
"num_frame_batches": num_frame_batches,
"is_final_frame_batch": frame_batch_index == num_frame_batches - 1,
}
if event_id is not None:
header["event_id"] = event_id
header.update(transport_metadata)
header.update(transport_payload.metadata)
if len(transport_payload.payload) >= FRAME_BATCH_PACK_OFFLOAD_BYTES:
header_payload = _pack_frame_batch_header(header)
stats["header_pack_ms"] += timer.mark_ms()
await ws.send_bytes(header_payload)
stats["header_write_ms"] += timer.mark_ms()
await ws.send_bytes(transport_payload.payload)
stats["raw_write_ms"] += timer.mark_ms()
stats["ws_payload_bytes"] += len(header_payload) + len(
transport_payload.payload
)
else:
message_payload = _pack_frame_batch_message(
header,
transport_payload.payload,
)
stats["header_pack_ms"] += timer.mark_ms()
stats["header_write_ms"] += timer.mark_ms()
await ws.send_bytes(message_payload)
stats["raw_write_ms"] += timer.mark_ms()
stats["ws_payload_bytes"] += len(message_payload)
stats["raw_bytes"] += sum(len(frame) for frame in transport_frames)
stats["num_frames"] += len(transport_frames)
stats["num_batches"] += 1
stats["content_type"] = transport_payload.content_type
chunk_index += 1
stats["ws_write_ms"] = stats["header_write_ms"] + stats["raw_write_ms"]
return stats
@@ -0,0 +1,500 @@
# SPDX-License-Identifier: Apache-2.0
import asyncio
import shutil
import time
from typing import TYPE_CHECKING
import msgspec.msgpack
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
RealtimeEvent,
RealtimeVideoGenerationsRequest,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.generate_session import (
GenerateSession,
RealtimeChunkContext,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_output_adapter import (
RealtimeFrameSendStats,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.registry import (
get_realtime_model_adapter,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.timer import (
RealtimeStageTimer,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
process_generation_batch,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
ReleaseRealtimeSessionReq,
)
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
logger = init_logger(__name__)
router = APIRouter(prefix="/v1/realtime_video", tags=["realtime"])
_ACTIVE_SESSION_IDS: set[str] = set()
_ACTIVE_SESSION_WAIT_SECONDS = 1.0
_ACTIVE_SESSION_WAIT_INTERVAL_SECONDS = 0.1
def _transport_ms(value: float) -> int:
return max(0, int(value + 0.5))
async def _wait_for_active_session_slot(
*,
timeout_s: float = _ACTIVE_SESSION_WAIT_SECONDS,
interval_s: float = _ACTIVE_SESSION_WAIT_INTERVAL_SECONDS,
) -> bool:
deadline = time.monotonic() + timeout_s
while _ACTIVE_SESSION_IDS and time.monotonic() < deadline:
await asyncio.sleep(interval_s)
return not _ACTIVE_SESSION_IDS
def _log_realtime_chunk_timing(
session: GenerateSession,
chunk: RealtimeChunkContext,
batch: "Req",
request_prepare_ms: float,
scheduler_forward_ms: float,
chunk_total_ms: float,
send_stats: RealtimeFrameSendStats,
) -> None:
logger.info(
"realtime chunk timing: session_id=%s request_id=%s "
"chunk_idx=%s event_id=%s condition_kinds=%s "
"request_prepare=%.2fms scheduler_forward=%.2fms "
"output_pace=%.2fms "
"header_pack=%.2fms "
"header_write=%.2fms raw_payload_build=%.2fms raw_write=%.2fms "
"ws_write=%.2fms chunk_total=%.2fms batches=%d frames=%d "
"frame_shape=%s raw_bytes=%d ws_payload_bytes=%d content_type=%s",
session.id,
chunk.request_id,
batch.block_idx,
getattr(batch, "realtime_event_id", None),
sorted(batch.condition_inputs) if batch.condition_inputs else [],
request_prepare_ms,
scheduler_forward_ms,
send_stats["pace_wait_ms"],
send_stats["header_pack_ms"],
send_stats["header_write_ms"],
send_stats["raw_payload_build_ms"],
send_stats["raw_write_ms"],
send_stats["ws_write_ms"],
chunk_total_ms,
send_stats["num_batches"],
send_stats["num_frames"],
send_stats["frame_shape"],
send_stats["raw_bytes"],
send_stats["ws_payload_bytes"],
send_stats["content_type"],
)
async def _send_realtime_chunk_stats(
ws: WebSocket,
session: GenerateSession,
chunk: RealtimeChunkContext,
batch: "Req",
request_prepare_ms: float,
scheduler_forward_ms: float,
chunk_total_ms: float,
send_stats: RealtimeFrameSendStats,
) -> None:
await ws.send_bytes(
msgspec.msgpack.encode(
{
"type": "chunk_stats",
"session_id": session.id,
"request_id": chunk.request_id,
"chunk_index": batch.block_idx,
"event_id": getattr(batch, "realtime_event_id", None),
"request_prepare_ms": _transport_ms(request_prepare_ms),
"scheduler_forward_ms": _transport_ms(scheduler_forward_ms),
"pace_wait_ms": _transport_ms(send_stats["pace_wait_ms"]),
"header_write_ms": _transport_ms(send_stats["header_write_ms"]),
"raw_payload_build_ms": _transport_ms(
send_stats["raw_payload_build_ms"]
),
"raw_write_ms": _transport_ms(send_stats["raw_write_ms"]),
"ws_write_ms": _transport_ms(send_stats["ws_write_ms"]),
"chunk_total_ms": _transport_ms(chunk_total_ms),
"num_batches": send_stats["num_batches"],
"num_frames": send_stats["num_frames"],
"raw_bytes": send_stats["raw_bytes"],
"ws_payload_bytes": send_stats["ws_payload_bytes"],
"content_type": send_stats["content_type"],
}
)
)
async def _generate_loop(ws: WebSocket, session: GenerateSession):
adapter = session.adapter
if adapter is None:
raise ValueError("realtime adapter is not initialized")
pending_send_task = None
while not session.reached_max_chunks():
try:
if pending_send_task is not None and pending_send_task.done():
await pending_send_task
pending_send_task = None
# send to scheduler and generate video chunk
server_args = get_global_server_args()
await adapter.wait_for_next_chunk(session)
timer = RealtimeStageTimer()
chunk_started = time.perf_counter()
chunk = session.new_chunk()
batch = adapter.prepare_next_request(
session,
server_args,
chunk,
)
if batch.condition_inputs:
logger.debug(
"consume realtime conditions, session_id=%s, block_idx=%s, kinds=%s",
session.id,
batch.block_idx,
sorted(batch.condition_inputs),
)
request_prepare_ms = timer.mark_ms()
_, result = await process_generation_batch(async_scheduler_client, batch)
scheduler_forward_ms = timer.mark_ms()
# finish
adapter.on_chunk_complete(session, result)
if pending_send_task is not None:
await pending_send_task
if getattr(batch, "realtime_output_pacing", False):
await _send_output_and_log(
ws,
session,
chunk,
batch,
result,
request_prepare_ms,
scheduler_forward_ms,
chunk_started,
)
pending_send_task = None
else:
pending_send_task = asyncio.create_task(
_send_output_and_log(
ws,
session,
chunk,
batch,
result,
request_prepare_ms,
scheduler_forward_ms,
chunk_started,
)
)
except asyncio.CancelledError:
if pending_send_task is not None:
pending_send_task.cancel()
await _await_realtime_task(pending_send_task)
logger.info("generation completed, session_id=%s", session.id)
break
except WebSocketDisconnect:
if pending_send_task is not None:
pending_send_task.cancel()
await _await_realtime_task(pending_send_task)
logger.info(
"client disconnected during generation, session_id=%s", session.id
)
break
except Exception as e:
if pending_send_task is not None:
pending_send_task.cancel()
await _await_realtime_task(pending_send_task)
err_msg = str(e).splitlines()[0]
logger.error("error during generate loop: %s", err_msg)
try:
await write_error_msg(f"error during generate loop: {err_msg}", ws)
except Exception as send_error:
logger.error(
"error during sending complete msg: %s",
send_error,
)
break
else:
if pending_send_task is not None:
await pending_send_task
logger.info(
"generation reached max chunks, session_id=%s, max_chunks=%s",
session.id,
session.request.max_chunks if session.request is not None else None,
)
async def _send_output_and_log(
ws: WebSocket,
session: GenerateSession,
chunk: RealtimeChunkContext,
batch: "Req",
result,
request_prepare_ms: float,
scheduler_forward_ms: float,
chunk_started: float,
) -> RealtimeFrameSendStats:
if session.adapter is None:
raise ValueError("realtime adapter is not initialized")
pace_wait_ms = await _wait_for_realtime_output_slot(session, batch, result)
send_stats = await session.adapter.send_output(
ws,
session,
result,
batch,
)
send_stats["pace_wait_ms"] = pace_wait_ms
chunk_total_ms = (time.perf_counter() - chunk_started) * 1000
_log_realtime_chunk_timing(
session,
chunk,
batch,
request_prepare_ms,
scheduler_forward_ms,
chunk_total_ms,
send_stats,
)
await _send_realtime_chunk_stats(
ws,
session,
chunk,
batch,
request_prepare_ms,
scheduler_forward_ms,
chunk_total_ms,
send_stats,
)
return send_stats
def _result_num_frames(result) -> int:
if result.raw_frame_batches is None:
return 0
return sum(len(frames) for frames in result.raw_frame_batches)
def _output_pacing_fps(batch: "Req") -> float:
fps = float(batch.fps or 0)
if batch.enable_frame_interpolation:
fps *= 2 ** int(batch.frame_interpolation_exp or 1)
return fps
async def _wait_for_realtime_output_slot(
session: GenerateSession,
batch: "Req",
result,
) -> float:
if not getattr(batch, "realtime_output_pacing", False):
return 0.0
frame_count = _result_num_frames(result)
output_fps = _output_pacing_fps(batch)
if frame_count <= 0 or output_fps <= 0:
return 0.0
now = time.perf_counter()
next_send_at = session.output_pace_next_send_at
if next_send_at is None:
next_send_at = now
if (
batch.realtime_event_id is not None
and batch.realtime_event_id != session.output_pace_last_event_id
):
next_send_at = min(next_send_at, now)
session.output_pace_last_event_id = batch.realtime_event_id
wait_s = max(0.0, next_send_at - now)
if wait_s > 0:
await asyncio.sleep(wait_s)
send_started_at = time.perf_counter()
session.output_pace_next_send_at = (
max(next_send_at, send_started_at) + frame_count / output_fps
)
return wait_s * 1000
async def _await_realtime_task(task: asyncio.Task | None) -> None:
if task is None:
return
try:
await task
except (asyncio.CancelledError, WebSocketDisconnect):
pass
except Exception as e:
logger.debug("realtime task exited with error: %s", e)
async def _listen_events(ws: WebSocket, session: GenerateSession):
"""listen for user events: usually condition inputs"""
async for message in ws.iter_bytes():
data = None
try:
data = msgspec.msgpack.decode(message)
if not isinstance(data, dict):
raise ValueError("realtime event must be a map")
realtime_event = RealtimeEvent.model_validate(data)
if session.adapter is None:
raise ValueError("realtime adapter is not initialized")
event_log = session.adapter.ingest_event(session, realtime_event)
logger.info(
"receive realtime event, session_id=%s, event_id=%s, %s",
session.id,
realtime_event.event_id,
event_log,
)
except Exception as e:
event_kind = data.get("kind") if isinstance(data, dict) else None
logger.warning("invalid event, kind=%s, error=%s", event_kind, e)
await write_error_msg("invalid event", ws)
continue
async def _listen_generate_request(ws: WebSocket, session: GenerateSession):
while True:
try:
data = msgspec.msgpack.decode(await ws.receive_bytes())
if not isinstance(data, dict):
raise ValueError("generate request must be a map")
realtime_req = RealtimeVideoGenerationsRequest.model_validate(data)
adapter = get_realtime_model_adapter(get_global_server_args())
session.set_adapter(adapter)
await adapter.on_init(session, realtime_req)
# Keep session state update atomic with validated request.
session.set_request(realtime_req)
break
except WebSocketDisconnect:
raise
except Exception as e:
logger.warning(
"invalid generate request, session_id=%s, error=%s",
session.id,
e,
)
await write_error_msg("invalid generate request", ws)
continue
async def _cleanup_realtime_session(
session: GenerateSession,
generate_task: asyncio.Task | None,
listen_task: asyncio.Task | None,
) -> None:
logger.info("terminating session, session_id=%s", session.id)
for task in (generate_task, listen_task):
if task and not task.done():
task.cancel()
for task in (generate_task, listen_task):
if task is None:
continue
await _await_realtime_task(task)
try:
await async_scheduler_client.forward(
ReleaseRealtimeSessionReq(session_id=session.id)
)
except Exception as e:
logger.warning(
"failed to release realtime session on scheduler, session_id=%s, error=%s",
session.id,
e,
)
if session.input_temp_dir is not None:
shutil.rmtree(session.input_temp_dir, ignore_errors=True)
session.dispose()
async def _close_realtime_websocket(
websocket: WebSocket,
*,
code: int,
reason: str,
) -> None:
try:
await websocket.close(code=code, reason=reason)
except (RuntimeError, WebSocketDisconnect):
pass
async def _wait_for_server_warmup(websocket: WebSocket) -> None:
warmup_done = getattr(websocket.app.state, "server_warmup_done", None)
if warmup_done is not None and not warmup_done.is_set():
await warmup_done.wait()
@router.websocket("/generate")
async def generate(websocket: WebSocket):
"""endpoint for creating a new realtime session"""
await websocket.accept()
await _wait_for_server_warmup(websocket)
if _ACTIVE_SESSION_IDS and not await _wait_for_active_session_slot():
logger.warning(
"reject realtime session because another session is active: %s",
sorted(_ACTIVE_SESSION_IDS),
)
try:
await write_error_msg(
"another realtime session is already active", websocket
)
finally:
await websocket.close(code=1008)
return
session = GenerateSession()
_ACTIVE_SESSION_IDS.add(session.id)
generate_task = None
listen_task = None
try:
# receive new generate request
await _listen_generate_request(websocket, session)
# continuously generate video chunk
generate_task = asyncio.create_task(_generate_loop(websocket, session))
# continuously listen for user events
listen_task = asyncio.create_task(_listen_events(websocket, session))
wait_tasks = [generate_task, listen_task]
await asyncio.wait(wait_tasks, return_when=asyncio.FIRST_COMPLETED)
if generate_task.done() and session.reached_max_chunks():
await _close_realtime_websocket(
websocket,
code=1000,
reason="generation complete",
)
except WebSocketDisconnect:
logger.info("client disconnected, session_id=%s", session.id)
finally:
try:
await _cleanup_realtime_session(session, generate_task, listen_task)
finally:
_ACTIVE_SESSION_IDS.discard(session.id)
async def write_error_msg(error_msg: str, websocket: WebSocket):
await websocket.send_bytes(
msgspec.msgpack.encode({"type": "error", "content": error_msg})
)
@@ -0,0 +1,69 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import TYPE_CHECKING
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.realtime_adapter import (
BaseRealtimeModelAdapter,
)
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.server_args import ServerArgs
_REALTIME_ADAPTER_REGISTRY: dict[type, type[BaseRealtimeModelAdapter]] = {}
_BUILTIN_ADAPTERS_REGISTERED = False
def register_realtime_model_adapter(
pipeline_config_cls: type,
adapter_cls: type[BaseRealtimeModelAdapter],
) -> None:
_REALTIME_ADAPTER_REGISTRY[pipeline_config_cls] = adapter_cls
def _register_builtin_realtime_model_adapters() -> None:
global _BUILTIN_ADAPTERS_REGISTERED
if _BUILTIN_ADAPTERS_REGISTERED:
return
from sglang.multimodal_gen.configs.pipeline_configs.lingbot_world import (
LingBotWorldCausalDMDConfig,
)
from sglang.multimodal_gen.configs.pipeline_configs.sana_wm import (
SanaWMRealtimeConfig,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.adapters.lingbot_world_realtime_adapter import (
LingBotWorldRealtimeAdapter,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.realtime.adapters.sana_wm_realtime_adapter import (
SanaWMRealtimeAdapter,
)
register_realtime_model_adapter(
LingBotWorldCausalDMDConfig,
LingBotWorldRealtimeAdapter,
)
register_realtime_model_adapter(
SanaWMRealtimeConfig,
SanaWMRealtimeAdapter,
)
_BUILTIN_ADAPTERS_REGISTERED = True
def get_realtime_model_adapter(
server_args: ServerArgs,
) -> BaseRealtimeModelAdapter:
_register_builtin_realtime_model_adapters()
pipeline_config = server_args.pipeline_config
for config_cls in type(pipeline_config).__mro__:
adapter_cls = _REALTIME_ADAPTER_REGISTRY.get(config_cls)
if adapter_cls is not None:
return adapter_cls()
raise ValueError(
"Realtime video is not supported for pipeline config "
f"{type(pipeline_config).__name__}; no realtime adapter is registered."
)
@@ -0,0 +1,21 @@
# SPDX-License-Identifier: Apache-2.0
import time
class RealtimeStageTimer:
__slots__ = ("_last", "_start")
def __init__(self):
now = time.perf_counter()
self._start = now
self._last = now
def mark_ms(self) -> float:
now = time.perf_counter()
elapsed_ms = (now - self._last) * 1000.0
self._last = now
return elapsed_ms
def total_ms(self) -> float:
return (time.perf_counter() - self._start) * 1000.0
@@ -0,0 +1,109 @@
import asyncio
import os
from typing import Optional
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
class CloudStorage:
def __init__(self):
self.enabled = os.getenv("SGLANG_CLOUD_STORAGE_TYPE", "").lower() == "s3"
if not self.enabled:
return
try:
import boto3
except ImportError:
logger.error(
"boto3 is not installed. Please install it with `pip install boto3` to use cloud storage."
)
self.enabled = False
return
self.bucket_name = os.getenv("SGLANG_S3_BUCKET_NAME")
if not self.bucket_name:
self.enabled = False
return
endpoint_url = os.getenv("SGLANG_S3_ENDPOINT_URL") or None
region_name = os.getenv("SGLANG_S3_REGION_NAME") or None
self.client = boto3.client(
"s3",
aws_access_key_id=os.getenv("SGLANG_S3_ACCESS_KEY_ID"),
aws_secret_access_key=os.getenv("SGLANG_S3_SECRET_ACCESS_KEY"),
endpoint_url=endpoint_url,
region_name=region_name,
)
self.endpoint_url = endpoint_url
self.region_name = region_name
def is_enabled(self) -> bool:
return self.enabled
async def upload_file(self, local_path: str, destination_key: str) -> Optional[str]:
if not self.is_enabled():
return None
def _sync_upload():
"""Synchronous part of the upload to run in a thread."""
ext = os.path.splitext(local_path)[1].lower()
content_type = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".mp4": "video/mp4",
".glb": "model/gltf-binary",
".obj": "text/plain",
}.get(ext, "application/octet-stream")
# Use the client created once in __init__
self.client.upload_file(
local_path,
self.bucket_name,
destination_key,
ExtraArgs={"ContentType": content_type},
)
try:
# Offload the blocking I/O call to a thread executor
await asyncio.get_running_loop().run_in_executor(None, _sync_upload)
except Exception as e:
# If upload fails, log the error and return None for fallback
logger.error(f"Upload failed for {destination_key}: {e}")
return None
# Simplified URL generation with a default region
if self.endpoint_url:
url = (
f"{self.endpoint_url.rstrip('/')}/{self.bucket_name}/{destination_key}"
)
else:
region = self.region_name or "us-east-1"
url = f"https://{self.bucket_name}.s3.{region}.amazonaws.com/{destination_key}"
logger.info(f"Uploaded {local_path} to {url}")
return url
async def upload_and_cleanup(self, file_path: str) -> Optional[str]:
"""Helper to upload a file and delete the local copy if successful."""
if not self.is_enabled():
return None
key = os.path.basename(file_path)
url = await self.upload_file(file_path, key)
if url:
try:
# pass if removal fails
os.remove(file_path)
except OSError as e:
logger.warning(f"Failed to remove temporary file {file_path}: {e}")
return url
# Global instance
cloud_storage = CloudStorage()
@@ -0,0 +1,48 @@
import asyncio
from typing import Any, Dict, List, Optional
class AsyncDictStore:
"""A small async-safe in-memory key-value store for dict items.
This encapsulates the usual pattern of a module-level dict guarded by
an asyncio.Lock and provides simple CRUD methods that are safe to call
concurrently from FastAPI request handlers and background tasks.
"""
def __init__(self) -> None:
self._items: Dict[str, Dict[str, Any]] = {}
self._lock = asyncio.Lock()
async def upsert(self, key: str, value: Dict[str, Any]) -> None:
async with self._lock:
self._items[key] = value
async def update_fields(
self, key: str, updates: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
async with self._lock:
item = self._items.get(key)
if item is None:
return None
item.update(updates)
return item
async def get(self, key: str) -> Optional[Dict[str, Any]]:
async with self._lock:
return self._items.get(key)
async def pop(self, key: str) -> Optional[Dict[str, Any]]:
async with self._lock:
return self._items.pop(key, None)
async def list_values(self) -> List[Dict[str, Any]]:
async with self._lock:
return list(self._items.values())
# Global stores shared by OpenAI entrypoints
# [request_id, dict]
VIDEO_STORE = AsyncDictStore()
IMAGE_STORE = AsyncDictStore()
MESH_STORE = AsyncDictStore()
@@ -0,0 +1,452 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
import asyncio
import inspect
import json
import os
import shutil
import tempfile
import time
from contextlib import contextmanager
from typing import Any, Generator, List, Optional, Union
import httpx
from fastapi import HTTPException, UploadFile
from sglang.multimodal_gen.configs.sample.sampling_params import (
DataType,
SamplingParams,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
ShutdownReq,
UnmergeLoraWeightsReq,
format_lora_message,
save_outputs,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.scheduler_client import AsyncSchedulerClient
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.common import parse_size
from sglang.multimodal_gen.runtime.utils.image_io import save_base64_image_to_path
from sglang.multimodal_gen.runtime.utils.logging_utils import (
init_logger,
log_batch_completion,
log_generation_timer,
)
from sglang.multimodal_gen.runtime.utils.trace_wrapper import trace_req
# re-export LoRA protocol types for backward compatibility
__all__ = [
"SetLoraReq",
"MergeLoraWeightsReq",
"UnmergeLoraWeightsReq",
"ListLorasReq",
"ShutdownReq",
"format_lora_message",
]
logger = init_logger(__name__)
OUTPUT_QUALITY_MAPPER = {"maximum": 100, "high": 90, "medium": 55, "low": 35}
DEFAULT_FPS = 24
DEFAULT_VIDEO_SECONDS = 4
def _bad_request(message: str) -> HTTPException:
return HTTPException(status_code=400, detail=message)
def _parse_size_or_raise(size: str) -> tuple[int, int]:
width, height = parse_size(size)
if width is None or height is None or width <= 0 or height <= 0:
raise _bad_request("size must be formatted as positive WIDTHxHEIGHT")
return width, height
def _validate_positive_int(kwargs: dict[str, Any], name: str) -> None:
value = kwargs.get(name)
if value is not None and int(value) <= 0:
raise _bad_request(f"{name} must be positive")
def flatten_extra_params(payload: Any) -> dict[str, Any]:
"""Promote vLLM-Omni-style extra_params into regular request fields."""
if not isinstance(payload, dict):
return {}
extra_params = payload.pop("extra_params", None)
if isinstance(extra_params, str):
try:
extra_params = json.loads(extra_params)
except Exception:
extra_params = None
if not isinstance(extra_params, dict):
if "guardrails" in payload:
payload.setdefault("use_guardrails", payload["guardrails"])
return payload
for key, value in extra_params.items():
payload.setdefault(key, value)
if "guardrails" in extra_params:
payload.setdefault("use_guardrails", extra_params["guardrails"])
return payload
@contextmanager
def temp_dir_if_disabled(
configured_path: str | None,
) -> Generator[str, None, None]:
"""Yield *configured_path* when it is set, otherwise create a temporary
directory that is automatically removed when the context exits."""
if configured_path is not None:
os.makedirs(configured_path, exist_ok=True)
yield configured_path
else:
tmp = tempfile.mkdtemp(prefix="sglang_")
try:
yield tmp
finally:
shutil.rmtree(tmp, ignore_errors=True)
def choose_output_image_ext(
output_format: Optional[str], background: Optional[str]
) -> str:
fmt = (output_format or "").lower()
if fmt in {"png", "webp", "jpeg", "jpg"}:
return "jpg" if fmt == "jpeg" else fmt
if (background or "auto").lower() == "transparent":
return "png"
return "jpg"
def build_sampling_params(request_id: str, **kwargs) -> SamplingParams:
"""Build SamplingParams from request parameters.
Handles size parsing, output_quality resolution, and None filtering before
delegating to SamplingParams.from_user_sampling_params_args. Callers pass
only the parameters they have; None values are stripped automatically so
that SamplingParams defaults apply.
"""
server_args = get_global_server_args()
# pop HTTP-layer params that aren't SamplingParams fields
output_quality = kwargs.pop("output_quality", None)
has_explicit_compression = kwargs.get("output_compression") is not None
# parse "WxH" size string if provided
size = kwargs.pop("size", None)
if size:
w, h = _parse_size_or_raise(size)
# treat None dimensions as unset so parsed size can fill them
if kwargs.get("width") is None:
kwargs["width"] = w
if kwargs.get("height") is None:
kwargs["height"] = h
for name in (
"width",
"height",
"num_frames",
"num_inference_steps",
"num_outputs_per_prompt",
):
_validate_positive_int(kwargs, name)
# filter out None values to let SamplingParams defaults apply
kwargs = {k: v for k, v in kwargs.items() if v is not None}
kwargs.setdefault("save_output", True)
sampling_params = SamplingParams.from_user_sampling_params_args(
model_path=server_args.model_path,
server_args=server_args,
request_id=request_id,
**kwargs,
)
# resolve output_quality → output_compression with the correct data_type.
# SamplingParams.__post_init__ may have resolved with the wrong data_type
# (default VIDEO) before _adjust() set the correct one.
if not has_explicit_compression and output_quality is not None:
resolved = adjust_output_quality(output_quality, sampling_params.data_type)
if resolved is not None:
sampling_params.output_compression = resolved
return sampling_params
async def save_image_to_path(
image: Union[UploadFile, bytes, str],
target_path: str,
*,
prefer_remote_source: bool = False,
) -> str:
input_path = await _maybe_url_image(
image, target_path, prefer_remote_source=prefer_remote_source
)
if input_path is None:
input_path = await _save_upload_to_path(image, target_path)
return input_path
# Helpers
async def _save_upload_to_path(
upload: Union[UploadFile, bytes], target_path: str
) -> str:
os.makedirs(os.path.dirname(target_path), exist_ok=True)
if isinstance(upload, bytes):
content = upload
elif isinstance(upload, (bytearray, memoryview)):
content = bytes(upload)
else:
read = getattr(upload, "read", None)
if not callable(read):
raise TypeError(f"Unsupported image upload type: {type(upload).__name__}")
content = read()
if inspect.isawaitable(content):
content = await content
if isinstance(content, (bytearray, memoryview)):
content = bytes(content)
if not isinstance(content, bytes):
raise TypeError(
f"Image upload read() returned {type(content).__name__}, expected bytes"
)
with open(target_path, "wb") as f:
f.write(content)
return target_path
async def _maybe_url_image(
img_url: str,
target_path: str,
*,
prefer_remote_source: bool = False,
) -> str | None:
if not isinstance(img_url, str):
return None
if img_url.lower().startswith(("http://", "https://")):
# Only bypass persistence when the caller explicitly disables input saves.
# Otherwise keep the prefetch outside the measured server stages.
if prefer_remote_source:
return img_url
# download image from URL and persist on disk
input_path = await _save_url_image_to_path(img_url, target_path)
return input_path
elif img_url.startswith("data:image"):
if prefer_remote_source:
return img_url
# encode image base64 url and persist on disk
input_path = save_base64_image_to_path(img_url, target_path)
return input_path
else:
raise ValueError("Unsupported image url format")
async def _save_url_image_to_path(image_url: str, target_path: str) -> str:
"""Download image from URL and save to target path."""
def _is_retryable_download_error(error: Exception) -> bool:
if isinstance(error, httpx.HTTPStatusError):
status_code = error.response.status_code
# Retry on rate limit and transient server-side failures.
return status_code == 429 or 500 <= status_code < 600
# Retry on transient network/protocol issues.
return isinstance(
error,
(
httpx.TimeoutException,
httpx.NetworkError,
httpx.RemoteProtocolError,
),
)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
max_attempts = 3
backoff_seconds = 0.2
last_error: Exception | None = None
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
for attempt in range(1, max_attempts + 1):
try:
response = await client.get(image_url, timeout=10.0)
response.raise_for_status()
# Determine file extension from content type or URL after downloading
if not os.path.splitext(target_path)[1]:
content_type = response.headers.get("content-type", "").lower()
url_path = image_url.split("?")[0]
_, url_ext = os.path.splitext(url_path)
url_ext = url_ext.lower()
if url_ext in {
".jpg",
".jpeg",
".png",
".webp",
".gif",
".bmp",
}:
ext = ".jpg" if url_ext == ".jpeg" else url_ext
elif content_type.startswith("image/"):
if "jpeg" in content_type or "jpg" in content_type:
ext = ".jpg"
elif "png" in content_type:
ext = ".png"
elif "webp" in content_type:
ext = ".webp"
else:
ext = ".jpg" # Default to jpg
elif content_type == "application/octet-stream":
# for octet-stream, if we couldn't get it from URL, default to jpg
ext = ".jpg"
else:
raise ValueError(
f"URL does not point to an image. Content-Type: {content_type}"
)
target_path = f"{target_path}{ext}"
with open(target_path, "wb") as f:
f.write(response.content)
return target_path
except Exception as e:
last_error = e
if attempt == max_attempts or not _is_retryable_download_error(e):
raise
wait_s = backoff_seconds * (2 ** (attempt - 1))
logger.warning(
"Retrying image download (%s/%s) for %s after %.1fs due to: %s",
attempt,
max_attempts,
image_url,
wait_s,
e,
)
await asyncio.sleep(wait_s)
except Exception as e:
final_error = last_error or e
raise Exception(
f"Failed to download image from URL {image_url}: {str(final_error)}"
)
async def process_generation_batch(
scheduler_client: AsyncSchedulerClient,
batch,
) -> tuple[list[str], OutputBatch]:
total_start_time = time.perf_counter()
with trace_req(batch.trace_ctx), log_generation_timer(logger, batch.prompt):
result = await scheduler_client.forward([batch])
if (
result.output is None
and result.output_file_paths is None
and result.raw_frame_batches is None
):
error_msg = result.error or "Unknown error"
raise RuntimeError(
f"Model generation returned no output. Error from scheduler: {error_msg}"
)
save_file_path_list = []
if result.output_file_paths:
save_file_path_list = result.output_file_paths
elif result.output is not None:
num_outputs = len(result.output)
save_file_path_list = save_outputs(
result.output,
batch.data_type,
batch.fps,
batch.save_output,
lambda idx: str(batch.output_file_path(num_outputs, idx)),
audio=result.audio,
audio_sample_rate=result.audio_sample_rate,
output_compression=batch.output_compression,
enable_frame_interpolation=batch.enable_frame_interpolation,
frame_interpolation_exp=batch.frame_interpolation_exp,
frame_interpolation_scale=batch.frame_interpolation_scale,
frame_interpolation_model_path=batch.frame_interpolation_model_path,
enable_upscaling=batch.enable_upscaling,
upscaling_model_path=batch.upscaling_model_path,
upscaling_scale=batch.upscaling_scale,
)
total_time = time.perf_counter() - total_start_time
if get_global_server_args().batching_max_size > 1:
log_batch_completion(
logger,
len(save_file_path_list),
total_time,
)
if result.peak_memory_mb and result.peak_memory_mb > 0:
logger.info(f"Peak memory usage: {result.peak_memory_mb:.2f} MB")
return save_file_path_list, result
def merge_image_input_list(*inputs: Union[List, Any, None]) -> List:
"""
Merge multiple image input sources into a single list.
This function handles both single items and lists of items, merging them
into a single flattened list. Useful for processing images, URLs, or other
multimedia inputs that can come as either single items or lists.
Args:
*inputs: Variable number of inputs, each can be None, single item, or list
Returns:
List: Flattened list of all non-None inputs
Example:
>>> merge_image_input_list(["img1", "img2"], "img3", None)
["img1", "img2", "img3"]
"""
result = []
for input_item in inputs:
if input_item is not None:
if isinstance(input_item, list):
result.extend(input_item)
else:
result.append(input_item)
return result
def add_common_data_to_response(
response: dict, request_id: str, result: OutputBatch
) -> dict:
if result.peak_memory_mb and result.peak_memory_mb > 0:
response["peak_memory_mb"] = result.peak_memory_mb
if result.metrics and result.metrics.total_duration_s > 0:
response["inference_time_s"] = result.metrics.total_duration_s
response["id"] = request_id
if result.action_pred is not None:
t = result.action_pred
response["action"] = {
"data": t.tolist(),
"shape": list(t.shape),
"dtype": str(t.dtype).replace("torch.", ""),
"raw_action_dim": result.action_raw_action_dim,
"action_mode": result.action_mode,
"domain_id": result.action_domain_id,
}
return response
def adjust_output_quality(output_quality: str, data_type: DataType = None) -> int:
if output_quality == "default":
return 50 if data_type == DataType.VIDEO else 75
return OUTPUT_QUALITY_MAPPER.get(output_quality, None)
@@ -0,0 +1,741 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
import asyncio
import json
import os
import shutil
import tempfile
import time
from typing import Any, Dict, Optional
from fastapi import (
APIRouter,
File,
Form,
HTTPException,
Path,
Query,
Request,
UploadFile,
)
from fastapi.responses import FileResponse
from sglang.multimodal_gen.configs.sample.sampling_params import (
SamplingParams,
generate_request_id,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
VideoGenerationsRequest,
VideoListResponse,
VideoResponse,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.storage import cloud_storage
from sglang.multimodal_gen.runtime.entrypoints.openai.stores import VIDEO_STORE
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
DEFAULT_FPS,
DEFAULT_VIDEO_SECONDS,
add_common_data_to_response,
build_sampling_params,
flatten_extra_params,
merge_image_input_list,
process_generation_batch,
save_image_to_path,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.srt.observability.trace import extract_trace_headers
logger = init_logger(__name__)
router = APIRouter(prefix="/v1/videos", tags=["videos"])
_VIDEO_EXTENSIONS = {
".avi",
".gif",
".m4v",
".mkv",
".mov",
".mp4",
".mpeg",
".mpg",
".webm",
}
def _extra_value(request: VideoGenerationsRequest, name: str) -> Any:
return (request.model_extra or {}).get(name)
def _request_value(request: VideoGenerationsRequest, name: str) -> Any:
value = getattr(request, name, None)
if value is not None:
return value
return _extra_value(request, name)
def _parse_form_extra_value(value: Any) -> Any:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except Exception:
return value
def _is_probably_video_source(source: Any) -> bool:
content_type = (getattr(source, "content_type", "") or "").lower()
if content_type.startswith("video/"):
return True
if isinstance(source, str):
if source.lower().startswith("data:video"):
return True
source_name = source
else:
source_name = getattr(source, "filename", None)
if not source_name:
return False
source_name = str(source_name).split("?", 1)[0].split("#", 1)[0]
return os.path.splitext(source_name)[1].lower() in _VIDEO_EXTENSIONS
def _is_cosmos3_server(server_args) -> bool:
from sglang.multimodal_gen.configs.pipeline_configs.cosmos3 import Cosmos3Config
return isinstance(server_args.pipeline_config, Cosmos3Config)
def _normalize_optional_string(value: Any) -> Any:
if isinstance(value, str) and not value.strip():
return None
return value
def _coerce_optional_int_list(value: Any) -> list[int] | None:
value = _parse_form_extra_value(value)
if value is None:
return None
if isinstance(value, str) and not value.strip():
return None
if isinstance(value, (list, tuple)):
return [int(item) for item in value]
return [int(value)]
def _resolve_video_path(req: VideoGenerationsRequest) -> str | None:
video_path = _request_value(req, "video_path") or _request_value(req, "video_url")
if video_path:
return str(video_path)
input_reference = _request_value(req, "input_reference")
if _is_probably_video_source(input_reference):
return str(input_reference)
reference_url = _request_value(req, "reference_url")
if _is_probably_video_source(reference_url):
return str(reference_url)
return None
def _resolve_image_path(
req: VideoGenerationsRequest, video_path: str | None
) -> str | None:
image_path = _request_value(req, "input_reference")
if video_path and image_path == video_path:
return None
if _is_probably_video_source(image_path):
return None
return image_path
def _resolve_sound_duration(
req: VideoGenerationsRequest, *, num_frames: int, fps: int
) -> float | None:
generate_sound = _request_value(req, "generate_sound")
sound_duration = _request_value(req, "sound_duration")
if generate_sound is False:
return 0.0
if sound_duration is not None:
return float(sound_duration)
if generate_sound is True:
return float(num_frames) / float(fps)
return None
def _cosmos3_sampling_param_kwargs(
req: VideoGenerationsRequest, *, num_frames: int, fps: int
) -> Dict[str, Any]:
"""Map HTTP/API aliases to Cosmos3SamplingParams field names."""
kwargs: Dict[str, Any] = {}
sound_duration = _resolve_sound_duration(req, num_frames=num_frames, fps=fps)
if sound_duration is not None:
kwargs["sound_duration"] = sound_duration
condition_frame_indexes = _request_value(req, "condition_frame_indexes")
if condition_frame_indexes is None:
condition_frame_indexes = _request_value(req, "condition_frame_indexes_vision")
condition_frame_indexes = _coerce_optional_int_list(condition_frame_indexes)
if condition_frame_indexes is not None:
kwargs["condition_frame_indexes"] = condition_frame_indexes
for name in (
"condition_video_keep",
"action_mode",
"domain_id",
"domain_name",
"raw_action_dim",
"action_fps",
"action",
"action_view_point",
"action_normalization",
):
value = _parse_form_extra_value(_request_value(req, name))
value = _normalize_optional_string(value)
if value is not None:
kwargs[name] = value
return kwargs
def _build_video_sampling_params(request_id: str, request: VideoGenerationsRequest):
"""Resolve video-specific defaults (fps, seconds → num_frames) then
delegate to the shared build_sampling_params."""
server_args = get_global_server_args()
seconds = request.seconds if request.seconds is not None else DEFAULT_VIDEO_SECONDS
fps = request.fps if request.fps is not None else DEFAULT_FPS
num_frames = request.num_frames if request.num_frames is not None else fps * seconds
num_outputs = request.num_outputs_per_prompt
if num_outputs is None:
num_outputs = request.n or 1
video_path = _resolve_video_path(request)
image_path = _resolve_image_path(request, video_path)
cosmos3_kwargs = {}
if _is_cosmos3_server(server_args):
cosmos3_kwargs = _cosmos3_sampling_param_kwargs(
request, num_frames=num_frames, fps=fps
)
if server_args.pipeline_config.action_stats_path is not None:
cosmos3_kwargs["action_stats_path"] = (
server_args.pipeline_config.action_stats_path
)
return build_sampling_params(
request_id,
prompt=request.prompt,
num_outputs_per_prompt=max(1, min(int(num_outputs), 10)),
size=request.size,
width=request.width,
height=request.height,
num_frames=num_frames,
fps=fps,
image_path=image_path,
video_path=video_path,
output_file_name=request_id,
seed=request.seed,
generator_device=request.generator_device,
num_inference_steps=request.num_inference_steps,
guidance_scale=request.guidance_scale,
guidance_scale_2=request.guidance_scale_2,
negative_prompt=request.negative_prompt,
max_sequence_length=request.max_sequence_length,
flow_shift=request.flow_shift,
use_duration_template=_extra_value(request, "use_duration_template"),
use_resolution_template=_extra_value(request, "use_resolution_template"),
use_system_prompt=_extra_value(request, "use_system_prompt"),
use_guardrails=_extra_value(request, "use_guardrails"),
enable_teacache=request.enable_teacache,
enable_frame_interpolation=request.enable_frame_interpolation,
frame_interpolation_exp=request.frame_interpolation_exp,
frame_interpolation_scale=request.frame_interpolation_scale,
frame_interpolation_model_path=request.frame_interpolation_model_path,
enable_upscaling=request.enable_upscaling,
upscaling_model_path=request.upscaling_model_path,
upscaling_scale=request.upscaling_scale,
output_path=request.output_path,
output_compression=request.output_compression,
output_quality=request.output_quality,
perf_dump_path=request.perf_dump_path,
diffusers_kwargs=request.diffusers_kwargs,
**cosmos3_kwargs,
)
# extract metadata which http_server needs to know
def _video_job_from_sampling(
request_id: str, req: VideoGenerationsRequest, sampling: SamplingParams
) -> Dict[str, Any]:
size_str = f"{sampling.width}x{sampling.height}"
seconds = int(round((sampling.num_frames or 0) / float(sampling.fps or 24)))
return {
"id": request_id,
"object": "video",
"model": req.model or "sora-2",
"status": "queued",
"progress": 0,
"created_at": int(time.time()),
"size": size_str,
"seconds": str(seconds),
"quality": "standard",
"file_path": os.path.abspath(sampling.output_file_path()),
}
async def _save_first_input_image(
image_sources,
request_id: str,
uploads_dir: str,
*,
prefer_remote_source: bool = False,
) -> str | None:
"""Save the first input image from a list of sources and return its path."""
image_list = merge_image_input_list(image_sources)
if not image_list:
return None
image = image_list[0]
os.makedirs(uploads_dir, exist_ok=True)
filename = image.filename if hasattr(image, "filename") else "url_image"
target_path = os.path.join(uploads_dir, f"{request_id}_{filename}")
return await save_image_to_path(
image, target_path, prefer_remote_source=prefer_remote_source
)
async def _dispatch_job_async(
job_id: str,
batch: Req,
*,
temp_dirs: list[str] | None = None,
output_persistent: bool = True,
) -> None:
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
try:
save_file_path_list, result = await process_generation_batch(
async_scheduler_client, batch
)
save_file_path = save_file_path_list[0]
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
persistent_path = (
save_file_path if not cloud_url and output_persistent else None
)
update_fields = {
"status": "completed",
"progress": 100,
"completed_at": int(time.time()),
"url": cloud_url,
"file_path": persistent_path,
"file_paths": (
[os.path.abspath(path) for path in save_file_path_list]
if output_persistent
else None
),
"num_outputs": len(save_file_path_list),
}
update_fields = add_common_data_to_response(
update_fields, request_id=job_id, result=result
)
await VIDEO_STORE.update_fields(job_id, update_fields)
except Exception as e:
logger.error(f"{e}")
await VIDEO_STORE.update_fields(
job_id, {"status": "failed", "error": {"message": str(e)}}
)
finally:
for td in temp_dirs or []:
shutil.rmtree(td, ignore_errors=True)
# TODO: support image to video generation
@router.post("", response_model=VideoResponse)
async def create_video(
request: Request,
# multipart/form-data fields (optional; used only when content-type is multipart)
prompt: Optional[str] = Form(None),
input_reference: Optional[UploadFile] = File(None),
reference_url: Optional[str] = Form(None),
video_reference: Optional[UploadFile] = File(None),
video_url: Optional[str] = Form(None),
video_path: Optional[str] = Form(None),
model: Optional[str] = Form(None),
n: Optional[int] = Form(1),
num_outputs_per_prompt: Optional[int] = Form(None),
seconds: Optional[int] = Form(None),
size: Optional[str] = Form(None),
fps: Optional[int] = Form(None),
num_frames: Optional[int] = Form(None),
seed: Optional[int] = Form(None),
generator_device: Optional[str] = Form("cuda"),
negative_prompt: Optional[str] = Form(None),
guidance_scale: Optional[float] = Form(None),
num_inference_steps: Optional[int] = Form(None),
max_sequence_length: Optional[int] = Form(None),
flow_shift: Optional[float] = Form(None),
enable_teacache: Optional[bool] = Form(None),
enable_frame_interpolation: Optional[bool] = Form(None),
frame_interpolation_exp: Optional[int] = Form(None),
frame_interpolation_scale: Optional[float] = Form(None),
frame_interpolation_model_path: Optional[str] = Form(None),
enable_upscaling: Optional[bool] = Form(None),
upscaling_model_path: Optional[str] = Form(None),
upscaling_scale: Optional[int] = Form(None),
output_quality: Optional[str] = Form(None),
output_compression: Optional[int] = Form(None),
output_path: Optional[str] = Form(None),
extra_params: Optional[str] = Form(None),
extra_body: Optional[str] = Form(None),
):
content_type = request.headers.get("content-type", "").lower()
request_id = generate_request_id()
server_args = get_global_server_args()
task_type = server_args.pipeline_config.task_type
# Resolve input upload directory (may be a temp dir when saving is disabled)
temp_dirs: list[str] = []
if server_args.input_save_path is not None:
uploads_dir = server_args.input_save_path
os.makedirs(uploads_dir, exist_ok=True)
else:
uploads_dir = tempfile.mkdtemp(prefix="sglang_input_")
temp_dirs.append(uploads_dir)
# Resolve output directory
effective_output_path = server_args.output_path
output_persistent = True
if "multipart/form-data" not in content_type:
# JSON body may carry a per-request output_path; checked after parsing below
pass
if "multipart/form-data" in content_type:
if not prompt:
raise HTTPException(status_code=400, detail="prompt is required")
video_input_path = None
image_sources = merge_image_input_list(input_reference, reference_url)
if video_reference is not None:
video_input_path = await _save_first_input_image(
video_reference,
request_id,
uploads_dir,
prefer_remote_source=server_args.input_save_path is None,
)
elif video_path or video_url:
video_input_path = video_path or video_url
elif input_reference is not None and _is_probably_video_source(input_reference):
video_input_path = await _save_first_input_image(
input_reference,
request_id,
uploads_dir,
prefer_remote_source=server_args.input_save_path is None,
)
image_sources = merge_image_input_list(reference_url)
elif reference_url and _is_probably_video_source(reference_url):
video_input_path = reference_url
image_sources = merge_image_input_list(input_reference)
# Validate image input based on model task type
if task_type.requires_image_input() and not image_sources:
raise HTTPException(
status_code=400,
detail="input_reference or reference_url is required for image-to-video generation",
)
input_path = None
if image_sources:
try:
input_path = await _save_first_input_image(
image_sources,
request_id,
uploads_dir,
prefer_remote_source=server_args.input_save_path is None,
)
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Failed to process image source: {str(e)}"
)
# Parse extra_body JSON (if provided in multipart form) to get fps/num_frames overrides
extra_from_form: Dict[str, Any] = {}
if extra_body:
try:
extra_from_form = flatten_extra_params(json.loads(extra_body))
except Exception:
extra_from_form = {}
if extra_params:
try:
extra_from_form.update(
flatten_extra_params({"extra_params": json.loads(extra_params)})
)
except Exception:
pass
def form_value(name: str, value: Any) -> Any:
selected = value if value is not None else extra_from_form.get(name)
return _parse_form_extra_value(selected)
raw_form = await request.form()
for key in (
"use_duration_template",
"use_resolution_template",
"use_system_prompt",
"use_guardrails",
"guardrails",
"video_path",
"video_url",
"generate_sound",
"sound_duration",
"condition_frame_indexes",
"action_mode",
"domain_id",
"domain_name",
"raw_action_dim",
"action_fps",
"action",
"action_view_point",
"action_normalization",
"condition_frame_indexes_vision",
"condition_video_keep",
):
if key in raw_form and key not in extra_from_form:
extra_from_form[key] = _parse_form_extra_value(raw_form[key])
flatten_extra_params(extra_from_form)
request_field_names = set(VideoGenerationsRequest.model_fields)
extra_request_fields = {
key: value
for key, value in extra_from_form.items()
if key not in request_field_names
}
fps_val = form_value("fps", fps)
num_frames_val = form_value("num_frames", num_frames)
req = VideoGenerationsRequest(
prompt=prompt,
input_reference=input_path,
video_path=form_value("video_path", video_input_path),
video_url=form_value("video_url", video_url),
model=form_value("model", model),
n=form_value("n", n),
num_outputs_per_prompt=form_value(
"num_outputs_per_prompt", num_outputs_per_prompt
),
seconds=form_value("seconds", seconds) or 4,
size=form_value("size", size),
fps=fps_val,
num_frames=num_frames_val,
seed=form_value("seed", seed),
generator_device=form_value("generator_device", generator_device),
negative_prompt=form_value("negative_prompt", negative_prompt),
num_inference_steps=form_value("num_inference_steps", num_inference_steps),
guidance_scale=form_value("guidance_scale", guidance_scale),
max_sequence_length=form_value("max_sequence_length", max_sequence_length),
flow_shift=form_value("flow_shift", flow_shift),
enable_teacache=form_value("enable_teacache", enable_teacache),
enable_frame_interpolation=form_value(
"enable_frame_interpolation", enable_frame_interpolation
),
frame_interpolation_exp=form_value(
"frame_interpolation_exp", frame_interpolation_exp
),
frame_interpolation_scale=form_value(
"frame_interpolation_scale", frame_interpolation_scale
),
frame_interpolation_model_path=form_value(
"frame_interpolation_model_path", frame_interpolation_model_path
),
enable_upscaling=form_value("enable_upscaling", enable_upscaling),
upscaling_model_path=form_value(
"upscaling_model_path", upscaling_model_path
),
upscaling_scale=form_value("upscaling_scale", upscaling_scale),
output_compression=form_value("output_compression", output_compression),
output_quality=form_value("output_quality", output_quality),
output_path=form_value("output_path", output_path),
diffusers_kwargs=form_value("diffusers_kwargs", None),
**extra_request_fields,
)
else:
try:
body = await request.json()
except Exception:
body = {}
try:
# If client uses extra_body, merge it into the top-level payload
payload: Dict[str, Any] = dict(body or {})
extra = payload.pop("extra_body", None)
if isinstance(extra, str):
extra = json.loads(extra)
if isinstance(extra, dict):
payload.update(flatten_extra_params(extra))
# openai may turn extra_body to extra_json
extra_json = payload.pop("extra_json", None)
if isinstance(extra_json, str):
extra_json = json.loads(extra_json)
if isinstance(extra_json, dict):
payload.update(flatten_extra_params(extra_json))
flatten_extra_params(payload)
# Validate image input based on model task type
if payload.get("video_url") and not payload.get("video_path"):
payload["video_path"] = payload["video_url"]
if _is_probably_video_source(payload.get("reference_url")):
payload.setdefault("video_path", payload.get("reference_url"))
if _is_probably_video_source(payload.get("input_reference")):
payload.setdefault("video_path", payload.get("input_reference"))
has_image_input = (
payload.get("reference_url")
and not _is_probably_video_source(payload.get("reference_url"))
) or (
payload.get("input_reference")
and not _is_probably_video_source(payload.get("input_reference"))
)
if task_type.requires_image_input() and not has_image_input:
raise HTTPException(
status_code=400,
detail="input_reference or reference_url is required for image-to-video generation",
)
# for non-multipart/form-data type
if payload.get("reference_url") and not _is_probably_video_source(
payload.get("reference_url")
):
try:
input_path = await _save_first_input_image(
payload.get("reference_url"),
request_id,
uploads_dir,
prefer_remote_source=server_args.input_save_path is None,
)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Failed to process image source: {str(e)}",
)
payload["input_reference"] = input_path
req = VideoGenerationsRequest(**payload)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid request body: {e}")
# Resolve per-request output_path override
effective_output_path = req.output_path or server_args.output_path
if effective_output_path is None:
output_tmp = tempfile.mkdtemp(prefix="sglang_output_")
temp_dirs.append(output_tmp)
effective_output_path = output_tmp
output_persistent = False
# Inject resolved output_path so _build_video_sampling_params picks it up
req.output_path = effective_output_path
logger.debug(f"Server received from create_video endpoint: req={req}")
try:
sampling_params = _build_video_sampling_params(request_id, req)
except (ValueError, TypeError) as e:
raise HTTPException(status_code=400, detail=str(e))
job = _video_job_from_sampling(request_id, req, sampling_params)
await VIDEO_STORE.upsert(request_id, job)
# Build Req for scheduler
trace_headers = extract_trace_headers(request.headers)
batch = prepare_request(
server_args=server_args,
sampling_params=sampling_params,
external_trace_header=trace_headers,
)
# Add diffusers_kwargs if provided
if req.diffusers_kwargs:
batch.extra["diffusers_kwargs"] = req.diffusers_kwargs
if "max_sequence_length" in req.diffusers_kwargs:
batch.max_sequence_length = req.diffusers_kwargs["max_sequence_length"]
if "flow_shift" in req.diffusers_kwargs:
batch.flow_shift = req.diffusers_kwargs["flow_shift"]
# Enqueue the job asynchronously and return immediately
asyncio.create_task(
_dispatch_job_async(
request_id,
batch,
temp_dirs=temp_dirs or None,
output_persistent=output_persistent,
)
)
return VideoResponse(**job)
@router.get("", response_model=VideoListResponse)
async def list_videos(
after: Optional[str] = Query(None),
limit: Optional[int] = Query(None, ge=1, le=100),
order: Optional[str] = Query("desc"),
):
# Normalize order
order = (order or "desc").lower()
if order not in ("asc", "desc"):
order = "desc"
jobs = await VIDEO_STORE.list_values()
reverse = order != "asc"
jobs.sort(key=lambda j: j.get("created_at", 0), reverse=reverse)
if after is not None:
try:
idx = next(i for i, j in enumerate(jobs) if j["id"] == after)
jobs = jobs[idx + 1 :]
except StopIteration:
jobs = []
if limit is not None:
jobs = jobs[:limit]
items = [VideoResponse(**j) for j in jobs]
return VideoListResponse(data=items)
@router.get("/{video_id}", response_model=VideoResponse)
async def retrieve_video(video_id: str = Path(...)):
job = await VIDEO_STORE.get(video_id)
if not job:
raise HTTPException(status_code=404, detail="Video not found")
return VideoResponse(**job)
# TODO: support aborting a job.
@router.delete("/{video_id}", response_model=VideoResponse)
async def delete_video(video_id: str = Path(...)):
job = await VIDEO_STORE.pop(video_id)
if not job:
raise HTTPException(status_code=404, detail="Video not found")
# Mark as deleted in response semantics
job["status"] = "deleted"
return VideoResponse(**job)
@router.get("/{video_id}/content")
async def download_video_content(
video_id: str = Path(...), variant: Optional[str] = Query(None)
):
job = await VIDEO_STORE.get(video_id)
if not job:
raise HTTPException(status_code=404, detail="Video not found")
if job.get("url"):
raise HTTPException(
status_code=400,
detail=f"Video has been uploaded to cloud storage. Please use the cloud URL: {job.get('url')}",
)
file_path = job.get("file_path")
if not file_path or not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Generation is still in-progress")
media_type = "video/mp4" # default variant
return FileResponse(
path=file_path, media_type=media_type, filename=os.path.basename(file_path)
)
@@ -0,0 +1,110 @@
"""Request/response data structures for post-training APIs."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional
from pydantic import BaseModel
@dataclass
class UpdateWeightFromDiskReqInput:
"""Request to update model weights from disk for diffusion models."""
model_path: str
flush_cache: bool = True
target_modules: list[str] | None = None
@dataclass
class UpdateWeightFromTensorReqInput:
"""Request to update model weights from tensor payloads for diffusion models."""
serialized_named_tensors: list[str | bytes]
load_format: str | None = None
target_modules: list[str] | None = None
@dataclass
class UpdateWeightFromTensorCheckerReqInput:
"""Request to verify live module weights against expected SHA-256 values."""
target_module: str
expected_named_tensors_sha256: dict[str, str]
@dataclass
class GetWeightsChecksumReqInput:
"""Compute SHA-256 checksum of loaded module weights for verification."""
module_names: list[str] | None = None
@dataclass
class ReleaseMemoryOccupationReqInput:
"""Request to release (sleep) GPU memory occupation for the diffusion engine."""
pass
@dataclass
class ResumeMemoryOccupationReqInput:
"""Request to resume (wake) GPU memory occupation for the diffusion engine."""
pass
class RolloutRequest(BaseModel):
prompt: str
negative_prompt: Optional[str] = None
seed: Optional[int] = None
generator_device: str = "cuda"
width: Optional[int] = None
height: Optional[int] = None
num_inference_steps: Optional[int] = None
num_outputs_per_prompt: Optional[int] = None
guidance_scale: Optional[float] = None
true_cfg_scale: Optional[float] = None
# video-specific (ignored by image pipelines)
num_frames: Optional[int] = None
fps: Optional[int] = None
rollout: bool = True
rollout_sde_type: str = "sde"
rollout_noise_level: float = 0.7
rollout_log_prob_no_const: bool = False
rollout_debug_mode: bool = True
rollout_return_denoising_env: bool = False
rollout_return_dit_trajectory: bool = False
# 0-indexed denoising-loop step filters. None = all steps.
rollout_sde_step_indices: Optional[list[int]] = None
rollout_return_step_indices: Optional[list[int]] = None
image_path: Optional[list[str]] = None
# suppress verbose per-request logging (also gates peak_memory_mb collection)
suppress_logs: bool = False
extra_sampling_params: Optional[dict[str, Any]] = None
class RolloutResponse(BaseModel):
request_id: str
prompt: str
seed: int
generated_output: Any = None
rollout_log_probs: Optional[dict[str, Any]] = None
rollout_debug_tensors: Optional[dict[str, Any]] = None
denoising_env: Optional[dict[str, Any]] = None
dit_trajectory: Optional[dict[str, Any]] = None
inference_time_s: Optional[float] = None
peak_memory_mb: Optional[float] = None
@@ -0,0 +1,329 @@
"""Rollout HTTP API (``POST /rollout/generate``)."""
from __future__ import annotations
from typing import Any
import torch
from fastapi import APIRouter, HTTPException
from fastapi.responses import ORJSONResponse
from sglang.multimodal_gen.configs.sample.sampling_params import generate_request_id
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import build_sampling_params
from sglang.multimodal_gen.runtime.entrypoints.post_training.io_struct import (
RolloutRequest,
RolloutResponse,
)
from sglang.multimodal_gen.runtime.entrypoints.post_training.utils import (
_maybe_serialize,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.post_training.rl_dataclasses import (
RolloutDebugTensors,
RolloutDenoisingEnv,
RolloutDitTrajectory,
RolloutTrajectoryData,
)
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
logger = init_logger(__name__)
router = APIRouter(prefix="/rollout", tags=["rollout"])
def _extract_single_sample_tensor(
obj: Any, sample_idx: int, batch_size: int, *, current_key: str | None = None
) -> Any:
if isinstance(obj, torch.Tensor):
if obj.dim() >= 1 and obj.shape[0] == batch_size:
return obj[sample_idx].contiguous()
return obj
if isinstance(obj, dict):
return {
k: _extract_single_sample_tensor(v, sample_idx, batch_size, current_key=k)
for k, v in obj.items()
}
if isinstance(obj, list):
if current_key == "img_shapes" and len(obj) == batch_size:
return [obj[sample_idx]]
return [
_extract_single_sample_tensor(
v, sample_idx, batch_size, current_key=current_key
)
for v in obj
]
if isinstance(obj, tuple):
return tuple(
_extract_single_sample_tensor(
v, sample_idx, batch_size, current_key=current_key
)
for v in obj
)
return obj
def _slice_rollout_trajectory_for_sample(
rtd: RolloutTrajectoryData | None,
sample_idx: int,
batch_size: int,
) -> RolloutTrajectoryData | None:
if rtd is None:
return None
log_probs = rtd.rollout_log_probs
if (
isinstance(log_probs, torch.Tensor)
and log_probs.dim() >= 1
and log_probs.shape[0] == batch_size
):
log_probs = log_probs[sample_idx].contiguous()
debug_tensors = None
if rtd.rollout_debug_tensors:
rd = rtd.rollout_debug_tensors
debug_tensors = RolloutDebugTensors(
rollout_variance_noises=_extract_single_sample_tensor(
rd.rollout_variance_noises, sample_idx, batch_size
),
rollout_prev_sample_means=_extract_single_sample_tensor(
rd.rollout_prev_sample_means, sample_idx, batch_size
),
rollout_noise_std_devs=_extract_single_sample_tensor(
rd.rollout_noise_std_devs, sample_idx, batch_size
),
rollout_model_outputs=_extract_single_sample_tensor(
rd.rollout_model_outputs, sample_idx, batch_size
),
)
denoising_env = None
if rtd.denoising_env:
env = rtd.denoising_env
denoising_env = RolloutDenoisingEnv(
image_kwargs=(
_extract_single_sample_tensor(env.image_kwargs, sample_idx, batch_size)
if env.image_kwargs
else None
),
pos_cond_kwargs=(
_extract_single_sample_tensor(
env.pos_cond_kwargs, sample_idx, batch_size
)
if env.pos_cond_kwargs
else None
),
neg_cond_kwargs=(
_extract_single_sample_tensor(
env.neg_cond_kwargs, sample_idx, batch_size
)
if env.neg_cond_kwargs
else None
),
guidance=(
_extract_single_sample_tensor(env.guidance, sample_idx, batch_size)
if env.guidance is not None
else None
),
)
dit_trajectory = None
if rtd.dit_trajectory:
dit = rtd.dit_trajectory
dit_trajectory = RolloutDitTrajectory(
latents=_extract_single_sample_tensor(dit.latents, sample_idx, batch_size),
timesteps=dit.timesteps,
)
return RolloutTrajectoryData(
rollout_log_probs=log_probs,
rollout_debug_tensors=debug_tensors,
denoising_env=denoising_env,
dit_trajectory=dit_trajectory,
)
def _serialize_rollout_trajectory(
rtd: RolloutTrajectoryData | None,
*,
serialized_dit_timesteps: dict | None = None,
) -> tuple[dict | None, dict | None, dict | None, dict | None]:
"""Return order: rollout_log_probs, rollout_debug_tensors, denoising_env, dit_trajectory."""
if rtd is None:
return None, None, None, None
serialized_log_probs = _maybe_serialize(rtd.rollout_log_probs)
serialized_debug_tensors = None
if rtd.rollout_debug_tensors:
rd = rtd.rollout_debug_tensors
serialized_debug_tensors = {
"rollout_variance_noises": _maybe_serialize(rd.rollout_variance_noises),
"rollout_prev_sample_means": _maybe_serialize(rd.rollout_prev_sample_means),
"rollout_noise_std_devs": _maybe_serialize(rd.rollout_noise_std_devs),
"rollout_model_outputs": _maybe_serialize(rd.rollout_model_outputs),
}
serialized_denoising_env = None
if rtd.denoising_env:
env = rtd.denoising_env
serialized_denoising_env = {
"image_kwargs": (
_maybe_serialize(env.image_kwargs) if env.image_kwargs else None
),
"pos_cond_kwargs": (
_maybe_serialize(env.pos_cond_kwargs) if env.pos_cond_kwargs else None
),
"neg_cond_kwargs": (
_maybe_serialize(env.neg_cond_kwargs) if env.neg_cond_kwargs else None
),
"guidance": (
_maybe_serialize(env.guidance) if env.guidance is not None else None
),
}
serialized_dit_trajectory = None
if rtd.dit_trajectory:
dit = rtd.dit_trajectory
serialized_dit_trajectory = {
"latents": (
_maybe_serialize(dit.latents) if dit.latents is not None else None
),
"timesteps": serialized_dit_timesteps,
}
return (
serialized_log_probs,
serialized_debug_tensors,
serialized_denoising_env,
serialized_dit_trajectory,
)
def _build_response(
request_id: str, prompt: str, seed: int, rollout: bool, result: OutputBatch
) -> list[RolloutResponse]:
"""
rollout: bool - set to False when evaluating the model
"""
batch_size = result.output.shape[0]
inference_time_s = (
result.metrics.total_duration_s
if result.metrics and result.metrics.total_duration_s > 0
else None
)
peak_memory_mb = result.peak_memory_mb if result.peak_memory_mb > 0 else None
rollout_trajectory_data = result.rollout_trajectory_data
if rollout:
assert (
rollout_trajectory_data is not None
), "rollout_trajectory_data must be present when rollout=True"
serialized_dit_timesteps = None
if rollout and rollout_trajectory_data and rollout_trajectory_data.dit_trajectory:
serialized_dit_timesteps = _maybe_serialize(
rollout_trajectory_data.dit_trajectory.timesteps
)
responses: list[RolloutResponse] = []
for sample_idx in range(batch_size):
out_i = result.output[sample_idx]
if isinstance(out_i, torch.Tensor):
out_i = out_i.contiguous()
serialized_generated_output = _maybe_serialize(out_i)
if not rollout:
responses.append(
RolloutResponse(
request_id=request_id,
prompt=prompt,
seed=seed,
generated_output=serialized_generated_output,
inference_time_s=inference_time_s,
peak_memory_mb=peak_memory_mb,
)
)
continue
per_sample_trajectory = _slice_rollout_trajectory_for_sample(
result.rollout_trajectory_data, sample_idx, batch_size
)
(
serialized_log_probs,
serialized_debug_tensors,
serialized_denoising_env,
serialized_dit_trajectory,
) = _serialize_rollout_trajectory(
per_sample_trajectory,
serialized_dit_timesteps=serialized_dit_timesteps,
)
responses.append(
RolloutResponse(
request_id=request_id,
prompt=prompt,
seed=seed,
generated_output=serialized_generated_output,
rollout_log_probs=serialized_log_probs,
rollout_debug_tensors=serialized_debug_tensors,
denoising_env=serialized_denoising_env,
dit_trajectory=serialized_dit_trajectory,
inference_time_s=inference_time_s,
peak_memory_mb=peak_memory_mb,
)
)
return responses
def _build_sampling_kwargs(request: RolloutRequest) -> dict:
sampling_kwargs: dict = dict(
prompt=request.prompt,
negative_prompt=request.negative_prompt,
seed=request.seed,
generator_device=request.generator_device,
width=request.width,
height=request.height,
num_inference_steps=request.num_inference_steps,
num_outputs_per_prompt=request.num_outputs_per_prompt,
guidance_scale=request.guidance_scale,
true_cfg_scale=request.true_cfg_scale,
num_frames=request.num_frames,
fps=request.fps,
image_path=request.image_path,
rollout=request.rollout,
rollout_sde_type=request.rollout_sde_type,
rollout_noise_level=request.rollout_noise_level,
rollout_log_prob_no_const=request.rollout_log_prob_no_const,
rollout_debug_mode=request.rollout_debug_mode,
rollout_return_denoising_env=request.rollout_return_denoising_env,
rollout_return_dit_trajectory=request.rollout_return_dit_trajectory,
rollout_sde_step_indices=request.rollout_sde_step_indices,
rollout_return_step_indices=request.rollout_return_step_indices,
suppress_logs=request.suppress_logs,
save_output=False,
return_trajectory_latents=False,
return_trajectory_decoded=False,
)
if request.extra_sampling_params:
sampling_kwargs.update(request.extra_sampling_params)
sampling_kwargs["rollout"] = request.rollout
return {k: v for k, v in sampling_kwargs.items() if v is not None}
@router.post("/generate", response_model=list[RolloutResponse])
async def rollout_generate(request: RolloutRequest):
request_id = generate_request_id()
server_args = get_global_server_args()
sampling_kwargs = _build_sampling_kwargs(request)
try:
sampling_params = build_sampling_params(request_id, **sampling_kwargs)
except Exception as exc:
raise HTTPException(
status_code=400, detail=f"Invalid sampling params: {exc}"
) from exc
pipeline_request = prepare_request(
server_args=server_args, sampling_params=sampling_params
)
try:
output_batch: OutputBatch = await async_scheduler_client.forward(
pipeline_request
)
except Exception as exc:
logger.error("Rollout generation failed: %s", exc, exc_info=True)
raise HTTPException(
status_code=500, detail=f"Generation failed: {exc}"
) from exc
if output_batch.error:
raise HTTPException(status_code=500, detail=output_batch.error)
rollout_responses = _build_response(
request_id, request.prompt, request.seed, request.rollout, output_batch
)
return ORJSONResponse(content=[r.model_dump() for r in rollout_responses])
@@ -0,0 +1,48 @@
"""Tensor serialization for post-training / rollout HTTP responses."""
from __future__ import annotations
import base64
from typing import Any
import numpy as np
import torch
from safetensors.torch import load, save
def tensor_to_base64(t: torch.Tensor) -> str:
t = t.detach().contiguous().cpu()
raw = save({"t": t})
return base64.b64encode(raw).decode("ascii")
def base64_to_tensor(s: str) -> torch.Tensor:
raw = base64.b64decode(s)
return load(raw)["t"]
def _maybe_serialize(obj: Any) -> Any:
if isinstance(obj, torch.Tensor):
return {
"__tensor__": True,
"data": tensor_to_base64(obj),
"shape": list(obj.shape),
"dtype": str(obj.dtype),
}
if isinstance(obj, np.ndarray):
return _maybe_serialize(torch.from_numpy(obj))
if isinstance(obj, dict):
return {k: _maybe_serialize(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_maybe_serialize(v) for v in obj]
return obj
def _maybe_deserialize(obj: Any) -> Any:
if isinstance(obj, dict):
if obj.get("__tensor__"):
return base64_to_tensor(obj["data"])
return {k: _maybe_deserialize(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_maybe_deserialize(v) for v in obj]
return obj
@@ -0,0 +1,199 @@
"""Weight update API for the diffusion engine."""
from fastapi import APIRouter, Request
from sglang.multimodal_gen.runtime.entrypoints.post_training.io_struct import (
GetWeightsChecksumReqInput,
ReleaseMemoryOccupationReqInput,
ResumeMemoryOccupationReqInput,
UpdateWeightFromDiskReqInput,
UpdateWeightFromTensorCheckerReqInput,
UpdateWeightFromTensorReqInput,
)
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.srt.utils.json_response import orjson_response
router = APIRouter()
@router.post("/update_weights_from_disk")
async def update_weights_from_disk(request: Request):
"""Update model weights from disk inplace without restarting the server."""
body = await request.json()
model_path = body.get("model_path")
if not model_path:
return orjson_response(
{"success": False, "message": "model_path is required"},
status_code=400,
)
req = UpdateWeightFromDiskReqInput(
model_path=model_path,
flush_cache=body.get("flush_cache", True),
target_modules=body.get("target_modules"),
)
try:
response = await async_scheduler_client.forward(req)
except Exception as e:
return orjson_response(
{"success": False, "message": str(e)},
status_code=500,
)
if response.output is None:
return orjson_response(
{
"success": False,
"message": response.error or "Unknown status",
},
status_code=500,
)
result = response.output
return orjson_response(
result,
status_code=200 if result["success"] else 400,
)
@router.post("/update_weights_from_tensor")
async def update_weights_from_tensor(request: Request):
"""Update model weights from serialized tensor payloads."""
body = await request.json()
serialized_named_tensors = body.get("serialized_named_tensors")
if not serialized_named_tensors:
return orjson_response(
{"success": False, "message": "serialized_named_tensors is required"},
status_code=400,
)
req = UpdateWeightFromTensorReqInput(
serialized_named_tensors=serialized_named_tensors,
load_format=body.get("load_format"),
target_modules=body.get("target_modules"),
)
try:
response = await async_scheduler_client.forward(req)
except Exception as e:
return orjson_response(
{"success": False, "message": str(e)},
status_code=500,
)
result = response.output
return orjson_response(
result,
status_code=200 if result["success"] else 400,
)
@router.post("/update_weights_from_tensor_checker")
async def update_weights_from_tensor_checker(request: Request):
"""Verify live module weights against expected SHA-256 values."""
body = await request.json()
target_module = body.get("target_module")
if not target_module:
return orjson_response(
{"success": False, "message": "target_module is required"},
status_code=400,
)
expected_named_tensors_sha256 = body.get("expected_named_tensors_sha256")
if (
not isinstance(expected_named_tensors_sha256, dict)
or not expected_named_tensors_sha256
):
return orjson_response(
{
"success": False,
"message": "expected_named_tensors_sha256 is required",
},
status_code=400,
)
req = UpdateWeightFromTensorCheckerReqInput(
target_module=target_module,
expected_named_tensors_sha256=expected_named_tensors_sha256,
)
try:
response = await async_scheduler_client.forward(req)
except Exception as e:
return orjson_response(
{"success": False, "message": str(e)},
status_code=500,
)
result = response.output
success = result.get("success", False)
message = result.get("message", "Unknown status")
return orjson_response(
{"success": success, "message": message},
status_code=200 if success else 400,
)
@router.post("/get_weights_checksum")
async def get_weights_checksum(request: Request):
"""Return SHA-256 checksum of each requested module's weights."""
body = await request.json()
req = GetWeightsChecksumReqInput(
module_names=body.get("module_names"),
)
try:
response = await async_scheduler_client.forward(req)
except Exception as e:
return orjson_response({"error": str(e)}, status_code=500)
return orjson_response(response.output, status_code=200)
@router.post("/release_memory_occupation")
async def release_memory_occupation():
"""Release GPU memory occupation (sleep the engine)."""
try:
response = await async_scheduler_client.forward(
ReleaseMemoryOccupationReqInput()
)
except Exception as e:
return orjson_response({"success": False, "message": str(e)}, status_code=500)
if response.output is None:
return orjson_response(
{
"success": False,
"message": response.error or "Unknown status",
},
status_code=500,
)
payload = response.output
success = bool(payload["success"])
return orjson_response(payload, status_code=200 if success else 400)
@router.post("/resume_memory_occupation")
async def resume_memory_occupation():
"""Resume GPU memory occupation (wake the engine)."""
try:
response = await async_scheduler_client.forward(
ResumeMemoryOccupationReqInput()
)
except Exception as e:
return orjson_response({"success": False, "message": str(e)}, status_code=500)
if response.output is None:
return orjson_response(
{
"success": False,
"message": response.error or "Unknown status",
},
status_code=500,
)
payload = response.output
success = bool(payload["success"])
return orjson_response(payload, status_code=200 if success else 400)
@@ -0,0 +1,755 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
"""
DiffGenerator module for sglang-diffusion.
This module provides a consolidated interface for generating videos using
diffusion models.
"""
import json
import os
import shutil
import subprocess
import tempfile
from copy import copy
from dataclasses import dataclass, field
from typing import Any, Callable, List, Optional, Sequence, Union
import imageio
import numpy as np
import torch
from PIL import Image
try:
import scipy.io.wavfile as scipy_wavfile
except ImportError: # pragma: no cover
scipy_wavfile = None
try:
import imageio_ffmpeg as _imageio_ffmpeg
except ImportError: # pragma: no cover
_imageio_ffmpeg = None
from sglang.multimodal_gen.configs.sample.sampling_params import (
DataType,
SamplingParams,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import CYAN, RESET, init_logger
from sglang.srt.observability.trace import TraceReqContext
logger = init_logger(__name__)
@dataclass
class SetLoraReq:
lora_nickname: Union[str, List[str]]
lora_path: Optional[Union[str, List[Optional[str]]]] = None
target: Union[str, List[str]] = "all"
strength: Union[float, List[float]] = 1.0
merge_mode: Optional[str] = None
@dataclass
class MergeLoraWeightsReq:
target: str = "all"
strength: float = 1.0
@dataclass
class UnmergeLoraWeightsReq:
target: str = "all"
@dataclass
class ListLorasReq:
pass
@dataclass
class ShutdownReq:
pass
@dataclass
class ReleaseRealtimeSessionReq:
session_id: str
@dataclass
class GetDisaggStatsReq:
"""Request to get disagg pipeline metrics from the scheduler."""
pass
def format_lora_message(
lora_nickname: Union[str, List[str]],
target: Union[str, List[str]],
strength: Union[float, List[float]],
) -> tuple[str, str, str]:
"""Format success message for single or multiple LoRAs."""
if isinstance(lora_nickname, list):
nickname_str = ", ".join(lora_nickname)
target_str = ", ".join(target) if isinstance(target, list) else target
strength_str = (
", ".join(f"{s:.2f}" for s in strength)
if isinstance(strength, list)
else f"{strength:.2f}"
)
else:
nickname_str = lora_nickname
target_str = target if isinstance(target, str) else ", ".join(target)
strength_str = (
f"{strength:.2f}"
if isinstance(strength, (int, float))
else ", ".join(f"{s:.2f}" for s in strength)
)
return nickname_str, target_str, strength_str
@dataclass
class GenerationResult:
"""Result of a single generation request from DiffGenerator."""
samples: Any = None
frames: Any = None
audio: Any = None
action: Any = None # [T, raw_action_dim] predicted action (policy/inverse_dynamics)
prompt: str | None = None
size: tuple | None = None # (height, width, num_frames)
generation_time: float = 0.0
peak_memory_mb: float = 0.0
metrics: dict = field(default_factory=dict)
trajectory_latents: Any = None
trajectory_timesteps: Any = None
rollout_trajectory_data: Any = None
trajectory_decoded: Any = None
prompt_index: int = 0
output_file_path: str | None = None
@dataclass
class MaterializedOutput:
sample: Any
frames: list[Any]
audio: Any = None
fps: int = 0
def normalize_output_seeds(
seed: int | list[int],
*,
num_outputs_per_prompt: int,
num_prompts: int = 1,
prompt_index: int = 0,
) -> list[int]:
"""
return a list of seed with size equal to `num_outputs_per_prompt`
"""
if num_outputs_per_prompt <= 0:
raise ValueError(
f"num_outputs_per_prompt must be positive, got {num_outputs_per_prompt}"
)
if isinstance(seed, list):
seeds = [int(item) for item in seed]
total_outputs = num_outputs_per_prompt * num_prompts
if len(seeds) == num_outputs_per_prompt:
return seeds
if len(seeds) == total_outputs:
start = prompt_index * num_outputs_per_prompt
return seeds[start : start + num_outputs_per_prompt]
raise ValueError(
"seed list length must match num_outputs_per_prompt "
f"({num_outputs_per_prompt}) or total outputs ({total_outputs}), "
f"got {len(seeds)}"
)
base_seed = int(seed)
return [base_seed + i for i in range(num_outputs_per_prompt)]
def _with_output_index_suffix(output_file_name: str, output_index: int) -> str:
base, ext = os.path.splitext(output_file_name)
return f"{base}_{output_index}{ext}"
def _copy_trace_ctx_for_output(req: Req, request_id: str | None, output_index: int):
trace_ctx = req.trace_ctx
if output_index == 0 or not trace_ctx.tracing_enable:
return trace_ctx
output_trace_ctx = TraceReqContext(
rid=request_id,
module_name=trace_ctx.module_name,
external_trace_header=trace_ctx.external_trace_header,
)
output_trace_ctx.trace_req_start()
return output_trace_ctx
def _copy_req_for_output(
req: Req,
*,
request_id: str | None,
output_index: int,
) -> Req:
"""Create a lightweight per-output ``Req`` without deep-copying tensors."""
output_req = copy(req)
output_req.sampling_params = copy(req.sampling_params)
output_req.extra = dict(req.extra)
output_req.condition_inputs = dict(req.condition_inputs)
output_req.trace_ctx = _copy_trace_ctx_for_output(req, request_id, output_index)
return output_req
def expand_request_outputs(
req: Req,
*,
num_prompts: int = 1,
prompt_index: int = 0,
) -> list[Req]:
"""
Expand a req to a list with size equal to `num_prompts`
"""
num_outputs = int(req.num_outputs_per_prompt)
# each req must has different seed
seeds = normalize_output_seeds(
req.seed,
num_outputs_per_prompt=num_outputs,
num_prompts=num_prompts,
prompt_index=prompt_index,
)
if num_outputs == 1:
req.seed = seeds[0]
req.seeds = None
req.generator = None
return [req]
expanded: list[Req] = []
for output_index, seed in enumerate(seeds):
output_request_id = (
f"{req.request_id}:{output_index}" if req.request_id is not None else None
)
output_req = _copy_req_for_output(
req, request_id=output_request_id, output_index=output_index
)
output_req.seed = seed
output_req.num_outputs_per_prompt = 1
output_req.seeds = None
output_req.generator = None
output_req.extra["parent_request_id"] = req.request_id
output_req.extra["output_index"] = output_index
if output_request_id is not None:
output_req.request_id = output_request_id
if req.output_file_name:
output_req.output_file_name = _with_output_index_suffix(
req.output_file_name, output_index
)
output_req.validate()
expanded.append(output_req)
return expanded
def _normalize_audio_to_numpy(audio: Any) -> np.ndarray | None:
"""Convert audio (torch / numpy) into a float32 numpy array in [-1, 1], best-effort."""
if audio is None:
return None
if isinstance(audio, torch.Tensor):
audio_np = audio.detach().float().clamp(-1.0, 1.0).cpu().numpy()
elif isinstance(audio, np.ndarray):
audio_np = audio.astype(np.float32, copy=False)
audio_np = np.clip(audio_np, -1.0, 1.0)
else:
return None
# 1. Squeeze leading singleton dimensions (Batch, etc.)
while audio_np.ndim > 1 and audio_np.shape[0] == 1:
audio_np = audio_np.squeeze(0)
# 2. Handle (C, L) -> (L, C)
if audio_np.ndim == 2 and audio_np.shape[0] < audio_np.shape[1]:
audio_np = audio_np.transpose(1, 0)
# 3. Final safety check: if still 2D and channels (dim 1) is huge, something is wrong
if audio_np.ndim == 2 and audio_np.shape[1] > 256 and audio_np.shape[0] == 1:
audio_np = audio_np.flatten()
return audio_np
def _pick_audio_sample_rate(
*,
audio_np: np.ndarray,
audio_sample_rate: Optional[int],
fps: int,
num_frames: int,
) -> int:
"""Pick a plausible sample rate, falling back to inferring from video duration."""
selected_sr = int(audio_sample_rate) if audio_sample_rate is not None else None
if selected_sr is None or not (8000 <= selected_sr <= 192000):
selected_sr = 24000
try:
duration_s = float(num_frames) / float(fps) if fps else 0.0
if duration_s > 0:
audio_len = (
int(audio_np.shape[0])
if audio_np.ndim == 2
else int(audio_np.shape[-1])
)
inferred_sr = int(round(float(audio_len) / duration_s))
if 8000 <= inferred_sr <= 192000:
selected_sr = inferred_sr
except Exception:
pass
return selected_sr
def _resolve_ffmpeg_exe() -> str:
ffmpeg_exe = "ffmpeg"
ffmpeg_on_path = shutil.which("ffmpeg")
if ffmpeg_on_path:
ffmpeg_exe = ffmpeg_on_path
try:
if _imageio_ffmpeg is not None:
ffmpeg_exe = _imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
pass
ffmpeg_ok = False
if ffmpeg_exe:
if os.path.isabs(ffmpeg_exe):
ffmpeg_ok = os.path.exists(ffmpeg_exe)
else:
ffmpeg_ok = shutil.which(ffmpeg_exe) is not None
if not ffmpeg_ok:
raise RuntimeError("ffmpeg not found")
return ffmpeg_exe
def _mux_audio_np_into_mp4(
*,
save_file_path: str,
audio_np: np.ndarray,
sample_rate: int,
ffmpeg_exe: str,
) -> None:
merged_path = save_file_path.rsplit(".", 1)[0] + ".tmp_mux.mp4"
tmp_wav_path = None
try:
if scipy_wavfile is None:
raise RuntimeError(
"scipy is required to mux audio into mp4 (pip install scipy)"
)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
tmp_wav_path = f.name
scipy_wavfile.write(tmp_wav_path, sample_rate, audio_np)
subprocess.run(
[
ffmpeg_exe,
"-y",
"-i",
save_file_path,
"-i",
tmp_wav_path,
"-c:v",
"copy",
"-c:a",
"aac",
"-strict",
"experimental",
merged_path,
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
os.replace(merged_path, save_file_path)
finally:
if tmp_wav_path:
try:
os.remove(tmp_wav_path)
except OSError:
pass
if os.path.exists(merged_path):
try:
os.remove(merged_path)
except OSError:
pass
def _maybe_mux_audio_into_mp4(
*,
save_file_path: str,
audio: Any,
frames: list,
fps: int,
audio_sample_rate: Optional[int],
) -> None:
"""Best-effort mux audio into an already-written mp4 at save_file_path.
Any failure should keep the silent video and only log a warning.
"""
audio_np = _normalize_audio_to_numpy(audio)
if audio_np is None:
return
selected_sr = _pick_audio_sample_rate(
audio_np=audio_np,
audio_sample_rate=audio_sample_rate,
fps=fps,
num_frames=len(frames),
)
try:
ffmpeg_exe = _resolve_ffmpeg_exe()
_mux_audio_np_into_mp4(
save_file_path=save_file_path,
audio_np=audio_np,
sample_rate=selected_sr,
ffmpeg_exe=ffmpeg_exe,
)
except Exception as e:
logger.warning(
"Failed to mux audio into mp4 (saved silent video): %s",
str(e),
)
def prepare_request(
server_args: ServerArgs,
sampling_params: SamplingParams,
external_trace_header: dict[str, str] | None = None,
) -> Req:
"""
Create a Req object with sampling_params as a parameter.
"""
req = Req(
sampling_params=sampling_params,
VSA_sparsity=server_args.attention_backend_config.VSA_sparsity,
)
sampling_params.apply_request_extra(req)
if getattr(sampling_params, "max_sequence_length", None) is not None:
req.max_sequence_length = sampling_params.max_sequence_length
diffusers_kwargs = getattr(sampling_params, "diffusers_kwargs", None)
if diffusers_kwargs and "max_sequence_length" in diffusers_kwargs:
req.max_sequence_length = diffusers_kwargs["max_sequence_length"]
if not isinstance(req.prompt, str):
raise TypeError(f"`prompt` must be a string, but got {type(req.prompt)}")
req_width = getattr(req, "width", None)
req_height = getattr(req, "height", None)
if (req_width is not None and req_width <= 0) or (
req_height is not None and req_height <= 0
):
raise ValueError(
f"Height and width must be positive, got height={req_height}, width={req_width}"
)
if server_args.enable_trace:
trace_ctx = TraceReqContext(
rid=sampling_params.request_id,
module_name="diffusion",
external_trace_header=external_trace_header,
)
trace_ctx.trace_req_start()
req.trace_ctx = trace_ctx
return req
def attach_audio_to_video_sample(
sample: Any,
audio: Any,
output_idx: int,
) -> Any:
"""Attach per-sample audio for video outputs when available."""
audio = select_output_audio(audio, output_idx)
if audio is None:
return sample
if not (isinstance(sample, (tuple, list)) and len(sample) == 2):
return (sample, audio)
return sample
def select_output_audio(audio: Any, output_idx: int) -> Any:
if isinstance(audio, torch.Tensor) and audio.ndim >= 2:
return audio[output_idx] if audio.shape[0] > output_idx else None
if isinstance(audio, np.ndarray) and audio.ndim >= 2:
return audio[output_idx] if audio.shape[0] > output_idx else None
return audio
def _split_sample_audio(sample: Any) -> tuple[Any, Any]:
if isinstance(sample, (tuple, list)) and len(sample) == 2:
return sample[0], sample[1]
return sample, None
def _sample_to_uint8_frames(sample: Any) -> list[Any]:
"""return numpy frames in THCW format"""
if isinstance(sample, torch.Tensor):
# sample is raw tensor
if sample.dim() == 3:
sample = sample.unsqueeze(1)
sample = (sample * 255).clamp(0, 255).to(torch.uint8)
videos = sample.permute(1, 2, 3, 0).contiguous().cpu().numpy()
return list(videos)
if not isinstance(sample, np.ndarray):
raise TypeError(f"Unsupported sample type: {type(sample)}")
# sample is numpy frames
arr = sample
if arr.ndim == 3:
if arr.shape[-1] in (1, 3, 4):
arr = arr[None, ...]
else:
arr = arr[..., None]
if arr.ndim != 4:
raise ValueError(f"Unexpected numpy sample shape: {tuple(arr.shape)}")
if arr.shape[-1] not in (1, 3, 4) and arr.shape[0] in (1, 3, 4):
t = torch.from_numpy(arr)
if t.dim() == 3:
t = t.unsqueeze(1)
t = (t * 255).clamp(0, 255).to(torch.uint8)
videos = t.permute(1, 2, 3, 0).contiguous().cpu().numpy()
return list(videos)
if arr.dtype != np.uint8:
arr = (np.clip(arr, 0.0, 1.0) * 255.0).astype(np.uint8)
return list(arr)
def materialize_output_sample(
sample: Any,
data_type: DataType,
fps: int,
*,
enable_frame_interpolation: bool = False,
frame_interpolation_exp: int = 1,
frame_interpolation_scale: float = 1.0,
frame_interpolation_model_path: Optional[str] = None,
enable_upscaling: bool = False,
upscaling_model_path: Optional[str] = None,
upscaling_scale: int = 4,
) -> MaterializedOutput:
"""materialize samples, apply postprocessing if applicable"""
sample_without_audio, audio = _split_sample_audio(sample)
frames = _sample_to_uint8_frames(sample_without_audio)
# frames are uint8 numpy arrays in THWC format at this point
if enable_frame_interpolation and data_type == DataType.VIDEO and len(frames) > 1:
from sglang.multimodal_gen.runtime.postprocess import (
interpolate_video_frames,
)
frames, multiplier = interpolate_video_frames(
frames,
exp=frame_interpolation_exp,
scale=frame_interpolation_scale,
model_path=frame_interpolation_model_path,
)
fps = fps * multiplier
if enable_upscaling and frames:
from sglang.multimodal_gen.runtime.postprocess import upscale_frames
frames = upscale_frames(
frames,
model_path=upscaling_model_path,
scale=upscaling_scale,
)
return MaterializedOutput(sample=sample, frames=frames, audio=audio, fps=fps)
def save_materialized_output(
materialized: MaterializedOutput,
data_type: DataType,
save_file_path: Optional[str],
*,
save_output: bool = True,
audio_sample_rate: Optional[int] = None,
output_compression: Optional[int] = None,
) -> None:
if not save_output:
return
if not save_file_path:
logger.info("No output path provided, output not saved")
return
os.makedirs(os.path.dirname(save_file_path), exist_ok=True)
if data_type == DataType.VIDEO:
quality = output_compression / 10 if output_compression is not None else 5
imageio.mimsave(
save_file_path,
materialized.frames,
fps=materialized.fps,
format=data_type.get_default_extension(),
codec="libx264",
quality=quality,
)
_maybe_mux_audio_into_mp4(
save_file_path=save_file_path,
audio=materialized.audio,
frames=materialized.frames,
fps=materialized.fps,
audio_sample_rate=audio_sample_rate,
)
else:
quality = output_compression if output_compression is not None else 75
if len(materialized.frames) > 1:
for i, image in enumerate(materialized.frames):
parts = save_file_path.rsplit(".", 1)
if len(parts) == 2:
indexed_path = f"{parts[0]}_{i}.{parts[1]}"
else:
indexed_path = f"{save_file_path}_{i}"
_save_image_frame(indexed_path, image, quality, output_compression)
else:
_save_image_frame(
save_file_path, materialized.frames[0], quality, output_compression
)
logger.info(f"Output saved to {CYAN}{save_file_path}{RESET}")
def _save_image_frame(
path: str, frame: np.ndarray, quality: int | None, output_compression: int | None
) -> None:
ext = os.path.splitext(path)[1].lower()
if ext == ".png":
compress_level = 1
if output_compression is not None and output_compression != 75:
compress_level = max(0, min(9, round(output_compression / 100 * 9)))
if frame.ndim == 3 and frame.shape[-1] == 1:
frame = frame[..., 0]
Image.fromarray(frame).save(path, format="PNG", compress_level=compress_level)
else:
imageio.imwrite(path, frame, quality=quality)
def save_outputs(
outputs: Sequence[Any],
data_type: DataType,
fps: int,
save_output: bool,
build_output_path: Callable[[int], str],
*,
audio: Any = None,
audio_sample_rate: Optional[int] = None,
samples_out: Optional[list[Any]] = None,
audios_out: Optional[list[Any]] = None,
frames_out: Optional[list[Any]] = None,
output_compression: Optional[int] = None,
enable_frame_interpolation: bool = False,
frame_interpolation_exp: int = 1,
frame_interpolation_scale: float = 1.0,
frame_interpolation_model_path: Optional[str] = None,
enable_upscaling: bool = False,
upscaling_model_path: Optional[str] = None,
upscaling_scale: int = 4,
) -> list[str]:
output_paths: list[str] = []
for idx, sample in enumerate(outputs):
save_file_path = build_output_path(idx)
if data_type == DataType.ACTION:
if samples_out is not None:
samples_out.append(sample)
if audios_out is not None:
audios_out.append(None)
if frames_out is not None:
frames_out.append([])
if save_output and save_file_path:
os.makedirs(os.path.dirname(save_file_path) or ".", exist_ok=True)
with open(save_file_path, "w", encoding="utf-8") as f:
json.dump(sample, f, ensure_ascii=False)
logger.info(f"Output saved to {CYAN}{save_file_path}{RESET}")
output_paths.append(save_file_path)
continue
if data_type == DataType.VIDEO:
sample = attach_audio_to_video_sample(sample, audio, idx)
frames = post_process_sample(
sample,
data_type,
fps,
save_output,
save_file_path,
audio_sample_rate=audio_sample_rate,
output_compression=output_compression,
enable_frame_interpolation=enable_frame_interpolation,
frame_interpolation_exp=frame_interpolation_exp,
frame_interpolation_scale=frame_interpolation_scale,
frame_interpolation_model_path=frame_interpolation_model_path,
enable_upscaling=enable_upscaling,
upscaling_model_path=upscaling_model_path,
upscaling_scale=upscaling_scale,
)
if samples_out is not None:
samples_out.append(sample)
if audios_out is not None:
if data_type == DataType.VIDEO:
audios_out.append(select_output_audio(audio, idx))
else:
audios_out.append(audio)
if frames_out is not None:
frames_out.append(frames)
output_paths.append(save_file_path)
return output_paths
def post_process_sample(
sample: Any,
data_type: DataType,
fps: int,
save_output: bool = True,
save_file_path: Optional[str] = None,
audio_sample_rate: Optional[int] = None,
output_compression: Optional[int] = None,
enable_frame_interpolation: bool = False,
frame_interpolation_exp: int = 1,
frame_interpolation_scale: float = 1.0,
frame_interpolation_model_path: Optional[str] = None,
enable_upscaling: bool = False,
upscaling_model_path: Optional[str] = None,
upscaling_scale: int = 4,
) -> list[Any]:
"""materialize frames and save outputs (optional)"""
if data_type == DataType.ACTION:
return []
materialized = materialize_output_sample(
sample,
data_type,
fps,
enable_frame_interpolation=enable_frame_interpolation,
frame_interpolation_exp=frame_interpolation_exp,
frame_interpolation_scale=frame_interpolation_scale,
frame_interpolation_model_path=frame_interpolation_model_path,
enable_upscaling=enable_upscaling,
upscaling_model_path=upscaling_model_path,
upscaling_scale=upscaling_scale,
)
save_materialized_output(
materialized,
data_type,
save_file_path,
save_output=save_output,
audio_sample_rate=audio_sample_rate,
output_compression=output_compression,
)
return materialized.frames
@@ -0,0 +1 @@
# SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
from sglang.multimodal_gen.runtime.entrypoints.vla.protocol import (
action_generation_response,
action_metadata,
action_raw_response,
infer_action,
pack_msgpack,
unpack_msgpack,
)
from sglang.multimodal_gen.runtime.entrypoints.vla.ws_utils import (
run_action_msgpack_ws,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.srt.utils.json_response import orjson_response
router = APIRouter(prefix="/v1/actions", tags=["actions"])
def _wants_msgpack(request: Request) -> bool:
content_type = request.headers.get("content-type", "").lower()
accept = request.headers.get("accept", "").lower()
return "msgpack" in content_type or "msgpack" in accept
def _response_format(payload: dict) -> str:
runtime = payload.get("runtime") or {}
response_format = str(runtime.get("response_format", "envelope")).lower()
if response_format not in ("envelope", "raw"):
raise ValueError("runtime.response_format must be 'envelope' or 'raw'")
return response_format
def _prefer_numpy_output(payload: dict) -> None:
runtime = payload.setdefault("runtime", {})
runtime.setdefault("output_format", "numpy")
@router.post("/generations")
async def create_action_generation(request: Request):
server_args: ServerArgs = request.app.state.server_args
try:
if "msgpack" in request.headers.get("content-type", "").lower():
payload = unpack_msgpack(await request.body())
else:
payload = await request.json()
wants_msgpack = _wants_msgpack(request)
if wants_msgpack:
_prefer_numpy_output(payload)
output = await infer_action(payload, server_args)
if _response_format(payload) == "raw":
response = action_raw_response(output, preserve_numpy=wants_msgpack)
else:
response = action_generation_response(
output,
server_args,
preserve_numpy=wants_msgpack,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if wants_msgpack:
return Response(
content=pack_msgpack(response), media_type="application/msgpack"
)
return orjson_response(response)
@router.get("/metadata")
async def action_metadata_endpoint(request: Request):
return orjson_response(action_metadata(request.app.state.server_args))
@router.websocket("/realtime")
async def action_realtime_ws(websocket: WebSocket):
server_args: ServerArgs = websocket.app.state.server_args
await run_action_msgpack_ws(
websocket,
server_args,
prepare_payload=_prefer_numpy_output,
build_response=lambda output: action_generation_response(
output,
server_args,
preserve_numpy=True,
),
)
@@ -0,0 +1,29 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, WebSocket
from sglang.multimodal_gen.runtime.entrypoints.vla.ws_utils import (
run_action_msgpack_ws,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
router = APIRouter()
def _prefer_numpy_output(observation: dict[str, Any]) -> None:
observation.setdefault("output_format", "numpy")
@router.websocket("/openpi/policy")
async def openpi_policy_ws(websocket: WebSocket):
server_args: ServerArgs = websocket.app.state.server_args
await run_action_msgpack_ws(
websocket,
server_args,
prepare_payload=_prefer_numpy_output,
build_response=lambda output: output,
)
@@ -0,0 +1,443 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import base64
import dataclasses
import io
import time
import uuid
from functools import lru_cache
from typing import Any
import numpy as np
from PIL import Image
from sglang.multimodal_gen.configs.sample.vla import VLASamplingParams
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import ServerArgs
def pack_numpy_payload(obj):
if isinstance(obj, (np.ndarray, np.generic)) and obj.dtype.kind in ("V", "O", "c"):
raise ValueError(f"Unsupported dtype: {obj.dtype}")
if isinstance(obj, np.ndarray):
return {
b"__ndarray__": True,
b"data": obj.tobytes(),
b"dtype": obj.dtype.str,
b"shape": obj.shape,
}
if isinstance(obj, np.generic):
return {
b"__npgeneric__": True,
b"data": obj.item(),
b"dtype": obj.dtype.str,
}
return obj
def unpack_numpy_payload(obj):
ndarray_marker = obj.get("__ndarray__") or obj.get(b"__ndarray__")
npgeneric_marker = obj.get("__npgeneric__") or obj.get(b"__npgeneric__")
data = obj.get("data", obj.get(b"data"))
dtype = obj.get("dtype", obj.get(b"dtype"))
shape = obj.get("shape", obj.get(b"shape"))
if ndarray_marker:
return np.ndarray(
buffer=data,
dtype=np.dtype(dtype),
shape=shape,
)
if npgeneric_marker:
return np.dtype(dtype).type(data)
return obj
def pack_msgpack(payload: Any) -> bytes:
import msgpack
return msgpack.packb(payload, default=pack_numpy_payload, use_bin_type=True)
def unpack_msgpack(payload: bytes) -> Any:
import msgpack
return msgpack.unpackb(payload, object_hook=unpack_numpy_payload, raw=False)
def _decode_b64_image(payload: dict[str, Any]) -> Image.Image:
data = payload.get("b64_json") or payload.get("base64")
if not data:
raise ValueError("image payload requires b64_json")
if isinstance(data, str) and "," in data and data.startswith("data:"):
data = data.split(",", 1)[1]
return Image.open(io.BytesIO(base64.b64decode(data))).convert("RGB")
def _decode_tensor_payload(payload: dict[str, Any]) -> Any:
values = payload.get("values")
if values is None:
values = payload.get("data")
if values is None:
return payload
dtype = payload.get("dtype")
array = np.asarray(values, dtype=np.dtype(dtype) if dtype else None)
shape = payload.get("shape")
if shape is not None:
array = array.reshape(tuple(shape))
return array
def _normalize_image_value(value: Any) -> Any:
if not isinstance(value, dict):
return value
if "b64_json" in value or "base64" in value:
return _decode_b64_image(value)
if "values" in value or "data" in value:
return _decode_tensor_payload(value)
return value
def _normalize_observation(observation: dict[str, Any]) -> dict[str, Any]:
normalized = dict(observation)
images = normalized.get("images")
if isinstance(images, dict):
normalized["images"] = {
name: _normalize_image_value(value) for name, value in images.items()
}
state = normalized.get("state")
if isinstance(state, dict):
normalized["state"] = _decode_tensor_payload(state)
observation_state = normalized.get("observation.state")
if isinstance(observation_state, dict):
normalized["observation.state"] = _decode_tensor_payload(observation_state)
noise = normalized.get("noise")
if isinstance(noise, dict):
normalized["noise"] = _decode_tensor_payload(noise)
observation_noise = normalized.get("observation.noise")
if isinstance(observation_noise, dict):
normalized["observation.noise"] = _decode_tensor_payload(observation_noise)
return normalized
def images_from_observation(
observation: dict[str, Any],
pipeline_config: Any,
) -> dict[str, Any]:
if isinstance(observation.get("images"), dict):
images = dict(observation["images"])
else:
images = {}
for key in pipeline_config.image_keys:
if key in observation:
images[key] = observation[key]
full_key = f"observation.images.{key}"
if full_key in observation:
images[key] = observation[full_key]
return {name: _normalize_image_value(value) for name, value in images.items()}
def action_metadata(server_args: ServerArgs) -> dict[str, Any]:
pipeline_config = server_args.pipeline_config
policy_family = getattr(
pipeline_config,
"policy_family",
type(pipeline_config).__name__.removesuffix("PipelineConfig").lower(),
)
return {
"object": "action.metadata",
"model": server_args.model_id or server_args.model_path,
"model_path": server_args.model_path,
"policy_family": policy_family,
"input": {
"image_keys": list(pipeline_config.image_keys),
"image_size": list(pipeline_config.image_size),
"state_dim": pipeline_config.state_dim,
},
"output": {
"action_type": "continuous",
"action_horizon": pipeline_config.action_horizon,
"action_dim": pipeline_config.output_action_dim,
"padded_action_dim": pipeline_config.action_dim,
"dtype": "float32",
},
"runtime": {
"materialize_dtype": pipeline_config.materialize_dtype,
"enable_autocast": pipeline_config.enable_autocast,
"parallelism": {
"num_gpus": server_args.num_gpus,
"tp_size": server_args.tp_size,
"sp_degree": server_args.sp_degree,
"ulysses_degree": server_args.ulysses_degree,
"ring_degree": server_args.ring_degree,
"prefix_strategy": pipeline_config.prefix_parallel_strategy,
"action_strategy": pipeline_config.action_parallel_strategy,
"layout_version": pipeline_config.parallel_layout_version,
},
},
"defaults": {
"num_inference_steps": pipeline_config.default_num_inference_steps,
"prefix_cache": (
"auto" if pipeline_config.enable_global_prefix_cache else False
),
"cuda_graph": "auto" if pipeline_config.enable_action_cuda_graph else False,
},
"capabilities": {
"exact_prefix_cache": True,
"cuda_graph": pipeline_config.enable_action_cuda_graph,
"realtime_websocket": True,
"openpi_websocket": True,
"batch_inputs": False,
"multiple_candidates": False,
},
}
def _runtime_bool(value: Any, default: bool) -> bool:
if value is None:
return default
if isinstance(value, str):
value = value.lower()
if value == "auto":
return default
if value in ("true", "1", "yes"):
return True
if value in ("false", "0", "no"):
return False
return bool(value)
def _action_request_to_observation(payload: dict[str, Any]) -> dict[str, Any]:
if "input" not in payload:
return _normalize_observation(payload)
input_payload = payload.get("input") or {}
observation = dict(input_payload.get("observation") or {})
if "task" in input_payload:
observation["prompt"] = input_payload["task"]
elif "prompt" in input_payload:
observation["prompt"] = input_payload["prompt"]
if "images" in input_payload:
observation["images"] = input_payload["images"]
if "state" in input_payload:
observation["state"] = input_payload["state"]
if "noise" in input_payload:
observation["noise"] = input_payload["noise"]
return _normalize_observation(observation)
@lru_cache(maxsize=32)
def _resolve_action_sampling_params_cls_cached(
model_path: str,
backend: str | None,
model_id: str | None,
pipeline_class_name: str | None,
) -> type[VLASamplingParams]:
if pipeline_class_name:
from sglang.multimodal_gen.registry import get_pipeline_config_classes
config_classes = get_pipeline_config_classes(pipeline_class_name)
if config_classes is not None:
_, sampling_params_cls = config_classes
if issubclass(sampling_params_cls, VLASamplingParams):
return sampling_params_cls
from sglang.multimodal_gen.registry import get_model_info
model_info = get_model_info(
model_path,
backend=backend,
model_id=model_id,
)
sampling_params_cls = model_info.sampling_param_cls
if not issubclass(sampling_params_cls, VLASamplingParams):
raise ValueError(
f"Action endpoint requires VLASamplingParams, got {sampling_params_cls.__name__}"
)
return sampling_params_cls
def _resolve_action_sampling_params_cls(
server_args: ServerArgs,
) -> type[VLASamplingParams]:
return _resolve_action_sampling_params_cls_cached(
server_args.model_path,
getattr(server_args, "backend", None),
getattr(server_args, "model_id", None),
getattr(server_args, "pipeline_class_name", None),
)
@lru_cache(maxsize=32)
def _sampling_params_field_names(
sampling_params_cls: type[VLASamplingParams],
) -> frozenset[str]:
return frozenset(field.name for field in dataclasses.fields(sampling_params_cls))
def build_action_sampling_params(
payload: dict[str, Any],
server_args: ServerArgs,
) -> VLASamplingParams:
pipeline_config = server_args.pipeline_config
observation = _action_request_to_observation(payload)
parameters = dict(payload.get("parameters") or {})
runtime = dict(payload.get("runtime") or {})
if "return_timing" in payload and "return_timing" not in runtime:
runtime["return_timing"] = payload["return_timing"]
images = images_from_observation(observation, pipeline_config)
state = observation.get("state")
if state is None:
state = observation.get("observation.state")
noise = observation.get("noise")
if noise is None:
noise = observation.get("observation.noise")
prompt = observation.get("prompt") or observation.get("task") or ""
prefix_cache = runtime.get("prefix_cache")
if prefix_cache is None:
prefix_cache = observation.get("enable_prefix_cache")
if prefix_cache is None:
prefix_cache = observation.get("enable_pi_prefix_cache")
cuda_graph = runtime.get("cuda_graph")
if cuda_graph is None:
cuda_graph = observation.get("enable_cuda_graph")
if cuda_graph is None:
cuda_graph = observation.get("enable_pi_cuda_graph")
output_format = str(
runtime.get(
"output_format",
parameters.get(
"output_format",
observation.get("output_format", "list"),
),
)
).lower()
if output_format not in ("list", "numpy"):
raise ValueError("output_format must be 'list' or 'numpy'")
sampling_params_cls = _resolve_action_sampling_params_cls(server_args)
sampling_kwargs = {
"request_id": payload.get("request_id") or payload.get("id"),
"prompt": prompt,
"images": images,
"image_masks": observation.get("image_masks"),
"camera_order": observation.get("camera_order"),
"state": state,
"noise": noise,
"observation": observation,
"action_horizon": int(
parameters.get(
"action_horizon",
observation.get("action_horizon", pipeline_config.action_horizon),
)
),
"action_dim": int(
parameters.get(
"action_dim",
observation.get("action_dim", pipeline_config.action_dim),
)
),
"num_inference_steps": int(
parameters.get(
"num_inference_steps",
observation.get(
"num_inference_steps",
pipeline_config.default_num_inference_steps,
),
)
),
"output_format": output_format,
"return_timing": _runtime_bool(runtime.get("return_timing"), True),
"enable_prefix_cache": _runtime_bool(prefix_cache, True),
"enable_cuda_graph": _runtime_bool(cuda_graph, True),
}
supported_fields = _sampling_params_field_names(sampling_params_cls)
sp = sampling_params_cls(
**{
name: value
for name, value in sampling_kwargs.items()
if name in supported_fields
}
)
sp._adjust(server_args)
return sp
async def infer_action(
payload: dict[str, Any],
server_args: ServerArgs,
) -> dict[str, Any]:
sp = build_action_sampling_params(payload, server_args)
req = prepare_request(server_args, sp)
response = await async_scheduler_client.forward(req)
if getattr(response, "error", None):
raise RuntimeError(response.error)
if response.output is None:
raise RuntimeError("action policy returned no output")
return response.output[0]
def action_generation_response(
output: dict[str, Any],
server_args: ServerArgs,
*,
preserve_numpy: bool = False,
) -> dict[str, Any]:
actions = output["actions"]
if isinstance(actions, np.ndarray):
action_shape = list(actions.shape)
action_values = actions if preserve_numpy else actions.tolist()
else:
horizon = len(actions) if isinstance(actions, list) else 0
action_dim = len(actions[0]) if horizon and isinstance(actions[0], list) else 0
action_shape = [horizon, action_dim]
action_values = actions
response = {
"id": output.get("request_id") or f"act_{uuid.uuid4().hex}",
"object": "action.generation",
"created": int(time.time()),
"model": server_args.model_id or server_args.model_path,
"data": [
{
"index": 0,
"input_index": 0,
"candidate_index": 0,
"action": {
"type": "continuous",
"dtype": "float32",
"shape": action_shape,
"values": action_values,
},
}
],
"usage": {
"action_horizon": action_shape[0] if action_shape else 0,
"action_dim": action_shape[1] if len(action_shape) > 1 else 0,
"denoise_steps": output.get("parameters", {}).get(
"num_inference_steps",
server_args.pipeline_config.default_num_inference_steps,
),
"prefix_cache_hit": bool(output.get("cache", {}).get("hit", False)),
},
}
if "timings" in output:
response["timings"] = output["timings"]
if "cache" in output:
response["cache"] = output["cache"]
if "parallel" in output:
response["parallel"] = output["parallel"]
return response
def action_raw_response(
output: dict[str, Any],
*,
preserve_numpy: bool = False,
) -> dict[str, Any]:
response = dict(output)
actions = response.get("actions")
if isinstance(actions, np.ndarray) and not preserve_numpy:
response["actions"] = actions.tolist()
return response
@@ -0,0 +1,57 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import time
import traceback
from collections.abc import Callable
from typing import Any
from fastapi import WebSocket, WebSocketDisconnect
from sglang.multimodal_gen.runtime.entrypoints.vla.protocol import (
action_metadata,
infer_action,
pack_msgpack,
unpack_msgpack,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
async def run_action_msgpack_ws(
websocket: WebSocket,
server_args: ServerArgs,
*,
prepare_payload: Callable[[dict[str, Any]], None],
build_response: Callable[[dict[str, Any]], dict[str, Any]],
) -> None:
await websocket.accept()
await websocket.send_bytes(pack_msgpack(action_metadata(server_args)))
prev_total_time = None
while True:
try:
start_time = time.monotonic()
payload = unpack_msgpack(await websocket.receive_bytes())
prepare_payload(payload)
infer_start = time.monotonic()
output = await infer_action(payload, server_args)
response = build_response(output)
response.setdefault("server_timing", {})["infer_ms"] = (
time.monotonic() - infer_start
) * 1000
if prev_total_time is not None:
response["server_timing"]["prev_total_ms"] = prev_total_time * 1000
await websocket.send_bytes(pack_msgpack(response))
prev_total_time = time.monotonic() - start_time
except WebSocketDisconnect:
break
except Exception:
try:
await websocket.send_bytes(
pack_msgpack({"error": traceback.format_exc()})
)
except Exception:
pass
await websocket.close(code=1011, reason="Internal server error")
raise