A high-throughput and memory-efficient inference and serving engine for LLMs
[Rust Frontend] Add Python bridge for Rust tool parsers (#44624)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Jun 11, 2026 at 12:51 UTC
43914dd743ab0500abcd69fe072e02465c944dcf
16 files changed
+1302
-38
.buildkite/scripts/run-rust-frontend-cargo-ci.sh
+31
@@ -110,6 +110,36 @@ install_uv() {
110
| env UV_INSTALL_DIR="$CARGO_HOME/bin" sh
111
}
112
113
+setup_pyo3_python() {
114
+ local python_version="${PYO3_PYTHON_VERSION:-3.12}"
115
+
116
+ log_section "Installing Python ${python_version} for PyO3 tests"
117
+ uv python install "$python_version"
118
+ PYO3_PYTHON="$(uv python find \
119
+ --managed-python \
120
+ --no-project \
121
+ --resolve-links \
122
+ "$python_version")"
123
+ export PYO3_PYTHON
124
+
125
+ local python_libdir
126
+ python_libdir="$("$PYO3_PYTHON" - <<'PY'
127
+import pathlib
128
+import sysconfig
129
+
130
+libdir = pathlib.Path(sysconfig.get_config_var("LIBDIR"))
131
+ldlibrary = sysconfig.get_config_var("LDLIBRARY")
132
+assert sysconfig.get_config_var("Py_ENABLE_SHARED") == 1
133
+assert ldlibrary
134
+assert (libdir / ldlibrary).exists(), libdir / ldlibrary
135
+print(libdir)
136
+PY
137
+)"
138
+
139
+ export LD_LIBRARY_PATH="${python_libdir}:${LD_LIBRARY_PATH:-}"
140
+ export LIBRARY_PATH="${python_libdir}:${LIBRARY_PATH:-}"
141
+}
142
+
143
run_style_clippy() {
144
install_cargo_sort
145
@@ -132,6 +162,7 @@ run_style_clippy() {
162
163
run_tests() {
164
install_uv
165
+ setup_pyo3_python
166
install_cargo_nextest
167
168
log_section "Running cargo nextest"
.buildkite/test_areas/misc.yaml
+2
-2
@@ -301,9 +301,9 @@ steps:
301
- tests/multimodal
302
- tests/renderers
303
- tests/standalone_tests/lazy_imports.py
304
- - tests/tokenizers_
304
- tests/reasoning
305
- tests/tool_parsers
306
+ - tests/tokenizers_
307
- tests/parser
308
- tests/transformers_utils
309
- tests/config
@@ -317,9 +317,9 @@ steps:
317
- pytest -v -s test_ray_env.py
318
- pytest -v -s -m 'cpu_test' multimodal
319
- pytest -v -s renderers
320
- - pytest -v -s tokenizers_
320
- pytest -v -s reasoning --ignore=reasoning/test_seedoss_reasoning_parser.py --ignore=reasoning/test_glm4_moe_reasoning_parser.py
321
- pytest -v -s tool_parsers
322
+ - pytest -v -s tokenizers_
323
- pytest -v -s parser
324
- pytest -v -s transformers_utils
325
- pytest -v -s config
build_rust.sh
+1
-1
@@ -1,5 +1,5 @@
1
#!/bin/bash
2
-# Build the vllm-rs Rust frontend binary.
2
+# Build vLLM Rust artifacts and install them into the vllm package.
3
# Usage: ./build_rust.sh [--debug]
4
#
5
# By default builds in release mode. Pass --debug for faster compile times
docker/Dockerfile
+8
-5
@@ -281,7 +281,8 @@ COPY requirements/build/rust.txt requirements/build/rust.txt
281
RUN --mount=type=cache,target=/opt/uv/cache \
282
uv pip install --python /opt/venv/bin/python3 -r requirements/build/rust.txt
283
284
-# Copy only the Rust build inputs. The binary is the sole artifact we need.
284
+# Copy only the Rust build inputs; build_rust.sh publishes artifacts needed
285
+# by the wheel build stage.
286
COPY rust rust
287
COPY rust-toolchain.toml rust-toolchain.toml
288
COPY tools/build_rust.py tools/build_rust.py
@@ -291,8 +292,9 @@ COPY build_rust.sh build_rust.sh
292
# (rustc spawns enough concurrent processes to hit RLIMIT_NOFILE otherwise).
293
ENV CARGO_BUILD_JOBS=4
294
294
-# Build the release binary. Cache cargo registry/git, but not target/, because
295
-# stale target metadata can outlive source updates across BuildKit cache reuse.
295
+# Build the release artifacts. Cache cargo registry/git, but not target/,
296
+# because stale target metadata can outlive source updates across BuildKit
297
+# cache reuse.
298
RUN --mount=type=cache,target=/root/.cargo/registry \
299
--mount=type=cache,target=/root/.cargo/git \
300
bash build_rust.sh
@@ -503,9 +505,10 @@ WORKDIR /workspace
505
COPY --from=csrc-build /workspace/dist /precompiled-wheels
506
COPY . .
507
506
-# Drop the pre-built rust frontend binary into the source tree. setup.py
507
-# detects it and ships it as-is, skipping the local cargo build.
508
+# Drop the pre-built Rust artifacts into the source tree. setup.py detects
509
+# them and ships them as-is, skipping the local Rust build.
510
COPY --from=rust-build /workspace/vllm/vllm-rs vllm/vllm-rs
511
+COPY --from=rust-build /workspace/vllm/_rust_*.so vllm/
512
513
ARG GIT_REPO_CHECK=0
514
RUN --mount=type=bind,source=.git,target=.git \
docker/Dockerfile.cpu
+8
-5
@@ -104,7 +104,8 @@ WORKDIR /workspace
104
COPY requirements/build/rust.txt requirements/build/rust.txt
105
RUN python3 -m pip install --no-cache-dir -r requirements/build/rust.txt
106
107
-# Copy only the Rust build inputs. The binary is the sole artifact we need.
107
+# Copy only the Rust build inputs; build_rust.sh publishes artifacts needed
108
+# by the wheel build stage.
109
COPY rust rust
110
COPY rust-toolchain.toml rust-toolchain.toml
111
COPY tools/build_rust.py tools/build_rust.py
@@ -114,8 +115,9 @@ COPY build_rust.sh build_rust.sh
115
# (rustc spawns enough concurrent processes to hit RLIMIT_NOFILE otherwise).
116
ENV CARGO_BUILD_JOBS=4
117
117
-# Build the release binary. Cache cargo registry/git, but not target/, because
118
-# stale target metadata can outlive source updates across BuildKit cache reuse.
118
+# Build the release artifacts. Cache cargo registry/git, but not target/,
119
+# because stale target metadata can outlive source updates across BuildKit
120
+# cache reuse.
121
RUN --mount=type=cache,target=/root/.cargo/registry,sharing=locked \
122
--mount=type=cache,target=/root/.cargo/git,sharing=locked \
123
bash build_rust.sh
@@ -151,9 +153,10 @@ RUN --mount=type=cache,target=/root/.cache/uv \
153
154
COPY . .
155
154
-# Drop the pre-built rust frontend binary into the source tree. setup.py
155
-# detects it and ships it as-is, skipping the local cargo build.
156
+# Drop the pre-built Rust artifacts into the source tree. setup.py detects
157
+# them and ships them as-is, skipping the local Rust build.
158
COPY --from=rust-build /workspace/vllm/vllm-rs vllm/vllm-rs
159
+COPY --from=rust-build /workspace/vllm/_rust_*.so vllm/
160
161
RUN if [ "$GIT_REPO_CHECK" != 0 ]; then bash tools/check_repo.sh ; fi
162
docker/Dockerfile.nightly_torch
+5
-3
@@ -113,7 +113,8 @@ WORKDIR /workspace
113
COPY requirements/build/rust.txt requirements/build/rust.txt
114
RUN python3 -m pip install --no-cache-dir -r requirements/build/rust.txt
115
116
-# Copy only the Rust build inputs. The binary is the sole artifact we need.
116
+# Copy only the Rust build inputs; build_rust.sh publishes artifacts needed
117
+# by the wheel build stage.
118
COPY rust rust
119
COPY rust-toolchain.toml rust-toolchain.toml
120
COPY tools/build_rust.py tools/build_rust.py
@@ -138,9 +139,10 @@ ENV UV_HTTP_TIMEOUT=500
139
140
COPY . .
141
141
-# Drop the pre-built rust frontend binary into the source tree. setup.py
142
-# detects it and ships it as-is, skipping the local cargo build.
142
+# Drop the pre-built Rust artifacts into the source tree. setup.py detects
143
+# them and ships them as-is, skipping the local Rust build.
144
COPY --from=rust-build /workspace/vllm/vllm-rs vllm/vllm-rs
145
+COPY --from=rust-build /workspace/vllm/_rust_*.so vllm/
146
147
RUN python3 use_existing_torch.py
148
docker/Dockerfile.rocm
+6
-4
@@ -208,9 +208,10 @@ ENV VLLM_TARGET_DEVICE=rocm
208
209
COPY --from=csrc-build ${COMMON_WORKDIR}/vllm/dist /precompiled-wheels
210
211
-# Drop the pre-built rust frontend binary into the source tree. setup.py
212
-# detects it and ships it as-is, skipping the local cargo build.
211
+# Drop the pre-built Rust artifacts into the source tree. setup.py detects
212
+# them and ships them as-is, skipping the local Rust build.
213
COPY --from=rust-build ${COMMON_WORKDIR}/vllm/vllm/vllm-rs ${COMMON_WORKDIR}/vllm/vllm/vllm-rs
214
+COPY --from=rust-build ${COMMON_WORKDIR}/vllm/vllm/_rust_*.so ${COMMON_WORKDIR}/vllm/vllm/
215
216
RUN --mount=type=cache,id=vllm-rocm-uv,target=/root/.cache/uv \
217
cd vllm \
@@ -417,9 +418,10 @@ FROM fetch_vllm AS build_vllm_wheel_release
418
419
ARG COMMON_WORKDIR
420
420
-# Drop the pre-built rust frontend binary into the source tree. setup.py
421
-# detects it and ships it as-is, skipping the local cargo build.
421
+# Drop the pre-built Rust artifacts into the source tree. setup.py detects
422
+# them and ships them as-is, skipping the local Rust build.
423
COPY --from=rust-build ${COMMON_WORKDIR}/vllm/vllm/vllm-rs ${COMMON_WORKDIR}/vllm/vllm/vllm-rs
424
+COPY --from=rust-build ${COMMON_WORKDIR}/vllm/vllm/_rust_*.so ${COMMON_WORKDIR}/vllm/vllm/
425
426
# Create /install directory for custom wheels
427
RUN mkdir -p /install
docker/Dockerfile.xpu
+5
-3
@@ -17,7 +17,8 @@ WORKDIR /workspace
17
COPY requirements/build/rust.txt requirements/build/rust.txt
18
RUN python3 -m pip install --no-cache-dir -r requirements/build/rust.txt
19
20
-# Copy only the Rust build inputs. The binary is the sole artifact we need.
20
+# Copy only the Rust build inputs; build_rust.sh publishes artifacts needed
21
+# by the wheel build stage.
22
COPY rust rust
23
COPY rust-toolchain.toml rust-toolchain.toml
24
COPY tools/build_rust.py tools/build_rust.py
@@ -212,9 +213,10 @@ RUN --mount=type=cache,target=/root/.cache/uv \
213
# don't invalidate heavy dependency and UCX/NIXL layers.
214
COPY . .
215
215
-# Drop the pre-built rust frontend binary into the source tree. setup.py
216
-# detects it and ships it as-is, skipping the local cargo build.
216
+# Drop the pre-built Rust artifacts into the source tree. setup.py detects
217
+# them and ships them as-is, skipping the local Rust build.
218
COPY --from=rust-build /workspace/vllm/vllm-rs vllm/vllm-rs
219
+COPY --from=rust-build /workspace/vllm/_rust_*.so vllm/
220
221
ARG GIT_REPO_CHECK=0
222
RUN --mount=type=bind,source=.git,target=.git \
rust/Cargo.lock
+86
@@ -3458,6 +3458,75 @@ version = "0.1.29"
3458
source = "registry+https://github.com/rust-lang/crates.io-index"
3459
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
3460
3461
+[[package]]
3462
+name = "pyo3"
3463
+version = "0.28.3"
3464
+source = "registry+https://github.com/rust-lang/crates.io-index"
3465
+checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
3466
+dependencies = [
3467
+ "libc",
3468
+ "once_cell",
3469
+ "portable-atomic",
3470
+ "pyo3-build-config",
3471
+ "pyo3-ffi",
3472
+ "pyo3-macros",
3473
+]
3474
+
3475
+[[package]]
3476
+name = "pyo3-build-config"
3477
+version = "0.28.3"
3478
+source = "registry+https://github.com/rust-lang/crates.io-index"
3479
+checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
3480
+dependencies = [
3481
+ "target-lexicon",
3482
+]
3483
+
3484
+[[package]]
3485
+name = "pyo3-ffi"
3486
+version = "0.28.3"
3487
+source = "registry+https://github.com/rust-lang/crates.io-index"
3488
+checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
3489
+dependencies = [
3490
+ "libc",
3491
+ "pyo3-build-config",
3492
+]
3493
+
3494
+[[package]]
3495
+name = "pyo3-macros"
3496
+version = "0.28.3"
3497
+source = "registry+https://github.com/rust-lang/crates.io-index"
3498
+checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
3499
+dependencies = [
3500
+ "proc-macro2",
3501
+ "pyo3-macros-backend",
3502
+ "quote",
3503
+ "syn 2.0.117",
3504
+]
3505
+
3506
+[[package]]
3507
+name = "pyo3-macros-backend"
3508
+version = "0.28.3"
3509
+source = "registry+https://github.com/rust-lang/crates.io-index"
3510
+checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
3511
+dependencies = [
3512
+ "heck",
3513
+ "proc-macro2",
3514
+ "pyo3-build-config",
3515
+ "quote",
3516
+ "syn 2.0.117",
3517
+]
3518
+
3519
+[[package]]
3520
+name = "pythonize"
3521
+version = "0.28.0"
3522
+source = "registry+https://github.com/rust-lang/crates.io-index"
3523
+checksum = "0b79f670c9626c8b651c0581011b57b6ba6970bb69faf01a7c4c0cfc81c43f95"
3524
+dependencies = [
3525
+ "pyo3",
3526
+ "serde",
3527
+ "serde_json",
3528
+]
3529
+
3530
[[package]]
3531
name = "qoi"
3532
version = "0.4.1"
@@ -4669,6 +4738,12 @@ dependencies = [
4738
"libc",
4739
]
4740
4741
+[[package]]
4742
+name = "target-lexicon"
4743
+version = "0.13.5"
4744
+source = "registry+https://github.com/rust-lang/crates.io-index"
4745
+checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
4746
+
4747
[[package]]
4748
name = "task-local"
4749
version = "0.1.1"
@@ -5904,6 +5979,17 @@ dependencies = [
5979
"winnow",
5980
]
5981
5982
+[[package]]
5983
+name = "vllm-tool-parser-py"
5984
+version = "0.1.0"
5985
+dependencies = [
5986
+ "pyo3",
5987
+ "pythonize",
5988
+ "serde_json",
5989
+ "thiserror-ext",
5990
+ "vllm-tool-parser",
5991
+]
5992
+
5993
[[package]]
5994
name = "walkdir"
5995
version = "2.5.0"
rust/Cargo.toml
+3
@@ -12,6 +12,7 @@ members = [
12
"src/text",
13
"src/tokenizer",
14
"src/tool-parser",
15
+ "src/tool-parser/python",
16
]
17
resolver = "3"
18
@@ -60,6 +61,8 @@ prometheus-client = "0.24.0"
61
prometheus-client-derive-encode = "0.5.0"
62
prost = "0.14.3"
63
prost-types = "0.14.3"
64
+pyo3 = "0.28.3"
65
+pythonize = "0.28.0"
66
rand = "0.9.2"
67
reasoning-parser = "1.2.2"
68
reqwest = { version = "0.12.8", default-features = false, features = ["rustls-tls"] }
rust/src/tool-parser/python/Cargo.toml
new
+19
@@ -0,0 +1,19 @@
1
+[package]
2
+name = "vllm-tool-parser-py"
3
+version.workspace = true
4
+edition.workspace = true
5
+license.workspace = true
6
+
7
+[lib]
8
+name = "_rust_tool_parser"
9
+crate-type = ["cdylib", "rlib"]
10
+
11
+[dependencies]
12
+pyo3.workspace = true
13
+pythonize = { workspace = true, features = ["serde_json"] }
14
+serde_json.workspace = true
15
+thiserror-ext.workspace = true
16
+vllm-tool-parser.workspace = true
17
+
18
+[lints]
19
+workspace = true
rust/src/tool-parser/python/src/lib.rs
new
+392
@@ -0,0 +1,392 @@
1
+//! Thin PyO3 bindings for `vllm_tool_parser`.
2
+//!
3
+//! This crate exposes the Rust tool parser trait and data shapes to Python
4
+//! while keeping parser state, grammar, and schema-aware argument conversion in
5
+//! Rust. Python callers should use this module as a typed bridge and keep any
6
+//! vLLM protocol adaptation outside the binding.
7
+
8
+use pyo3::exceptions::PyValueError;
9
+use pyo3::prelude::*;
10
+use pyo3::types::{PyAny, PyModule};
11
+use pythonize::{depythonize, pythonize};
12
+use serde_json::Value;
13
+use thiserror_ext::AsReport as _;
14
+use vllm_tool_parser::{Tool, ToolCallDelta, ToolParser, ToolParserOutput};
15
+
16
+macro_rules! tool_parser_factory {
17
+ ($($parser:ident),+ $(,)?) => {
18
+ fn create_tool_parser(
19
+ name: &str,
20
+ tools: &[Tool],
21
+ ) -> PyResult<Box<dyn ToolParser>> {
22
+ match name {
23
+ $(
24
+ stringify!($parser) => {
25
+ <vllm_tool_parser::$parser as ToolParser>::create(tools)
26
+ }
27
+ )+
28
+ _ => {
29
+ return Err(PyValueError::new_err(format!(
30
+ "unsupported tool parser `{name}`"
31
+ )));
32
+ }
33
+ }
34
+ .map_err(|error| PyValueError::new_err(error.to_report_string()))
35
+ }
36
+ };
37
+}
38
+
39
+// Export a tool parser to Python by registering it here.
40
+tool_parser_factory! {
41
+ // Below are the parsers just for testing purposes on Python side.
42
+ DeepSeekV4ToolParser,
43
+ KimiK2ToolParser,
44
+}
45
+
46
+#[pyclass(name = "Tool", module = "vllm._rust_tool_parser", skip_from_py_object)]
47
+#[derive(Clone)]
48
+struct PyTool(Tool);
49
+
50
+#[pymethods]
51
+impl PyTool {
52
+ #[new]
53
+ #[pyo3(signature = (name, description, parameters, strict=None))]
54
+ fn new(
55
+ name: String,
56
+ description: Option<String>,
57
+ parameters: &Bound<'_, PyAny>,
58
+ strict: Option<bool>,
59
+ ) -> PyResult<Self> {
60
+ let parameters = depythonize::<Value>(parameters).map_err(|error| {
61
+ PyValueError::new_err(format!(
62
+ "failed to convert tool parameters from Python to JSON: {error}"
63
+ ))
64
+ })?;
65
+ Ok(Self(Tool {
66
+ name,
67
+ description,
68
+ parameters,
69
+ strict,
70
+ }))
71
+ }
72
+
73
+ #[getter]
74
+ fn name(&self) -> &str {
75
+ &self.0.name
76
+ }
77
+
78
+ #[getter]
79
+ fn description(&self) -> Option<&str> {
80
+ self.0.description.as_deref()
81
+ }
82
+
83
+ #[getter]
84
+ fn parameters(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
85
+ pythonize(py, &self.0.parameters).map(Bound::unbind).map_err(|error| {
86
+ PyValueError::new_err(format!(
87
+ "failed to convert tool parameters from JSON to Python: {error}"
88
+ ))
89
+ })
90
+ }
91
+
92
+ #[getter]
93
+ fn strict(&self) -> Option<bool> {
94
+ self.0.strict
95
+ }
96
+}
97
+
98
+#[pyclass(
99
+ name = "ToolCallDelta",
100
+ module = "vllm._rust_tool_parser",
101
+ skip_from_py_object
102
+)]
103
+#[derive(Clone)]
104
+struct PyToolCallDelta(ToolCallDelta);
105
+
106
+#[pymethods]
107
+impl PyToolCallDelta {
108
+ #[new]
109
+ #[pyo3(signature = (tool_index, name, arguments))]
110
+ fn new(tool_index: usize, name: Option<String>, arguments: String) -> Self {
111
+ Self(ToolCallDelta {
112
+ tool_index,
113
+ name,
114
+ arguments,
115
+ })
116
+ }
117
+
118
+ #[getter]
119
+ fn tool_index(&self) -> usize {
120
+ self.0.tool_index
121
+ }
122
+
123
+ #[getter]
124
+ fn name(&self) -> Option<&str> {
125
+ self.0.name.as_deref()
126
+ }
127
+
128
+ #[getter]
129
+ fn arguments(&self) -> &str {
130
+ &self.0.arguments
131
+ }
132
+}
133
+
134
+#[pyclass(
135
+ name = "ToolParserOutput",
136
+ module = "vllm._rust_tool_parser",
137
+ skip_from_py_object
138
+)]
139
+#[derive(Clone)]
140
+struct PyToolParserOutput(ToolParserOutput);
141
+
142
+#[pymethods]
143
+impl PyToolParserOutput {
144
+ #[new]
145
+ #[pyo3(signature = (normal_text="", calls=None))]
146
+ fn new(py: Python<'_>, normal_text: &str, calls: Option<Vec<Py<PyToolCallDelta>>>) -> Self {
147
+ let calls =
148
+ calls.unwrap_or_default().iter().map(|call| call.borrow(py).0.clone()).collect();
149
+ Self(ToolParserOutput {
150
+ normal_text: normal_text.to_owned(),
151
+ calls,
152
+ })
153
+ }
154
+
155
+ #[getter]
156
+ fn normal_text(&self) -> &str {
157
+ &self.0.normal_text
158
+ }
159
+
160
+ #[getter]
161
+ fn calls(&self) -> Vec<PyToolCallDelta> {
162
+ self.0.calls.iter().cloned().map(PyToolCallDelta).collect()
163
+ }
164
+
165
+ fn append(&mut self, other: PyRef<'_, PyToolParserOutput>) {
166
+ self.0.append(other.0.clone());
167
+ }
168
+
169
+ fn coalesce_calls(&self) -> Self {
170
+ Self(self.0.clone().coalesce_calls())
171
+ }
172
+}
173
+
174
+#[pyclass(name = "ToolParser", module = "vllm._rust_tool_parser", unsendable)]
175
+struct PyToolParser(Box<dyn ToolParser>);
176
+
177
+impl PyToolParser {
178
+ fn parse_into_output(&mut self, chunk: &str, output: &mut PyToolParserOutput) -> PyResult<()> {
179
+ self.0
180
+ .parse_into(chunk, &mut output.0)
181
+ .map_err(|error| PyValueError::new_err(error.to_report_string()))
182
+ }
183
+}
184
+
185
+#[pymethods]
186
+impl PyToolParser {
187
+ #[new]
188
+ fn new(py: Python<'_>, parser_name: &str, tools: Vec<Py<PyTool>>) -> PyResult<Self> {
189
+ let tools = tools.iter().map(|tool| tool.borrow(py).0.clone()).collect::<Vec<_>>();
190
+ create_tool_parser(parser_name, &tools).map(Self)
191
+ }
192
+
193
+ fn parse_into(
194
+ &mut self,
195
+ chunk: &str,
196
+ mut output: PyRefMut<'_, PyToolParserOutput>,
197
+ ) -> PyResult<()> {
198
+ self.parse_into_output(chunk, &mut output)
199
+ }
200
+
201
+ fn finish(&mut self) -> PyResult<PyToolParserOutput> {
202
+ self.0
203
+ .finish()
204
+ .map(PyToolParserOutput)
205
+ .map_err(|error| PyValueError::new_err(error.to_report_string()))
206
+ }
207
+
208
+ fn reset(&mut self) -> String {
209
+ self.0.reset()
210
+ }
211
+
212
+ fn preserve_special_tokens(&self) -> bool {
213
+ self.0.preserve_special_tokens()
214
+ }
215
+
216
+ fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
217
+ self.0.tool_call_id(tool_index)
218
+ }
219
+}
220
+
221
+#[pymodule]
222
+fn _rust_tool_parser(m: &Bound<'_, PyModule>) -> PyResult<()> {
223
+ m.add_class::<PyTool>()?;
224
+ m.add_class::<PyToolCallDelta>()?;
225
+ m.add_class::<PyToolParserOutput>()?;
226
+ m.add_class::<PyToolParser>()?;
227
+ Ok(())
228
+}
229
+
230
+#[cfg(test)]
231
+mod tests {
232
+ use super::*;
233
+ use serde_json::json;
234
+
235
+ fn with_python<R>(f: impl for<'py> FnOnce(Python<'py>) -> R) -> R {
236
+ Python::initialize();
237
+ Python::attach(f)
238
+ }
239
+
240
+ fn tool_schema() -> Value {
241
+ json!({
242
+ "type": "object",
243
+ "properties": {
244
+ "user_id": {"type": "integer"},
245
+ "shipping": {
246
+ "type": "object",
247
+ "properties": {
248
+ "city": {"type": "string"},
249
+ "zip": {"type": "integer"}
250
+ }
251
+ }
252
+ }
253
+ })
254
+ }
255
+
256
+ fn build_call() -> String {
257
+ r#"<|DSML|tool_calls>
258
+<|DSML|invoke name="create_order">
259
+<|DSML|parameter name="user_id" string="false">42</|DSML|parameter>
260
+<|DSML|parameter name="shipping" string="false">{"city":"Singapore","zip":18956}</|DSML|parameter>
261
+</|DSML|invoke>
262
+</|DSML|tool_calls>"#
263
+ .to_owned()
264
+ }
265
+
266
+ fn make_py_tool(py: Python<'_>) -> PyResult<Py<PyTool>> {
267
+ let parameters = pythonize(py, &tool_schema()).map_err(|error| {
268
+ PyValueError::new_err(format!(
269
+ "failed to convert test schema from JSON to Python: {error}"
270
+ ))
271
+ })?;
272
+ Py::new(
273
+ py,
274
+ PyTool::new(
275
+ "create_order".to_owned(),
276
+ Some("Create an order".to_owned()),
277
+ ¶meters,
278
+ None,
279
+ )?,
280
+ )
281
+ }
282
+
283
+ #[test]
284
+ fn tool_round_trips_typed_fields() {
285
+ with_python(|py| {
286
+ let tool = make_py_tool(py)?;
287
+ let borrowed = tool.borrow(py);
288
+ assert_eq!(borrowed.name(), "create_order");
289
+ assert_eq!(borrowed.description(), Some("Create an order"));
290
+ assert_eq!(borrowed.strict(), None);
291
+
292
+ let parameters = borrowed.parameters(py)?;
293
+ let parameters = depythonize::<Value>(parameters.bind(py))?;
294
+ assert_eq!(parameters, tool_schema());
295
+ PyResult::Ok(())
296
+ })
297
+ .unwrap();
298
+ }
299
+
300
+ #[test]
301
+ fn output_append_and_coalesce_calls() {
302
+ with_python(|py| {
303
+ let first = Py::new(
304
+ py,
305
+ PyToolCallDelta::new(0, Some("create_order".to_owned()), "{\"a\"".to_owned()),
306
+ )?;
307
+ let second = Py::new(py, PyToolCallDelta::new(0, None, ":1}".to_owned()))?;
308
+ let mut output = PyToolParserOutput::new(py, "text", Some(vec![first]));
309
+ let other = Py::new(py, PyToolParserOutput::new(py, "", Some(vec![second])))?;
310
+ output.append(other.borrow(py));
311
+
312
+ let coalesced = output.coalesce_calls();
313
+ assert_eq!(coalesced.normal_text(), "text");
314
+ let calls = coalesced.calls();
315
+ assert_eq!(calls.len(), 1);
316
+ assert_eq!(calls[0].tool_index(), 0);
317
+ assert_eq!(calls[0].name(), Some("create_order"));
318
+ assert_eq!(calls[0].arguments(), "{\"a\":1}");
319
+ PyResult::Ok(())
320
+ })
321
+ .unwrap();
322
+ }
323
+
324
+ #[test]
325
+ fn parser_parse_finish_and_preserve_special_tokens() {
326
+ with_python(|py| {
327
+ let tool = make_py_tool(py)?;
328
+ let mut parser = PyToolParser::new(py, "DeepSeekV4ToolParser", vec![tool])?;
329
+ assert!(parser.preserve_special_tokens());
330
+
331
+ let mut output = PyToolParserOutput::new(py, "", None);
332
+ parser.parse_into_output(&build_call(), &mut output)?;
333
+ let finish = Py::new(py, parser.finish()?)?;
334
+ output.append(finish.borrow(py));
335
+ let output = output.coalesce_calls();
336
+
337
+ assert_eq!(output.normal_text(), "");
338
+ let calls = output.calls();
339
+ assert_eq!(calls.len(), 1);
340
+ assert_eq!(calls[0].name(), Some("create_order"));
341
+ assert_eq!(
342
+ serde_json::from_str::<Value>(calls[0].arguments()).unwrap(),
343
+ json!({
344
+ "user_id": 42,
345
+ "shipping": {
346
+ "city": "Singapore",
347
+ "zip": 18956
348
+ }
349
+ })
350
+ );
351
+
352
+ assert_eq!(parser.reset(), "");
353
+ PyResult::Ok(())
354
+ })
355
+ .unwrap();
356
+ }
357
+
358
+ #[test]
359
+ fn parser_exposes_model_emitted_tool_call_ids() {
360
+ with_python(|py| {
361
+ let tool = make_py_tool(py)?;
362
+ let mut parser = PyToolParser::new(py, "KimiK2ToolParser", vec![tool])?;
363
+
364
+ let input = "<|tool_calls_section_begin|>\
365
+ <|tool_call_begin|>functions.create_order:0<|tool_call_argument_begin|>\
366
+ {\"user_id\":42}<|tool_call_end|>\
367
+ <|tool_calls_section_end|>";
368
+ let mut output = PyToolParserOutput::new(py, "", None);
369
+ parser.parse_into_output(input, &mut output)?;
370
+
371
+ assert_eq!(parser.tool_call_id(0), Some("functions.create_order:0"));
372
+ assert_eq!(parser.tool_call_id(1), None);
373
+ PyResult::Ok(())
374
+ })
375
+ .unwrap();
376
+ }
377
+
378
+ #[test]
379
+ fn parser_errors_for_unknown_name() {
380
+ with_python(|py| {
381
+ let tool = make_py_tool(py)?;
382
+ let error = match PyToolParser::new(py, "missing", vec![tool]) {
383
+ Ok(_) => panic!("missing parser name unexpectedly succeeded"),
384
+ Err(error) => error,
385
+ };
386
+ let message = format!("{error}");
387
+ assert!(message.contains("unsupported tool parser `missing`"));
388
+ PyResult::Ok(())
389
+ })
390
+ .unwrap();
391
+ }
392
+}
setup.py
+63
-13
@@ -35,6 +35,9 @@ ROOT_DIR = Path(__file__).parent
35
logger = logging.getLogger(__name__)
36
37
PRECOMPILED_RUST_FRONTEND_PATH = ROOT_DIR / "vllm" / "vllm-rs"
38
+# setuptools-rust installs PyO3 artifacts as `<module>.<ext-suffix>`, where the
39
+# suffix ends with `.so` on Linux and macOS alike (e.g. `_rust_foo.abi3.so`).
40
+PRECOMPILED_RUST_EXTENSION_MEMBER_REGEX = re.compile(r"vllm/_rust_[^/]*\.so$")
41
42
# cannot import envs directly because it depends on vllm,
43
# which is not installed yet
@@ -56,6 +59,25 @@ def should_require_rust_frontend() -> bool:
59
return value.lower() not in ("", "0", "false", "no")
60
61
62
+def get_precompiled_rust_extension_paths() -> list[Path]:
63
+ return sorted((ROOT_DIR / "vllm").glob("_rust_*.so"))
64
+
65
+
66
+def get_missing_precompiled_rust_extension_modules() -> list[str]:
67
+ present = {
68
+ path.name.split(".", 1)[0] for path in get_precompiled_rust_extension_paths()
69
+ }
70
+ return [
71
+ module_name
72
+ for module_name in rust_build.rust_py_extension_module_names()
73
+ if module_name not in present
74
+ ]
75
+
76
+
77
+def has_precompiled_rust_extensions() -> bool:
78
+ return not get_missing_precompiled_rust_extension_modules()
79
+
80
+
81
if sys.platform.startswith("darwin") and VLLM_TARGET_DEVICE != "cpu":
82
logger.warning("VLLM_TARGET_DEVICE automatically set to `cpu` due to macOS")
83
VLLM_TARGET_DEVICE = "cpu"
@@ -423,19 +445,31 @@ class precompiled_build_ext(build_ext):
445
446
447
class precompiled_build_rust(build_rust):
426
- """Skips local Rust builds when the precompiled wheel already ships vllm-rs."""
448
+ """Skips local Rust builds when all precompiled Rust artifacts are present."""
449
450
def run(self) -> None:
429
- if PRECOMPILED_RUST_FRONTEND_PATH.exists():
451
+ missing = []
452
+ if not PRECOMPILED_RUST_FRONTEND_PATH.exists():
453
+ missing.append(str(PRECOMPILED_RUST_FRONTEND_PATH))
454
+ missing_rust_extensions = get_missing_precompiled_rust_extension_modules()
455
+ if missing_rust_extensions:
456
+ missing.extend(
457
+ str(ROOT_DIR / "vllm" / f"{module_name}*.so")
458
+ for module_name in missing_rust_extensions
459
+ )
460
+
461
+ if not missing:
462
logger.info(
431
- "Skipping local Rust build: using precompiled %s",
463
+ "Skipping local Rust build: using precompiled %s and %s",
464
PRECOMPILED_RUST_FRONTEND_PATH,
465
+ get_precompiled_rust_extension_paths(),
466
)
467
return
468
469
logger.warning(
437
- "Precompiled wheel did not provide %s; falling back to local Rust build.",
438
- PRECOMPILED_RUST_FRONTEND_PATH,
470
+ "Precompiled wheel did not provide all Rust artifacts (%s); "
471
+ "falling back to local Rust build.",
472
+ ", ".join(missing),
473
)
474
super().run()
475
@@ -758,6 +792,14 @@ class precompiled_wheel_utils:
792
if member.filename in exact_members:
793
file_members.append(member)
794
continue
795
+ if (
796
+ extract_rust_frontend
797
+ and PRECOMPILED_RUST_EXTENSION_MEMBER_REGEX.match(
798
+ member.filename
799
+ )
800
+ ):
801
+ file_members.append(member)
802
+ continue
803
804
if not extract_extensions:
805
continue
@@ -1111,6 +1153,12 @@ package_data = {
1153
}
1154
1155
1156
+def add_vllm_package_data(filename: str) -> None:
1157
+ vllm_files = package_data.setdefault("vllm", [])
1158
+ if filename not in vllm_files:
1159
+ vllm_files.append(filename)
1160
+
1161
+
1162
# If using precompiled artifacts, extract and patch package_data in advance.
1163
if USE_PRECOMPILED_RUST_FRONTEND:
1164
wheel_url, download_filename = precompiled_wheel_utils.determine_wheel_url()
@@ -1126,9 +1174,9 @@ if USE_PRECOMPILED_RUST_FRONTEND:
1174
# If the rust frontend binary is already present in the source tree (e.g.,
1175
# pre-built in a separate Docker build stage), ship it as-is.
1176
if PRECOMPILED_RUST_FRONTEND_PATH.exists():
1129
- vllm_files = package_data.setdefault("vllm", [])
1130
- if "vllm-rs" not in vllm_files:
1131
- vllm_files.append("vllm-rs")
1177
+ add_vllm_package_data("vllm-rs")
1178
+for rust_extension_path in get_precompiled_rust_extension_paths():
1179
+ add_vllm_package_data(rust_extension_path.name)
1180
1181
if _no_device():
1182
ext_modules = []
@@ -1141,13 +1189,15 @@ else:
1189
if USE_PRECOMPILED_EXTENSIONS
1190
else cmake_build_ext,
1191
}
1144
-if USE_PRECOMPILED_RUST_FRONTEND or PRECOMPILED_RUST_FRONTEND_PATH.exists():
1192
+if (
1193
+ USE_PRECOMPILED_RUST_FRONTEND
1194
+ or PRECOMPILED_RUST_FRONTEND_PATH.exists()
1195
+ or has_precompiled_rust_extensions()
1196
+):
1197
cmdclass["build_rust"] = precompiled_build_rust
1198
1147
-# Rust frontend binary, built via setuptools-rust and installed into the
1148
-# package directory alongside the Python modules.
1149
-# TODO: we may use `RustBin` to directly install it into `bin` directory, but this
1150
-# requires extra work on using precompiled binaries.
1199
+# Rust artifacts, built via setuptools-rust and installed into the package
1200
+# directory alongside the Python modules.
1201
rust_extensions = rust_build.rust_extensions(
1202
optional=not should_require_rust_frontend()
1203
)
tests/tool_parsers/test_rust_tool_parser.py
new
+328
@@ -0,0 +1,328 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import json
5
+from collections.abc import Sequence
6
+from unittest.mock import MagicMock
7
+
8
+import pytest
9
+
10
+from vllm.entrypoints.openai.chat_completion.protocol import (
11
+ ChatCompletionRequest,
12
+ ChatCompletionToolsParam,
13
+)
14
+from vllm.tool_parsers.rust_tool_parser import RustToolParser
15
+
16
+# The PyO3 extension is an optional build artifact; skip when absent.
17
+_rust_tool_parser = pytest.importorskip("vllm._rust_tool_parser")
18
+
19
+MOCK_TOKENIZER = MagicMock()
20
+MOCK_TOKENIZER.get_vocab.return_value = {}
21
+
22
+TC_START = "<|DSML|tool_calls>"
23
+TC_END = "</|DSML|tool_calls>"
24
+INV_START = '<|DSML|invoke name="'
25
+INV_END = "</|DSML|invoke>"
26
+PARAM_START = '<|DSML|parameter name="'
27
+PARAM_END = "</|DSML|parameter>"
28
+
29
+
30
+class DeepSeekV4RustToolParser(RustToolParser):
31
+ rust_parser_name = "DeepSeekV4ToolParser"
32
+ tool_call_start_token = TC_START
33
+
34
+
35
+class KimiK2RustToolParser(RustToolParser):
36
+ rust_parser_name = "KimiK2ToolParser"
37
+ tool_call_start_token = "<|tool_calls_section_begin|>"
38
+
39
+
40
+def sample_tools() -> list[ChatCompletionToolsParam]:
41
+ return [
42
+ ChatCompletionToolsParam(
43
+ type="function",
44
+ function={
45
+ "name": "get_weather",
46
+ "description": "Get weather for a location",
47
+ "parameters": {
48
+ "type": "object",
49
+ "properties": {
50
+ "location": {"type": "string"},
51
+ "date": {"type": "string"},
52
+ },
53
+ },
54
+ },
55
+ ),
56
+ ChatCompletionToolsParam(
57
+ type="function",
58
+ function={
59
+ "name": "add",
60
+ "description": "Add two integers",
61
+ "parameters": {
62
+ "type": "object",
63
+ "properties": {
64
+ "x": {"type": "integer"},
65
+ "y": {"type": "integer"},
66
+ },
67
+ },
68
+ },
69
+ ),
70
+ ]
71
+
72
+
73
+EXPECTED_CALLS = [
74
+ ("get_weather", {"location": "SF", "date": "2024-01-16"}),
75
+ ("add", {"x": 3, "y": 5}),
76
+]
77
+
78
+
79
+def build_invoke(
80
+ function_name: str,
81
+ params: Sequence[tuple[str, str, bool]],
82
+) -> str:
83
+ param_text = "\n".join(
84
+ f'{PARAM_START}{name}" string="{str(is_string).lower()}">{value}{PARAM_END}'
85
+ for name, value, is_string in params
86
+ )
87
+ return f'{INV_START}{function_name}">\n{param_text}\n{INV_END}\n'
88
+
89
+
90
+def build_tool_call() -> str:
91
+ weather = build_invoke(
92
+ "get_weather",
93
+ [
94
+ ("location", "SF", True),
95
+ ("date", "2024-01-16", True),
96
+ ],
97
+ )
98
+ add = build_invoke(
99
+ "add",
100
+ [
101
+ ("x", "3", False),
102
+ ("y", "5", False),
103
+ ],
104
+ )
105
+ return f"{TC_START}\n{weather}{add}{TC_END}"
106
+
107
+
108
+def parse_streaming(
109
+ parser: DeepSeekV4RustToolParser,
110
+ text: str,
111
+ chunk_size: int,
112
+) -> list:
113
+ deltas = []
114
+ previous_text = ""
115
+ for start in range(0, len(text), chunk_size):
116
+ delta_text = text[start : start + chunk_size]
117
+ current_text = previous_text + delta_text
118
+ delta = parser.extract_tool_calls_streaming(
119
+ previous_text=previous_text,
120
+ current_text=current_text,
121
+ delta_text=delta_text,
122
+ previous_token_ids=[],
123
+ current_token_ids=[],
124
+ delta_token_ids=[1],
125
+ request=MagicMock(),
126
+ )
127
+ previous_text = current_text
128
+ if delta is not None:
129
+ deltas.append(delta)
130
+
131
+ delta = parser.extract_tool_calls_streaming(
132
+ previous_text=previous_text,
133
+ current_text=previous_text,
134
+ delta_text="",
135
+ previous_token_ids=[],
136
+ current_token_ids=[],
137
+ delta_token_ids=[2],
138
+ request=MagicMock(),
139
+ )
140
+ if delta is not None:
141
+ deltas.append(delta)
142
+
143
+ return deltas
144
+
145
+
146
+def collect_streamed_arguments(deltas: Sequence, tool_index: int = 0) -> str:
147
+ return "".join(
148
+ tool_call.function.arguments
149
+ for delta in deltas
150
+ for tool_call in delta.tool_calls or []
151
+ if (
152
+ tool_call.index == tool_index
153
+ and tool_call.function is not None
154
+ and tool_call.function.arguments is not None
155
+ )
156
+ )
157
+
158
+
159
+def test_rust_tool_parser_extension_typed_api() -> None:
160
+ tools = [
161
+ _rust_tool_parser.Tool(
162
+ tool.function.name,
163
+ tool.function.description,
164
+ tool.function.parameters,
165
+ None,
166
+ )
167
+ for tool in sample_tools()
168
+ ]
169
+ parser = _rust_tool_parser.ToolParser("DeepSeekV4ToolParser", tools)
170
+ output = _rust_tool_parser.ToolParserOutput()
171
+
172
+ parser.parse_into(build_tool_call(), output)
173
+ output.append(parser.finish())
174
+ output = output.coalesce_calls()
175
+
176
+ assert parser.preserve_special_tokens()
177
+ assert output.normal_text == ""
178
+ assert len(output.calls) == 2
179
+ for call, (name, arguments) in zip(output.calls, EXPECTED_CALLS):
180
+ assert call.name == name
181
+ assert json.loads(call.arguments) == arguments
182
+
183
+
184
+def test_rust_tool_parser_adapter_extracts_complete_output() -> None:
185
+ tools = sample_tools()
186
+ parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=tools)
187
+
188
+ result = parser.extract_tool_calls(
189
+ "Let me create it. " + build_tool_call(),
190
+ ChatCompletionRequest(messages=[], model="m", tools=tools),
191
+ )
192
+
193
+ assert result.tools_called
194
+ assert result.content == "Let me create it. "
195
+ assert len(result.tool_calls) == 2
196
+ for tool_call, (name, arguments) in zip(result.tool_calls, EXPECTED_CALLS):
197
+ assert tool_call.function.name == name
198
+ assert json.loads(tool_call.function.arguments) == arguments
199
+
200
+
201
+def test_rust_tool_parser_adapter_streaming_handles_multiple_calls() -> None:
202
+ parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools())
203
+
204
+ deltas = parse_streaming(parser, build_tool_call(), chunk_size=5)
205
+
206
+ names = [
207
+ tool_call.function.name
208
+ for delta in deltas
209
+ for tool_call in delta.tool_calls or []
210
+ if tool_call.function is not None and tool_call.function.name is not None
211
+ ]
212
+ assert names == [name for name, _ in EXPECTED_CALLS]
213
+ for index, (_, arguments) in enumerate(EXPECTED_CALLS):
214
+ assert json.loads(collect_streamed_arguments(deltas, index)) == arguments
215
+
216
+
217
+def test_rust_tool_parser_adapter_ignores_midstream_empty_delta() -> None:
218
+ parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools())
219
+ text = build_tool_call()
220
+ split_at = len(TC_START) + 8
221
+ deltas = []
222
+ previous_text = ""
223
+
224
+ for delta_text in (text[:split_at], "", text[split_at:], ""):
225
+ current_text = previous_text + delta_text
226
+ delta = parser.extract_tool_calls_streaming(
227
+ previous_text=previous_text,
228
+ current_text=current_text,
229
+ delta_text=delta_text,
230
+ previous_token_ids=[],
231
+ current_token_ids=[],
232
+ delta_token_ids=[1],
233
+ request=MagicMock(),
234
+ )
235
+ previous_text = current_text
236
+ if delta is not None:
237
+ deltas.append(delta)
238
+
239
+ names = [
240
+ tool_call.function.name
241
+ for delta in deltas
242
+ for tool_call in delta.tool_calls or []
243
+ if tool_call.function is not None and tool_call.function.name is not None
244
+ ]
245
+ assert names == [name for name, _ in EXPECTED_CALLS]
246
+ for index, (_, arguments) in enumerate(EXPECTED_CALLS):
247
+ assert json.loads(collect_streamed_arguments(deltas, index)) == arguments
248
+
249
+
250
+KIMI_EXPECTED_IDS = ["functions.get_weather:0", "functions.add:1"]
251
+
252
+
253
+def build_kimi_tool_call() -> str:
254
+ return (
255
+ "<|tool_calls_section_begin|>"
256
+ "<|tool_call_begin|>functions.get_weather:0<|tool_call_argument_begin|>"
257
+ '{"location": "SF", "date": "2024-01-16"}<|tool_call_end|>'
258
+ "<|tool_call_begin|>functions.add:1<|tool_call_argument_begin|>"
259
+ '{"x": 3, "y": 5}<|tool_call_end|>'
260
+ "<|tool_calls_section_end|>"
261
+ )
262
+
263
+
264
+def test_rust_tool_parser_adapter_complete_prefers_model_tool_call_ids() -> None:
265
+ tools = sample_tools()
266
+ parser = KimiK2RustToolParser(MOCK_TOKENIZER, tools=tools)
267
+
268
+ result = parser.extract_tool_calls(
269
+ "Let me check. " + build_kimi_tool_call(),
270
+ ChatCompletionRequest(messages=[], model="m", tools=tools),
271
+ )
272
+
273
+ assert result.tools_called
274
+ assert [tool_call.id for tool_call in result.tool_calls] == KIMI_EXPECTED_IDS
275
+ for tool_call, (name, arguments) in zip(result.tool_calls, EXPECTED_CALLS):
276
+ assert tool_call.function.name == name
277
+ assert json.loads(tool_call.function.arguments) == arguments
278
+
279
+
280
+def test_rust_tool_parser_adapter_streaming_prefers_model_tool_call_ids() -> None:
281
+ parser = KimiK2RustToolParser(MOCK_TOKENIZER, tools=sample_tools())
282
+
283
+ deltas = parse_streaming(parser, build_kimi_tool_call(), chunk_size=5)
284
+
285
+ ids = [
286
+ tool_call.id
287
+ for delta in deltas
288
+ for tool_call in delta.tool_calls or []
289
+ if tool_call.id is not None
290
+ ]
291
+ assert ids == KIMI_EXPECTED_IDS
292
+ for index, (_, arguments) in enumerate(EXPECTED_CALLS):
293
+ assert json.loads(collect_streamed_arguments(deltas, index)) == arguments
294
+
295
+
296
+def test_rust_tool_parser_adapter_streaming_generates_ids_as_fallback() -> None:
297
+ # DeepSeekV4 never emits model tool call IDs, so the bridge mints them.
298
+ parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools())
299
+
300
+ deltas = parse_streaming(parser, build_tool_call(), chunk_size=5)
301
+
302
+ ids = [
303
+ tool_call.id
304
+ for delta in deltas
305
+ for tool_call in delta.tool_calls or []
306
+ if tool_call.function is not None and tool_call.function.name is not None
307
+ ]
308
+ assert len(ids) == len(EXPECTED_CALLS)
309
+ assert all(ids)
310
+ assert len(set(ids)) == len(ids)
311
+
312
+
313
+def test_rust_tool_parser_adapter_adjust_request_is_opaque() -> None:
314
+ tools = sample_tools()
315
+ parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=tools)
316
+ request = ChatCompletionRequest(
317
+ messages=[],
318
+ model="m",
319
+ tools=tools,
320
+ tool_choice="required",
321
+ skip_special_tokens=True,
322
+ )
323
+
324
+ adjusted = parser.adjust_request(request)
325
+
326
+ assert adjusted is request
327
+ assert adjusted.skip_special_tokens is False
328
+ assert adjusted.structured_outputs is None
tools/build_rust.py
+23
-2
@@ -1,7 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-"""Shared setuptools-rust build entry for the vllm-rs binary."""
4
+"""Shared setuptools-rust build entry for Rust artifacts."""
5
6
from __future__ import annotations
7
@@ -15,7 +15,7 @@ from setuptools_rust import Binding, RustExtension
15
ROOT_DIR = Path(__file__).resolve().parents[1]
16
17
18
-def rust_extensions(*, optional: bool) -> list[RustExtension]:
18
+def rust_extensions(*, optional: bool = False) -> list[RustExtension]:
19
return [
20
RustExtension(
21
target="vllm.vllm-rs",
@@ -25,9 +25,30 @@ def rust_extensions(*, optional: bool) -> list[RustExtension]:
25
binding=Binding.Exec,
26
optional=optional,
27
),
28
+ RustExtension(
29
+ target="vllm._rust_tool_parser",
30
+ path="rust/src/tool-parser/python/Cargo.toml",
31
+ features=["pyo3/abi3-py38"],
32
+ binding=Binding.PyO3,
33
+ optional=optional,
34
+ py_limited_api=True,
35
+ ),
36
]
37
38
39
+def rust_py_extension_module_names() -> list[str]:
40
+ module_names = []
41
+ for extension in rust_extensions():
42
+ if extension.binding != Binding.PyO3:
43
+ continue
44
+
45
+ for target_name in extension.target.values():
46
+ if target_name.startswith("vllm._rust_"):
47
+ module_names.append(target_name.rsplit(".", 1)[-1])
48
+
49
+ return module_names
50
+
51
+
52
def build_binary(build_rust_args: list[str]) -> None:
53
os.chdir(ROOT_DIR)
54
(ROOT_DIR / "vllm").mkdir(exist_ok=True)
vllm/tool_parsers/rust_tool_parser.py
new
+322
@@ -0,0 +1,322 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import importlib
5
+from collections.abc import Sequence
6
+from typing import Any
7
+
8
+from openai.types.responses.function_tool import FunctionTool
9
+
10
+from vllm.entrypoints.chat_utils import make_tool_call_id
11
+from vllm.entrypoints.openai.chat_completion.protocol import (
12
+ ChatCompletionRequest,
13
+ ChatCompletionToolsParam,
14
+)
15
+from vllm.entrypoints.openai.engine.protocol import (
16
+ DeltaFunctionCall,
17
+ DeltaMessage,
18
+ DeltaToolCall,
19
+ ExtractedToolCallInformation,
20
+ FunctionCall,
21
+ ToolCall,
22
+)
23
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
24
+from vllm.logger import init_logger
25
+from vllm.tokenizers import TokenizerLike
26
+from vllm.tool_parsers.abstract_tool_parser import Tool, ToolParser
27
+
28
+logger = init_logger(__name__)
29
+
30
+
31
+def _rust_tool_parser_module() -> Any:
32
+ try:
33
+ return importlib.import_module("vllm._rust_tool_parser")
34
+ except ImportError as exc:
35
+ raise RuntimeError(
36
+ "Rust tool parsing requires the vllm._rust_tool_parser PyO3 "
37
+ "extension. Rebuild vLLM with Rust frontend/extensions enabled."
38
+ ) from exc
39
+
40
+
41
+class RustToolParser(ToolParser):
42
+ """Adapter from an opaque Rust parser to the vLLM ToolParser API.
43
+
44
+ Subclasses provide only model-specific configuration: the exact Rust parser
45
+ name and an optional tool-call start marker for fast complete-output
46
+ rejection.
47
+
48
+ This class keeps the vLLM-specific bridge work:
49
+ - convert vLLM tool definitions into the Rust ``Tool`` shape;
50
+ - translate typed Rust parser outputs into vLLM protocol objects; and
51
+ - maintain vLLM streaming bookkeeping used by finish-reason handling.
52
+
53
+ The parser grammar and incremental parser state stay in Rust.
54
+ """
55
+
56
+ # Rust-backed parsers are opaque to Python by default. Do not use vLLM's
57
+ # standard JSON required/named handling; let the Rust parser consume the
58
+ # model's native tool-call syntax.
59
+ supports_required_and_named = False
60
+
61
+ rust_parser_name: str
62
+ tool_call_start_token: str | None = None
63
+
64
+ def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
65
+ super().__init__(tokenizer, tools)
66
+ self._parser: Any | None = None
67
+ self._error: Exception | None = None
68
+
69
+ if not self.model_tokenizer:
70
+ raise ValueError(
71
+ "The model tokenizer must be passed to the ToolParser "
72
+ "constructor during construction."
73
+ )
74
+
75
+ logger.debug(
76
+ "vLLM successfully imported tool parser %s", self.__class__.__name__
77
+ )
78
+
79
+ def adjust_request(
80
+ self, request: ChatCompletionRequest | ResponsesRequest
81
+ ) -> ChatCompletionRequest | ResponsesRequest:
82
+ """Adjust request options without installing Python-side constraints.
83
+
84
+ Rust-backed parsers are treated as source-of-truth opaque parsers. The
85
+ bridge intentionally avoids ``super().adjust_request()`` so Python does
86
+ not install JSON schema guidance or structural-tag constraints that may
87
+ conflict with the Rust parser's native grammar.
88
+ """
89
+ if self._get_parser().preserve_special_tokens():
90
+ request.skip_special_tokens = False
91
+ return request
92
+
93
+ def _rust_tools(self) -> list[Any]:
94
+ """Build Rust ``Tool`` objects from vLLM tool definitions."""
95
+ if not self.tools:
96
+ return []
97
+
98
+ tools: list[Any] = []
99
+ for tool in self.tools:
100
+ if isinstance(tool, FunctionTool):
101
+ name = tool.name
102
+ description = tool.description
103
+ parameters = tool.parameters or {}
104
+ strict = getattr(tool, "strict", None)
105
+ elif isinstance(tool, ChatCompletionToolsParam):
106
+ name = tool.function.name
107
+ description = tool.function.description
108
+ parameters = tool.function.parameters or {}
109
+ strict = getattr(tool.function, "strict", None)
110
+ else:
111
+ continue
112
+ tools.append(
113
+ _rust_tool_parser_module().Tool(name, description, parameters, strict)
114
+ )
115
+ return tools
116
+
117
+ def _new_parser(self) -> Any:
118
+ """Create a fresh Rust parser with the current tool schemas."""
119
+ return _rust_tool_parser_module().ToolParser(
120
+ self.rust_parser_name, self._rust_tools()
121
+ )
122
+
123
+ def _get_parser(self) -> Any:
124
+ if self._parser is None:
125
+ self._parser = self._new_parser()
126
+ return self._parser
127
+
128
+ def _reset_streaming_state(self) -> None:
129
+ """Reset parser state for a new request on a reused parser instance."""
130
+ self._parser = self._new_parser()
131
+ self._error = None
132
+ self.prev_tool_call_arr.clear()
133
+ self.streamed_args_for_tool.clear()
134
+ self.current_tool_id = -1
135
+ self.current_tool_name_sent = False
136
+
137
+ def _ensure_tool_state(self, index: int) -> None:
138
+ """Grow vLLM streaming state arrays to contain ``index``."""
139
+ while len(self.prev_tool_call_arr) <= index:
140
+ self.prev_tool_call_arr.append({})
141
+ while len(self.streamed_args_for_tool) <= index:
142
+ self.streamed_args_for_tool.append("")
143
+
144
+ def _record_delta(
145
+ self, index: int, name: str | None, arguments: str | None
146
+ ) -> str | None:
147
+ """Mirror a Rust parser delta into vLLM streaming bookkeeping.
148
+
149
+ ``prev_tool_call_arr`` and ``streamed_args_for_tool`` are read later by
150
+ the chat serving layer to decide the final ``tool_calls`` finish reason
151
+ and to flush any remaining argument bytes.
152
+ """
153
+ tool_call_id = None
154
+ self._ensure_tool_state(index)
155
+
156
+ if name is not None:
157
+ # Prefer the model-emitted ID surfaced by the Rust parser (e.g.
158
+ # Kimi K2) over a randomly generated one.
159
+ tool_call_id = self._get_parser().tool_call_id(index) or make_tool_call_id()
160
+ self.prev_tool_call_arr[index] = {"name": name, "arguments": {}}
161
+ self.current_tool_name_sent = True
162
+
163
+ if arguments is not None:
164
+ self.streamed_args_for_tool[index] += arguments
165
+ self.prev_tool_call_arr[index]["arguments"] = self.streamed_args_for_tool[
166
+ index
167
+ ]
168
+ self.current_tool_id = index
169
+
170
+ return tool_call_id
171
+
172
+ def _delta_message_from_parser_output(
173
+ self, parser_output: Any | None
174
+ ) -> DeltaMessage | None:
175
+ """Translate one Rust parser output into a vLLM ``DeltaMessage``."""
176
+ if parser_output is None:
177
+ return None
178
+
179
+ normal_text = parser_output.normal_text or None
180
+ tool_calls: list[DeltaToolCall] = []
181
+ for tool_call in parser_output.calls:
182
+ index = tool_call.tool_index
183
+ name = tool_call.name
184
+ arguments: str | None = tool_call.arguments
185
+ if name is None and arguments is None:
186
+ continue
187
+
188
+ tool_call_id = self._record_delta(index, name, arguments)
189
+ tool_calls.append(
190
+ DeltaToolCall(
191
+ index=index,
192
+ id=tool_call_id,
193
+ type="function" if name is not None else None,
194
+ function=DeltaFunctionCall(
195
+ name=name,
196
+ arguments=arguments,
197
+ ),
198
+ )
199
+ )
200
+
201
+ if normal_text is None and not tool_calls:
202
+ return None
203
+ return DeltaMessage(content=normal_text, tool_calls=tool_calls)
204
+
205
+ def _parse_complete(self, model_output: str) -> tuple[Any, dict[int, str]] | None:
206
+ """Parse complete model output with a throwaway Rust parser instance.
207
+
208
+ Returns the coalesced parser output along with any model-emitted tool
209
+ call IDs keyed by tool index.
210
+ """
211
+ parser = self._new_parser()
212
+ output = _rust_tool_parser_module().ToolParserOutput()
213
+ try:
214
+ parser.parse_into(model_output, output)
215
+ # finish() clears parser state, so snapshot model-emitted IDs first.
216
+ tool_call_ids = {
217
+ call.tool_index: tool_call_id
218
+ for call in output.calls
219
+ if (tool_call_id := parser.tool_call_id(call.tool_index)) is not None
220
+ }
221
+ output.append(parser.finish())
222
+ except Exception:
223
+ logger.exception(
224
+ "Error parsing %s tool call output.", self.rust_parser_name
225
+ )
226
+ return None
227
+ return output.coalesce_calls(), tool_call_ids
228
+
229
+ def extract_tool_calls(
230
+ self,
231
+ model_output: str,
232
+ request: ChatCompletionRequest,
233
+ ) -> ExtractedToolCallInformation:
234
+ """Extract tool calls from complete model output (non-streaming)."""
235
+ if (
236
+ self.tool_call_start_token is not None
237
+ and self.tool_call_start_token not in model_output
238
+ ):
239
+ return ExtractedToolCallInformation(
240
+ tools_called=False,
241
+ tool_calls=[],
242
+ content=model_output,
243
+ )
244
+
245
+ parse_result = self._parse_complete(model_output)
246
+ if parse_result is None:
247
+ return ExtractedToolCallInformation(
248
+ tools_called=False,
249
+ tool_calls=[],
250
+ content=model_output,
251
+ )
252
+ parsed, tool_call_ids = parse_result
253
+
254
+ tool_calls: list[ToolCall] = []
255
+ self.prev_tool_call_arr.clear()
256
+ for parsed_tool_call in parsed.calls:
257
+ name = parsed_tool_call.name
258
+ arguments = parsed_tool_call.arguments or "{}"
259
+ if name is None:
260
+ continue
261
+ tool_calls.append(
262
+ ToolCall(
263
+ id=tool_call_ids.get(parsed_tool_call.tool_index)
264
+ or make_tool_call_id(),
265
+ type="function",
266
+ function=FunctionCall(name=name, arguments=arguments),
267
+ )
268
+ )
269
+ self.prev_tool_call_arr.append({"name": name, "arguments": arguments})
270
+
271
+ if not tool_calls:
272
+ return ExtractedToolCallInformation(
273
+ tools_called=False,
274
+ tool_calls=[],
275
+ content=model_output,
276
+ )
277
+
278
+ content = parsed.normal_text or None
279
+ return ExtractedToolCallInformation(
280
+ tools_called=True,
281
+ tool_calls=tool_calls,
282
+ content=content,
283
+ )
284
+
285
+ def extract_tool_calls_streaming(
286
+ self,
287
+ previous_text: str,
288
+ current_text: str,
289
+ delta_text: str,
290
+ previous_token_ids: Sequence[int], # pylint: disable=unused-argument
291
+ current_token_ids: Sequence[int], # pylint: disable=unused-argument
292
+ delta_token_ids: Sequence[int], # pylint: disable=unused-argument
293
+ request: ChatCompletionRequest, # pylint: disable=unused-argument
294
+ ) -> DeltaMessage | None:
295
+ """Extract tool calls from streaming model output.
296
+
297
+ The Rust parser owns the incremental buffer, so this adapter feeds only
298
+ the newest text delta and lets the serving layer handle final empty
299
+ chunks.
300
+ """
301
+ # TODO: Add a final-chunk hook if streaming needs to call Rust finish().
302
+ if not previous_text:
303
+ self._reset_streaming_state()
304
+
305
+ if self._error is not None:
306
+ return None
307
+
308
+ parser_output = _rust_tool_parser_module().ToolParserOutput()
309
+ try:
310
+ self._get_parser().parse_into(delta_text, parser_output)
311
+ except Exception as error:
312
+ self._error = error
313
+ logger.exception(
314
+ "Error parsing %s streaming tool call output.",
315
+ self.rust_parser_name,
316
+ )
317
+
318
+ delta_message = self._delta_message_from_parser_output(parser_output)
319
+ if delta_message is not None:
320
+ return delta_message
321
+
322
+ return None