[Frontend] Add site-packages support for reasoning/tool parser plugins (#45241)

Signed-off-by: odashi <yusuke.oda@predicate.jp> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>

Yusuke Oda committed Sep 2, 2026 at 16:41 UTC 798b557e061b5b7554e2a26ef92f1ec88e9518e9
4 files changed +63 -28
tests/utils_/test_import_utils.py
+35 -1
@@ -1,10 +1,11 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import sys
4 from unittest.mock import MagicMock, patch
5
6 import pytest
7
7 -from vllm.utils.import_utils import PlaceholderModule, _has_module
8 +from vllm.utils.import_utils import PlaceholderModule, _has_module, import_plugin
9
10
11 def _raises_module_not_found():
@@ -101,3 +102,36 @@ class TestHasModule:
102 result = _has_module("json") # should hit cache
103 mock_spec.assert_not_called()
104 assert result is True
105 +
106 +
107 +class TestImportPlugin:
108 + def test_importing_from_site_packages(self):
109 + import json
110 +
111 + result = import_plugin("json")
112 + assert result is json
113 +
114 + def test_importing_from_file(self, tmp_path):
115 + plugin_file = tmp_path / "my_test_plugin.py"
116 + plugin_file.write_text("VALUE = 42\n")
117 +
118 + try:
119 + result = import_plugin(str(plugin_file))
120 + assert result is not None
121 + assert result.VALUE == 42
122 + finally:
123 + sys.modules.pop("my_test_plugin", None)
124 +
125 + def test_returns_none_when_both_attempts_fail(self):
126 + with (
127 + patch(
128 + "vllm.utils.import_utils.import_from_path",
129 + side_effect=FileNotFoundError("no such file"),
130 + ),
131 + patch(
132 + "vllm.utils.import_utils.importlib.import_module",
133 + side_effect=ModuleNotFoundError("no such module"),
134 + ),
135 + ):
136 + result = import_plugin("nonexistent_plugin_xyz")
137 + assert result is None
vllm/reasoning/abs_reasoning_parsers.py
+3 -15
@@ -2,7 +2,6 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import importlib
5 -import os
5 from abc import abstractmethod
6 from collections.abc import Callable, Iterable, Sequence
7 from functools import cached_property
@@ -11,7 +10,7 @@ from typing import TYPE_CHECKING, cast
10 from vllm.entrypoints.mcp.tool_server import ToolServer
11 from vllm.logger import init_logger
12 from vllm.utils.collection_utils import is_list_of
14 -from vllm.utils.import_utils import import_from_path
13 +from vllm.utils.import_utils import import_plugin
14
15 if TYPE_CHECKING:
16 from vllm.config import ModelConfig
@@ -359,16 +358,5 @@ class ReasoningParserManager:
358
359 @classmethod
360 def import_reasoning_parser(cls, plugin_path: str) -> None:
362 - """
363 - Import a user-defined reasoning parser by the path
364 - of the reasoning parser define file.
365 - """
366 - module_name = os.path.splitext(os.path.basename(plugin_path))[0]
367 -
368 - try:
369 - import_from_path(module_name, plugin_path)
370 - except Exception:
371 - logger.exception(
372 - "Failed to load module '%s' from %s.", module_name, plugin_path
373 - )
374 - return
361 + """Import a user-defined reasoning parser."""
362 + import_plugin(plugin_path)
vllm/tool_parsers/abstract_tool_parser.py
+3 -11
@@ -3,7 +3,6 @@
3
4 import importlib
5 import json
6 -import os
6 from collections.abc import Callable, Sequence
7 from functools import cached_property
8 from typing import Any
@@ -33,7 +32,7 @@ from vllm.sampling_params import (
32 from vllm.tokenizers import TokenizerLike
33 from vllm.tool_parsers.utils import Tool, get_json_schema_from_tools
34 from vllm.utils.collection_utils import is_list_of
36 -from vllm.utils.import_utils import import_from_path
35 +from vllm.utils.import_utils import import_plugin
36
37 __all__ = ["Tool"]
38
@@ -367,12 +366,5 @@ class ToolParserManager:
366
367 @classmethod
368 def import_tool_parser(cls, plugin_path: str) -> None:
370 - """Import a user-defined parser file from arbitrary path."""
371 -
372 - module_name = os.path.splitext(os.path.basename(plugin_path))[0]
373 - try:
374 - import_from_path(module_name, plugin_path)
375 - except Exception:
376 - logger.exception(
377 - "Failed to load module '%s' from %s.", module_name, plugin_path
378 - )
369 + """Import a user-defined tool parser."""
370 + import_plugin(plugin_path)
vllm/utils/import_utils.py
+22 -1
@@ -82,7 +82,28 @@ def import_triton_kernels():
82 )
83
84
85 -def import_from_path(module_name: str, file_path: str | os.PathLike):
85 +def import_plugin(plugin_path: str) -> ModuleType | None:
86 + """
87 + Import a user-defined plugin.
88 +
89 + Plugin can be either:
90 + * a module in site-packages
91 + * a Python file specified by its path
92 + """
93 + try:
94 + return importlib.import_module(plugin_path)
95 + except ModuleNotFoundError:
96 + module_name = os.path.splitext(os.path.basename(plugin_path))[0]
97 + try:
98 + return import_from_path(module_name, plugin_path)
99 + except Exception:
100 + logger.exception(
101 + "Failed to load module '%s' from %s.", module_name, plugin_path
102 + )
103 + return None
104 +
105 +
106 +def import_from_path(module_name: str, file_path: str | os.PathLike) -> ModuleType:
107 """
108 Import a Python file according to its file path.
109