vLLM Explained

Plugins in the API server

IntermediatevLLM conceptsabout 10 min read + 3 min deep dives

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 when VLLM_PLUGINS names 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-plugin or --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#

Figure: when each front-end plugin loads (panel 1) and what it touches during one request (panel 2).
Open full size ↗

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
  1. 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).
  2. Line 133. The shared loader filters by the entry-point name in VLLM_PLUGINS (vllm/plugins/__init__.py:63-64). The name attribute of the object is for logs; the docstring calls it the allowlist name too, so give both the same value.
  3. Lines 137-141. A factory that raises is logged and skipped.
  4. Lines 143-154. required_tasks must 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_client

Its 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,
            )
            raise

register_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)} }})"
        )
  1. Lines 579-583. The value is imported only if it is longer than 3 characters. The code does not say why.
  2. import_plugin tries 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 into sys.modules (vllm/utils/import_utils.py:101-116), so a decorator entry that points to it works.
  3. Lines 589-594. With --enable-auto-tool-choice, an unknown --tool-call-parser raises KeyError at 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"

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_result

The 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 factories

AsyncLLM.__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 dive

The five extension points side by side

Extension pointHow it is foundVLLM_PLUGINSLoaded atFailure
endpoint plugingroup vllm.endpoint_pluginsmust name itbuild_app (vllm/entrypoints/launchers/app.py:51)factory error: log and skip (vllm/plugins/__init__.py:137-141)
IO processor plugingroup vllm.io_processor_plugins + a namefilterspooling serving set-up (vllm/entrypoints/pooling/pooling/io_processor.py:51-55)function error: WARNING; none usable: ValueError
tool / reasoning parserflag imports a module or filenot usedvllm/entrypoints/common/cli_args.py:576-583import error of a file: ERROR log only
LoRA resolverregistry, filled by a general pluginfilters the general pluginvllm/entrypoints/openai/models/serving.py:113-117filesystem resolver: ValueError at registration
stat loggergroup vllm.stat_logger_pluginsfiltersvllm/v1/engine/async_llm.py:156not a subclass: TypeError
Deep dive

How 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):

  1. Remember the ids of all routes that exist before the plugins run (line 109).
  2. Call attach_router(app) for each plugin, in discovery order (lines 110-111).
  3. Walk all routes. A new APIRoute removes 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).
  4. Insert the new route at the place of the first route it replaced, so it stays ahead of mounts (lines 133, 140).
  5. Reset the OpenAPI schema, and keep the plugin list in app.state.endpoint_plugins for 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 dive

Lazy 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.

  1. init_parser_plugin sees a value longer than 3 characters and calls import_plugin (vllm/entrypoints/common/cli_args.py:579-580).
  2. importlib.import_module("/opt/acme_parser.py") raises ModuleNotFoundError, so vLLM loads the file as module acme_parser (vllm/utils/import_utils.py:88-93).
  3. The decorator stores lazy_parsers["acme"] = ("acme_parser", "AcmeToolParser") (vllm/tool_parsers/abstract_tool_parser.py:353-366).
  4. validate_api_server_args finds "acme" in list_registered() (vllm/tool_parsers/abstract_tool_parser.py:373-375).
  5. The first lookup imports acme_parser from sys.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 dive

Environment variables

VariableDefaultRead atEffect
VLLM_PLUGINSnot setvllm/plugins/__init__.py:122Endpoint plugins load only when it is set and names them.
VLLM_ALLOW_RUNTIME_LORA_UPDATING0 (vllm/envs.py:1197-1200)vllm/entrypoints/serve/engine/serving.py:63Without it, LoRA resolvers are never asked.
VLLM_LORA_RESOLVER_CACHE_DIRnot set (vllm/envs.py:1171-1173)vllm/plugins/lora_resolvers/filesystem_resolver.py:51Turns on the filesystem resolver.
VLLM_LORA_RESOLVER_HF_REPO_LISTnot set (vllm/envs.py:1178-1180)vllm/plugins/lora_resolvers/hf_hub_resolver.py:131Comma-separated repos for the HF Hub resolver.

What can go wrong#

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#

  1. 1
    Which front-end plugin group loads only when VLLM_PLUGINS names it?
    easy
    Show answer

    vllm.endpoint_plugins. With VLLM_PLUGINS unset, load_endpoint_plugins returns an empty list after a warning (vllm/plugins/__init__.py:122-131).

  2. 2
    What must the object behind a vllm.stat_logger_plugins entry point be?
    easy
    Show answer

    A class that subclasses StatLoggerBase, else TypeError (vllm/v1/metrics/loggers.py:87-94).

  3. 3
    You pass --reasoning-parser-plugin my.py --reasoning-parser mine. In which processes does vLLM import my.py?
    medium
    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).

  4. 4
    Your endpoint plugin registers POST /v1/completions. What happens to vLLM's own route, and to a GET on the same path?
    medium
    Show answer

    The plugin route replaces the core POST route; other methods of the path stay (vllm/plugins/endpoint_plugins/interface.py:121-141, docstring vllm/plugins/endpoint_plugins/interface.py:72-74).

  5. 5
    A request asks for model "sql-lora", which the server does not know. You set VLLM_LORA_RESOLVER_CACHE_DIR=/loras and VLLM_PLUGINS=acme_admin. Is the adapter found?
    hard
    Show answer

    No. The filesystem resolver registers from the general plugin lora_filesystem_resolver (pyproject.toml:45-46), and VLLM_PLUGINS=acme_admin filters it out (vllm/plugins/__init__.py:63-64). Also, resolvers run only with VLLM_ALLOW_RUNTIME_LORA_UPDATING=1 (vllm/entrypoints/serve/engine/serving.py:62-66). The request gets "The model `sql-lora` does not exist."