Plugins in the API server
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
Five plugin kinds act in the API server: HTTP routes, pooling IO processors, text parsers, LoRA resolvers and stat loggers.
The idea#
The front end of vllm serve is the API server process. It parses HTTP requests, renders prompts, talks to the engine core and turns the engine output into OpenAI responses (The OpenAI-compatible server: launch modes, app, serving classes and streaming). Several parts of this work are open to plugins, because deployments differ in these places. Examples are extra routes, output formats, model-specific parsing, adapter storage and metrics systems.
Each front-end extension point reaches vLLM in its own way:
- Endpoint plugins (group
vllm.endpoint_plugins) add HTTP routes, and can replace an existing route. They load only whenVLLM_PLUGINSnames them. - IO processor plugins (group
vllm.io_processor_plugins) change the input and output of a pooling model. - Tool parsers and reasoning parsers are registries. A flag imports your module:
--tool-parser-pluginor--reasoning-parser-plugin. - LoRA resolvers are a registry that a general plugin fills.
- Stat loggers (group
vllm.stat_logger_plugins) receive the statistics of each engine step.
An everyday comparison. Think of the reception of a hotel. A partner company can add a service desk (an endpoint) or bring a translator for some guests (a parser). It can give the porters a list of storage rooms for unknown luggage (a resolver), or put a counter on the door (a stat logger). The comparison stops here: in vLLM the receptionist does not ask the partner; each plugin must be installed and, for routes, named in an allowlist.
The picture#
Read panel 1 in number order: this is the start-up of the API server process. Panel 2 follows one request. Green items are entry-point groups that vLLM finds by itself. Blue items are registries. The violet item is a pair of CLI flags.
In the code#
Step 1: endpoint plugins#
The contract is a Protocol. An entry point gives a factory with no arguments that returns such an object:
@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.
"""
...Loading has a stricter rule than the other groups. With VLLM_PLUGINS unset, vLLM loads none and warns:
from importlib.metadata import entry_points
if envs.VLLM_PLUGINS is None:
discovered = entry_points(group=ENDPOINT_PLUGINS_GROUP)
if discovered:
logger.warning(
"Found endpoint plugin(s) %s but VLLM_PLUGINS is not set. "
"Endpoint plugins add HTTP routes and must be explicitly "
"allowlisted via VLLM_PLUGINS to be loaded.",
[p.name for p in discovered],
)
return []
factories = load_plugins_by_group(ENDPOINT_PLUGINS_GROUP)
endpoint_plugins: list[EndpointPlugin] = []
for name, factory in factories.items():
try:
plugin = factory()
except Exception:
logger.exception("Failed to instantiate endpoint plugin %s", name)
continue
required_tasks = plugin.required_tasks
if required_tasks is not None and (
supported_tasks is None or not set(required_tasks) & set(supported_tasks)
):
logger.info(
"Skipping endpoint plugin %s: requires one of tasks %s, "
"server supports %s",
name,
required_tasks,
supported_tasks,
)
continue
logger.info("Loaded endpoint plugin %s", name)
endpoint_plugins.append(plugin)
return endpoint_plugins- Lines 122-131. Not set: a WARNING names the discovered plugins, and nothing loads.
VLLM_PLUGINS=""is an allowlist that matches nothing (vllm/plugins/__init__.py:107-109). - Line 133. The shared loader filters by the entry-point name in
VLLM_PLUGINS(vllm/plugins/__init__.py:63-64). Thenameattribute of the object is for logs; the docstring calls it the allowlist name too, so give both the same value. - Lines 137-141. A factory that raises is logged and skipped.
- Lines 143-154.
required_tasksmust meet the server's tasks, or the plugin is skipped with an INFO log.
Phase A attaches the routes in build_app (vllm/entrypoints/launchers/app.py:51). A plugin route with the same path and the same HTTP method replaces the core route; other methods of that path stay (vllm/plugins/endpoint_plugins/interface.py:109-143). vLLM logs "Endpoint plugin overrides ..." at INFO level. Phase B calls init_state in init_app_state (vllm/entrypoints/launchers/api_server/app_state.py:173). The CPU-only render server passes engine_client=None (vllm/entrypoints/launchers/render/app_state.py:124).
The test plugin shows both phases:
class DummyAdminEndpointPlugin:
name = "dummy_admin_endpoint_plugin"
required_tasks: tuple[str, ...] | None = None
def attach_router(self, app: FastAPI) -> None:
@app.get("/v1/admin/scheduler_config")
async def scheduler_config(raw_request: Request):
engine_client = raw_request.app.state.dummy_engine_client
if engine_client is None:
raise HTTPException(
status_code=503,
detail="scheduler_config requires an engine, which this "
"server does not have",
)
results = await engine_client.collective_rpc("get_scheduler_config")
return {"scheduler_config": results}
async def init_state(self, engine_client, state, args) -> None:
state.dummy_engine_client = engine_clientIts route calls engine_client.collective_rpc("get_scheduler_config"). The module docstring says that a plugin that needs engine-side behaviour pairs the endpoint entry point with a general plugin (vllm/plugins/endpoint_plugins/interface.py:11-16). The end-to-end test uses a fake engine client for this call (tests/plugins_tests/test_endpoint_plugins.py:394).
Step 2: IO processor plugins (pooling only)#
An IO processor converts a custom request into pooling inputs and the pooling output into a custom response. The abstract methods are pre_process and post_process (vllm/plugins/io_processors/interface.py:18-66). The plugin entry point is a function that returns the dotted class name, or None:
setup(
name="prithvi_io_processor_plugin",
version="0.1",
packages=["prithvi_io_processor"],
entry_points={
"vllm.io_processor_plugins": [
"prithvi_to_tiff = prithvi_io_processor:register_prithvi", # noqa: E501
]
},
)vLLM selects one plugin by name. The name comes from --io-processor-plugin, or from the key io_processor_plugin in the model's HF config:
if plugin_from_init:
model_plugin = plugin_from_init
else:
# A plugin can be specified via the model config
# Retrieve the model specific plugin if available
# This is using a custom field in the hf_config for the model
hf_config = vllm_config.model_config.hf_config.to_dict()
config_plugin = hf_config.get("io_processor_plugin")
model_plugin = config_plugin
if model_plugin is None:
logger.debug("No IOProcessor plugins requested by the model")
return None
logger.debug("IOProcessor plugin to be loaded %s", model_plugin)
# Load all installed plugin in the group
multimodal_data_processor_plugins = load_plugins_by_group(
IO_PROCESSOR_PLUGINS_GROUP
)
loadable_plugins = {}
for name, func in multimodal_data_processor_plugins.items():
try:
assert callable(func)
processor_cls_qualname = func()
if processor_cls_qualname is not None:
loadable_plugins[name] = processor_cls_qualname
except Exception:
logger.warning("Failed to load plugin %s.", name, exc_info=True)
num_available_plugins = len(loadable_plugins.keys())
if num_available_plugins == 0:
raise ValueError(
f"No IOProcessor plugins installed but one is required ({model_plugin})."
)
if model_plugin not in loadable_plugins:
raise ValueError(
f"The model requires the '{model_plugin}' IO Processor plugin "
"but it is not installed. "
f"Available plugins: {list(loadable_plugins.keys())}"
)
activated_plugin_cls = resolve_obj_by_qualname(loadable_plugins[model_plugin])
return activated_plugin_cls(vllm_config, renderer)Only the pooling entry points ask for it (vllm/entrypoints/pooling/factories.py:73-84). A plugin function that raises gives a WARNING. No usable plugin, or the wrong name, raises ValueError (lines 74-84).
Step 3: tool parser and reasoning parser plugins#
ToolParserManager keeps two dicts: classes and lazy entries (module path, class name) (vllm/tool_parsers/abstract_tool_parser.py:243-244). vLLM registers its own parsers lazily at import (vllm/tool_parsers/__init__.py:264-270). A lookup tries the classes first, then imports a lazy entry and caches it:
@classmethod
def get_tool_parser(cls, name: str) -> type[ToolParser]:
"""Retrieve a registered or lazily registered ToolParser class.
If the parser is lazily registered,
it will be imported and cached on first access.
Raises KeyError if not found.
"""
if name in cls.tool_parsers:
return cls.tool_parsers[name]
if name in cls.lazy_parsers:
return cls._load_lazy_parser(name)
raise KeyError(f"Tool parser '{name}' not found.")
@classmethod
def _load_lazy_parser(cls, name: str) -> type[ToolParser]:
"""Import and register a lazily loaded parser."""
module_path, class_name = cls.lazy_parsers[name]
try:
mod = importlib.import_module(module_path)
parser_cls = getattr(mod, class_name)
if not issubclass(parser_cls, ToolParser):
raise TypeError(
f"{class_name} in {module_path} is not a ToolParser subclass."
)
cls.tool_parsers[name] = parser_cls # cache
return parser_cls
except Exception as e:
logger.exception(
"Failed to import lazy tool parser '%s' from %s: %s",
name,
module_path,
e,
)
raiseregister_module has two forms (vllm/tool_parsers/abstract_tool_parser.py:327-370). With module=... it registers the class at once; force=False raises KeyError for a taken name. As a decorator it only stores a lazy entry with the class's module and name, and overwrites any entry with that name. ReasoningParserManager has the same shape (vllm/reasoning/abs_reasoning_parsers.py:225-373).
Your module reaches the manager through a flag:
def init_parser_plugin(args: argparse.Namespace):
from vllm.reasoning import ReasoningParserManager
if args.tool_parser_plugin and len(args.tool_parser_plugin) > 3:
ToolParserManager.import_tool_parser(args.tool_parser_plugin)
if args.reasoning_parser_plugin and len(args.reasoning_parser_plugin) > 3:
ReasoningParserManager.import_reasoning_parser(args.reasoning_parser_plugin)
def validate_api_server_args(args: argparse.Namespace):
from vllm.reasoning import ReasoningParserManager
valid_tool_parses = ToolParserManager.list_registered()
if args.enable_auto_tool_choice and args.tool_call_parser not in valid_tool_parses:
raise KeyError(
f"invalid tool call parser: {args.tool_call_parser} "
f"(chose from {{ {','.join(valid_tool_parses)} }})"
)- Lines 579-583. The value is imported only if it is longer than 3 characters. The code does not say why.
import_plugintries a module name first, then a file path. A file that fails to import is logged, not raised (vllm/utils/import_utils.py:81-98). The file is loaded under its file name as a module and put intosys.modules(vllm/utils/import_utils.py:101-116), so a decorator entry that points to it works.- Lines 589-594. With
--enable-auto-tool-choice, an unknown--tool-call-parserraisesKeyErrorat start-up. So a plugin that failed to import shows up here.
The API server calls init_parser_plugin in setup_server and again in run_server_worker (vllm/entrypoints/launchers/launcher.py:369-370, vllm/entrypoints/launchers/api_server/entry.py:183). The reasoning parser is also needed in the engine core, for structured outputs. So the engine core imports the reasoning parser plugin itself, with the same length rule:
reasoning_parser_plugin = (
self.vllm_config.structured_outputs_config.reasoning_parser_plugin
)
if reasoning_parser_plugin and len(reasoning_parser_plugin) > 3:
ReasoningParserManager.import_reasoning_parser(reasoning_parser_plugin)
reasoning_parser = (
self.vllm_config.structured_outputs_config.reasoning_parser
)
if reasoning_parser:
self.reasoner_cls = ReasoningParserManager.get_reasoning_parser(
reasoning_parser
)The page Tool and reasoning parsers: from model text to tool_calls and reasoning explains what the parsers do with the text.
Step 4: LoRA resolvers#
A resolver finds a LoRA adapter by name when a request asks for a model name that the server does not know. The registry holds instances by name; a second registration overwrites with a WARNING (vllm/lora/resolver.py:52-72). vLLM ships two resolvers, as general plugins:
[project.entry-points."vllm.general_plugins"]
lora_filesystem_resolver = "vllm.plugins.lora_resolvers.filesystem_resolver:register_filesystem_resolver"
lora_hf_hub_resolver = "vllm.plugins.lora_resolvers.hf_hub_resolver:register_hf_hub_resolver"- Filesystem resolver. It registers only when
VLLM_LORA_RESOLVER_CACHE_DIRis set, and raisesValueErrorif that is not a directory (vllm/plugins/lora_resolvers/filesystem_resolver.py:49-61). - HF Hub resolver. It needs
VLLM_LORA_RESOLVER_HF_REPO_LISTand the namelora_hf_hub_resolverinVLLM_PLUGINS, "because it allows remote downloads" (vllm/plugins/lora_resolvers/hf_hub_resolver.py:129-147).
The serving layer copies all resolvers when it starts (vllm/entrypoints/openai/models/serving.py:113-117). It asks them only when VLLM_ALLOW_RUNTIME_LORA_UPDATING is on:
if self._is_model_supported(request.model):
return None
if request.model in self.models.lora_requests:
return None
if (
envs.VLLM_ALLOW_RUNTIME_LORA_UPDATING
and request.model
and (load_result := await self.models.resolve_lora(request.model))
):
if isinstance(load_result, LoRARequest):
return None
if (
isinstance(load_result, ErrorResponse)
and load_result.error.code == HTTPStatus.BAD_REQUEST.value
):
error_response = load_resultThe resolvers run in registration order. The first one that finds the adapter and loads it wins. If a found adapter fails to load, vLLM logs a WARNING and asks the next resolver. When adapters were found but none loads, the answer is HTTP 400 (vllm/entrypoints/openai/models/serving.py:315-359). The page Serving LoRA adapters: from the model name to the wrapped layer covers adapters in general.
Step 5: stat logger plugins#
A stat logger implements StatLoggerBase: a constructor with vllm_config and engine_index, record, and log_engine_initialized (vllm/v1/metrics/loggers.py:54-81). The plugin group gives the class itself:
def load_stat_logger_plugin_factories() -> list[StatLoggerFactory]:
factories: list[StatLoggerFactory] = []
for name, plugin_class in load_plugins_by_group(STAT_LOGGER_PLUGINS_GROUP).items():
if not isinstance(plugin_class, type) or not issubclass(
plugin_class, StatLoggerBase
):
raise TypeError(
f"Stat logger plugin {name!r} must be a subclass of "
f"StatLoggerBase (got {plugin_class!r})."
)
factories.append(plugin_class)
return factoriesAsyncLLM.__init__ loads them, after any loggers that the caller passed (vllm/v1/engine/async_llm.py:155-165). A plugin turns on stats even with --disable-log-stats, but then without the default loggers (lines 158-165). StatLoggerManager wraps a per-engine class in an adapter, and always adds PrometheusStatLogger unless one of the loggers is a Prometheus logger (vllm/v1/metrics/loggers.py:1398-1419). The test plugin is a minimal example (tests/plugins/vllm_add_dummy_stat_logger/dummy_stat_logger/dummy_stat_logger.py:7-30).
Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep diveThe five extension points side by side
| Extension point | How it is found | VLLM_PLUGINS | Loaded at | Failure |
|---|---|---|---|---|
| endpoint plugin | group vllm.endpoint_plugins | must name it | build_app (vllm/entrypoints/launchers/app.py:51) | factory error: log and skip (vllm/plugins/__init__.py:137-141) |
| IO processor plugin | group vllm.io_processor_plugins + a name | filters | pooling serving set-up (vllm/entrypoints/pooling/pooling/io_processor.py:51-55) | function error: WARNING; none usable: ValueError |
| tool / reasoning parser | flag imports a module or file | not used | vllm/entrypoints/common/cli_args.py:576-583 | import error of a file: ERROR log only |
| LoRA resolver | registry, filled by a general plugin | filters the general plugin | vllm/entrypoints/openai/models/serving.py:113-117 | filesystem resolver: ValueError at registration |
| stat logger | group vllm.stat_logger_plugins | filters | vllm/v1/engine/async_llm.py:156 | not a subclass: TypeError |
Deep diveHow an endpoint plugin replaces a route
attach_endpoint_plugins rebuilds the route list after the plugins have attached their routes (vllm/plugins/endpoint_plugins/interface.py:108-146):
- Remember the ids of all routes that exist before the plugins run (line 109).
- Call
attach_router(app)for each plugin, in discovery order (lines 110-111). - Walk all routes. A new
APIRouteremoves the methods it shares with an earlier route of the same path. An earlier route with no methods left is dropped; one with other methods stays, as a copy (lines 121-139). - Insert the new route at the place of the first route it replaced, so it stays ahead of mounts (lines 133, 140).
- Reset the OpenAPI schema, and keep the plugin list in
app.state.endpoint_pluginsfor phase B (lines 144-146).
So a later plugin also replaces an earlier plugin's route for the same method. A test checks this (tests/plugins_tests/test_endpoint_plugins.py:280).
Deep diveLazy parser entries, step by step
Example: your file /opt/acme_parser.py has @ToolParserManager.register_module("acme") on class AcmeToolParser(ToolParser). You start vllm serve ... --enable-auto-tool-choice --tool-call-parser acme --tool-parser-plugin /opt/acme_parser.py.
init_parser_pluginsees a value longer than 3 characters and callsimport_plugin(vllm/entrypoints/common/cli_args.py:579-580).importlib.import_module("/opt/acme_parser.py")raisesModuleNotFoundError, so vLLM loads the file as moduleacme_parser(vllm/utils/import_utils.py:88-93).- The decorator stores
lazy_parsers["acme"] = ("acme_parser", "AcmeToolParser")(vllm/tool_parsers/abstract_tool_parser.py:353-366). validate_api_server_argsfinds"acme"inlist_registered()(vllm/tool_parsers/abstract_tool_parser.py:373-375).- The first lookup imports
acme_parserfromsys.modules, checks the subclass and caches the class (vllm/tool_parsers/abstract_tool_parser.py:263-274).
A name like "hermes" exists as a lazy built-in entry. Your decorator with that name replaces the lazy entry, unless the built-in class was already loaded into tool_parsers, which the lookup checks first (lines 254-255). (Derived.)
Deep diveEnvironment variables
| Variable | Default | Read at | Effect |
|---|---|---|---|
VLLM_PLUGINS | not set | vllm/plugins/__init__.py:122 | Endpoint plugins load only when it is set and names them. |
VLLM_ALLOW_RUNTIME_LORA_UPDATING | 0 (vllm/envs.py:1197-1200) | vllm/entrypoints/serve/engine/serving.py:63 | Without it, LoRA resolvers are never asked. |
VLLM_LORA_RESOLVER_CACHE_DIR | not set (vllm/envs.py:1171-1173) | vllm/plugins/lora_resolvers/filesystem_resolver.py:51 | Turns on the filesystem resolver. |
VLLM_LORA_RESOLVER_HF_REPO_LIST | not set (vllm/envs.py:1178-1180) | vllm/plugins/lora_resolvers/hf_hub_resolver.py:131 | Comma-separated repos for the HF Hub resolver. |
What can go wrong#
- A short parser plugin value is ignored. Three characters or fewer are not imported (
vllm/entrypoints/common/cli_args.py:579). - A broken parser file only logs. The ERROR log "Failed to load module ..." is easy to miss. Start-up then fails later with "invalid tool call parser" only when auto tool choice is on (
vllm/utils/import_utils.py:94-98,vllm/entrypoints/common/cli_args.py:589-594). - The resolver never runs.
VLLM_ALLOW_RUNTIME_LORA_UPDATINGis off, orVLLM_PLUGINSfiltered out the general plugin that registers it. - A stat logger class is wrong. A non-
StatLoggerBaseentry raisesTypeErrorinAsyncLLM.__init__and stops start-up (vllm/v1/metrics/loggers.py:88-94). - An IO processor plugin is missing. A pooling model that names a plugin fails with "The model requires the ... IO Processor plugin but it is not installed" (
vllm/plugins/io_processors/__init__.py:79-84).
Why it matters. These plugins change what clients see: routes, response formats, parsed tool calls and metrics. They all live in the API server, so they cost no GPU time, but a mistake in them shows up directly in the HTTP responses.
Check yourself#
- 1Which front-end plugin group loads only wheneasy
VLLM_PLUGINSnames it?Show answer
vllm.endpoint_plugins. WithVLLM_PLUGINSunset,load_endpoint_pluginsreturns an empty list after a warning (vllm/plugins/__init__.py:122-131). - 2What must the object behind aeasy
vllm.stat_logger_pluginsentry point be?Show answer
A class that subclasses
StatLoggerBase, elseTypeError(vllm/v1/metrics/loggers.py:87-94). - 3You passmedium
--reasoning-parser-plugin my.py --reasoning-parser mine. In which processes does vLLM importmy.py?Show answer
In two processes.
"my.py"has 5 characters, so it passes the length rule. The API server imports it (vllm/entrypoints/common/cli_args.py:582-583), and the engine core imports it again for structured outputs (vllm/v1/structured_output/__init__.py:103-104). - 4Your endpoint plugin registersmedium
POST /v1/completions. What happens to vLLM's own route, and to aGETon the same path?Show answer
The plugin route replaces the core
POSTroute; other methods of the path stay (vllm/plugins/endpoint_plugins/interface.py:121-141, docstringvllm/plugins/endpoint_plugins/interface.py:72-74). - 5A request asks for modelhard
"sql-lora", which the server does not know. You setVLLM_LORA_RESOLVER_CACHE_DIR=/lorasandVLLM_PLUGINS=acme_admin. Is the adapter found?Show answer
No. The filesystem resolver registers from the general plugin
lora_filesystem_resolver(pyproject.toml:45-46), andVLLM_PLUGINS=acme_adminfilters it out (vllm/plugins/__init__.py:63-64). Also, resolvers run only withVLLM_ALLOW_RUNTIME_LORA_UPDATING=1(vllm/entrypoints/serve/engine/serving.py:62-66). The request gets "The model `sql-lora` does not exist."