222 lines
7.4 KiB
Python
222 lines
7.4 KiB
Python
# Adapted from SGLang
|
|
# (https://github.com/sgl-project/sglang/blob/main/python/sglang/multimodal_gen/runtime/entrypoints/http_server.py)
|
|
|
|
from contextlib import asynccontextmanager
|
|
from collections.abc import AsyncIterator
|
|
import os
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from fastvideo.api.presets import validate_preset_selection
|
|
from fastvideo.api.schema import GenerationRequest
|
|
from fastvideo.entrypoints.openai.state import (
|
|
DEFAULT_OUTPUT_DIR,
|
|
clear_state,
|
|
set_state,
|
|
)
|
|
from fastvideo.entrypoints.openai.serving_engine import OpenAIServingEngine
|
|
from fastvideo.entrypoints.video_generator import VideoGenerator
|
|
from fastvideo.fastvideo_args import FastVideoArgs
|
|
from fastvideo.logger import init_logger
|
|
from fastvideo.registry import get_preset_selection
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
DEFAULT_HOST = "0.0.0.0"
|
|
DEFAULT_PORT = 8000
|
|
|
|
|
|
def _validate_default_request_against_preset(
|
|
default_request: GenerationRequest,
|
|
model_path: str,
|
|
) -> None:
|
|
"""Validate ``default_request.stage_overrides`` against the model's preset.
|
|
|
|
Called once at server startup from :func:`run_server`. The
|
|
``default_request`` is static server config, so validation results are
|
|
invariant across requests — there's no reason to re-run per request.
|
|
"""
|
|
if not default_request.stage_overrides:
|
|
return
|
|
preset_name, model_family = get_preset_selection(model_path)
|
|
if preset_name is None or model_family is None:
|
|
return
|
|
validate_preset_selection(
|
|
preset_name,
|
|
model_family,
|
|
stage_overrides=default_request.stage_overrides,
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
"""Load model on startup, clean up on shutdown"""
|
|
args: FastVideoArgs = app.state.fastvideo_args
|
|
output_dir: str = app.state.output_dir
|
|
served_model_name: str | None = app.state.served_model_name
|
|
default_request: GenerationRequest | None = getattr(app.state, "default_request", None)
|
|
|
|
logger.info("Loading model from %s ...", args.model_path)
|
|
generator = VideoGenerator.from_fastvideo_args(args)
|
|
serving_engine = OpenAIServingEngine(generator)
|
|
logger.info("Model loaded successfully.")
|
|
|
|
set_state(
|
|
generator,
|
|
serving_engine,
|
|
args,
|
|
output_dir,
|
|
default_request=default_request,
|
|
served_model_name=served_model_name,
|
|
)
|
|
|
|
try:
|
|
yield # server is running
|
|
finally:
|
|
logger.info("Shutting down — releasing model resources ...")
|
|
from fastvideo.entrypoints.openai.video_api import shutdown_video_jobs
|
|
|
|
await shutdown_video_jobs()
|
|
await serving_engine.shutdown()
|
|
clear_state()
|
|
logger.info("Shutdown complete.")
|
|
|
|
|
|
def create_app(
|
|
fastvideo_args: FastVideoArgs,
|
|
output_dir: str = DEFAULT_OUTPUT_DIR,
|
|
default_request: GenerationRequest | None = None,
|
|
served_model_name: str | None = None,
|
|
) -> FastAPI:
|
|
"""Build the FastAPI application with all routers mounted"""
|
|
|
|
app = FastAPI(
|
|
title="FastVideo OpenAI-Compatible API",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
app.state.fastvideo_args = fastvideo_args
|
|
app.state.output_dir = output_dir
|
|
app.state.default_request = default_request
|
|
app.state.served_model_name = served_model_name
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def openai_http_error(_request: Request, exc: HTTPException) -> JSONResponse:
|
|
"""Return the error envelope consumed by OpenAI-compatible clients."""
|
|
message = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
headers=exc.headers,
|
|
content={
|
|
"error": {
|
|
"message": message,
|
|
"type": "invalid_request_error" if exc.status_code < 500 else "server_error",
|
|
"param": None,
|
|
"code": exc.status_code,
|
|
}
|
|
},
|
|
)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def openai_validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": {
|
|
"message": str(exc),
|
|
"type": "invalid_request_error",
|
|
"param": None,
|
|
"code": 400,
|
|
}},
|
|
)
|
|
|
|
# Import and mount routers
|
|
from fastvideo.entrypoints.openai.common_api import router as common_router
|
|
from fastvideo.entrypoints.openai.image_api import router as image_router
|
|
from fastvideo.entrypoints.openai.video_api import router as video_router
|
|
|
|
app.include_router(common_router)
|
|
app.include_router(video_router)
|
|
app.include_router(image_router)
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
from fastvideo.entrypoints.openai.state import get_serving_engine
|
|
|
|
engine = get_serving_engine()
|
|
if not engine.healthy:
|
|
raise HTTPException(status_code=503, detail=engine.unhealthy_reason or "generation engine is unhealthy")
|
|
return {"status": "ok"}
|
|
|
|
return app
|
|
|
|
|
|
def _parse_args() -> tuple[FastVideoArgs, str, int, str]:
|
|
"""Parse CLI arguments and return (FastVideoArgs, host, port, output_dir)"""
|
|
from fastvideo.utils import FlexibleArgumentParser
|
|
|
|
parser = FlexibleArgumentParser(description="FastVideo OpenAI-compatible API server")
|
|
parser.add_argument("--host", type=str, default=DEFAULT_HOST)
|
|
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
parser.add_argument("--output-dir", type=str, default=DEFAULT_OUTPUT_DIR)
|
|
parser = FastVideoArgs.add_cli_args(parser)
|
|
|
|
args = parser.parse_args()
|
|
host = args.host
|
|
port = args.port
|
|
output_dir = args.output_dir
|
|
|
|
# Build FastVideoArgs from the remaining CLI args
|
|
excluded = {"host", "port", "output_dir", "subparser", "config", "dispatch_function"}
|
|
cli_kwargs = {k: v for k, v in vars(args).items() if k not in excluded and v is not None}
|
|
fastvideo_args = FastVideoArgs.from_kwargs(**cli_kwargs)
|
|
return fastvideo_args, host, port, output_dir
|
|
|
|
|
|
def run_server(
|
|
fastvideo_args: FastVideoArgs,
|
|
host: str = DEFAULT_HOST,
|
|
port: int = DEFAULT_PORT,
|
|
output_dir: str = DEFAULT_OUTPUT_DIR,
|
|
default_request: GenerationRequest | None = None,
|
|
served_model_name: str | None = None,
|
|
):
|
|
"""Create the app and run it with uvicorn"""
|
|
os.environ.setdefault("FASTVIDEO_STAGE_LOGGING", "1")
|
|
if default_request is not None:
|
|
_validate_default_request_against_preset(default_request, fastvideo_args.model_path)
|
|
|
|
app = create_app(
|
|
fastvideo_args,
|
|
output_dir=output_dir,
|
|
default_request=default_request,
|
|
served_model_name=served_model_name,
|
|
)
|
|
|
|
logger.info("Starting FastVideo server on %s:%d", host, port)
|
|
logger.info("Model: %s", fastvideo_args.model_path)
|
|
|
|
uvicorn.run(
|
|
app,
|
|
host=host,
|
|
port=port,
|
|
log_level="info",
|
|
timeout_keep_alive=300,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
fastvideo_args, host, port, output_dir = _parse_args()
|
|
run_server(fastvideo_args, host, port, output_dir=output_dir)
|