All the ways to extend vLLM
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
vLLM has three kinds of extension points: entry-point groups that vLLM reads, registries that your plugin fills, and config fields that name your class.
The idea#
An extension point is a place where code from outside the vLLM repository can change what vLLM does. Such code is out-of-tree (OOT). vLLM has about twenty extension points. They come in three kinds, and each kind reaches your code in a different way.
- Entry-point groups. Your package publishes an entry point under a plugin group, for example
vllm.platform_plugins. vLLM finds it with no help from you. There are six groups. The page How vLLM finds and loads plugins explains how and when each process loads them. - Registries. vLLM keeps tables of models, layers, attention backends, quantization methods, connectors and more. A function such as
ModelRegistry.register_modeladds your class to one table. You call these functions from a general plugin, so that the call runs in every process. - Config hooks. A config field or a CLI flag names your class or your module, for example
--scheduler-cls my_pkg.MyScheduler. vLLM imports the name when it needs the class. No entry point is necessary.
The three kinds work together. A typical plugin package publishes one general plugin. Its function calls several register functions. A platform plugin also sets config fields, for example worker_cls, in its check_and_update_config hook.
An everyday comparison. Think of a large office building. The entry-point groups are the front desk: it has a list of named visitors, and it calls each one at a fixed time. The registries are the notice boards on each floor: a visitor pins a card there, and the staff on that floor read it. The config hooks are an order form: you write the name of a contractor, and the building calls that contractor. The comparison stops here. In vLLM each process is its own building with its own notice boards. A card that you pin in the API server is not visible in a worker.
The picture#
Read the diagram column by column. Each item names the line that reads the extension point, so it shows where your code must be visible. Green and violet items need no extra work in that process. A blue item works only if a general plugin (or your platform) has filled the registry in that same process.
The interactive timeline below shows the order of the start-up. Choose the settings, and read which plugin groups and Platform hooks run in each process.
This interactive visual needs JavaScript. Turn on JavaScript in your browser to use it. The text on this page explains the same idea.
In the code#
Step 1: the six entry-point groups#
Five group names are constants in vllm/plugins/__init__.py. The sixth group is in the sampler code:
# Default plugins group will be loaded in all processes(process0, engine core
# process and worker processes)
DEFAULT_PLUGINS_GROUP = "vllm.general_plugins"
# IO processor plugins group will be loaded in process0 only
IO_PROCESSOR_PLUGINS_GROUP = "vllm.io_processor_plugins"
# Platform plugins group will be loaded in all processes when
# `vllm.platforms.current_platform` is called and the value not initialized,
PLATFORM_PLUGINS_GROUP = "vllm.platform_plugins"
# Stat logger plugins group will be loaded in process0 only when serve vLLM with
# async mode.
STAT_LOGGER_PLUGINS_GROUP = "vllm.stat_logger_plugins"
# Endpoint plugins group is loaded in the API server front end process only.
# Each entry point resolves to a factory returning an `EndpointPlugin`
# (see `vllm/plugins/endpoint_plugins/interface.py`).
ENDPOINT_PLUGINS_GROUP = "vllm.endpoint_plugins"The table gives the short form. The page How vLLM finds and loads plugins has the full detail for each group: the loader, the filter and the error handling.
| Group | Your entry point gives | Loaded at | Process, when | It can change | Page |
|---|---|---|---|---|---|
vllm.general_plugins | a function with no arguments | vllm/plugins/__init__.py:77-90 | every process, early in start-up | anything; usually it calls the register functions of Step 2 | How vLLM finds and loads plugins |
vllm.platform_plugins | a detector that returns a Platform class name or None | vllm/platforms/__init__.py:250 | every process, first read of current_platform | the whole hardware layer | Platform plugins and current_platform |
vllm.stat_logger_plugins | a StatLoggerBase subclass | vllm/v1/metrics/loggers.py:84-98 | front end, AsyncLLM.__init__ | where metrics go | Plugins in the API server |
vllm.endpoint_plugins | a factory that returns an EndpointPlugin | vllm/plugins/__init__.py:93-159 | front end, build_app; only when named in VLLM_PLUGINS | HTTP routes and app state | Plugins in the API server |
vllm.io_processor_plugins | a function that returns an IO processor class name or None | vllm/plugins/io_processors/__init__.py:42-61 | front end, only for a pooling model that names a plugin (vllm/plugins/io_processors/__init__.py:52-54) | input and output of pooling requests | Plugins in the API server |
vllm.logits_processors | a Model Runner V2 LogitsProcessor subclass | vllm/v1/worker/gpu/sample/logits_processor/loader.py:37-74 | workers (load_model) and front end (request check); ignores VLLM_PLUGINS | the logits of a batch | Sampling and logits processors in Model Runner V2 |
Step 2: registries that a general plugin fills#
Most extension points are registries. A registry is a module-level table plus a register function. The KV connector factory is a short example:
class KVConnectorFactory:
_registry: dict[str, Callable[[], type[KVConnectorBase]]] = {}
@classmethod
def register_connector(cls, name: str, module_path: str, class_name: str) -> None:
"""Register a connector with a lazy-loading module and class name."""
if name in cls._registry:
raise ValueError(f"Connector '{name}' is already registered.")
def loader() -> type[KVConnectorBase]:
module = importlib.import_module(module_path)
return getattr(module, class_name)
cls._registry[name] = loaderValueError.vLLM reads the table later, in the process that builds the part. So the register call must run in that process before the read. A general plugin does this, because each process runs it (Step 1). The table lists the registries.
Step 3: config hooks that name your class#
A config hook is a field of VllmConfig (or a CLI flag) that holds the name of your class or module. The front end parses it. The other processes get it inside the pickled VllmConfig, because the start method is spawn and the config is a process argument (vllm/v1/engine/utils.py:256-287). The scheduler hook shows the pattern:
def get_scheduler_cls(self) -> type["SchedulerInterface"]:
if self.scheduler_cls is None:
if self.async_scheduling:
from vllm.v1.core.sched.async_scheduler import AsyncScheduler
return AsyncScheduler
from vllm.v1.core.sched.scheduler import Scheduler
return Scheduler
# The first half of this warning can be removed once the Scheduler interface is
# finalized and we can maintain support for scheduler classes that implement it
if not (
isinstance(self.scheduler_cls, str)
and self.scheduler_cls.startswith("vllm.")
):
logger.warning_once(
"Using custom scheduler class %s. This scheduler interface is not "
"public and compatibility may not be maintained. If you have "
"subclassed Scheduler instead of AsyncScheduler, you will see "
"degraded performance due to async scheduling being disabled.",
self.scheduler_cls, # type: ignore[arg-type]
)
if not isinstance(self.scheduler_cls, str):
return cast(type["SchedulerInterface"], self.scheduler_cls)
return resolve_obj_by_qualname(self.scheduler_cls)vllm.: a warning, then resolve_obj_by_qualname imports the class from a dotted name.Two more fields name classes. logits_processors takes classes or "module:Class" strings (vllm/config/model.py:407, vllm/v1/worker/gpu/sample/logits_processor/loader.py:77-105). model_class_overrides registers a model class in every process, but its docstring says it is "for development and debugging purposes only" (vllm/config/model.py:351-357, vllm/config/model.py:1170-1193).
Step 4: which one do I need?#
Start from your goal. Each line names the extension point and the page that explains it.
- New hardware. Write a platform plugin (
vllm.platform_plugins) with aPlatformsubclass. Setworker_clsincheck_and_update_config, and add a general plugin for your custom ops. See Platform plugins and current_platform and The Platform interface. - New model architecture. Call
ModelRegistry.register_model("MyArch", "my_pkg.model:MyModel")from a general plugin. See Out-of-tree models and the model registry. - A faster kernel for one op. Subclass the in-tree
CustomOpand decorate it withregister_oot. On an out-of-tree platform, an enabled op callsforward_oot(vllm/model_executor/custom_op.py:183,vllm/model_executor/custom_op.py:202-203). See CustomOp, out-of-tree ops and IR op providers. - New quantization format. Use
@register_quantization_config("my_quant")in a general plugin. See Quantization methods as plugins. - A custom HTTP route. Write an endpoint plugin (
vllm.endpoint_plugins), and name it inVLLM_PLUGINS. See Plugins in the API server. - A custom metrics sink. Publish a
StatLoggerBasesubclass invllm.stat_logger_plugins. See Plugins in the API server. - KV offload to your storage. Write a KV connector. Register it with
KVConnectorFactory.register_connector, or name its module inkv_connector_module_path. See KV connectors. - A different scheduling policy. Subclass the scheduler and set
--scheduler-cls. See Hooks in the engine core and the workers.
The tours Tour: Life of a plugin and Tour: Build a toy plugin follow one plugin through these steps. The page Extension points will hold the full reference table.
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 diveRegistries live in one process
Each registry is a Python object at module level: a dict or a list in a module or a class. Examples: KVConnectorFactory._registry (vllm/distributed/kv_transfer/kv_connector/factory.py:28), _ATTN_OVERRIDES (vllm/v1/attention/backends/registry.py:344), _CUSTOMIZED_METHOD_TO_QUANT_CONFIG (vllm/model_executor/layers/quantization/__init__.py:58). A spawned child is a new interpreter, so it starts with the built-in entries only. (Derived from the start method, vllm/entrypoints/common/startup.py:147-149.)
So the place of the register call matters:
- A general plugin function runs in each process (
vllm/v1/engine/core.py:132,vllm/v1/worker/worker_base.py:284). Its register calls reach every registry. This is the normal way. - A platform module can register at import time, or in a hook. The test platform uses a second entry point in
vllm.general_pluginsto import its custom ops (tests/plugins/vllm_add_dummy_platform/setup.py:10-17). - Code in your own launch script runs only in that process. With
vllm servethe workers never see it.
Config hooks do not have this problem. The value is a string in VllmConfig, and each process imports the class itself. For example, a worker resolves worker_cls with resolve_obj_by_qualname (vllm/v1/worker/worker_base.py:293-296).
Worked example. One general plugin function registers a model, a quantization method and a KV connector. You run vllm serve with TP = 2. The function runs 4 times: front end, engine core, worker 0, worker 1. The front end uses the model entry for inspect_model_cls and the quantization entry to make the quantization config object. It uses the connector entry to check the connector class. The engine core uses the connector in the scheduler role. Each worker uses the model entry and the connector entry. It gets the quantization config as an object inside the pickled VllmConfig; Python imports the module of its class by name when it unpickles it. (Derived from the read lines in Step 2. A model registry subprocess, if one runs, is one more process.)
Deep diveWhat a second registration does, per registry
A general plugin runs in each process once. But two packages can use the same name, or a name can collide with a built-in entry. The registries do not agree on what happens then:
| Register function | Name exists already | Where |
|---|---|---|
ModelRegistry.register_model | overwrites; DEBUG log | vllm/model_executor/models/registry.py:1179-1185 |
CustomOp.register_oot, PluggableLayer.register_oot | AssertionError ("Duplicate op name" or "Duplicate layer name") | vllm/model_executor/custom_op.py:338, vllm/model_executor/custom_op.py:86 |
register_backend | an enum member: overwrites the override, no log; a new string name that exists already: ValueError | vllm/v1/attention/backends/registry.py:406-418, vllm/v1/attention/backends/registry.py:180-185 |
register_quantization_config | overwrites; DEBUG log | vllm/model_executor/layers/quantization/__init__.py:89-95, vllm/model_executor/layers/quantization/__init__.py:106 |
register_model_loader | overwrites; WARNING log | vllm/model_executor/model_loader/__init__.py:101-107 |
KVConnectorFactory.register_connector, ECConnectorFactory.register_connector | ValueError | vllm/distributed/kv_transfer/kv_connector/factory.py:33-34, vllm/distributed/ec_transfer/ec_connector/factory.py:26-27 |
ToolParserManager.register_module(module=...) | overwrites when force=True (the default); KeyError when force=False | vllm/tool_parsers/abstract_tool_parser.py:307-311 |
ReasoningParserManager.register_module(module=...) | same rule as the tool parser manager | vllm/reasoning/abs_reasoning_parsers.py:310-314 |
LoRAResolverRegistry.register_resolver | overwrites; WARNING log | vllm/lora/resolver.py:64-72 |
SleepModeBackendFactory.register_backend | ValueError | vllm/device_allocator/sleep_mode_backend.py:173-174 |
register_kv_cache_dtype | overwrites the handler; WARNING log | vllm/config/cache.py:139-152 |
A ValueError or an AssertionError inside a general plugin function stops the start-up of that process, because load_general_plugins() does not catch it (vllm/plugins/__init__.py:89-90).
register_quantization_config has one more effect. A new name is added to QUANTIZATION_METHODS. It is also added to current_platform.supported_quantization, but only when that list is not empty (vllm/model_executor/layers/quantization/__init__.py:96-100). An empty list means "no limit" in verify_quantization (vllm/platforms/interface.py:1153-1158). The module imports current_platform at module level (vllm/model_executor/layers/quantization/__init__.py:11), so its import resolves the platform, if no code has done so before.
Deep diveName formats: dots, colons and paths
The extension points do not use one format for a class name. Use the right one, or the import fails.
| Where | Format | Parsed by |
|---|---|---|
Platform detector result, worker_cls, worker_extension_cls, scheduler_cls, register_backend(class_path=...) | pkg.module.Class (dots only) | resolve_obj_by_qualname: split at the last dot (vllm/utils/import_utils.py:119-123) |
register_model, logits_processors, model_class_overrides | pkg.module:Class (one colon) | split at ":"; other counts raise ValueError (vllm/model_executor/models/registry.py:1187-1193, vllm/v1/worker/gpu/sample/logits_processor/loader.py:99-104) |
| KV and EC connectors | module path and class name as two strings | importlib.import_module and getattr (vllm/distributed/kv_transfer/kv_connector/factory.py:36-38) |
--tool-parser-plugin, --reasoning-parser-plugin | a module name, or a path to a .py file | import_plugin: a module first; if no such module exists, a file. A failure of the file import is logged, not raised (vllm/utils/import_utils.py:81-98) |
The parser plugin flags have one more rule: vLLM imports the value only when it is longer than 3 characters (vllm/entrypoints/common/cli_args.py:579, vllm/entrypoints/common/cli_args.py:582). The code does not say why.
Module path versus registry. The KV and EC connector factories use the two sources in the opposite order. For KV connectors, kv_connector_module_path wins over the registry (vllm/distributed/kv_transfer/kv_connector/factory.py:106-108). For EC connectors, the registry wins, and ec_connector_module_path is the fallback (vllm/distributed/ec_transfer/ec_connector/factory.py:66-73).
Deep diveLazy registration: why many registries store strings
Several registries store a name or a loader, not a class:
register_modelwith a"module:Class"string makes a lazy entry. The docstring says that this avoids "Cannot re-initialize CUDA in forked subprocess" (vllm/model_executor/models/registry.py:1165-1174).- The connector factories and the sleep-mode factory store a function that imports the module later (
vllm/distributed/kv_transfer/kv_connector/factory.py:36-40,vllm/device_allocator/sleep_mode_backend.py:176-180). - The decorator form of
ToolParserManager.register_modulestores only the module path and the class name (vllm/tool_parsers/abstract_tool_parser.py:352-368). register_backendstores a dotted path (vllm/v1/attention/backends/registry.py:406-418).
The general plugin runs in the front end too, where no model runs. A lazy entry keeps heavy imports (CUDA code, kernels) out of the front end. Only the process that uses the entry imports the module.
What can go wrong#
- The registry is empty in a worker. You registered from a launch script or from code that runs only in the front end. Register from a general plugin instead (first deep dive).
- An allowlist filters your general plugin.
VLLM_PLUGINSapplies to general plugins too (vllm/plugins/__init__.py:63-64). Then no register call runs, with no error. Use the timeline above withVLLM_PLUGINS=acme_adminto see it. - A short parser plugin value is ignored. A value of 3 characters or fewer is not imported (
vllm/entrypoints/common/cli_args.py:579). A path that fails to import only writes an ERROR log (vllm/utils/import_utils.py:94-98). - A custom scheduler loses async scheduling. vLLM warns: if you subclass
Schedulerinstead ofAsyncScheduler, async scheduling is off and performance drops (vllm/config/scheduler.py:247-253). - A worker extension hides a method. If the worker class already has an attribute of the extension, an
assertstops start-up (vllm/v1/worker/worker_base.py:310-319).
Why it matters. The right extension point depends on the process that must see your code. An entry-point group or a config hook reaches its process by itself. A registry reaches only the process where your register call ran. Most "it works in the front end but not in the worker" problems come from this difference.
Check yourself#
- 1What are the three kinds of extension points, and which kind needs a general plugin?easy
Show answer
Entry-point groups, registries and config hooks. Registries need a general plugin (or another way to run the register call in each process). Each registry is a module-level object of one process. The general plugin runs in every process (
vllm/plugins/__init__.py:77-90). - 2You want a new HTTP route. Which extension point do you use, and what must you also set?easy
Show answer
An endpoint plugin in
vllm.endpoint_plugins. You must also name it inVLLM_PLUGINS, because endpoint plugins load only when the list names them (vllm/plugins/__init__.py:122-131). - 3Your general plugin callsmedium
KVConnectorFactory.register_connector("NixlConnector", ...). What happens at start-up?Show answer
vLLM already registers a connector with this name when the factory module loads (
vllm/distributed/kv_transfer/kv_connector/factory.py:177-181). The register function raisesValueErrorfor a name that exists (vllm/distributed/kv_transfer/kv_connector/factory.py:33-34). The plugin function does not catch it, so start-up stops (vllm/plugins/__init__.py:89-90). - 4You setmedium
--scheduler-cls my_pkg.sched:MySched. Why does it fail, and what is the right form?Show answer
scheduler_clsis parsed byresolve_obj_by_qualname, which splits at the last dot (vllm/config/scheduler.py:256,vllm/utils/import_utils.py:119-123). The colon form is forregister_model. Writemy_pkg.sched.MySched. - 5You serve with TP = 2. You sethard
kv_connector_module_pathand also register a connector with the same class name through a general plugin. Which class runs, and in which processes is it made?Show answer
The class from
kv_connector_module_path: for KV connectors the module path wins over the registry (vllm/distributed/kv_transfer/kv_connector/factory.py:106-108). It is made in the engine core, in the scheduler role (vllm/v1/core/sched/scheduler.py:177-181), and in each of the 2 workers, in the worker role (vllm/distributed/kv_transfer/kv_transfer_state.py:88-92). The front end only gets the class, for config checks (vllm/config/vllm.py:2243). For EC connectors the order is the opposite (vllm/distributed/ec_transfer/ec_connector/factory.py:66-73).