[Rust Frontend] Migrate gemma4 to unified parser (#46602)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Jun 26, 2026 at 07:59 UTC
f9e684499f67071641bb2333d52dadd879231ac2
23 files changed
+1000
-394
pyproject.toml
+2
-1
@@ -129,7 +129,8 @@ extend-exclude = ["tests/models/fixtures/*", "tests/prompts/*", "tests/tokenizer
129
"docs/governance/process.md", "docs/assets/contributing/vllm_bench_serve_timeline.html",
130
"tests/v1/engine/test_fast_incdec_prefix_err.py", ".git/*", "csrc/cpu/sgl-kernels/*",
131
"rust/src/chat/src/renderer/deepseek_v32/fixtures/*",
132
- "rust/src/parser/src/tool/gemma4.rs", "rust/src/text/src/output/decoded.rs",
132
+ "rust/src/parser/src/tool/gemma4.rs", "rust/src/parser/src/unified/gemma4.rs",
133
+ "rust/src/text/src/output/decoded.rs",
134
"rust/src/tokenizer/src/incremental.rs", "rust/src/parser/src/reasoning/tests.rs"]
135
ignore-hidden = false
136
rust/Cargo.toml
+1
-1
@@ -120,7 +120,7 @@ vllm-parser = { path = "src/parser" }
120
vllm-server = { path = "src/server" }
121
vllm-text = { path = "src/text" }
122
vllm-tokenizer = { path = "src/tokenizer" }
123
-winnow = "1.0.2"
123
+winnow = { version = "1.0.2", features = ["simd"] }
124
xgrammar-structural-tag = "0.1.0"
125
zeromq = { version = "0.6.0", default-features = false, features = [
126
"tokio-runtime",
rust/src/chat/src/output/default/mod.rs
+149
-15
@@ -18,7 +18,8 @@ use crate::output::{ChatOutputProcessor, DynChatEventStream, DynDecodedTextEvent
18
use crate::parser::ParserSelection;
19
use crate::parser::reasoning::{ReasoningParser, ReasoningParserFactory};
20
use crate::parser::tool::{ToolParser, ToolParserFactory};
21
-use crate::request::ChatRequest;
21
+use crate::parser::unified::UnifiedParserFactory;
22
+use crate::request::{ChatRequest, ChatTool};
23
use crate::{Error, Result as ChatResult};
24
25
/// Default request-scoped output processor used by Hugging Face style chat
@@ -46,20 +47,31 @@ impl DefaultChatOutputProcessor {
47
tool_call_parser: &ParserSelection,
48
reasoning_parser: &ParserSelection,
49
) -> ChatResult<Self> {
49
- let tool_parsing_enabled = request.tool_parsing_enabled();
50
- let tool_parser = if tool_parsing_enabled {
51
- Some(Self::resolve_tool_parser(
52
- request,
50
+ let parser = if tool_call_parser == reasoning_parser
51
+ && let Some(parser) = Self::resolve_optional_unified_parser(
52
+ &request.tools,
53
model_id,
54
+ tokenizer.clone(),
55
tool_call_parser,
55
- )?)
56
+ )? {
57
+ parser
58
} else {
57
- None
59
+ let tool_parsing_enabled = request.tool_parsing_enabled();
60
+ let tool_parser = if tool_parsing_enabled {
61
+ Some(Self::resolve_tool_parser(
62
+ &request.tools,
63
+ model_id,
64
+ tool_call_parser,
65
+ )?)
66
+ } else {
67
+ None
68
+ };
69
+ let reasoning_parser =
70
+ Self::resolve_optional_reasoning_parser(model_id, tokenizer, reasoning_parser)?;
71
+ Box::new(CombinedParser::new(reasoning_parser, tool_parser)) as Box<dyn UnifiedParser>
72
};
59
- let reasoning_parser =
60
- Self::resolve_optional_reasoning_parser(model_id, tokenizer, reasoning_parser)?;
61
- let parser: Box<dyn UnifiedParser> =
62
- Box::new(CombinedParser::new(reasoning_parser, tool_parser));
73
+
74
+ apply_structural_tag_constraint(request, parser.structural_tag_model())?;
75
76
if parser.preserve_special_tokens() {
77
request.decode_options.skip_special_tokens = false;
@@ -84,7 +96,7 @@ impl DefaultChatOutputProcessor {
96
}
97
98
fn resolve_tool_parser(
87
- request: &mut ChatRequest,
99
+ tools: &[ChatTool],
100
model_id: &str,
101
selection: &ParserSelection,
102
) -> ChatResult<Box<dyn ToolParser>> {
@@ -100,14 +112,36 @@ impl DefaultChatOutputProcessor {
112
ParserSelection::Explicit(name) => name.as_str(),
113
};
114
103
- let parser = factory.create(parser_name, &request.tools)?;
104
-
105
- apply_structural_tag_constraint(request, parser.as_ref())?;
115
+ let parser = factory.create(parser_name, tools)?;
116
117
TOOL_PARSER_LOG_ONCE.call_once(|| info!(parser_name, "using tool parser"));
118
Ok(parser)
119
}
120
121
+ fn resolve_optional_unified_parser(
122
+ tools: &[ChatTool],
123
+ model_id: &str,
124
+ tokenizer: DynTokenizer,
125
+ selection: &ParserSelection,
126
+ ) -> ChatResult<Option<Box<dyn UnifiedParser>>> {
127
+ let factory = UnifiedParserFactory::global();
128
+ let parser_name = match selection {
129
+ ParserSelection::Auto => factory.resolve_name_for_model(model_id),
130
+ ParserSelection::None => None,
131
+ ParserSelection::Explicit(name) if factory.contains(name) => Some(name.as_str()),
132
+ ParserSelection::Explicit(_) => None,
133
+ };
134
+
135
+ let Some(parser_name) = parser_name else {
136
+ return Ok(None);
137
+ };
138
+
139
+ let parser = factory.create(parser_name, tools, tokenizer)?;
140
+
141
+ UNIFIED_PARSER_LOG_ONCE.call_once(|| info!(parser_name, "using unified parser"));
142
+ Ok(Some(parser))
143
+ }
144
+
145
fn resolve_optional_reasoning_parser(
146
model_id: &str,
147
tokenizer: DynTokenizer,
@@ -134,6 +168,7 @@ impl DefaultChatOutputProcessor {
168
169
static TOOL_PARSER_LOG_ONCE: Once = Once::new();
170
static REASONING_PARSER_LOG_ONCE: Once = Once::new();
171
+static UNIFIED_PARSER_LOG_ONCE: Once = Once::new();
172
173
impl ChatOutputProcessor for DefaultChatOutputProcessor {
174
/// Transforms a raw generate-output token stream into structured chat
@@ -149,3 +184,102 @@ impl ChatOutputProcessor for DefaultChatOutputProcessor {
184
Ok(structured.boxed())
185
}
186
}
187
+
188
+#[cfg(test)]
189
+mod tests {
190
+ use std::sync::Arc;
191
+
192
+ use vllm_tokenizer::Tokenizer;
193
+
194
+ use super::DefaultChatOutputProcessor;
195
+ use crate::Error;
196
+ use crate::parser::ParserSelection;
197
+ use crate::request::ChatRequest;
198
+
199
+ struct FakeTokenizer;
200
+
201
+ impl Tokenizer for FakeTokenizer {
202
+ fn encode(
203
+ &self,
204
+ text: &str,
205
+ _add_special_tokens: bool,
206
+ ) -> vllm_tokenizer::Result<Vec<u32>> {
207
+ Ok(text.chars().map(u32::from).collect())
208
+ }
209
+
210
+ fn decode(
211
+ &self,
212
+ token_ids: &[u32],
213
+ _skip_special_tokens: bool,
214
+ ) -> vllm_tokenizer::Result<String> {
215
+ Ok(token_ids
216
+ .iter()
217
+ .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
218
+ .collect())
219
+ }
220
+
221
+ fn token_to_id(&self, token: &str) -> Option<u32> {
222
+ match token {
223
+ "<|channel>" => Some(1),
224
+ "<channel|>" => Some(2),
225
+ _ => None,
226
+ }
227
+ }
228
+ }
229
+
230
+ fn tokenizer() -> Arc<FakeTokenizer> {
231
+ Arc::new(FakeTokenizer)
232
+ }
233
+
234
+ #[test]
235
+ fn equal_explicit_gemma4_uses_unified_parser() {
236
+ let mut request = ChatRequest::for_test();
237
+ let selection = ParserSelection::Explicit("gemma4".to_string());
238
+
239
+ DefaultChatOutputProcessor::new(
240
+ &mut request,
241
+ "other-model",
242
+ tokenizer(),
243
+ &selection,
244
+ &selection,
245
+ )
246
+ .unwrap();
247
+ }
248
+
249
+ #[test]
250
+ fn auto_auto_gemma4_model_uses_unified_parser() {
251
+ let mut request = ChatRequest::for_test();
252
+
253
+ DefaultChatOutputProcessor::new(
254
+ &mut request,
255
+ "google/gemma-4-27b-it",
256
+ tokenizer(),
257
+ &ParserSelection::Auto,
258
+ &ParserSelection::Auto,
259
+ )
260
+ .unwrap();
261
+ }
262
+
263
+ #[test]
264
+ fn mixed_gemma4_selection_uses_split_dummy_error() {
265
+ let mut request = ChatRequest::for_test();
266
+ let error = match DefaultChatOutputProcessor::new(
267
+ &mut request,
268
+ "other-model",
269
+ tokenizer(),
270
+ &ParserSelection::Auto,
271
+ &ParserSelection::Explicit("gemma4".to_string()),
272
+ ) {
273
+ Ok(_) => panic!("expected mixed Gemma4 parser selection to fail"),
274
+ Err(error) => error,
275
+ };
276
+
277
+ let Error::ParserInitialization { error, .. } = error else {
278
+ panic!("expected parser initialization error");
279
+ };
280
+ assert_eq!(
281
+ error.to_string(),
282
+ "`gemma4` only provides a unified parser; the same reasoning parser and tool parser should be specified together"
283
+ );
284
+ }
285
+}
rust/src/chat/src/output/default/structural_tag.rs
+12
-12
@@ -2,12 +2,12 @@
2
3
use thiserror_ext::AsReport;
4
use vllm_engine_core_client::protocol::{StructuredOutputBackend, StructuredOutputsParams};
5
+use vllm_parser::tool::StructuralTagModel;
6
use xgrammar_structural_tag::{
7
FunctionDefinition, FunctionToolParam, ToolChoice as StructuralTagToolChoice, ToolParam,
8
build_structural_tag,
9
};
10
10
-use crate::parser::tool::ToolParser;
11
use crate::request::{ChatRequest, ChatToolChoice};
12
use crate::{Error, Result as ChatResult};
13
@@ -15,9 +15,9 @@ use crate::{Error, Result as ChatResult};
15
/// support and the request's tool choice.
16
pub(super) fn apply_structural_tag_constraint(
17
request: &mut ChatRequest,
18
- parser: &dyn ToolParser,
18
+ model: Option<StructuralTagModel>,
19
) -> ChatResult<()> {
20
- let Some(model) = parser.structural_tag_model() else {
20
+ let Some(model) = model else {
21
return Ok(());
22
};
23
let Some(tool_choice) = structural_tag_tool_choice(request) else {
@@ -77,7 +77,7 @@ fn structural_tag_tool_choice(request: &ChatRequest) -> Option<StructuralTagTool
77
mod tests {
78
use serde_json::{Value, json};
79
use vllm_engine_core_client::protocol::{StructuredOutputBackend, StructuredOutputsParams};
80
- use vllm_parser::tool::{Qwen3CoderToolParser, Tool};
80
+ use vllm_parser::tool::{Qwen3CoderToolParser, Tool, ToolParser};
81
82
use super::*;
83
@@ -134,7 +134,7 @@ mod tests {
134
let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]);
135
let parser = qwen3_coder_parser(&request.tools);
136
137
- apply_structural_tag_constraint(&mut request, parser.as_ref())
137
+ apply_structural_tag_constraint(&mut request, parser.structural_tag_model())
138
.expect("structural tag should build");
139
140
let tag = structural_tag_value(&request);
@@ -147,7 +147,7 @@ mod tests {
147
let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", None)]);
148
let parser = qwen3_coder_parser(&request.tools);
149
150
- apply_structural_tag_constraint(&mut request, parser.as_ref())
150
+ apply_structural_tag_constraint(&mut request, parser.structural_tag_model())
151
.expect("structural tag decision should succeed");
152
153
assert!(request.sampling_params.structured_outputs.is_none());
@@ -163,7 +163,7 @@ mod tests {
163
});
164
let parser = qwen3_coder_parser(&request.tools);
165
166
- apply_structural_tag_constraint(&mut request, parser.as_ref())
166
+ apply_structural_tag_constraint(&mut request, parser.structural_tag_model())
167
.expect("structural tag should build");
168
169
let params = structured_outputs(&request);
@@ -179,7 +179,7 @@ mod tests {
179
let mut request = request(ChatToolChoice::Required, vec![chat_tool("search", None)]);
180
let parser = qwen3_coder_parser(&request.tools);
181
182
- apply_structural_tag_constraint(&mut request, parser.as_ref())
182
+ apply_structural_tag_constraint(&mut request, parser.structural_tag_model())
183
.expect("structural tag should build");
184
185
let tag = structural_tag_value(&request);
@@ -197,7 +197,7 @@ mod tests {
197
});
198
let parser = qwen3_coder_parser(&request.tools);
199
200
- apply_structural_tag_constraint(&mut request, parser.as_ref())
200
+ apply_structural_tag_constraint(&mut request, parser.structural_tag_model())
201
.expect("structural tag should build");
202
203
let params = structured_outputs(&request);
@@ -218,7 +218,7 @@ mod tests {
218
);
219
let parser = qwen3_coder_parser(&request.tools);
220
221
- apply_structural_tag_constraint(&mut request, parser.as_ref())
221
+ apply_structural_tag_constraint(&mut request, parser.structural_tag_model())
222
.expect("structural tag should build");
223
224
let tag = structural_tag_value(&request).to_string();
@@ -231,7 +231,7 @@ mod tests {
231
let mut request = request(ChatToolChoice::None, vec![chat_tool("search", Some(true))]);
232
let parser = qwen3_coder_parser(&request.tools);
233
234
- apply_structural_tag_constraint(&mut request, parser.as_ref())
234
+ apply_structural_tag_constraint(&mut request, parser.structural_tag_model())
235
.expect("structural tag decision should succeed");
236
237
assert!(request.sampling_params.structured_outputs.is_none());
@@ -247,7 +247,7 @@ mod tests {
247
});
248
let parser = qwen3_coder_parser(&request.tools);
249
250
- apply_structural_tag_constraint(&mut request, parser.as_ref())
250
+ apply_structural_tag_constraint(&mut request, parser.structural_tag_model())
251
.expect("structural tag decision should succeed");
252
253
let params = structured_outputs(&request);
rust/src/chat/src/output/default/unified.rs
+1
-1
@@ -317,7 +317,7 @@ mod tests {
317
where
318
Self: Sized + 'static,
319
{
320
- unreachable!("ScriptedParser is constructed directly in tests")
320
+ Ok(Box::new(Self::new([])))
321
}
322
323
fn parse_into(
rust/src/chat/src/parser/mod.rs
+1
@@ -1,5 +1,6 @@
1
pub mod reasoning;
2
pub mod tool;
3
+pub mod unified;
4
5
use std::collections::HashMap;
6
use std::convert::Infallible;
rust/src/chat/src/parser/reasoning/mod.rs
+21
-10
@@ -1,13 +1,13 @@
1
//! Reasoning parser registration and selection boundary for `vllm-chat`.
2
3
-use std::sync::LazyLock;
3
+use std::sync::{Arc, LazyLock};
4
5
pub use vllm_parser::reasoning::{
6
CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DeepSeekV3ReasoningParser,
7
- DeepSeekV4ReasoningParser, Gemma4ReasoningParser, Glm45ReasoningParser, KimiK2ReasoningParser,
8
- KimiReasoningParser, MiniMaxM2ReasoningParser, MiniMaxM3ReasoningParser,
9
- NemotronV3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta, ReasoningError,
10
- ReasoningParser, SeedOssReasoningParser, Step3ReasoningParser, Step3p5ReasoningParser,
7
+ DeepSeekV4ReasoningParser, Glm45ReasoningParser, KimiK2ReasoningParser, KimiReasoningParser,
8
+ MiniMaxM2ReasoningParser, MiniMaxM3ReasoningParser, NemotronV3ReasoningParser,
9
+ Qwen3ReasoningParser, ReasoningDelta, ReasoningError, ReasoningParser, SeedOssReasoningParser,
10
+ Step3ReasoningParser, Step3p5ReasoningParser,
11
};
12
use vllm_tokenizer::DynTokenizer;
13
@@ -33,8 +33,9 @@ pub mod names {
33
}
34
35
/// Constructor signature for one registered reasoning parser implementation.
36
-type ReasoningParserCreator =
37
- fn(DynTokenizer) -> vllm_parser::reasoning::Result<Box<dyn ReasoningParser>>;
36
+type ReasoningParserCreator = Arc<
37
+ dyn Fn(DynTokenizer) -> vllm_parser::reasoning::Result<Box<dyn ReasoningParser>> + Send + Sync,
38
+>;
39
40
/// Registry and model matcher for reasoning parsers.
41
pub type ReasoningParserFactory = ParserFactory<ReasoningParserCreator>;
@@ -58,7 +59,7 @@ impl ReasoningParserFactory {
59
.register_parser::<DeepSeekR1ReasoningParser>(names::DEEPSEEK_R1)
60
.register_parser::<DeepSeekV3ReasoningParser>(names::DEEPSEEK_V3)
61
.register_parser::<DeepSeekV4ReasoningParser>(names::DEEPSEEK_V4)
61
- .register_parser::<Gemma4ReasoningParser>(names::GEMMA4)
62
+ .register_unified_dummy(names::GEMMA4)
63
.register_parser::<Glm45ReasoningParser>(names::GLM45)
64
.register_parser::<KimiReasoningParser>(names::KIMI)
65
.register_parser::<KimiK2ReasoningParser>(names::KIMI_K2)
@@ -109,7 +110,17 @@ impl ReasoningParserFactory {
110
where
111
T: ReasoningParser + 'static,
112
{
112
- self.register_creator(name, T::create)
113
+ self.register_creator(name, Arc::new(T::create))
114
+ }
115
+
116
+ /// Register one unified-only parser name in the split reasoning registry.
117
+ pub fn register_unified_dummy(&mut self, name: &str) -> &mut Self {
118
+ let name = name.to_string();
119
+ let registered_name = name.clone();
120
+ self.register_creator(
121
+ ®istered_name,
122
+ Arc::new(move |_| Err(ReasoningError::DummyUnifiedParser { name: name.clone() })),
123
+ )
124
}
125
126
/// Construct a parser from an exact name.
@@ -124,7 +135,7 @@ impl ReasoningParserFactory {
135
available_names: self.list(),
136
})?;
137
127
- creator(tokenizer).map_err(|error| crate::Error::ParserInitialization {
138
+ creator.as_ref()(tokenizer).map_err(|error| crate::Error::ParserInitialization {
139
kind: "reasoning",
140
name: name.to_string(),
141
error: error.into(),
rust/src/chat/src/parser/reasoning/tests.rs
+2
@@ -35,11 +35,13 @@ fn factory_contains_and_lists_registered_parsers() {
35
assert!(factory.contains(names::SEED_OSS));
36
assert!(factory.contains(names::STEP3P5));
37
assert!(factory.contains(names::MINIMAX_M3));
38
+ assert!(factory.contains(names::GEMMA4));
39
assert!(factory.list().contains(&names::QWEN3.to_string()));
40
assert!(factory.list().contains(&names::DEEPSEEK_V4.to_string()));
41
assert!(factory.list().contains(&names::SEED_OSS.to_string()));
42
assert!(factory.list().contains(&names::STEP3P5.to_string()));
43
assert!(factory.list().contains(&names::MINIMAX_M3.to_string()));
44
+ assert!(factory.list().contains(&names::GEMMA4.to_string()));
45
}
46
47
#[test]
rust/src/chat/src/parser/tool/mod.rs
+20
-9
@@ -1,13 +1,13 @@
1
//! Tool parser registration and selection boundary for `vllm-chat`.
2
3
-use std::sync::LazyLock;
3
+use std::sync::{Arc, LazyLock};
4
5
pub use vllm_parser::tool::{
6
DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser,
7
- Gemma4ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser, HermesToolParser,
8
- HyV3ToolParser, Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser,
9
- MinimaxM2ToolParser, MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser,
10
- Qwen3CoderToolParser, Qwen3XmlToolParser, ToolParser, ToolParserError,
7
+ Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser, HermesToolParser, HyV3ToolParser,
8
+ Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser,
9
+ MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser, Qwen3CoderToolParser,
10
+ Qwen3XmlToolParser, ToolParser, ToolParserError,
11
};
12
13
use crate::parser::ParserFactory;
@@ -40,7 +40,8 @@ pub mod names {
40
}
41
42
/// Constructor signature for one registered tool parser implementation.
43
-type ToolParserCreator = fn(&[ChatTool]) -> vllm_parser::tool::Result<Box<dyn ToolParser>>;
43
+type ToolParserCreator =
44
+ Arc<dyn Fn(&[ChatTool]) -> vllm_parser::tool::Result<Box<dyn ToolParser>> + Send + Sync>;
45
46
/// Registry and model matcher for tool parsers.
47
pub type ToolParserFactory = ParserFactory<ToolParserCreator>;
@@ -65,7 +66,7 @@ impl ToolParserFactory {
66
.register_parser::<DeepSeekV4ToolParser>(names::DEEPSEEK_V4)
67
.register_parser::<Glm45MoeToolParser>(names::GLM45)
68
.register_parser::<Glm47MoeToolParser>(names::GLM47)
68
- .register_parser::<Gemma4ToolParser>(names::GEMMA4)
69
+ .register_unified_dummy(names::GEMMA4)
70
.register_parser::<Granite4ToolParser>(names::GRANITE4)
71
.register_parser::<HermesToolParser>(names::HERMES)
72
.register_parser::<HyV3ToolParser>(names::HY_V3)
@@ -126,7 +127,17 @@ impl ToolParserFactory {
127
where
128
T: ToolParser + 'static,
129
{
129
- self.register_creator(name, T::create)
130
+ self.register_creator(name, Arc::new(T::create))
131
+ }
132
+
133
+ /// Register one unified-only parser name in the split tool registry.
134
+ pub fn register_unified_dummy(&mut self, name: &str) -> &mut Self {
135
+ let name = name.to_string();
136
+ let registered_name = name.clone();
137
+ self.register_creator(
138
+ ®istered_name,
139
+ Arc::new(move |_| Err(ToolParserError::DummyUnifiedParser { name: name.clone() })),
140
+ )
141
}
142
143
/// Construct a parser from an exact name.
@@ -137,7 +148,7 @@ impl ToolParserFactory {
148
available_names: self.list(),
149
})?;
150
140
- creator(tools).map_err(|error| crate::Error::ParserInitialization {
151
+ creator.as_ref()(tools).map_err(|error| crate::Error::ParserInitialization {
152
kind: "tool",
153
name: name.to_string(),
154
error: error.into(),
rust/src/chat/src/parser/unified.rs
new
+124
@@ -0,0 +1,124 @@
1
+//! Unified parser registration and selection boundary for `vllm-chat`.
2
+
3
+use std::sync::LazyLock;
4
+
5
+pub use vllm_parser::unified::{Gemma4UnifiedParser, UnifiedParser};
6
+use vllm_tokenizer::DynTokenizer;
7
+
8
+use crate::parser::ParserFactory;
9
+use crate::request::ChatTool;
10
+
11
+/// Canonical public names for registered unified parsers.
12
+pub mod names {
13
+ pub const GEMMA4: &str = "gemma4";
14
+}
15
+
16
+/// Constructor signature for one registered unified parser implementation.
17
+type UnifiedParserCreator =
18
+ fn(&[ChatTool], DynTokenizer) -> vllm_parser::unified::Result<Box<dyn UnifiedParser>>;
19
+
20
+/// Registry and model matcher for unified parsers.
21
+pub type UnifiedParserFactory = ParserFactory<UnifiedParserCreator>;
22
+
23
+impl UnifiedParserFactory {
24
+ /// Get the global unified parser factory with built-in registrations and
25
+ /// model mappings.
26
+ pub fn global() -> &'static Self {
27
+ static INSTANCE: LazyLock<UnifiedParserFactory> = LazyLock::new(UnifiedParserFactory::new);
28
+ &INSTANCE
29
+ }
30
+
31
+ /// Create the default registry with built-in parser names and model
32
+ /// mappings.
33
+ pub fn new() -> Self {
34
+ let mut factory = Self::default();
35
+
36
+ factory.register_parser::<Gemma4UnifiedParser>(names::GEMMA4);
37
+
38
+ factory
39
+ .register_pattern("gemma-4", names::GEMMA4)
40
+ .register_pattern("gemma4", names::GEMMA4);
41
+
42
+ factory
43
+ }
44
+
45
+ /// Register one parser type that exposes a static `create()` constructor.
46
+ pub fn register_parser<T>(&mut self, name: &str) -> &mut Self
47
+ where
48
+ T: UnifiedParser + 'static,
49
+ {
50
+ self.register_creator(name, T::create)
51
+ }
52
+
53
+ /// Construct a parser from an exact name.
54
+ pub fn create(
55
+ &self,
56
+ name: &str,
57
+ tools: &[ChatTool],
58
+ tokenizer: DynTokenizer,
59
+ ) -> crate::Result<Box<dyn UnifiedParser>> {
60
+ let creator = self.creator(name).ok_or_else(|| crate::Error::ParserUnavailableByName {
61
+ kind: "unified",
62
+ name: name.to_string(),
63
+ available_names: self.list(),
64
+ })?;
65
+
66
+ creator(tools, tokenizer).map_err(|error| crate::Error::ParserInitialization {
67
+ kind: "unified",
68
+ name: name.to_string(),
69
+ error: error.into(),
70
+ })
71
+ }
72
+}
73
+
74
+#[cfg(test)]
75
+mod tests {
76
+ use std::sync::Arc;
77
+
78
+ use vllm_tokenizer::Tokenizer;
79
+
80
+ use super::{UnifiedParserFactory, names};
81
+
82
+ struct FakeTokenizer;
83
+
84
+ impl Tokenizer for FakeTokenizer {
85
+ fn encode(
86
+ &self,
87
+ text: &str,
88
+ _add_special_tokens: bool,
89
+ ) -> vllm_tokenizer::Result<Vec<u32>> {
90
+ Ok(text.chars().map(u32::from).collect())
91
+ }
92
+
93
+ fn decode(
94
+ &self,
95
+ token_ids: &[u32],
96
+ _skip_special_tokens: bool,
97
+ ) -> vllm_tokenizer::Result<String> {
98
+ Ok(token_ids
99
+ .iter()
100
+ .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
101
+ .collect())
102
+ }
103
+
104
+ fn token_to_id(&self, token: &str) -> Option<u32> {
105
+ match token {
106
+ "<|channel>" => Some(1),
107
+ "<channel|>" => Some(2),
108
+ _ => None,
109
+ }
110
+ }
111
+ }
112
+
113
+ #[test]
114
+ fn factory_registers_gemma4() {
115
+ let factory = UnifiedParserFactory::new();
116
+
117
+ assert!(factory.contains(names::GEMMA4));
118
+ assert_eq!(
119
+ factory.resolve_name_for_model("google/gemma-4-27b-it"),
120
+ Some(names::GEMMA4)
121
+ );
122
+ factory.create(names::GEMMA4, &[], Arc::new(FakeTokenizer)).unwrap();
123
+ }
124
+}
rust/src/chat/tests/roundtrip.rs
+50
-3
@@ -37,6 +37,8 @@ struct RoundtripCase {
37
/// JSON formatting expected after this model's template has materialized
38
/// tool-call arguments.
39
json_fmt: JsonFmt,
40
+ /// Whether the template renders tool-call argument object keys in sorted order.
41
+ sort_json_keys: bool,
42
}
43
44
#[derive(Clone, Copy)]
@@ -81,6 +83,7 @@ impl RoundtripCase {
83
reasoning_parser: ParserSelection::Auto,
84
thinking_behavior: ThinkingBehavior::Toggleable { default: true },
85
json_fmt: spaced_json_fmt(),
86
+ sort_json_keys: false,
87
}
88
}
89
@@ -93,6 +96,7 @@ impl RoundtripCase {
96
reasoning_parser: ParserSelection::Auto,
97
thinking_behavior: ThinkingBehavior::Toggleable { default: true },
98
json_fmt: compact_json_fmt(),
99
+ sort_json_keys: false,
100
}
101
}
102
@@ -105,6 +109,7 @@ impl RoundtripCase {
109
reasoning_parser: ParserSelection::Auto,
110
thinking_behavior: ThinkingBehavior::Always { value: true },
111
json_fmt: compact_json_fmt(),
112
+ sort_json_keys: false,
113
}
114
}
115
@@ -117,6 +122,7 @@ impl RoundtripCase {
122
reasoning_parser: ParserSelection::Auto,
123
thinking_behavior: ThinkingBehavior::Toggleable { default: false },
124
json_fmt: compact_json_fmt(),
125
+ sort_json_keys: false,
126
}
127
}
128
@@ -129,6 +135,20 @@ impl RoundtripCase {
135
reasoning_parser: ParserSelection::Auto,
136
thinking_behavior: ThinkingBehavior::Toggleable { default: true },
137
json_fmt: compact_json_fmt(),
138
+ sort_json_keys: false,
139
+ }
140
+ }
141
+
142
+ /// Gemma4 channel reasoning with custom function-call arguments.
143
+ fn gemma4() -> Self {
144
+ Self {
145
+ model_id: "google/gemma-4-E4B-it",
146
+ assistant_stop_suffix: "<|tool_response>",
147
+ tool_call_parser: ParserSelection::Auto,
148
+ reasoning_parser: ParserSelection::Auto,
149
+ thinking_behavior: ThinkingBehavior::Always { value: true },
150
+ json_fmt: compact_json_fmt(),
151
+ sort_json_keys: true,
152
}
153
}
154
@@ -142,6 +162,7 @@ impl RoundtripCase {
162
reasoning_parser: ParserSelection::Auto,
163
thinking_behavior: ThinkingBehavior::Toggleable { default: true },
164
json_fmt: spaced_json_fmt(),
165
+ sort_json_keys: false,
166
}
167
}
168
@@ -154,6 +175,7 @@ impl RoundtripCase {
175
reasoning_parser: ParserSelection::Auto,
176
thinking_behavior: ThinkingBehavior::Always { value: true },
177
json_fmt: compact_json_fmt(),
178
+ sort_json_keys: false,
179
}
180
}
181
@@ -166,6 +188,7 @@ impl RoundtripCase {
188
reasoning_parser: ParserSelection::Auto,
189
thinking_behavior: ThinkingBehavior::Always { value: true },
190
json_fmt: compact_json_fmt(),
191
+ sort_json_keys: false,
192
}
193
}
194
}
@@ -195,8 +218,8 @@ roundtrip_tests! {
218
seed_oss => [reasoning_and_content],
219
step3p5 => [reasoning_and_content],
220
198
- // Note: Kimi K2.5 strips the reasoning content in history.
199
- kimi_k25 => [tool_call_mix],
221
+ gemma4 => [tool_call_mix], // Gemma4 strips reasoning in history if there's no tool call
222
+ kimi_k25 => [tool_call_mix], // Kimi K2.5 strips reasoning in history
223
}
224
225
/// Run the fixed reasoning+content fixture for one model/parser case.
@@ -347,14 +370,38 @@ fn spaced_json_fmt() -> JsonFmt {
370
/// Pass in a raw JSON string instead of a structured value to ensure the exact precision and
371
/// formatting of numbers are preserved.
372
fn expected_arguments(case: &RoundtripCase, raw_json: &str) -> Result<String> {
350
- let value: serde_json::Value =
373
+ let mut value: serde_json::Value =
374
serde_json::from_str(raw_json).context("invalid expected tool-call arguments")?;
375
+ if case.sort_json_keys {
376
+ sort_json_value(&mut value);
377
+ }
378
379
case.json_fmt
380
.format_to_string(&value)
381
.context("failed to format expected tool-call arguments")
382
}
383
384
+/// Sort JSON object keys recursively to match templates that render mappings with `dictsort`.
385
+fn sort_json_value(value: &mut serde_json::Value) {
386
+ match value {
387
+ serde_json::Value::Object(map) => {
388
+ for value in map.values_mut() {
389
+ sort_json_value(value);
390
+ }
391
+
392
+ let mut entries = std::mem::take(map).into_iter().collect::<Vec<_>>();
393
+ entries.sort_by(|(left, _), (right, _)| left.cmp(right));
394
+ map.extend(entries);
395
+ }
396
+ serde_json::Value::Array(values) => {
397
+ for value in values {
398
+ sort_json_value(value);
399
+ }
400
+ }
401
+ _ => {}
402
+ }
403
+}
404
+
405
/// Load the real model chat/text backend for one roundtrip case.
406
async fn load_roundtrip_backends(case: &RoundtripCase) -> Result<vllm_chat::LoadedModelBackends> {
407
load_model_backends(
rust/src/parser/benches/gemma4.rs
+5
-3
@@ -2,10 +2,11 @@ use std::time::Duration;
2
3
use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main};
4
use vllm_parser::tool::test_utils::{split_by_chars, test_tools};
5
-use vllm_parser::tool::{Gemma4ToolParser, Tool, ToolParser};
5
+use vllm_parser::tool::{Tool, ToolParser};
6
+use vllm_parser::unified::Gemma4UnifiedParser;
7
8
mod utils;
8
-use utils::feed_parser;
9
+use utils::{UnifiedToolParserAdapter, feed_parser};
10
11
const CHUNK_CHARS: usize = 7;
12
const LONG_NORMAL_TEXT_REPEATS: usize = 2048;
@@ -68,7 +69,8 @@ fn long_tool_argument_fixture() -> String {
69
}
70
71
fn parser(tools: &[Tool]) -> Box<dyn ToolParser> {
71
- Gemma4ToolParser::create(tools).expect("Gemma4 parser should initialize")
72
+ UnifiedToolParserAdapter::<Gemma4UnifiedParser>::create(tools)
73
+ .expect("Gemma4 unified parser should initialize")
74
}
75
76
fn run_stream_group(
rust/src/parser/benches/utils/adapter.rs
new
+106
@@ -0,0 +1,106 @@
1
+use std::sync::Arc;
2
+
3
+use vllm_parser::tool::{
4
+ Result, StructuralTagModel, Tool, ToolParser, ToolParserError, ToolParserOutput,
5
+};
6
+use vllm_parser::unified::{
7
+ UnifiedParser, UnifiedParserError, UnifiedParserEvent, UnifiedParserOutput,
8
+};
9
+use vllm_tokenizer::Tokenizer;
10
+
11
+/// Tokenizer stub used by unified-parser benchmarks.
12
+struct BenchTokenizer;
13
+
14
+impl Tokenizer for BenchTokenizer {
15
+ fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result<Vec<u32>> {
16
+ Ok(text.chars().map(|_| u32::MAX).collect())
17
+ }
18
+
19
+ fn decode(
20
+ &self,
21
+ token_ids: &[u32],
22
+ _skip_special_tokens: bool,
23
+ ) -> vllm_tokenizer::Result<String> {
24
+ Ok("\u{FFFD}".repeat(token_ids.len()))
25
+ }
26
+
27
+ fn token_to_id(&self, _token: &str) -> Option<u32> {
28
+ Some(u32::MAX)
29
+ }
30
+}
31
+
32
+/// Bench-only adapter that exposes a unified parser through the tool-parser
33
+/// benchmark harness.
34
+///
35
+/// Returns error if the unified parser produces reasoning events.
36
+pub struct UnifiedToolParserAdapter<T> {
37
+ inner: Box<dyn UnifiedParser>,
38
+ _marker: std::marker::PhantomData<T>,
39
+}
40
+
41
+fn map_unified_error(error: UnifiedParserError) -> ToolParserError {
42
+ ToolParserError::ParsingFailed {
43
+ message: format!("unified parser failed: {error}"),
44
+ }
45
+}
46
+
47
+fn append_unified_output(
48
+ output: UnifiedParserOutput,
49
+ tool_output: &mut ToolParserOutput,
50
+) -> Result<()> {
51
+ for event in output.events {
52
+ match event {
53
+ UnifiedParserEvent::Text(text) => tool_output.push_text(text),
54
+ UnifiedParserEvent::ToolCall(call) => tool_output.push_call(call),
55
+ UnifiedParserEvent::Reasoning(_) => {
56
+ return Err(ToolParserError::ParsingFailed {
57
+ message: "unified parser emitted reasoning in tool-parser adapter".to_string(),
58
+ });
59
+ }
60
+ }
61
+ }
62
+ Ok(())
63
+}
64
+
65
+impl<T: UnifiedParser> ToolParser for UnifiedToolParserAdapter<T> {
66
+ fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>>
67
+ where
68
+ Self: Sized + 'static,
69
+ {
70
+ let inner = T::create(tools, Arc::new(BenchTokenizer)).map_err(map_unified_error)?;
71
+ Ok(Box::new(Self {
72
+ inner,
73
+ _marker: std::marker::PhantomData,
74
+ }))
75
+ }
76
+
77
+ fn preserve_special_tokens(&self) -> bool {
78
+ self.inner.preserve_special_tokens()
79
+ }
80
+
81
+ fn structural_tag_model(&self) -> Option<StructuralTagModel> {
82
+ self.inner.structural_tag_model()
83
+ }
84
+
85
+ fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
86
+ self.inner.tool_call_id(tool_index)
87
+ }
88
+
89
+ fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
90
+ let mut unified_output = UnifiedParserOutput::default();
91
+ let result = self.inner.parse_into(chunk, &mut unified_output).map_err(map_unified_error);
92
+ append_unified_output(unified_output, output)?;
93
+ result
94
+ }
95
+
96
+ fn finish(&mut self) -> Result<ToolParserOutput> {
97
+ let unified_output = self.inner.finish().map_err(map_unified_error)?;
98
+ let mut output = ToolParserOutput::default();
99
+ append_unified_output(unified_output, &mut output)?;
100
+ Ok(output)
101
+ }
102
+
103
+ fn reset(&mut self) -> String {
104
+ self.inner.reset()
105
+ }
106
+}
rust/src/parser/benches/utils/mod.rs
+7
@@ -1,4 +1,9 @@
1
+// This module is shared by multiple benchmark targets.
2
+// There could be false positives for unused code or imports, and fixing them would lead to some other benchmarks failing to compile.
3
#![allow(dead_code)]
4
+#![allow(unused_imports)]
5
+
6
+mod adapter;
7
8
use futures::FutureExt as _;
9
use openai_protocol::common::{Function as OpenAiFunction, Tool as OpenAiTool};
@@ -6,6 +11,8 @@ use tool_parser::traits::ToolParser as ExternalToolParser;
11
use vllm_parser::tool::test_utils::collect_stream;
12
use vllm_parser::tool::{Tool, ToolParser};
13
14
+pub(super) use adapter::UnifiedToolParserAdapter;
15
+
16
pub(super) fn openai_tools(tools: &[Tool]) -> Vec<OpenAiTool> {
17
tools
18
.iter()
rust/src/parser/src/lib.rs
+1
@@ -3,3 +3,4 @@
3
pub mod reasoning;
4
pub mod tool;
5
pub mod unified;
6
+pub(crate) mod utils;
rust/src/parser/src/reasoning/delimited.rs
+5
-5
@@ -144,20 +144,20 @@ impl DelimitedReasoningParser {
144
}
145
146
/// Determine the reasoning state implied by the last prompt boundary, if any.
147
-fn last_reasoning_boundary(
147
+pub(crate) fn last_reasoning_boundary(
148
prompt_token_ids: &[u32],
149
start_token_id: u32,
150
end_token_id: u32,
151
tokenizer: &dyn Tokenizer,
152
) -> Option<bool> {
153
- for token_id in prompt_token_ids.iter().rev() {
154
- if *token_id == start_token_id {
153
+ for token_id in prompt_token_ids.iter().rev().copied() {
154
+ if token_id == start_token_id {
155
return Some(true);
156
}
157
- if *token_id == end_token_id {
157
+ if token_id == end_token_id {
158
return Some(false);
159
}
160
- if tokenizer.is_special_id(*token_id) {
160
+ if tokenizer.is_special_id(token_id) {
161
return None;
162
}
163
}
rust/src/parser/src/reasoning/gemma4.rs
deleted
-273
@@ -1,273 +0,0 @@
1
-use vllm_tokenizer::DynTokenizer;
2
-
3
-use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
4
-
5
-const THOUGHT_PREFIX: &str = "thought\n";
6
-
7
-/// Reasoning parser for Google Gemma4 thinking models.
8
-///
9
-/// Gemma4 emits reasoning inside `<|channel> ... <channel|>` spans and adds a
10
-/// structural `thought\n` label at the beginning of the reasoning channel.
11
-/// This parser keeps the delimiter handling in the shared delimited parser and
12
-/// only layers on Gemma4-specific request adjustment plus prefix stripping.
13
-///
14
-/// Original Python implementation:
15
-/// <https://github.com/vllm-project/vllm/blob/18b1c77211d8f6fe800bcfb89524d2b598708032/vllm/reasoning/gemma4_reasoning_parser.py#L23>
16
-pub struct Gemma4ReasoningParser {
17
- inner: DelimitedReasoningParser,
18
- reasoning_text: String,
19
- prefix_stripped: bool,
20
-}
21
-
22
-impl Gemma4ReasoningParser {
23
- /// Create a Gemma4 parser.
24
- pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
25
- Ok(Self {
26
- inner: DelimitedReasoningParser::new(tokenizer, "<|channel>", "<channel|>", false)?,
27
- reasoning_text: String::new(),
28
- prefix_stripped: false,
29
- })
30
- }
31
-
32
- /// Apply Gemma4's `thought\n` stripping rule to one reasoning delta.
33
- ///
34
- /// Early reasoning text is buffered until we can decide whether it begins
35
- /// with the structural channel label.
36
- fn strip_thought_prefix(&mut self, reasoning: &str) -> Option<String> {
37
- if self.prefix_stripped {
38
- return Some(reasoning.to_string());
39
- }
40
-
41
- self.reasoning_text.push_str(reasoning);
42
-
43
- if self.reasoning_text.starts_with(THOUGHT_PREFIX) {
44
- let prefix_len = THOUGHT_PREFIX.len();
45
- let previous_len = self.reasoning_text.len() - reasoning.len();
46
- if previous_len >= prefix_len {
47
- self.reasoning_text.clear();
48
- self.prefix_stripped = true;
49
- return Some(reasoning.to_string());
50
- }
51
-
52
- let prefix_chars_in_delta = prefix_len - previous_len;
53
- let stripped = &reasoning[prefix_chars_in_delta.min(reasoning.len())..];
54
- if stripped.is_empty() {
55
- if self.reasoning_text.len() >= prefix_len {
56
- self.reasoning_text.clear();
57
- self.prefix_stripped = true;
58
- }
59
- return None;
60
- }
61
-
62
- self.reasoning_text.clear();
63
- self.prefix_stripped = true;
64
- return Some(stripped.to_string());
65
- }
66
-
67
- if THOUGHT_PREFIX.starts_with(&self.reasoning_text) {
68
- return None;
69
- }
70
-
71
- self.prefix_stripped = true;
72
- Some(std::mem::take(&mut self.reasoning_text))
73
- }
74
-
75
- /// Apply Gemma4-specific reasoning post-processing to one parsed delta.
76
- fn post_process(&mut self, mut result: ReasoningDelta) -> ReasoningDelta {
77
- if let Some(reasoning) = result.reasoning.take() {
78
- result.reasoning =
79
- self.strip_thought_prefix(&reasoning).filter(|text| !text.is_empty());
80
- }
81
- result
82
- }
83
-}
84
-
85
-impl ReasoningParser for Gemma4ReasoningParser {
86
- fn create(tokenizer: DynTokenizer) -> Result<Box<dyn ReasoningParser>>
87
- where
88
- Self: Sized + 'static,
89
- {
90
- Ok(Box::new(Self::new(tokenizer)?))
91
- }
92
-
93
- fn preserve_special_tokens(&self) -> bool {
94
- true
95
- }
96
-
97
- fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
98
- self.inner.initialize(prompt_token_ids);
99
- self.reasoning_text.clear();
100
- self.prefix_stripped = false;
101
- Ok(())
102
- }
103
-
104
- fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
105
- let result = self.inner.push(delta);
106
- Ok(self.post_process(result))
107
- }
108
-
109
- fn finish(&mut self) -> Result<ReasoningDelta> {
110
- let result = self.inner.finish();
111
- Ok(self.post_process(result))
112
- }
113
-}
114
-
115
-#[cfg(test)]
116
-mod tests {
117
- use std::sync::Arc;
118
-
119
- use vllm_tokenizer::Tokenizer;
120
-
121
- use super::Gemma4ReasoningParser;
122
- use crate::reasoning::ReasoningParser;
123
-
124
- struct FakeTokenizer;
125
-
126
- impl Tokenizer for FakeTokenizer {
127
- fn encode(
128
- &self,
129
- text: &str,
130
- _add_special_tokens: bool,
131
- ) -> vllm_tokenizer::Result<Vec<u32>> {
132
- Ok(text.chars().map(u32::from).collect())
133
- }
134
-
135
- fn decode(
136
- &self,
137
- token_ids: &[u32],
138
- _skip_special_tokens: bool,
139
- ) -> vllm_tokenizer::Result<String> {
140
- Ok(token_ids
141
- .iter()
142
- .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
143
- .collect())
144
- }
145
-
146
- fn token_to_id(&self, token: &str) -> Option<u32> {
147
- match token {
148
- "<|channel>" => Some(1000),
149
- "<channel|>" => Some(1001),
150
- _ => None,
151
- }
152
- }
153
- }
154
-
155
- fn run_streaming(output: &[&str]) -> (Option<String>, Option<String>) {
156
- let tokenizer = Arc::new(FakeTokenizer);
157
- let mut parser = Gemma4ReasoningParser::new(tokenizer).unwrap();
158
- let mut reasoning = String::new();
159
- let mut content = String::new();
160
-
161
- for delta in output {
162
- let result = parser.push(delta).unwrap();
163
- if let Some(next) = result.reasoning {
164
- reasoning.push_str(&next);
165
- }
166
- if let Some(next) = result.content {
167
- content.push_str(&next);
168
- }
169
- }
170
-
171
- let final_delta = parser.finish().unwrap();
172
- if let Some(next) = final_delta.reasoning {
173
- reasoning.push_str(&next);
174
- }
175
- if let Some(next) = final_delta.content {
176
- content.push_str(&next);
177
- }
178
-
179
- (
180
- (!reasoning.is_empty()).then_some(reasoning),
181
- (!content.is_empty()).then_some(content),
182
- )
183
- }
184
-
185
- #[test]
186
- fn gemma4_reasoning_streaming_handles_channel_delimited_outputs() {
187
- let cases = [
188
- (
189
- "no_reasoning",
190
- vec!["This is content"],
191
- None,
192
- Some("This is content"),
193
- ),
194
- (
195
- "reasoning_and_content",
196
- vec!["<|channel>This is a reasoning section<channel|>This is the rest"],
197
- Some("This is a reasoning section"),
198
- Some("This is the rest"),
199
- ),
200
- (
201
- "complete_reasoning",
202
- vec!["<|channel>This is a reasoning section<channel|>"],
203
- Some("This is a reasoning section"),
204
- None,
205
- ),
206
- (
207
- "multiple_lines",
208
- vec!["<|channel>This\nThat<channel|>This is the rest\nThat"],
209
- Some("This\nThat"),
210
- Some("This is the rest\nThat"),
211
- ),
212
- (
213
- "no_end",
214
- vec!["<|channel>This is a reasoning section"],
215
- Some("This is a reasoning section"),
216
- None,
217
- ),
218
- ("empty", vec![""], None, None),
219
- (
220
- "newline_around_reasoning",
221
- vec!["Before\n<|channel>This is a reasoning section<channel|>\nThis is the rest"],
222
- Some("This is a reasoning section"),
223
- Some("Before\n\nThis is the rest"),
224
- ),
225
- (
226
- "thought_prefix",
227
- vec!["<|channel>thought\nActual reasoning here<channel|>Final answer"],
228
- Some("Actual reasoning here"),
229
- Some("Final answer"),
230
- ),
231
- (
232
- "thought_prefix_only",
233
- vec!["<|channel>thought\n<channel|>"],
234
- None,
235
- None,
236
- ),
237
- (
238
- "thought_prefix_multiline",
239
- vec!["<|channel>thought\nLine1\nLine2<channel|>Answer"],
240
- Some("Line1\nLine2"),
241
- Some("Answer"),
242
- ),
243
- (
244
- "thought_prefix_diverge",
245
- vec!["<|channel>thousand reasons<channel|>Done"],
246
- Some("thousand reasons"),
247
- Some("Done"),
248
- ),
249
- ];
250
-
251
- for (name, output, expected_reasoning, expected_content) in cases {
252
- let (reasoning, content) = run_streaming(&output);
253
- assert_eq!(reasoning.as_deref(), expected_reasoning, "{name}");
254
- assert_eq!(content.as_deref(), expected_content, "{name}");
255
- }
256
- }
257
-
258
- #[test]
259
- fn gemma4_strips_thought_prefix_even_when_split_across_deltas() {
260
- let (reasoning, content) =
261
- run_streaming(&["<|channel>thou", "ght", "\nabc", "<channel|>done"]);
262
- assert_eq!(reasoning.as_deref(), Some("abc"));
263
- assert_eq!(content.as_deref(), Some("done"));
264
- }
265
-
266
- #[test]
267
- fn gemma4_preserves_special_tokens() {
268
- let tokenizer = Arc::new(FakeTokenizer);
269
- let parser = Gemma4ReasoningParser::new(tokenizer).unwrap();
270
-
271
- assert!(parser.preserve_special_tokens());
272
- }
273
-}
rust/src/parser/src/reasoning/mod.rs
+5
-3
@@ -17,7 +17,6 @@
17
mod cohere_cmd;
18
mod deepseek_r1;
19
mod delimited;
20
-mod gemma4;
20
mod kimi;
21
mod minimax_m3;
22
mod qwen3;
@@ -29,8 +28,7 @@ use vllm_tokenizer::DynTokenizer;
28
29
pub use self::cohere_cmd::CohereCmdReasoningParser;
30
pub use self::deepseek_r1::DeepSeekR1ReasoningParser;
32
-pub(crate) use self::delimited::DelimitedReasoningParser;
33
-pub use self::gemma4::Gemma4ReasoningParser;
31
+pub(crate) use self::delimited::{DelimitedReasoningParser, last_reasoning_boundary};
32
pub use self::kimi::KimiReasoningParser;
33
pub use self::minimax_m3::MiniMaxM3ReasoningParser;
34
pub use self::qwen3::Qwen3ReasoningParser;
@@ -129,6 +127,10 @@ pub trait ReasoningParser: Send {
127
pub enum ReasoningError {
128
#[error("tokenizer is missing reasoning delimiter token `{token}`")]
129
MissingToken { token: String },
130
+ #[error(
131
+ "`{name}` only provides a unified parser; the same reasoning parser and tool parser should be specified together"
132
+ )]
133
+ DummyUnifiedParser { name: String },
134
}
135
136
#[cfg(test)]
rust/src/parser/src/tool/error.rs
+4
@@ -10,4 +10,8 @@ pub type Result<T> = std::result::Result<T, ToolParserError>;
10
pub enum ToolParserError {
11
#[error("tool parser parsing failed: {message}")]
12
ParsingFailed { message: String },
13
+ #[error(
14
+ "`{name}` only provides a unified parser; the same reasoning parser and tool parser should be specified together"
15
+ )]
16
+ DummyUnifiedParser { name: String },
17
}
rust/src/parser/src/tool/mod.rs
+1
-3
@@ -4,7 +4,6 @@
4
pub(crate) mod error;
5
mod deepseek_dsml;
6
pub(crate) mod deepseek_json;
7
-mod gemma4;
7
mod glm_xml;
8
mod hy_v3;
9
mod json;
@@ -15,14 +14,13 @@ mod parameters;
14
mod qwen_coder;
15
#[cfg(any(test, feature = "test-util"))]
16
pub mod test_utils;
18
-pub(crate) mod utils;
17
+use crate::utils;
18
19
use std::collections::{BTreeMap, btree_map};
20
21
pub use deepseek_dsml::{DeepSeekV4ToolParser, DeepSeekV32ToolParser};
22
pub use deepseek_json::{DeepSeekV3ToolParser, DeepSeekV31ToolParser};
23
pub use error::{Result, ToolParserError};
25
-pub use gemma4::Gemma4ToolParser;
24
pub use glm_xml::{Glm45MoeToolParser, Glm47MoeToolParser};
25
pub use hy_v3::HyV3ToolParser;
26
pub use json::{
rust/src/parser/src/unified/gemma4.rs
renamed
+371
-38
@@ -6,10 +6,17 @@ use winnow::prelude::*;
6
use winnow::stream::{Partial, Stream};
7
use winnow::token::{literal, take_till, take_until};
8
9
-use super::utils::{incomplete, parse_buffered_event, partial_prefix_len, safe_text_len};
10
-use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
11
-use crate::tool::Tool;
9
+use vllm_tokenizer::DynTokenizer;
10
11
+use super::{Result, UnifiedParser, UnifiedParserError, UnifiedParserOutput};
12
+use crate::reasoning::last_reasoning_boundary;
13
+use crate::tool::{Tool, ToolCallDelta};
14
+use crate::unified::parsing_failed;
15
+use crate::utils::{incomplete, parse_buffered_event, partial_prefix_len, safe_text_len_mul};
16
+
17
+const REASONING_START: &str = "<|channel>thought\n";
18
+const CHANNEL_START: &str = "<|channel>";
19
+const CHANNEL_END: &str = "<channel|>";
20
const TOOL_CALL_START: &str = "<|tool_call>";
21
const TOOL_CALL_END: &str = "<tool_call|>";
22
const STRING_DELIM: &str = "<|\"|>";
@@ -20,6 +27,9 @@ type Gemma4Input<'i> = Partial<&'i str>;
27
#[derive(Debug, Clone, PartialEq)]
28
enum Gemma4Event {
29
Text { len: usize },
30
+ Reasoning { len: usize },
31
+ ReasoningStart,
32
+ ReasoningEnd,
33
ToolCallStart,
34
ToolCallHeader { name: String },
35
ToolCall { args: Map<String, Value> },
@@ -35,6 +45,7 @@ struct Gemma4ArgsScanState {
45
enum Gemma4Mode {
46
#[default]
47
Text,
48
+ Reasoning,
49
Header,
50
ToolCall {
51
name: String,
@@ -42,36 +53,62 @@ enum Gemma4Mode {
53
},
54
}
55
45
-/// Tool parser for Google Gemma4 models.
56
+/// Unified parser for Google Gemma4 models.
57
///
58
/// Original Python implementation:
48
-/// <https://github.com/vllm-project/vllm/blob/bf45e6d0a558da2b8d7b60efb07b4aa394f3b60b/vllm/tool_parsers/gemma4_tool_parser.py>
59
+/// <https://github.com/vllm-project/vllm/blob/main/vllm/parser/gemma4.py>
60
+///
61
+/// Handles Gemma4 reasoning and function-call formats:
62
///
50
-/// Handles the Gemma4 function call format:
63
+/// `<|channel>thought\nreasoning<channel|>`
64
///
65
/// `<|tool_call>call:func_name{key:<|"|>value<|"|>}<tool_call|>`
66
///
67
/// Arguments are emitted only after a full Gemma4 tool call is parsed.
55
-pub struct Gemma4ToolParser {
68
+pub struct Gemma4UnifiedParser {
69
buffer: String,
70
mode: Gemma4Mode,
71
emitted_tool_count: usize,
72
+ tokenizer: DynTokenizer,
73
+ channel_start_token_id: u32,
74
+ channel_end_token_id: u32,
75
}
76
61
-impl Gemma4ToolParser {
62
- fn new(_tools: &[Tool]) -> Self {
63
- Self {
77
+impl Gemma4UnifiedParser {
78
+ /// Create a Gemma4 parser.
79
+ pub fn new(_tools: &[Tool], tokenizer: DynTokenizer) -> Result<Self> {
80
+ let channel_start_token_id = tokenizer.token_to_id(CHANNEL_START).ok_or_else(|| {
81
+ UnifiedParserError::MissingToken {
82
+ token: CHANNEL_START.to_string(),
83
+ }
84
+ })?;
85
+ let channel_end_token_id =
86
+ tokenizer
87
+ .token_to_id(CHANNEL_END)
88
+ .ok_or_else(|| UnifiedParserError::MissingToken {
89
+ token: CHANNEL_END.to_string(),
90
+ })?;
91
+
92
+ Ok(Self {
93
buffer: String::new(),
94
mode: Gemma4Mode::default(),
95
emitted_tool_count: 0,
67
- }
96
+ channel_start_token_id,
97
+ channel_end_token_id,
98
+ tokenizer,
99
+ })
100
}
101
70
- fn apply_event(&mut self, event: Gemma4Event, output: &mut ToolParserOutput) -> Result<()> {
102
+ fn apply_event(&mut self, event: Gemma4Event, output: &mut UnifiedParserOutput) -> Result<()> {
103
match event {
104
Gemma4Event::Text { len: consumed_len } => {
73
- output.push_text(&self.buffer[..consumed_len]);
105
+ output.push_text(self.buffer[..consumed_len].to_string());
106
}
107
+ Gemma4Event::Reasoning { len: consumed_len } => {
108
+ output.push_reasoning(self.buffer[..consumed_len].to_string());
109
+ }
110
+ Gemma4Event::ReasoningStart => self.mode = Gemma4Mode::Reasoning,
111
+ Gemma4Event::ReasoningEnd => self.mode = Gemma4Mode::Text,
112
Gemma4Event::ToolCallStart => self.mode = Gemma4Mode::Header,
113
Gemma4Event::ToolCallHeader { name } => {
114
self.mode = Gemma4Mode::ToolCall {
@@ -100,9 +137,24 @@ impl Gemma4ToolParser {
137
Ok(())
138
}
139
140
+ fn initialize_mode(&mut self, prompt_token_ids: &[u32]) {
141
+ self.mode = match last_reasoning_boundary(
142
+ prompt_token_ids,
143
+ self.channel_start_token_id,
144
+ self.channel_end_token_id,
145
+ self.tokenizer.as_ref(),
146
+ ) {
147
+ Some(true) => Gemma4Mode::Reasoning,
148
+ Some(false) | None => Gemma4Mode::Text,
149
+ };
150
+ }
151
+
152
fn reset(&mut self) -> String {
153
let raw = match std::mem::replace(&mut self.mode, Gemma4Mode::Text) {
154
Gemma4Mode::Text => std::mem::take(&mut self.buffer),
155
+ Gemma4Mode::Reasoning => {
156
+ format!("{}{}", REASONING_START, std::mem::take(&mut self.buffer))
157
+ }
158
Gemma4Mode::Header => {
159
format!("{}{}", TOOL_CALL_START, std::mem::take(&mut self.buffer))
160
}
@@ -122,19 +174,26 @@ impl Gemma4ToolParser {
174
}
175
}
176
125
-impl ToolParser for Gemma4ToolParser {
126
- fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>>
177
+impl UnifiedParser for Gemma4UnifiedParser {
178
+ fn create(tools: &[Tool], tokenizer: DynTokenizer) -> Result<Box<dyn UnifiedParser>>
179
where
180
Self: Sized + 'static,
181
{
130
- Ok(Box::new(Self::new(tools)))
182
+ Self::new(tools, tokenizer).map(|parser| Box::new(parser) as Box<dyn UnifiedParser>)
183
+ }
184
+
185
+ fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
186
+ self.buffer.clear();
187
+ self.emitted_tool_count = 0;
188
+ self.initialize_mode(prompt_token_ids);
189
+ Ok(())
190
}
191
192
fn preserve_special_tokens(&self) -> bool {
193
true
194
}
195
137
- fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
196
+ fn parse_into(&mut self, chunk: &str, output: &mut UnifiedParserOutput) -> Result<()> {
197
self.buffer.push_str(chunk);
198
199
while let Some((event, consumed_len)) = {
@@ -149,11 +208,12 @@ impl ToolParser for Gemma4ToolParser {
208
Ok(())
209
}
210
152
- fn finish(&mut self) -> Result<ToolParserOutput> {
153
- let mut output = ToolParserOutput::default();
211
+ fn finish(&mut self) -> Result<UnifiedParserOutput> {
212
+ let mut output = UnifiedParserOutput::default();
213
214
match &self.mode {
156
- Gemma4Mode::Text => output.push_text(&self.buffer),
215
+ Gemma4Mode::Text => output.push_text(std::mem::take(&mut self.buffer)),
216
+ Gemma4Mode::Reasoning => output.push_reasoning(std::mem::take(&mut self.buffer)),
217
Gemma4Mode::Header | Gemma4Mode::ToolCall { .. } => {
218
return Err(parsing_failed!("incomplete Gemma4 tool call"));
219
}
@@ -164,7 +224,7 @@ impl ToolParser for Gemma4ToolParser {
224
}
225
226
fn reset(&mut self) -> String {
167
- Gemma4ToolParser::reset(self)
227
+ Gemma4UnifiedParser::reset(self)
228
}
229
}
230
@@ -175,6 +235,7 @@ fn parse_next_gemma4_event(
235
) -> ModalResult<Gemma4Event> {
236
match mode {
237
Gemma4Mode::Text => parse_text_event(input),
238
+ Gemma4Mode::Reasoning => parse_reasoning_event(input),
239
Gemma4Mode::Header => tool_call_header_event(input),
240
Gemma4Mode::ToolCall { args_scan, .. } => tool_call_args_event(input, args_scan),
241
}
@@ -182,7 +243,32 @@ fn parse_next_gemma4_event(
243
244
/// Parse a Gemma4 text-mode event.
245
fn parse_text_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event> {
185
- alt((tool_call_start_event, safe_text_event)).parse_next(input)
246
+ alt((
247
+ reasoning_start_event,
248
+ tool_call_start_event,
249
+ safe_text_event,
250
+ ))
251
+ .parse_next(input)
252
+}
253
+
254
+/// Parse a Gemma4 reasoning-mode event.
255
+fn parse_reasoning_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event> {
256
+ alt((
257
+ reasoning_end_event,
258
+ tool_call_start_event,
259
+ safe_reasoning_event,
260
+ ))
261
+ .parse_next(input)
262
+}
263
+
264
+/// Parse a Gemma4 reasoning start marker.
265
+fn reasoning_start_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event> {
266
+ literal(REASONING_START).value(Gemma4Event::ReasoningStart).parse_next(input)
267
+}
268
+
269
+/// Parse a Gemma4 reasoning end marker.
270
+fn reasoning_end_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event> {
271
+ literal(CHANNEL_END).value(Gemma4Event::ReasoningEnd).parse_next(input)
272
}
273
274
/// Parse a Gemma4 tool-call start marker.
@@ -226,7 +312,14 @@ fn gemma4_tool_name(input: &mut Gemma4Input<'_>) -> ModalResult<String> {
312
313
/// Parse a safe text run before the next Gemma4 marker.
314
fn safe_text_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event> {
229
- safe_text_len(input, TOOL_CALL_START).map(|len| Gemma4Event::Text { len })
315
+ safe_text_len_mul(input, &[REASONING_START, TOOL_CALL_START])
316
+ .map(|len| Gemma4Event::Text { len })
317
+}
318
+
319
+/// Parse a safe reasoning run before the next Gemma4 marker.
320
+fn safe_reasoning_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event> {
321
+ safe_text_len_mul(input, &[CHANNEL_END, TOOL_CALL_START])
322
+ .map(|len| Gemma4Event::Reasoning { len })
323
}
324
325
/// Parse raw Gemma4 arguments through the first end marker outside a Gemma string.
@@ -418,17 +511,145 @@ fn parse_gemma4_scalar(value: &str) -> Value {
511
512
#[cfg(test)]
513
mod tests {
514
+ use std::sync::Arc;
515
+
516
use serde_json::{Value, json};
517
use thiserror_ext::AsReport;
518
+ use vllm_tokenizer::Tokenizer;
519
use winnow::combinator::{eof, terminated};
520
use winnow::error::ErrMode;
521
use winnow::prelude::*;
522
523
use super::{
428
- Gemma4ToolParser, ToolCallDelta, ToolParser, ToolParserOutput, gemma4_array_content,
429
- parse_gemma4_args,
524
+ CHANNEL_END, CHANNEL_START, Gemma4UnifiedParser, ToolCallDelta, UnifiedParser,
525
+ UnifiedParserError, UnifiedParserOutput, gemma4_array_content, parse_gemma4_args,
526
};
431
- use crate::tool::{Tool, ToolParserTestExt as _};
527
+ use crate::tool::Tool;
528
+ use crate::unified::{UnifiedParserEvent, parsing_failed};
529
+
530
+ struct FakeTokenizer;
531
+
532
+ impl Tokenizer for FakeTokenizer {
533
+ fn encode(
534
+ &self,
535
+ text: &str,
536
+ _add_special_tokens: bool,
537
+ ) -> vllm_tokenizer::Result<Vec<u32>> {
538
+ Ok(text.chars().map(u32::from).collect())
539
+ }
540
+
541
+ fn decode(
542
+ &self,
543
+ token_ids: &[u32],
544
+ _skip_special_tokens: bool,
545
+ ) -> vllm_tokenizer::Result<String> {
546
+ Ok(token_ids
547
+ .iter()
548
+ .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
549
+ .collect())
550
+ }
551
+
552
+ fn token_to_id(&self, token: &str) -> Option<u32> {
553
+ match token {
554
+ CHANNEL_START => Some(100),
555
+ CHANNEL_END => Some(101),
556
+ _ => None,
557
+ }
558
+ }
559
+
560
+ fn is_special_id(&self, token_id: u32) -> bool {
561
+ matches!(token_id, 100..=105)
562
+ }
563
+ }
564
+
565
+ struct MissingTokenTokenizer;
566
+
567
+ impl Tokenizer for MissingTokenTokenizer {
568
+ fn encode(
569
+ &self,
570
+ text: &str,
571
+ _add_special_tokens: bool,
572
+ ) -> vllm_tokenizer::Result<Vec<u32>> {
573
+ Ok(text.chars().map(u32::from).collect())
574
+ }
575
+
576
+ fn decode(
577
+ &self,
578
+ token_ids: &[u32],
579
+ _skip_special_tokens: bool,
580
+ ) -> vllm_tokenizer::Result<String> {
581
+ Ok(token_ids
582
+ .iter()
583
+ .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
584
+ .collect())
585
+ }
586
+
587
+ fn token_to_id(&self, _token: &str) -> Option<u32> {
588
+ None
589
+ }
590
+ }
591
+
592
+ trait UnifiedParserTestExt {
593
+ fn parse_chunk(&mut self, chunk: &str) -> super::Result<UnifiedParserOutput>;
594
+ fn parse_complete(&mut self, text: &str) -> super::Result<UnifiedParserOutput>;
595
+ }
596
+
597
+ impl UnifiedParserTestExt for Gemma4UnifiedParser {
598
+ fn parse_chunk(&mut self, chunk: &str) -> super::Result<UnifiedParserOutput> {
599
+ let mut output = UnifiedParserOutput::default();
600
+ self.parse_into(chunk, &mut output)?;
601
+ Ok(output)
602
+ }
603
+
604
+ fn parse_complete(&mut self, text: &str) -> super::Result<UnifiedParserOutput> {
605
+ let mut output = self.parse_chunk(text)?;
606
+ output.append(self.finish()?);
607
+ Ok(output)
608
+ }
609
+ }
610
+
611
+ trait UnifiedOutputTestExt {
612
+ fn normal_text(&self) -> String;
613
+ fn reasoning_text(&self) -> String;
614
+ fn calls(&self) -> Vec<&ToolCallDelta>;
615
+ fn coalesce(self) -> Self;
616
+ }
617
+
618
+ impl UnifiedOutputTestExt for UnifiedParserOutput {
619
+ fn normal_text(&self) -> String {
620
+ self.events
621
+ .iter()
622
+ .filter_map(|event| match event {
623
+ UnifiedParserEvent::Text(text) => Some(text.as_str()),
624
+ UnifiedParserEvent::Reasoning(_) | UnifiedParserEvent::ToolCall(_) => None,
625
+ })
626
+ .collect()
627
+ }
628
+
629
+ fn reasoning_text(&self) -> String {
630
+ self.events
631
+ .iter()
632
+ .filter_map(|event| match event {
633
+ UnifiedParserEvent::Reasoning(text) => Some(text.as_str()),
634
+ UnifiedParserEvent::Text(_) | UnifiedParserEvent::ToolCall(_) => None,
635
+ })
636
+ .collect()
637
+ }
638
+
639
+ fn calls(&self) -> Vec<&ToolCallDelta> {
640
+ self.events
641
+ .iter()
642
+ .filter_map(|event| match event {
643
+ UnifiedParserEvent::Text(_) | UnifiedParserEvent::Reasoning(_) => None,
644
+ UnifiedParserEvent::ToolCall(call) => Some(call),
645
+ })
646
+ .collect()
647
+ }
648
+
649
+ fn coalesce(self) -> Self {
650
+ self
651
+ }
652
+ }
653
654
fn parse_gemma4_array(array: &str) -> super::Result<Vec<Value>> {
655
let mut input = array;
@@ -494,9 +715,26 @@ mod tests {
715
]
716
}
717
497
- fn collect_stream(chunks: &[&str]) -> ToolParserOutput {
498
- let mut parser = Gemma4ToolParser::new(&test_tools());
499
- let mut output = ToolParserOutput::default();
718
+ fn test_parser() -> Gemma4UnifiedParser {
719
+ Gemma4UnifiedParser::new(&test_tools(), Arc::new(FakeTokenizer)).unwrap()
720
+ }
721
+
722
+ #[test]
723
+ fn gemma4_create_requires_channel_start_token() {
724
+ let error = match Gemma4UnifiedParser::new(&test_tools(), Arc::new(MissingTokenTokenizer)) {
725
+ Ok(_) => panic!("expected missing token error"),
726
+ Err(error) => error,
727
+ };
728
+
729
+ assert!(matches!(
730
+ error,
731
+ UnifiedParserError::MissingToken { token } if token == CHANNEL_START
732
+ ));
733
+ }
734
+
735
+ fn collect_stream(chunks: &[&str]) -> UnifiedParserOutput {
736
+ let mut parser = test_parser();
737
+ let mut output = UnifiedParserOutput::default();
738
for chunk in chunks {
739
output.append(parser.parse_chunk(chunk).unwrap());
740
}
@@ -504,7 +742,7 @@ mod tests {
742
output.coalesce()
743
}
744
507
- fn first_call(output: &ToolParserOutput) -> ToolCallDelta {
745
+ fn first_call(output: &UnifiedParserOutput) -> ToolCallDelta {
746
(*output.calls().first().expect("expected one tool call")).clone()
747
}
748
@@ -542,7 +780,7 @@ mod tests {
780
781
#[test]
782
fn gemma4_parse_complete_extracts_single_tool_call() {
545
- let mut parser = Gemma4ToolParser::new(&test_tools());
783
+ let mut parser = test_parser();
784
let output = parser
785
.parse_complete("<|tool_call>call:get_weather{location:<|\"|>London<|\"|>}<tool_call|>")
786
.unwrap();
@@ -558,7 +796,7 @@ mod tests {
796
797
#[test]
798
fn gemma4_parse_complete_rejects_incomplete_tool_call() {
561
- let mut parser = Gemma4ToolParser::new(&test_tools());
799
+ let mut parser = test_parser();
800
let error = parser
801
.parse_complete("<|tool_call>call:get_weather{location:<|\"|>London")
802
.unwrap_err();
@@ -607,8 +845,8 @@ mod tests {
845
846
#[test]
847
fn gemma4_streaming_waits_for_complete_tool_call() {
610
- let mut parser = Gemma4ToolParser::new(&test_tools());
611
- let mut output = ToolParserOutput::default();
848
+ let mut parser = test_parser();
849
+ let mut output = UnifiedParserOutput::default();
850
851
for chunk in [
852
"<|tool_call>",
@@ -773,7 +1011,7 @@ mod tests {
1011
1012
#[test]
1013
fn gemma4_finish_flushes_partial_start_marker_as_text() {
776
- let mut parser = Gemma4ToolParser::new(&test_tools());
1014
+ let mut parser = test_parser();
1015
let mut output = parser.parse_chunk("<").unwrap();
1016
output.append(parser.finish().unwrap());
1017
@@ -781,9 +1019,104 @@ mod tests {
1019
assert!(output.calls().is_empty());
1020
}
1021
1022
+ #[test]
1023
+ fn gemma4_streaming_emits_reasoning_then_text() {
1024
+ let output = collect_stream(&["<|channel>thought\nreason<channel|>answer"]);
1025
+
1026
+ assert_eq!(output.reasoning_text(), "reason");
1027
+ assert_eq!(output.normal_text(), "answer");
1028
+ assert!(output.calls().is_empty());
1029
+ }
1030
+
1031
+ #[test]
1032
+ fn gemma4_streaming_holds_split_reasoning_start() {
1033
+ let mut parser = test_parser();
1034
+
1035
+ let first = parser.parse_chunk("<|channel>").unwrap();
1036
+ assert!(first.events.is_empty());
1037
+
1038
+ let mut output = parser.parse_chunk("thought\nrea").unwrap();
1039
+ output.append(parser.parse_chunk("son<channel|>answer").unwrap());
1040
+ output.append(parser.finish().unwrap());
1041
+
1042
+ assert_eq!(output.reasoning_text(), "reason");
1043
+ assert_eq!(output.normal_text(), "answer");
1044
+ }
1045
+
1046
+ #[test]
1047
+ fn gemma4_initialize_open_channel_prompt_starts_in_reasoning() {
1048
+ let mut parser = test_parser();
1049
+ parser.initialize(&[100, 3000, 3001]).unwrap();
1050
+
1051
+ let output = parser.parse_complete("reason<channel|>answer").unwrap();
1052
+
1053
+ assert_eq!(output.reasoning_text(), "reason");
1054
+ assert_eq!(output.normal_text(), "answer");
1055
+ }
1056
+
1057
+ #[test]
1058
+ fn gemma4_initialize_turn_prompt_starts_in_text() {
1059
+ let mut parser = test_parser();
1060
+ parser.initialize(&[104, 3000, 3001]).unwrap();
1061
+
1062
+ let output = parser.parse_complete("<|channel>thought\nreason<channel|>answer").unwrap();
1063
+
1064
+ assert_eq!(output.reasoning_text(), "reason");
1065
+ assert_eq!(output.normal_text(), "answer");
1066
+ }
1067
+
1068
+ #[test]
1069
+ fn gemma4_initialize_special_token_caps_boundary_scan() {
1070
+ let mut parser = test_parser();
1071
+ parser.initialize(&[100, 3000, 104, 3001]).unwrap();
1072
+
1073
+ let output = parser.parse_complete("answer").unwrap();
1074
+
1075
+ assert!(output.reasoning_text().is_empty());
1076
+ assert_eq!(output.normal_text(), "answer");
1077
+ }
1078
+
1079
+ #[test]
1080
+ fn gemma4_initialize_closed_channel_prompt_starts_in_text() {
1081
+ let mut parser = test_parser();
1082
+ parser.initialize(&[100, 3000, 3001, 101]).unwrap();
1083
+
1084
+ let output = parser.parse_complete("answer").unwrap();
1085
+
1086
+ assert!(output.reasoning_text().is_empty());
1087
+ assert_eq!(output.normal_text(), "answer");
1088
+ }
1089
+
1090
+ #[test]
1091
+ fn gemma4_reasoning_tool_call_implicitly_ends_reasoning() {
1092
+ let output = collect_stream(&[
1093
+ "<|channel>thought\nNeed weather.",
1094
+ "<|tool_call>",
1095
+ "call:get_weather{location:<|\"|>Paris<|\"|>}",
1096
+ "<tool_call|>",
1097
+ ]);
1098
+
1099
+ assert_eq!(output.reasoning_text(), "Need weather.");
1100
+ assert!(output.normal_text().is_empty());
1101
+ assert_eq!(first_call(&output).name.as_deref(), Some("get_weather"));
1102
+ assert_eq!(
1103
+ serde_json::from_str::<Value>(&first_call(&output).arguments).unwrap(),
1104
+ json!({ "location": "Paris" })
1105
+ );
1106
+ }
1107
+
1108
+ #[test]
1109
+ fn gemma4_bare_channel_start_is_plain_text() {
1110
+ let output = collect_stream(&["<|channel>plain"]);
1111
+
1112
+ assert_eq!(output.normal_text(), "<|channel>plain");
1113
+ assert!(output.reasoning_text().is_empty());
1114
+ assert!(output.calls().is_empty());
1115
+ }
1116
+
1117
#[test]
1118
fn gemma4_finish_rejects_complete_args_without_end_marker() {
786
- let mut parser = Gemma4ToolParser::new(&test_tools());
1119
+ let mut parser = test_parser();
1120
for chunk in ["<|tool_call>", "call:get_status{}"] {
1121
parser.parse_chunk(chunk).unwrap();
1122
}
@@ -795,7 +1128,7 @@ mod tests {
1128
1129
#[test]
1130
fn gemma4_reset_preserves_internally_buffered_arguments() {
798
- let mut parser = Gemma4ToolParser::new(&test_tools());
1131
+ let mut parser = test_parser();
1132
for chunk in [
1133
"<|tool_call>",
1134
"call:write_file{",
@@ -815,7 +1148,7 @@ mod tests {
1148
1149
#[test]
1150
fn gemma4_reset_preserves_completed_arguments_after_parse_error() {
818
- let mut parser = Gemma4ToolParser::new(&test_tools());
1151
+ let mut parser = test_parser();
1152
let input = "<|tool_call>call:set{broken}<tool_call|>";
1153
1154
let _error = parser.parse_chunk(input).unwrap_err();
rust/src/parser/src/unified/mod.rs
+9
-1
@@ -1,11 +1,14 @@
1
//! Unified parser interface for reasoning and tool-call deltas.
2
3
mod combined;
4
+mod gemma4;
5
6
use thiserror::Error;
7
+use thiserror_ext::Macro;
8
use vllm_tokenizer::DynTokenizer;
9
10
pub use combined::CombinedParser;
11
+pub use gemma4::Gemma4UnifiedParser;
12
13
use crate::reasoning::ReasoningError;
14
use crate::tool::{
@@ -189,10 +192,15 @@ pub trait UnifiedParser: Send {
192
}
193
194
/// Errors produced while creating or running unified parsers.
192
-#[derive(Debug, Error)]
195
+#[derive(Debug, Error, Macro)]
196
+#[thiserror_ext(macro(path = "crate::unified", mangle))]
197
pub enum UnifiedParserError {
198
#[error("combined parser is constructed from split parser instances")]
199
CombinedParserConstructor,
200
+ #[error("tokenizer is missing unified parser token `{token}`")]
201
+ MissingToken { token: String },
202
+ #[error("unified parser parsing failed: {message}")]
203
+ ParsingFailed { message: String },
204
#[error(transparent)]
205
Reasoning(#[from] ReasoningError),
206
#[error(transparent)]
rust/src/parser/src/utils.rs
renamed
+103
-16
@@ -1,10 +1,10 @@
1
-//! Shared helpers for tool parsers.
1
+//! Shared helpers for streaming parsers.
2
3
use winnow::Parser;
4
use winnow::error::{ContextError, ErrMode, ModalResult, Needed, StrContext, StrContextValue};
5
-use winnow::stream::{Offset, Partial, Stream};
5
+use winnow::stream::{FindSlice, Offset, Partial, Stream};
6
7
-use super::Result;
7
+use crate::tool::{Result, ToolParserError};
8
9
/// Return the byte length of the longest proper prefix of `token` that is also
10
/// a suffix of `buffer`.
@@ -15,7 +15,7 @@ use super::Result;
15
/// The returned length is always a valid UTF-8 boundary in `token`, so callers
16
/// can safely slice `&token[..len]` even when markers contain non-ASCII
17
/// characters such as DeepSeek's DSML delimiters.
18
-pub(super) fn partial_prefix_len(buffer: &str, token: &str) -> usize {
18
+pub fn partial_prefix_len(buffer: &str, token: &str) -> usize {
19
let Some(first_byte) = token.as_bytes().first().copied() else {
20
return 0;
21
};
@@ -44,9 +44,10 @@ pub(super) fn partial_prefix_len(buffer: &str, token: &str) -> usize {
44
}
45
46
/// Parse a safe text run before the next marker.
47
+/// This is the single-marker variant of [`safe_text_len_mul`].
48
///
49
/// Returns the text length in bytes, and advances the input.
49
-pub(super) fn safe_text_len(input: &mut Partial<&str>, marker: &str) -> ModalResult<usize> {
50
+pub fn safe_text_len(input: &mut Partial<&str>, marker: &str) -> ModalResult<usize> {
51
let text = **input;
52
if text.is_empty() {
53
return incomplete();
@@ -67,15 +68,53 @@ pub(super) fn safe_text_len(input: &mut Partial<&str>, marker: &str) -> ModalRes
68
Ok(emit_len)
69
}
70
71
+/// Parse a safe text run before the earliest next marker.
72
+/// This is the multi-marker variant of [`safe_text_len`].
73
+///
74
+/// Returns the text length in bytes, and advances the input.
75
+pub fn safe_text_len_mul(input: &mut Partial<&str>, markers: &[&str]) -> ModalResult<usize> {
76
+ let text = **input;
77
+ if text.is_empty() {
78
+ return incomplete();
79
+ }
80
+
81
+ if let Some(start_idx) = find_slice_mul(text, markers) {
82
+ input.next_slice(start_idx);
83
+ return Ok(start_idx);
84
+ }
85
+
86
+ let keep_len = markers.iter().map(|marker| partial_prefix_len(text, marker)).max().unwrap_or(0);
87
+ let emit_len = text.len().saturating_sub(keep_len);
88
+ if emit_len == 0 {
89
+ return incomplete();
90
+ }
91
+
92
+ input.next_slice(emit_len);
93
+ Ok(emit_len)
94
+}
95
+
96
+#[inline(always)]
97
+fn find_slice_mul(text: &str, markers: &[&str]) -> Option<usize> {
98
+ let range = match markers {
99
+ // Use the fast specialized `winnow::stream::FindSlice` impl for 1-3 markers.
100
+ [first] => text.find_slice(*first),
101
+ [first, second] => text.find_slice((*first, *second)),
102
+ [first, second, third] => text.find_slice((*first, *second, *third)),
103
+ // Fall back to a linear scan for 4+ markers.
104
+ _ => return markers.iter().filter_map(|marker| text.find(marker)).min(),
105
+ };
106
+ range.map(|range| range.start)
107
+}
108
+
109
/// Streaming scan state for a buffered marker search [`take_until_marker`],
110
/// so that we don't have to rescan the whole buffered prefix when resuming.
111
#[derive(Debug, Clone, Default, PartialEq, Eq)]
73
-pub(super) struct MarkerScanState {
112
+pub struct MarkerScanState {
113
scan_start: usize,
114
}
115
116
impl MarkerScanState {
78
- pub(super) fn reset(&mut self) {
117
+ pub fn reset(&mut self) {
118
self.scan_start = 0;
119
}
120
}
@@ -92,7 +131,7 @@ impl MarkerScanState {
131
/// chunks while waiting for a closing marker. Plain `take_until` is still a
132
/// better fit for one-shot parsers over a complete body, and for `1..` cases
133
/// where an empty slice before the marker should be rejected.
95
-pub(super) fn take_until_marker<'i, 'a>(
134
+pub fn take_until_marker<'i, 'a>(
135
marker: &'a str,
136
state: &'a mut MarkerScanState,
137
) -> impl Parser<Partial<&'i str>, &'i str, ErrMode<ContextError>> + 'a {
@@ -137,7 +176,7 @@ fn floor_char_boundary(text: &str, index: usize) -> usize {
176
177
/// Streaming lexical state for a top-level JSON object.
178
#[derive(Debug, Clone, Default, PartialEq, Eq)]
140
-pub(super) struct JsonObjectScanState {
179
+pub struct JsonObjectScanState {
180
object_depth: usize,
181
array_depth: usize,
182
in_string: bool,
@@ -155,7 +194,7 @@ enum JsonObjectScanPhase {
194
195
impl JsonObjectScanState {
196
/// Returns whether the top-level JSON object has closed.
158
- pub(super) const fn complete(&self) -> bool {
197
+ pub const fn complete(&self) -> bool {
198
matches!(self.phase, JsonObjectScanPhase::Complete)
199
}
200
}
@@ -165,7 +204,7 @@ impl JsonObjectScanState {
204
/// The returned length is safe to emit as raw argument text. This scans only
205
/// lexical boundaries from `{` through the matching `}`, preserving
206
/// malformed-but-balanced JSON without deserializing or normalizing it.
168
-pub(super) fn take_json_object(
207
+pub fn take_json_object(
208
input: &mut Partial<&str>,
209
state: &mut JsonObjectScanState,
210
) -> ModalResult<usize> {
@@ -252,7 +291,7 @@ pub(super) fn take_json_object(
291
}
292
293
/// Parse a JSON string literal.
255
-pub(super) fn json_str(input: &mut Partial<&str>) -> ModalResult<String> {
294
+pub fn json_str(input: &mut Partial<&str>) -> ModalResult<String> {
295
let text = **input;
296
if text.is_empty() {
297
return incomplete();
@@ -311,7 +350,7 @@ fn json_scan_error(label: &'static str, expected: StrContextValue) -> ErrMode<Co
350
/// of bytes consumed from the buffer.
351
/// - `Ok(None)` if the buffer does not contain a full event yet, and more data is needed.
352
/// - `Err` if a parsing error occurred.
314
-pub(super) fn parse_buffered_event<E>(
353
+pub fn parse_buffered_event<E>(
354
buffer: &str,
355
parse: impl FnOnce(&mut Partial<&str>) -> ModalResult<E>,
356
) -> Result<Option<(E, usize)>> {
@@ -322,7 +361,9 @@ pub(super) fn parse_buffered_event<E>(
361
Err(ErrMode::Incomplete(_)) => return Ok(None),
362
Err(ErrMode::Backtrack(e) | ErrMode::Cut(e)) => {
363
// TODO: enrich context for error reporting
325
- return Err(parsing_failed!("{}", e));
364
+ return Err(ToolParserError::ParsingFailed {
365
+ message: e.to_string(),
366
+ });
367
}
368
};
369
let consumed_len = input.offset_from(&checkpoint);
@@ -334,7 +375,7 @@ pub(super) fn parse_buffered_event<E>(
375
}
376
377
/// Returns an error indicating that we need more data to continue parsing.
337
-pub(super) fn incomplete<T>() -> ModalResult<T> {
378
+pub fn incomplete<T>() -> ModalResult<T> {
379
Err(ErrMode::Incomplete(Needed::Unknown))
380
}
381
@@ -348,7 +389,7 @@ mod tests {
389
390
use super::{
391
JsonObjectScanState, MarkerScanState, json_str, partial_prefix_len, safe_text_len,
351
- take_json_object, take_until_marker,
392
+ safe_text_len_mul, take_json_object, take_until_marker,
393
};
394
395
#[test]
@@ -406,6 +447,52 @@ mod tests {
447
assert!(matches!(error, ErrMode::Incomplete(_)));
448
}
449
450
+ #[test]
451
+ fn safe_text_len_mul_stops_before_earliest_marker() {
452
+ let mut input = Partial::new("hello<channel|><|tool_call>");
453
+ let checkpoint = input.checkpoint();
454
+
455
+ let len = safe_text_len_mul(&mut input, &["<|tool_call>", "<channel|>"]).unwrap();
456
+
457
+ assert_eq!(len, "hello".len());
458
+ assert_eq!(input.offset_from(&checkpoint), "hello".len());
459
+ assert_eq!(*input, "<channel|><|tool_call>");
460
+ }
461
+
462
+ #[test]
463
+ fn safe_text_len_mul_holds_back_longest_partial_marker() {
464
+ let mut input = Partial::new("hello<|tool");
465
+ let checkpoint = input.checkpoint();
466
+
467
+ let len = safe_text_len_mul(&mut input, &["<|tool_call>", "<|channel>thought\n"]).unwrap();
468
+
469
+ assert_eq!(len, "hello".len());
470
+ assert_eq!(input.offset_from(&checkpoint), "hello".len());
471
+ assert_eq!(*input, "<|tool");
472
+ }
473
+
474
+ #[test]
475
+ fn safe_text_len_mul_skips_false_same_prefix_candidate() {
476
+ let mut input = Partial::new("hello<not_marker><|tool_call>");
477
+ let checkpoint = input.checkpoint();
478
+
479
+ let len = safe_text_len_mul(&mut input, &["<|tool_call>", "<|channel>thought\n"]).unwrap();
480
+
481
+ assert_eq!(len, "hello<not_marker>".len());
482
+ assert_eq!(input.offset_from(&checkpoint), "hello<not_marker>".len());
483
+ assert_eq!(*input, "<|tool_call>");
484
+ }
485
+
486
+ #[test]
487
+ fn safe_text_len_mul_reports_incomplete_for_only_partial_marker() {
488
+ let mut input = Partial::new("<|channel>thought");
489
+
490
+ let error =
491
+ safe_text_len_mul(&mut input, &["<|tool_call>", "<|channel>thought\n"]).unwrap_err();
492
+
493
+ assert!(matches!(error, ErrMode::Incomplete(_)));
494
+ }
495
+
496
#[test]
497
fn take_until_marker_stops_before_marker() {
498
let mut state = MarkerScanState::default();