Signed-off-by: 子华 <huaxi.shx@alibaba-inc.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>
165 lines
6.6 KiB
Python
165 lines
6.6 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Contract for `vllm.endpoint_plugins` entry points.
|
|
|
|
An endpoint plugin adds HTTP routes to the OpenAI compatible API server.
|
|
Its scope is HTTP surface only. It registers routes and optionally
|
|
per app state used by those routes. It must not open new paths into the
|
|
engine by reaching the engine the same way an in-tree serving handler does
|
|
via `EngineClient` (e.g. `engine_client.collective_rpc(...)`).
|
|
|
|
If a plugin also needs engine side behavior (a new worker side RPC method,
|
|
a custom stat, etc.) pair this entry point with one registered under
|
|
`vllm.general_plugins` (see `vllm/plugins/__init__.py`). The
|
|
`general_plugins` entry installs the engine side method and the
|
|
`endpoint_plugins` entry exposes it over HTTP. The two are registered and
|
|
loaded independently where neither implies the other.
|
|
|
|
Plugins are opt-in. See `load_endpoint_plugins` in `vllm/plugins/__init__.py`
|
|
for the loading/gating rules and `docs/usage/security.md` for the security
|
|
posture of exposing plugin defined routes.
|
|
|
|
The CPU only render server (see `build_and_serve_renderer` in
|
|
`vllm/entrypoints/launchers/render`) has no `EngineClient`. A plugin
|
|
eligible for the `render` task (`required_tasks` is `None` or includes
|
|
`"render"`) still gets `attach_router` called but `init_state` receives
|
|
`engine_client=None`. Plugins that cannot function without an engine should
|
|
either exclude `"render"` from `required_tasks` or check for `None` in
|
|
`init_state`/their route handlers and degrade gracefully.
|
|
"""
|
|
|
|
from argparse import Namespace
|
|
from copy import copy
|
|
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.routing import APIRoute
|
|
from starlette.datastructures import State
|
|
from starlette.routing import BaseRoute
|
|
|
|
from vllm.engine.protocol import EngineClient
|
|
from vllm.logger import init_logger
|
|
|
|
if TYPE_CHECKING:
|
|
from vllm.tasks import SupportedTask
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
@runtime_checkable
|
|
class EndpointPlugin(Protocol):
|
|
"""Protocol implemented by `vllm.endpoint_plugins` entry point factories.
|
|
|
|
An entry point registered under the `vllm.endpoint_plugins` group must
|
|
resolve to a zero argument callable (a class or factory function) that
|
|
returns an object satisfying this protocol.
|
|
"""
|
|
|
|
name: str
|
|
"""Unique plugin name used in logs and for `VLLM_PLUGINS` allowlisting."""
|
|
|
|
required_tasks: "tuple[SupportedTask, ...] | None"
|
|
"""Tasks the server must support for this plugin to be loaded.
|
|
|
|
The plugin is loaded only if this set intersects the server's
|
|
`supported_tasks`. `None` means the plugin has no task requirement and
|
|
is always eligible (subject to the `VLLM_PLUGINS` allowlist).
|
|
"""
|
|
|
|
def attach_router(self, app: FastAPI) -> None:
|
|
"""Register this plugin's routes on `app`.
|
|
|
|
Called once during `build_app()` after all core routers have been
|
|
attached. HTTP routes registered here replace earlier routes with the
|
|
same path template and method. Other methods remain available.
|
|
"""
|
|
...
|
|
|
|
async def init_state(
|
|
self, engine_client: "EngineClient | None", state: State, args: Namespace
|
|
) -> None:
|
|
"""Initialize per app state consumed by this plugin's routes.
|
|
|
|
Called once during `init_app_state()` after core state has been
|
|
initialized. Use `engine_client` (e.g. `collective_rpc`) to reach
|
|
the engine. Do not open new engine access paths.
|
|
|
|
`engine_client` is `None` on the CPU only render server which has
|
|
no engine. This only happens for plugins eligible for the `render`
|
|
task (`required_tasks` is `None` or includes `"render"`). Handle
|
|
`None` explicitly (e.g. skip engine dependent setup, or have route
|
|
handlers return an error) if the plugin is loadable for `render` but
|
|
cannot function without an engine.
|
|
"""
|
|
...
|
|
|
|
|
|
def attach_endpoint_plugins(
|
|
app: FastAPI, supported_tasks: tuple["SupportedTask", ...]
|
|
) -> None:
|
|
"""Phase A of endpoint plugin wiring: discover, gate and attach routes.
|
|
|
|
Hooks run after all core routers have been attached. Plugin HTTP routes
|
|
replace earlier operations with the same path template and method in both
|
|
routing and OpenAPI. No-ops when no plugins are discovered/allowlisted.
|
|
"""
|
|
from vllm.plugins import load_endpoint_plugins
|
|
|
|
endpoint_plugins = load_endpoint_plugins(supported_tasks)
|
|
existing_routes = {id(route): route for route in app.routes}
|
|
for plugin in endpoint_plugins:
|
|
plugin.attach_router(app)
|
|
|
|
routes: list[BaseRoute] = []
|
|
for route in app.routes:
|
|
if id(route) in existing_routes or not isinstance(route, APIRoute):
|
|
routes.append(route)
|
|
continue
|
|
|
|
insert_at = len(routes)
|
|
retained: list[BaseRoute] = []
|
|
for previous in routes:
|
|
if (
|
|
isinstance(previous, APIRoute)
|
|
and previous.path == route.path
|
|
and previous.methods & route.methods
|
|
):
|
|
logger.info(
|
|
"Endpoint plugin overrides %s %s",
|
|
", ".join(sorted(previous.methods & route.methods)),
|
|
route.path,
|
|
)
|
|
# Keep replacements ahead of mounts, preserving other methods.
|
|
insert_at = min(insert_at, len(retained))
|
|
methods = previous.methods - route.methods
|
|
if not methods:
|
|
continue
|
|
previous = copy(previous)
|
|
previous.methods = methods
|
|
retained.append(previous)
|
|
retained.insert(insert_at, route)
|
|
routes = retained
|
|
|
|
app.router.routes[:] = routes
|
|
if endpoint_plugins:
|
|
app.openapi_schema = None
|
|
app.state.endpoint_plugins = endpoint_plugins
|
|
|
|
|
|
async def init_endpoint_plugins_state(
|
|
engine_client: EngineClient | None, state: State, args: Namespace
|
|
) -> None:
|
|
"""Phase B of endpoint plugin wiring: initialize per app plugin state.
|
|
|
|
`state.endpoint_plugins` is set by `_attach_endpoint_plugins` (Phase A)
|
|
in `build_app`. Some `init_app_state` callers (e.g. `run_batch.py`)
|
|
build their own bare `State` without going through `build_app`. As a result
|
|
`endpoint_plugins` may be absent and are treated that the same as "none attached".
|
|
|
|
`engine_client` is `None` for the CPU only render server which has no
|
|
engine (see `init_render_app_state`). Plugins must handle a `None`
|
|
`engine_client` themselves (see `EndpointPlugin.init_state`).
|
|
"""
|
|
for plugin in getattr(state, "endpoint_plugins", []):
|
|
await plugin.init_state(engine_client, state, args)
|