[Rust Frontend] Add `hf` parser for response templates (#59005)

Signed-off-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Bugen Zhao committed Oct 1, 2026 at 16:16 UTC 24fb5fc08c2bcae8575bd2e861f463d8cf919ffb
23 files changed +4573 -33
rust/Cargo.lock
+4
@@ -5971,12 +5971,16 @@ dependencies = [
5971 "expect-test",
5972 "futures",
5973 "openai-protocol",
5974 + "regex-automata",
5975 + "regex-syntax",
5976 "serde",
5977 "serde_json",
5978 + "serde_path_to_error",
5979 "serde_with",
5980 "thiserror 2.0.18",
5981 "thiserror-ext",
5982 "tool-parser",
5983 + "tracing",
5984 "vllm-tokenizer",
5985 "winnow",
5986 "xgrammar-structural-tag",
rust/Cargo.toml
+3
@@ -85,6 +85,8 @@ rand = "0.9.3"
85 rand_distr = "0.5.1"
86 rayon = "1.11.0"
87 reasoning-parser = "1.2.2"
88 +regex-automata = "0.4.14"
89 +regex-syntax = "0.8.10"
90 reqwest = { version = "0.12.8", default-features = false, features = ["native-tls"] }
91 reqwest-0-13 = { package = "reqwest", version = "0.13.4", default-features = false, features = ["native-tls"] }
92 riptoken = { version = "0.3.0", default-features = false }
@@ -96,6 +98,7 @@ serde = { version = "1.0.228", features = ["derive", "rc"] }
98 serde-json-fmt = "0.1.0"
99 serde_default = "0.2.0"
100 serde_json = { version = "1.0.145", features = ["preserve_order"] }
101 +serde_path_to_error = "0.1.20"
102 serde_repr = "0.1.20"
103 serde_tuple = "1.1.3"
104 serde_with = "3.21.0"
rust/src/chat/src/backend/hf.rs
+46 -10
@@ -3,8 +3,11 @@
3
4 use std::sync::Arc;
5
6 -use tracing::info;
7 -use vllm_text::backend::hf::{HfTextBackend, ResolvedModelFiles, load_model_config};
6 +use thiserror_ext::AsReport as _;
7 +use tracing::{info, warn};
8 +use vllm_text::backend::hf::{
9 + HfTextBackend, ResolvedModelFiles, load_model_config, load_tokenizer_config,
10 +};
11 use vllm_text::tokenizer::DynTokenizer;
12 use vllm_text::{DynTextBackend, TextBackend as _};
13
@@ -17,6 +20,7 @@ use crate::multimodal::{MultimodalConfigFiles, MultimodalModelInfo};
20 use crate::output::{
21 DefaultChatOutputProcessor, HarmonyChatOutputProcessor, validate_harmony_parser_overrides,
22 };
23 +use crate::parser::unified::{HfTemplateError, ResponseTemplate};
24 use crate::renderer::hf::{HfChatRenderer, MultimodalRenderInfo};
25 use crate::renderer::{
26 DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DeepSeekV41ChatRenderer, DynChatRenderer,
@@ -32,6 +36,9 @@ pub struct HfChatBackend {
36 tokenizer: DynTokenizer,
37 chat_renderer: DynChatRenderer,
38 multimodal_model_info: Option<MultimodalModelInfo>,
39 + /// `response_template` from `tokenizer_config.json` for the `hf` parser, or
40 + /// why it is unavailable.
41 + response_template: std::result::Result<Arc<ResponseTemplate>, HfTemplateError>,
42 }
43
44 impl HfChatBackend {
@@ -61,6 +68,7 @@ impl HfChatBackend {
68 )?
69 };
70 let multimodal_render_info = resolve_multimodal_render_info(multimodal_model_info.as_ref());
71 + let response_template = load_response_template(&files)?;
72
73 let renderer = options.renderer.resolve(model_type);
74 let chat_renderer: DynChatRenderer = match renderer {
@@ -105,10 +113,35 @@ impl HfChatBackend {
113 tokenizer,
114 chat_renderer,
115 multimodal_model_info,
116 + response_template,
117 })
118 }
119 }
120
121 +/// Load and compile the checkpoint's `response_template`, if any.
122 +///
123 +/// A missing or unusable template is kept as an error, so that only requests
124 +/// selecting the `hf` parser fail.
125 +fn load_response_template(
126 + files: &ResolvedModelFiles,
127 +) -> Result<std::result::Result<Arc<ResponseTemplate>, HfTemplateError>> {
128 + let Some(template) =
129 + load_tokenizer_config(files.tokenizer_config_path.as_deref())?.response_template
130 + else {
131 + return Ok(Err(HfTemplateError::Missing));
132 + };
133 + Ok(match ResponseTemplate::from_json(&template) {
134 + Ok(template) => {
135 + info!("loaded response_template for the `hf` parser");
136 + Ok(Arc::new(template))
137 + }
138 + Err(error) => {
139 + warn!(error = %error.as_report(), "the `hf` parser cannot execute the model's response_template");
140 + Err(error)
141 + }
142 + })
143 +}
144 +
145 impl ChatBackend for HfChatBackend {
146 fn chat_renderer(&self) -> DynChatRenderer {
147 self.chat_renderer.clone()
@@ -128,14 +161,17 @@ impl ChatBackend for HfChatBackend {
161 return Ok(Box::new(HarmonyChatOutputProcessor::new(request)?));
162 }
163
131 - Ok(Box::new(DefaultChatOutputProcessor::new(
132 - request,
133 - &self.model_id,
134 - self.tokenizer.clone(),
135 - options.tool_call_parser,
136 - options.reasoning_parser,
137 - options.tool_strict_level,
138 - )?))
164 + Ok(Box::new(
165 + DefaultChatOutputProcessor::with_response_template(
166 + request,
167 + &self.model_id,
168 + self.tokenizer.clone(),
169 + &self.response_template,
170 + options.tool_call_parser,
171 + options.reasoning_parser,
172 + options.tool_strict_level,
173 + )?,
174 + ))
175 }
176 }
177
rust/src/chat/src/lib.rs
+8 -2
@@ -412,6 +412,12 @@ mod tests {
412 validate_parser_overrides(&selection, &selection).unwrap();
413 }
414
415 + #[test]
416 + fn validate_parser_overrides_accepts_explicit_hf() {
417 + let selection = ParserSelection::Explicit("hf".to_string());
418 + validate_parser_overrides(&selection, &selection).unwrap();
419 + }
420 +
421 #[test]
422 fn validate_parser_overrides_accepts_auto_and_none() {
423 validate_parser_overrides(&ParserSelection::Auto, &ParserSelection::None).unwrap();
@@ -425,7 +431,7 @@ mod tests {
431 )
432 .unwrap_err();
433
428 - expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, granite4, hermes, hy_v3, hy_v4, inkling, internlm, kimi_k2, kimi_k3, llama3_json, llama4_json, mimo, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
434 + expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, granite4, hermes, hf, hy_v3, hy_v4, inkling, internlm, kimi_k2, kimi_k3, llama3_json, llama4_json, mimo, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
435 }
436
437 #[test]
@@ -436,6 +442,6 @@ mod tests {
442 )
443 .unwrap_err();
444
439 - expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, hy_v3, hy_v4, inkling, kimi, kimi_k2, kimi_k3, mimo, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
445 + expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, deepseek_v41, gemma4, glm45, glm47, hf, hy_v3, hy_v4, inkling, kimi, kimi_k2, kimi_k3, mimo, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
446 }
447 }
rust/src/chat/src/output/default/mod.rs
+145 -5
@@ -5,7 +5,7 @@
5
6 mod unified;
7
8 -use std::sync::Once;
8 +use std::sync::{Arc, Once};
9
10 use futures::StreamExt as _;
11 use tracing::info;
@@ -20,7 +20,9 @@ use crate::error::Result;
20 use crate::output::{ChatOutputProcessor, DynChatEventStream, DynDecodedTextEventStream};
21 use crate::parser::reasoning::{ReasoningParser, ReasoningParserFactory};
22 use crate::parser::tool::{ToolParser, ToolParserFactory};
23 -use crate::parser::unified::UnifiedParserFactory;
23 +use crate::parser::unified::{
24 + HfTemplateError, HfUnifiedParser, ResponseTemplate, UnifiedParserFactory, names,
25 +};
26 use crate::parser::{ParserSelection, ToolStrictLevel};
27 use crate::request::{ChatRequest, ChatTool};
28 use crate::{Error, Result as ChatResult};
@@ -64,11 +66,48 @@ impl DefaultChatOutputProcessor {
66 reasoning_parser: &ParserSelection,
67 tool_strict_level: ToolStrictLevel,
68 ) -> ChatResult<Self> {
69 + Self::with_response_template(
70 + request,
71 + model_id,
72 + tokenizer,
73 + &Err(HfTemplateError::Missing),
74 + tool_call_parser,
75 + reasoning_parser,
76 + tool_strict_level,
77 + )
78 + }
79 +
80 + /// Like [`Self::new`], additionally providing the model's `response_template`
81 + /// for the `hf` parser, or why it is unavailable.
82 + pub fn with_response_template(
83 + request: &mut ChatRequest,
84 + model_id: &str,
85 + tokenizer: DynTokenizer,
86 + response_template: &std::result::Result<Arc<ResponseTemplate>, HfTemplateError>,
87 + tool_call_parser: &ParserSelection,
88 + reasoning_parser: &ParserSelection,
89 + tool_strict_level: ToolStrictLevel,
90 + ) -> ChatResult<Self> {
91 + let tool_name = tool_call_parser.resolve_tool_name(model_id);
92 + let reasoning_name = reasoning_parser.resolve_reasoning_name(model_id);
93 + // `hf` is opt-in: `Auto` never selects it, but a usable template is worth a hint.
94 + let auto = [tool_call_parser, reasoning_parser]
95 + .iter()
96 + .any(|selection| matches!(selection, ParserSelection::Auto));
97 + if auto && tool_name.is_none() && reasoning_name.is_none() && response_template.is_ok() {
98 + RESPONSE_TEMPLATE_HINT_ONCE.call_once(|| {
99 + info!(
100 + "the model ships a response_template; pass `--tool-call-parser hf \
101 + --reasoning-parser hf` to parse with it"
102 + );
103 + });
104 + }
105 let parser = if let Some(parser) = Self::resolve_optional_unified_parser(
106 request.tools(),
107 tokenizer.clone(),
70 - tool_call_parser.resolve_tool_name(model_id),
71 - reasoning_parser.resolve_reasoning_name(model_id),
108 + response_template,
109 + tool_name,
110 + reasoning_name,
111 )? {
112 parser
113 } else {
@@ -141,6 +180,7 @@ impl DefaultChatOutputProcessor {
180 fn resolve_optional_unified_parser(
181 tools: &[ChatTool],
182 tokenizer: DynTokenizer,
183 + response_template: &std::result::Result<Arc<ResponseTemplate>, HfTemplateError>,
184 tool_name: Option<&str>,
185 reasoning_name: Option<&str>,
186 ) -> ChatResult<Option<Box<dyn UnifiedParser>>> {
@@ -157,7 +197,18 @@ impl DefaultChatOutputProcessor {
197 });
198 }
199
160 - let parser = factory.create(parser_name, tools, tokenizer)?;
200 + // `hf` is built from the model's template, never by its registry constructor.
201 + let parser = if parser_name == names::HF {
202 + let template =
203 + response_template.as_ref().map_err(|error| Error::ParserInitialization {
204 + kind: "unified",
205 + name: parser_name.to_string(),
206 + error: Box::new(error.clone()),
207 + })?;
208 + Box::new(HfUnifiedParser::new(template.clone(), tools, tokenizer))
209 + } else {
210 + factory.create(parser_name, tools, tokenizer)?
211 + };
212
213 UNIFIED_PARSER_LOG_ONCE.call_once(|| info!(parser_name, "using unified parser"));
214 Ok(Some(parser))
@@ -186,6 +237,7 @@ impl DefaultChatOutputProcessor {
237 static TOOL_PARSER_LOG_ONCE: Once = Once::new();
238 static REASONING_PARSER_LOG_ONCE: Once = Once::new();
239 static UNIFIED_PARSER_LOG_ONCE: Once = Once::new();
240 +static RESPONSE_TEMPLATE_HINT_ONCE: Once = Once::new();
241
242 impl ChatOutputProcessor for DefaultChatOutputProcessor {
243 fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
@@ -230,10 +282,12 @@ impl ChatOutputProcessor for DefaultChatOutputProcessor {
282 mod tests {
283 use std::sync::Arc;
284
285 + use thiserror_ext::AsReport as _;
286 use vllm_tokenizer::test_utils::TestTokenizer;
287
288 use super::DefaultChatOutputProcessor;
289 use crate::output::ChatOutputProcessor;
290 + use crate::parser::unified::{HfTemplateError, ResponseTemplate};
291 use crate::parser::{ParserSelection, ToolStrictLevel};
292 use crate::request::{ChatRequest, ChatTool, ChatToolChoice, ResolvedToolContext};
293
@@ -390,4 +444,90 @@ mod tests {
444 expect_test::expect!["unified parsing requires the tool and reasoning selections to resolve to the same parser; resolved tool=qwen3_xml, reasoning=gemma4"]
445 .assert_eq(&format!("{error}"));
446 }
447 +
448 + type TemplateResult = Result<Arc<ResponseTemplate>, HfTemplateError>;
449 +
450 + fn loaded_template() -> TemplateResult {
451 + Ok(Arc::new(
452 + ResponseTemplate::from_json(&serde_json::json!({
453 + "start_anchor": "<|turn>model\n",
454 + "fields": {
455 + "thinking": {"open": "<|channel>thought\n", "close": "<channel|>"},
456 + "content": {"close": "<turn|>"},
457 + },
458 + }))
459 + .unwrap(),
460 + ))
461 + }
462 +
463 + fn invalid_template() -> TemplateResult {
464 + let error = ResponseTemplate::from_json(&serde_json::json!({"fields": {"content": {}}}))
465 + .unwrap_err();
466 + Err(error)
467 + }
468 +
469 + /// Build a processor for `model_id`, returning whether it keeps special
470 + /// tokens (only parsers that need them, such as `hf`, turn this on).
471 + fn keeps_special_tokens(
472 + model_id: &str,
473 + template: &TemplateResult,
474 + tool: &ParserSelection,
475 + reasoning: &ParserSelection,
476 + ) -> crate::Result<bool> {
477 + let mut request = ChatRequest::for_test();
478 + DefaultChatOutputProcessor::with_response_template(
479 + &mut request,
480 + model_id,
481 + tokenizer(),
482 + template,
483 + tool,
484 + reasoning,
485 + ToolStrictLevel::Auto,
486 + )?;
487 + Ok(!request.decode_options.skip_special_tokens)
488 + }
489 +
490 + #[test]
491 + fn explicit_hf_uses_the_model_response_template() {
492 + let hf = ParserSelection::Explicit("hf".to_string());
493 + assert!(keeps_special_tokens("other-model", &loaded_template(), &hf, &hf).unwrap());
494 + }
495 +
496 + #[test]
497 + fn explicit_hf_without_usable_template_fails() {
498 + let hf = ParserSelection::Explicit("hf".to_string());
499 + let error = |template| {
500 + keeps_special_tokens("other-model", &template, &hf, &hf)
501 + .unwrap_err()
502 + .to_report_string()
503 + };
504 + expect_test::expect!["failed to initialize unified parser `hf`: the model's tokenizer_config.json provides no response_template"]
505 + .assert_eq(&error(Err(HfTemplateError::Missing)));
506 + expect_test::expect!["failed to initialize unified parser `hf`: invalid response_template: response_template must define 'start_anchor' or 'start_anchor_pattern'."]
507 + .assert_eq(&error(invalid_template()));
508 + }
509 +
510 + #[test]
511 + fn auto_never_selects_hf() {
512 + let auto = ParserSelection::Auto;
513 + // Without a matching parser, parsing stays disabled even with a usable template.
514 + assert!(!keeps_special_tokens("other-model", &loaded_template(), &auto, &auto).unwrap());
515 + // An unusable template is not consulted unless `hf` is selected.
516 + assert!(!keeps_special_tokens("other-model", &invalid_template(), &auto, &auto).unwrap());
517 + keeps_special_tokens("google/gemma-4-27b-it", &invalid_template(), &auto, &auto).unwrap();
518 + }
519 +
520 + #[test]
521 + fn hf_requires_matching_parser_selections() {
522 + let error = keeps_special_tokens(
523 + "other-model",
524 + &loaded_template(),
525 + &ParserSelection::Explicit("hf".to_string()),
526 + &ParserSelection::None,
527 + )
528 + .unwrap_err();
529 +
530 + expect_test::expect!["unified parsing requires the tool and reasoning selections to resolve to the same parser; resolved tool=hf, reasoning=none"]
531 + .assert_eq(&format!("{error}"));
532 + }
533 }
rust/src/chat/src/parser/unified.rs
+9 -2
@@ -6,8 +6,8 @@
6 use std::sync::LazyLock;
7
8 pub use vllm_parser::unified::{
9 - Gemma4UnifiedParser, HyV3UnifiedParser, HyV4UnifiedParser, InklingUnifiedParser,
10 - KimiK3UnifiedParser, UnifiedParser,
9 + Gemma4UnifiedParser, HfTemplateError, HfUnifiedParser, HyV3UnifiedParser, HyV4UnifiedParser,
10 + InklingUnifiedParser, KimiK3UnifiedParser, ResponseTemplate, UnifiedParser,
11 };
12 use vllm_tokenizer::DynTokenizer;
13
@@ -21,6 +21,11 @@ pub mod names {
21 pub const HY_V4: &str = "hy_v4";
22 pub const INKLING: &str = "inkling";
23 pub const KIMI_K3: &str = "kimi_k3";
24 +
25 + /// Executes the checkpoint's Hugging Face `response_template`. Registered for
26 + /// name resolution only: it is built from the model's template, never by its
27 + /// registry constructor.
28 + pub const HF: &str = "hf";
29 }
30
31 /// Constructor signature for one registered unified parser implementation.
@@ -48,6 +53,8 @@ impl UnifiedParserFactory {
53 factory.register_parser::<HyV4UnifiedParser>(names::HY_V4);
54 factory.register_parser::<InklingUnifiedParser>(names::INKLING);
55 factory.register_parser::<KimiK3UnifiedParser>(names::KIMI_K3);
56 + // Opt-in only: no model patterns map to it.
57 + factory.register_parser::<HfUnifiedParser>(names::HF);
58
59 factory
60 .register_pattern("gemma-4", names::GEMMA4)
rust/src/chat/tests/roundtrip.rs
+10
@@ -309,6 +309,15 @@ impl RoundtripCase {
309 }
310 }
311
312 + /// Gemma4 through the checkpoint's `response_template` (`hf` parser).
313 + fn gemma4_hf() -> Self {
314 + Self {
315 + tool_call_parser: ParserSelection::Explicit("hf".to_string()),
316 + reasoning_parser: ParserSelection::Explicit("hf".to_string()),
317 + ..Self::gemma4()
318 + }
319 + }
320 +
321 /// Kimi K2.5 tool-call format with `<think>` reasoning tags.
322 #[allow(dead_code)]
323 fn kimi_k25() -> Self {
@@ -459,6 +468,7 @@ roundtrip_tests! {
468 step3p5 => [reasoning_and_content],
469 nemotron_v3 => [reasoning_and_content],
470 gemma4 => [tool_call_mix], // Gemma4 strips reasoning in history if there's no tool call
471 + gemma4_hf => [tool_call_mix],
472 kimi_k25 => [tool_call_mix], // Kimi K2.5 strips reasoning in history
473 // K3 drops plain-assistant reasoning in history; tool-call turns keep it.
474 kimi_k3 => [tool_call_mix],
rust/src/parser/Cargo.toml
+4
@@ -11,11 +11,15 @@ test-util = []
11 auto_impl.workspace = true
12 easy-ext.workspace = true
13 educe.workspace = true
14 +regex-automata.workspace = true
15 +regex-syntax.workspace = true
16 serde.workspace = true
17 serde_json.workspace = true
18 +serde_path_to_error.workspace = true
19 serde_with.workspace = true
20 thiserror.workspace = true
21 thiserror-ext.workspace = true
22 +tracing.workspace = true
23 vllm-tokenizer.workspace = true
24 winnow.workspace = true
25 xgrammar-structural-tag.workspace = true
rust/src/parser/src/tool/mod.rs
+1
@@ -34,6 +34,7 @@ pub use kimi_k2::KimiK2ToolParser;
34 pub use mimo::MiMoToolParser;
35 pub use minimax_m2::MinimaxM2ToolParser;
36 pub use minimax_m3::MinimaxM3ToolParser;
37 +pub(crate) use parameters::{ToolSchemas, convert_integer_text, convert_number_text};
38 pub use qwen_coder::Qwen3CoderToolParser;
39 pub use seed_oss::SeedOssToolParser;
40 use serde::{Deserialize, Serialize};
rust/src/parser/src/tool/parameters.rs
+21 -11
@@ -9,7 +9,7 @@ use crate::tool::Tool;
9
10 /// Normalized parameter schemas for all tools in one request.
11 #[derive(Debug, Clone, Default, PartialEq, Eq)]
12 -pub(super) struct ToolSchemas {
12 +pub(crate) struct ToolSchemas {
13 tools: BTreeMap<String, ToolSchema>,
14 }
15
@@ -28,7 +28,7 @@ pub(super) struct ToolSchema {
28 ///
29 /// It can be either a raw text string, or a structured input with named child elements.
30 #[derive(Debug, Clone, PartialEq, Eq)]
31 -pub(super) enum ParamInput {
31 +pub(crate) enum ParamInput {
32 Text(String),
33 #[allow(dead_code)]
34 Elements(Vec<ParamElement>),
@@ -42,7 +42,7 @@ impl From<String> for ParamInput {
42
43 /// One named structured parameter child.
44 #[derive(Debug, Clone, PartialEq, Eq)]
45 -pub(super) struct ParamElement {
45 +pub(crate) struct ParamElement {
46 pub name: String,
47 pub value: ParamInput,
48 }
@@ -67,7 +67,7 @@ pub(super) enum JsonParamType {
67
68 impl ToolSchemas {
69 /// Normalize OpenAI-style tool parameter JSON schemas for one request.
70 - pub(super) fn from_tools(tools: &[Tool]) -> Self {
70 + pub(crate) fn from_tools(tools: &[Tool]) -> Self {
71 let tools = tools
72 .iter()
73 .map(|tool| (tool.name.clone(), ToolSchema::from_schema(&tool.parameters)))
@@ -98,7 +98,7 @@ impl ToolSchemas {
98 }
99
100 /// Convert one parameter value for one named tool.
101 - pub(super) fn convert_param_with_schema<P>(
101 + pub(crate) fn convert_param_with_schema<P>(
102 &self,
103 function_name: &str,
104 name: &str,
@@ -361,12 +361,8 @@ fn try_convert_value(param_type: &JsonParamType, input: &ParamInput) -> Option<V
361 fn try_convert_text_value(param_type: &JsonParamType, value: &str) -> Option<Value> {
362 match param_type {
363 JsonParamType::String => Some(Value::String(value.to_string())),
364 - JsonParamType::Integer => without_digit_separators(value.trim())?
365 - .parse::<i64>()
366 - .ok()
367 - .map(Number::from)
368 - .map(Value::Number),
369 - JsonParamType::Number => try_convert_number(&without_digit_separators(value.trim())?),
364 + JsonParamType::Integer => convert_integer_text(value),
365 + JsonParamType::Number => convert_number_text(value),
366 JsonParamType::Boolean => try_convert_boolean(value),
367 JsonParamType::Object { .. } if value.trim().is_empty() => Some(Value::Object(Map::new())),
368 JsonParamType::Array { .. } if value.trim().is_empty() => Some(Value::Array(Vec::new())),
@@ -461,6 +457,20 @@ fn without_digit_separators(value: &str) -> Option<std::borrow::Cow<'_, str>> {
457 valid.then(|| value.replace('_', "").into())
458 }
459
460 +/// Convert raw text to a JSON integer, as for an `integer` parameter.
461 +pub(crate) fn convert_integer_text(value: &str) -> Option<Value> {
462 + without_digit_separators(value.trim())?
463 + .parse::<i64>()
464 + .ok()
465 + .map(Number::from)
466 + .map(Value::Number)
467 +}
468 +
469 +/// Convert raw text to a JSON number, as for a `number` parameter.
470 +pub(crate) fn convert_number_text(value: &str) -> Option<Value> {
471 + try_convert_number(&without_digit_separators(value.trim())?)
472 +}
473 +
474 /// Convert one raw string value to a JSON number.
475 fn try_convert_number(value: &str) -> Option<Value> {
476 serde_json::from_str::<Number>(value)
rust/src/parser/src/unified/combined.rs
+4 -1
@@ -65,7 +65,10 @@ impl UnifiedParser for CombinedParser {
65 where
66 Self: Sized + 'static,
67 {
68 - Err(UnifiedParserError::CombinedParserConstructor)
68 + Err(UnifiedParserError::NoNamedConstructor {
69 + parser: "combined",
70 + built_from: "split parser instances",
71 + })
72 }
73
74 fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
rust/src/parser/src/unified/hf/content.rs new
+537
@@ -0,0 +1,537 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Content parsers: turn one region's captured text into a JSON value.
5 +//!
6 +//! Port of `content_parsers.py`. Each parser takes a chunk of captured text and
7 +//! parses it into a single key in the output message dictionary.
8 +
9 +use regex_automata::meta::Regex;
10 +use regex_automata::util::syntax;
11 +use serde::Deserialize;
12 +use serde_json::{Map, Value};
13 +use thiserror_ext::AsReport as _;
14 +
15 +use super::spec::{ContentKind, deserialize};
16 +use super::{Result, invalid, unsupported};
17 +use crate::tool::{convert_integer_text, convert_number_text};
18 +use crate::unified::{self, parsing_failed};
19 +
20 +// Sentinel characters for lax-JSON string pre-extraction — ASCII control chars
21 +// that should never appear in real LLM output.
22 +const LAX_OPEN: char = '\u{1}';
23 +const LAX_CLOSE: char = '\u{2}';
24 +
25 +/// A configured content parser (`content` plus `content_args` of one field).
26 +#[derive(Debug, Clone)]
27 +pub(super) enum ContentParser {
28 + Text(TextArgs),
29 + /// Converted like an `integer` tool parameter.
30 + Int,
31 + /// Converted like a `number` tool parameter.
32 + Float,
33 + Bool(TextArgs),
34 + Json(JsonArgs),
35 + XmlInline(XmlInlineArgs),
36 + KvLines(KvLinesArgs),
37 +}
38 +
39 +/// The `strip` argument shared by parsers that go through `_text`.
40 +#[derive(Debug, Clone, Copy, Deserialize)]
41 +pub(super) struct TextArgs {
42 + #[serde(default = "default_strip")]
43 + pub strip: bool,
44 +}
45 +
46 +#[derive(Debug, Clone, Deserialize)]
47 +pub(super) struct JsonArgs {
48 + #[serde(flatten)]
49 + text: TextArgs,
50 + /// Quote bare-identifier keys before parsing.
51 + #[serde(default)]
52 + unquoted_keys: bool,
53 + /// Strings delimited by these custom markers are pre-extracted, then restored
54 + /// as standard JSON strings.
55 + #[serde(default)]
56 + string_delims: Vec<(String, String)>,
57 + /// Return stripped text if parsing fails.
58 + #[serde(default)]
59 + allow_non_json: bool,
60 +}
61 +
62 +#[derive(Debug, Clone)]
63 +pub(super) struct XmlInlineArgs {
64 + tag_pattern: Regex,
65 + /// Index of the required `key` group.
66 + key_group: usize,
67 + /// Index of the `value` group, if the pattern declares one.
68 + value_group: Option<usize>,
69 + value_parser: Option<Box<ContentParser>>,
70 + /// Collect duplicate keys into a list.
71 + merge_duplicates: bool,
72 +}
73 +
74 +#[derive(Debug, Clone)]
75 +pub(super) struct KvLinesArgs {
76 + text: TextArgs,
77 + line_sep: String,
78 + kv_sep: String,
79 + value_parser: Option<Box<ContentParser>>,
80 +}
81 +
82 +/// `content_args` of `xml-inline`.
83 +#[derive(Deserialize)]
84 +struct XmlInlineSpec {
85 + /// Must have named groups `key` and `value`.
86 + tag_pattern: String,
87 + value_parser: Option<ValueParserSpec>,
88 + #[serde(default)]
89 + merge_duplicates: bool,
90 +}
91 +
92 +/// `content_args` of `kv-lines`.
93 +#[derive(Deserialize)]
94 +struct KvLinesSpec {
95 + #[serde(flatten)]
96 + text: TextArgs,
97 + #[serde(default = "default_line_sep")]
98 + line_sep: String,
99 + #[serde(default = "default_kv_sep")]
100 + kv_sep: String,
101 + value_parser: Option<ValueParserSpec>,
102 +}
103 +
104 +/// A nested `value_parser`: `{"name": ..., "args": {...}}`.
105 +#[derive(Deserialize)]
106 +struct ValueParserSpec {
107 + #[serde(default)]
108 + name: ContentKind,
109 + #[serde(default)]
110 + args: Map<String, Value>,
111 +}
112 +
113 +impl ContentParser {
114 + /// Build a parser from its kind and `content_args`.
115 + ///
116 + /// Unknown argument keys are ignored, as in Transformers. Argument types and the
117 + /// `xml-inline` tag pattern are checked here rather than at parse time.
118 + pub fn new(scope: &str, kind: ContentKind, args: &Map<String, Value>) -> Result<Self> {
119 + let context = format!("{scope}: content_args");
120 + let args = Value::Object(args.clone());
121 + Ok(match kind {
122 + ContentKind::Text => Self::Text(deserialize(&args, &context)?),
123 + // Number conversion ignores `strip`, but a malformed value is still rejected.
124 + ContentKind::Int => {
125 + deserialize::<TextArgs>(&args, &context)?;
126 + Self::Int
127 + }
128 + ContentKind::Float => {
129 + deserialize::<TextArgs>(&args, &context)?;
130 + Self::Float
131 + }
132 + ContentKind::Bool => Self::Bool(deserialize(&args, &context)?),
133 + ContentKind::Json => {
134 + let args: JsonArgs = deserialize(&args, &context)?;
135 + if args
136 + .string_delims
137 + .iter()
138 + .any(|(open, close)| open.is_empty() || close.is_empty())
139 + {
140 + return Err(invalid!("{context}: string_delims cannot be empty strings"));
141 + }
142 + Self::Json(args)
143 + }
144 + ContentKind::XmlInline => {
145 + let spec: XmlInlineSpec = deserialize(&args, &context)?;
146 + let tag_pattern = compile_regex(scope, &spec.tag_pattern)?;
147 + let group_info = tag_pattern.group_info();
148 + let Some(key_group) = group_info.to_index(Default::default(), "key") else {
149 + return Err(invalid!(
150 + "{scope}: xml-inline: tag_pattern must have a named group 'key'. Pattern: {}",
151 + spec.tag_pattern
152 + ));
153 + };
154 + let value_group = group_info.to_index(Default::default(), "value");
155 + Self::XmlInline(XmlInlineArgs {
156 + tag_pattern,
157 + key_group,
158 + value_group,
159 + value_parser: ValueParserSpec::compile(scope, spec.value_parser)?,
160 + merge_duplicates: spec.merge_duplicates,
161 + })
162 + }
163 + ContentKind::KvLines => {
164 + let spec: KvLinesSpec = deserialize(&args, &context)?;
165 + if spec.line_sep.is_empty() {
166 + return Err(invalid!("{context}: line_sep cannot be empty"));
167 + }
168 + Self::KvLines(KvLinesArgs {
169 + text: spec.text,
170 + line_sep: spec.line_sep,
171 + kv_sep: spec.kv_sep,
172 + value_parser: ValueParserSpec::compile(scope, spec.value_parser)?,
173 + })
174 + }
175 + })
176 + }
177 +
178 + /// Parse one region body.
179 + pub fn parse(&self, text: &str) -> unified::Result<Value> {
180 + match self {
181 + Self::Text(args) => Ok(Value::String(args.apply(text).to_string())),
182 + // Numbers follow the tool-parameter conversion.
183 + Self::Int => convert_integer_text(text)
184 + .ok_or_else(|| parsing_failed!("int: invalid literal {text:?}")),
185 + Self::Float => convert_number_text(text)
186 + .ok_or_else(|| parsing_failed!("float: invalid or non-finite literal {text:?}")),
187 + Self::Bool(args) => {
188 + let text = args.apply(text).to_lowercase();
189 + Ok(Value::Bool(text == "true" || text == "1"))
190 + }
191 + Self::Json(args) => args.parse(text),
192 + Self::XmlInline(args) => args.parse(text),
193 + Self::KvLines(args) => args.parse(text),
194 + }
195 + }
196 +}
197 +
198 +impl ValueParserSpec {
199 + /// Build the nested parser, if any.
200 + fn compile(scope: &str, spec: Option<Self>) -> Result<Option<Box<ContentParser>>> {
201 + spec.map(|spec| {
202 + ContentParser::new(&format!("{scope}: value_parser"), spec.name, &spec.args)
203 + .map(Box::new)
204 + })
205 + .transpose()
206 + }
207 +}
208 +
209 +impl TextArgs {
210 + /// Apply `_text`: strip surrounding whitespace unless disabled.
211 + pub fn apply(self, text: &str) -> &str {
212 + if self.strip { text.trim() } else { text }
213 + }
214 +}
215 +
216 +impl JsonArgs {
217 + /// JSON parser with optional dialect knobs for LLM-emitted quirks.
218 + fn parse(&self, text: &str) -> unified::Result<Value> {
219 + if !self.string_delims.is_empty() && text.contains([LAX_OPEN, LAX_CLOSE]) {
220 + return Err(parsing_failed!(
221 + "json: input contains reserved sentinel characters (\\x01/\\x02); cannot parse safely."
222 + ));
223 + }
224 +
225 + let mut working = text.to_string();
226 + let mut captured: Vec<String> = Vec::new();
227 + for (open, close) in &self.string_delims {
228 + working = extract_delimited(&working, open, close, &mut captured);
229 + }
230 +
231 + if self.unquoted_keys {
232 + working = quote_bare_keys(&working);
233 + }
234 +
235 + for (index, string) in captured.iter().enumerate() {
236 + let quoted = serde_json::to_string(string).map_err(|error| {
237 + parsing_failed!("json: failed to quote string: {}", error.as_report())
238 + })?;
239 + working = working.replace(&format!("{LAX_OPEN}{index}{LAX_CLOSE}"), &quoted);
240 + }
241 +
242 + match serde_json::from_str(&working) {
243 + Ok(value) => Ok(value),
244 + Err(_) if self.allow_non_json => Ok(Value::String(self.text.apply(text).to_string())),
245 + Err(error) if working == text => Err(parsing_failed!(
246 + "json parser could not parse region as JSON.\nContent: {text:?}\nError: {}",
247 + error.as_report()
248 + )),
249 + Err(error) => Err(parsing_failed!(
250 + "json: could not parse after dialect transforms.\nOriginal: {text:?}\nTransformed: {working:?}\nError: {}",
251 + error.as_report()
252 + )),
253 + }
254 + }
255 +}
256 +
257 +impl XmlInlineArgs {
258 + /// Parse shallow XML-ish tags into a dict.
259 + fn parse(&self, text: &str) -> unified::Result<Value> {
260 + let mut out = Map::new();
261 + for captures in self.tag_pattern.captures_iter(text) {
262 + let key =
263 + captures.get_group(self.key_group).map(|span| &text[span.range()]).ok_or_else(
264 + || parsing_failed!("xml-inline: named group 'key' did not participate"),
265 + )?;
266 + let raw = match self.value_group {
267 + // The pattern has no `value` group.
268 + None => Some(""),
269 + Some(index) => captures.get_group(index).map(|span| &text[span.range()]),
270 + };
271 + let value = match raw {
272 + Some(raw) => sub_parse(raw, self.value_parser.as_deref())?,
273 + None if self.value_parser.is_none() => Value::Null,
274 + None => {
275 + return Err(parsing_failed!(
276 + "xml-inline: named group 'value' did not participate"
277 + ));
278 + }
279 + };
280 + match out.get_mut(key) {
281 + Some(existing) if self.merge_duplicates => match existing {
282 + Value::Array(values) => values.push(value),
283 + _ => {
284 + let first = existing.take();
285 + *existing = Value::Array(vec![first, value]);
286 + }
287 + },
288 + _ => {
289 + out.insert(key.to_string(), value);
290 + }
291 + }
292 + }
293 + Ok(Value::Object(out))
294 + }
295 +}
296 +
297 +impl KvLinesArgs {
298 + /// Parse line-delimited `key<sep>value` pairs into a dict.
299 + fn parse(&self, text: &str) -> unified::Result<Value> {
300 + let mut out = Map::new();
301 + for line in text.split(self.line_sep.as_str()) {
302 + let line = self.text.apply(line);
303 + let Some((key, value)) = line.split_once(self.kv_sep.as_str()) else {
304 + continue;
305 + };
306 + let (key, value) = (self.text.apply(key), self.text.apply(value));
307 + out.insert(
308 + key.to_string(),
309 + sub_parse(value, self.value_parser.as_deref())?,
310 + );
311 + }
312 + Ok(Value::Object(out))
313 + }
314 +}
315 +
316 +/// Parse `raw` with an optional nested value parser.
317 +fn sub_parse(raw: &str, value_parser: Option<&ContentParser>) -> unified::Result<Value> {
318 + match value_parser {
319 + None => Ok(Value::String(raw.to_string())),
320 + Some(parser) => parser.parse(raw),
321 + }
322 +}
323 +
324 +/// Replace every `open(.*?)close` span with an indexed sentinel, collecting the
325 +/// inner strings. Equivalent to the non-overlapping, left-to-right `re.sub` with
326 +/// `re.DOTALL` used by Transformers.
327 +fn extract_delimited(text: &str, open: &str, close: &str, captured: &mut Vec<String>) -> String {
328 + let mut out = String::with_capacity(text.len());
329 + let mut rest = text;
330 + while let Some(start) = rest.find(open) {
331 + let inner_start = start + open.len();
332 + // No close after this open means no close after any later open either.
333 + let Some(inner_len) = rest[inner_start..].find(close) else {
334 + break;
335 + };
336 + out.push_str(&rest[..start]);
337 + captured.push(rest[inner_start..inner_start + inner_len].to_string());
338 + out.push(LAX_OPEN);
339 + out.push_str(&(captured.len() - 1).to_string());
340 + out.push(LAX_CLOSE);
341 + rest = &rest[inner_start + inner_len + close.len()..];
342 + }
343 + out.push_str(rest);
344 + out
345 +}
346 +
347 +/// Quote bare-identifier keys: Transformers' `(?<=[{,])(\w+):` → `"\1":`.
348 +///
349 +/// The Rust regex engine has no lookbehind, so the preceding `{` or `,` is
350 +/// matched and written back. Matches cannot overlap either way, so the rewrite is
351 +/// equivalent.
352 +fn quote_bare_keys(text: &str) -> String {
353 + static BARE_KEY: std::sync::LazyLock<Regex> =
354 + std::sync::LazyLock::new(|| Regex::new(r"([{,])(\w+):").expect("valid regex"));
355 +
356 + let mut out = String::with_capacity(text.len());
357 + let mut last = 0;
358 + for captures in BARE_KEY.captures_iter(text) {
359 + let whole = captures.get_match().expect("match");
360 + let separator = captures.get_group(1).expect("group 1");
361 + let key = captures.get_group(2).expect("group 2");
362 + out.push_str(&text[last..whole.start()]);
363 + out.push_str(&text[separator.range()]);
364 + out.push('"');
365 + out.push_str(&text[key.range()]);
366 + out.push_str("\":");
367 + last = whole.end();
368 + }
369 + out.push_str(&text[last..]);
370 + out
371 +}
372 +
373 +/// Compile a Python-syntax regex with `re.DOTALL`, as Transformers does.
374 +pub(super) fn compile_regex(scope: &str, pattern: &str) -> Result<Regex> {
375 + Regex::builder()
376 + .syntax(syntax::Config::new().dot_matches_new_line(true))
377 + .build(pattern)
378 + .map_err(|error| {
379 + unsupported!(
380 + "{scope}: regex {pattern:?} is not supported by the Rust regex engine: {}",
381 + error.as_report()
382 + )
383 + })
384 +}
385 +
386 +fn default_strip() -> bool {
387 + true
388 +}
389 +
390 +fn default_line_sep() -> String {
391 + "\n".to_string()
392 +}
393 +
394 +fn default_kv_sep() -> String {
395 + ":".to_string()
396 +}
397 +
398 +#[cfg(test)]
399 +mod tests {
400 + use serde_json::json;
401 +
402 + use super::*;
403 +
404 + fn kind(name: &str) -> ContentKind {
405 + serde_json::from_value(json!(name)).unwrap()
406 + }
407 +
408 + fn parser(name: &str, args: Value) -> ContentParser {
409 + ContentParser::new("test", kind(name), args.as_object().unwrap()).unwrap()
410 + }
411 +
412 + #[test]
413 + fn scalar_parsers_convert_like_tool_parameters() {
414 + let int = parser("int", json!({}));
415 + assert_eq!(int.parse(" 42\n").unwrap(), json!(42));
416 + assert_eq!(int.parse("-1_000").unwrap(), json!(-1000));
417 + assert!(int.parse("1__0").is_err());
418 + assert!(int.parse("4.2").is_err());
419 +
420 + let float = parser("float", json!({}));
421 + assert_eq!(float.parse("1.5").unwrap(), json!(1.5));
422 + assert_eq!(float.parse(" 1e3 ").unwrap(), json!(1000.0));
423 + assert!(float.parse("inf").is_err(), "JSON cannot represent inf");
424 +
425 + let boolean = parser("bool", json!({}));
426 + assert_eq!(boolean.parse(" TRUE ").unwrap(), json!(true));
427 + assert_eq!(boolean.parse("1").unwrap(), json!(true));
428 + assert_eq!(boolean.parse("yes").unwrap(), json!(false));
429 +
430 + let text = parser("text", json!({"strip": false}));
431 + assert_eq!(text.parse(" a \n").unwrap(), json!(" a \n"));
432 + assert_eq!(
433 + parser("text", json!({})).parse(" a \n").unwrap(),
434 + json!("a")
435 + );
436 + }
437 +
438 + #[test]
439 + fn json_dialects_match_gemma4_syntax() {
440 + let json = parser(
441 + "json",
442 + json!({"unquoted_keys": true, "string_delims": [["<|\"|>", "<|\"|>"]]}),
443 + );
444 + let value = json
445 + .parse(
446 + r#"{bool_value:true,list_value:[<|"|>foo<|"|>,<|"|>bar<|"|>],null_value:null,number_value:1,string_value:<|"|>a, b: {c}<|"|>,struct_value:{foo:<|"|>bar<|"|>}}"#,
447 + )
448 + .unwrap();
449 + assert_eq!(
450 + value,
451 + json!({
452 + "bool_value": true,
453 + "list_value": ["foo", "bar"],
454 + "null_value": null,
455 + "number_value": 1,
456 + "string_value": "a, b: {c}",
457 + "struct_value": {"foo": "bar"},
458 + })
459 + );
460 + assert!(json.parse("{a:\u{1}}").is_err());
461 + }
462 +
463 + #[test]
464 + fn json_allow_non_json_returns_stripped_text() {
465 + let json = parser("json", json!({"allow_non_json": true}));
466 + assert_eq!(json.parse(" celsius \n").unwrap(), json!("celsius"));
467 + assert_eq!(json.parse(" [1, 2] ").unwrap(), json!([1, 2]));
468 + assert!(parser("json", json!({})).parse("celsius").is_err());
469 + }
470 +
471 + #[test]
472 + fn xml_inline_parses_tags_with_value_parser_and_merging() {
473 + let xml = parser(
474 + "xml-inline",
475 + json!({
476 + "tag_pattern": r"<parameter=(?P<key>\w+)>\s*(?P<value>.*?)\s*</parameter>",
477 + "value_parser": {"name": "json", "args": {"allow_non_json": true}},
478 + "merge_duplicates": true,
479 + }),
480 + );
481 + let value = xml
482 + .parse(
483 + "<parameter=locations>\n[{\"city\": \"Paris\"}]\n</parameter>\n\
484 + <parameter=unit>\ncelsius\n</parameter><parameter=unit>2</parameter>",
485 + )
486 + .unwrap();
487 + assert_eq!(
488 + value,
489 + json!({"locations": [{"city": "Paris"}], "unit": ["celsius", 2]})
490 + );
491 + }
492 +
493 + #[test]
494 + fn kv_lines_split_and_strip() {
495 + let kv = parser("kv-lines", json!({"value_parser": {"name": "int"}}));
496 + assert_eq!(
497 + kv.parse(" a : 1 \n\nnot a pair\nb:2").unwrap(),
498 + json!({"a": 1, "b": 2})
499 + );
500 + }
501 +
502 + #[test]
503 + fn invalid_arguments_are_rejected_at_load() {
504 + let error = |name: &str, args: Value| {
505 + ContentParser::new("Field 'x'", kind(name), args.as_object().unwrap()).unwrap_err()
506 + };
507 + expect_test::expect![[r#"
508 + [
509 + Invalid {
510 + message: "Field 'x': content_args.strip: invalid type: string \"no\", expected a boolean",
511 + },
512 + Invalid {
513 + message: "Field 'x': content_args.string_delims[0]: invalid length 1, expected a tuple of size 2",
514 + },
515 + Invalid {
516 + message: "Field 'x': content_args: missing field `tag_pattern`",
517 + },
518 + Invalid {
519 + message: "Field 'x': xml-inline: tag_pattern must have a named group 'key'. Pattern: <(?P<k>\\w+)>",
520 + },
521 + Invalid {
522 + message: "Field 'x': content_args.value_parser.name: unknown variant `yaml`, expected one of `text`, `int`, `float`, `bool`, `json`, `xml-inline`, `kv-lines`",
523 + },
524 + ]
525 + "#]].assert_debug_eq(&[
526 + error("text", json!({"strip": "no"})),
527 + error("json", json!({"string_delims": [["<s>"]]})),
528 + error("xml-inline", json!({})),
529 + error("xml-inline", json!({"tag_pattern": r"<(?P<k>\w+)>"})),
530 + error("kv-lines", json!({"value_parser": {"name": "yaml"}})),
531 + ]);
532 + assert!(matches!(
533 + error("xml-inline", json!({"tag_pattern": r"(?<=a)b"})),
534 + super::super::HfTemplateError::Unsupported { .. }
535 + ));
536 + }
537 +}
rust/src/parser/src/unified/hf/fixtures/differential.json new
+440
@@ -0,0 +1,440 @@
1 +{
2 + "templates": {
3 + "gemma4": {
4 + "defaults": {
5 + "role": "assistant"
6 + },
7 + "start_anchor": [
8 + "<|turn>model\n",
9 + "<tool_response|>"
10 + ],
11 + "fields": {
12 + "thinking": {
13 + "open": "<|channel>thought\n",
14 + "close": "<channel|>",
15 + "content": "text"
16 + },
17 + "tool_calls": {
18 + "open_pattern": "<\\|tool_call>call:(?P<name>\\w+)",
19 + "close": "<tool_call|>",
20 + "repeats": true,
21 + "content": "json",
22 + "content_args": {
23 + "unquoted_keys": true,
24 + "string_delims": [
25 + [
26 + "<|\"|>",
27 + "<|\"|>"
28 + ]
29 + ]
30 + },
31 + "transform": {
32 + "type": "function",
33 + "function": {
34 + "name": "{name}",
35 + "arguments": "{content}"
36 + }
37 + }
38 + },
39 + "content": {
40 + "close": [
41 + "<turn|>",
42 + "<|tool_response>",
43 + "<eos>"
44 + ],
45 + "content": "text"
46 + }
47 + }
48 + },
49 + "invoke": {
50 + "defaults": {
51 + "role": "assistant"
52 + },
53 + "start_anchor": "<|begin|>assistant",
54 + "fields": {
55 + "reasoning_content": {
56 + "open_pattern": "to=self<\\|msg\\|>",
57 + "close": "<|pause|>",
58 + "content": "text"
59 + },
60 + "tool_calls": {
61 + "open_pattern": "<invoke\\b[^>]*?\\bname=\"(?P<name>[^\"]+)\">",
62 + "close": "</invoke>",
63 + "repeats": true,
64 + "content": "xml-inline",
65 + "content_args": {
66 + "tag_pattern": "<param\\b[^>]*?\\bname=\"(?P<key>[^\"]+)\"[^>]*?>(?P<value>.*?)</param>",
67 + "value_parser": {
68 + "name": "json",
69 + "args": {
70 + "allow_non_json": true
71 + }
72 + }
73 + },
74 + "transform": {
75 + "type": "function",
76 + "function": {
77 + "name": "{name}",
78 + "arguments": "{content}"
79 + }
80 + }
81 + },
82 + "content": {
83 + "open_pattern": "to=(?:user|note)<\\|msg\\|>",
84 + "close": [
85 + "<|end|>",
86 + "<|pause|>"
87 + ],
88 + "repeats": true,
89 + "join": "",
90 + "content": "text"
91 + }
92 + }
93 + },
94 + "kv_tools": {
95 + "start_anchor": "<|im_start|>assistant\n",
96 + "fields": {
97 + "thinking": {
98 + "open": "<think>",
99 + "close": "</think>"
100 + },
101 + "tool_calls": {
102 + "open_pattern": "<tool_call>\\s*<function=(?P<name>\\w+)>\\n",
103 + "close": "</tool_call>",
104 + "repeats": true,
105 + "content": "kv-lines",
106 + "transform": {
107 + "type": "function",
108 + "function": {
109 + "name": "{name}",
110 + "arguments": "{content}"
111 + }
112 + }
113 + },
114 + "content": {
115 + "close": "<|im_end|>"
116 + }
117 + }
118 + }
119 + },
120 + "cases": [
121 + {
122 + "template": "gemma4",
123 + "prefix": "",
124 + "text": "Hello there.",
125 + "tools": null,
126 + "expected": {
127 + "content": "Hello there."
128 + }
129 + },
130 + {
131 + "template": "gemma4",
132 + "prefix": "",
133 + "text": " \n Hello\n\n",
134 + "tools": null,
135 + "expected": {
136 + "content": "Hello"
137 + }
138 + },
139 + {
140 + "template": "gemma4",
141 + "prefix": "",
142 + "text": "<|channel>thought\n plan\n<channel|>\n\nAnswer: 天气很好 ",
143 + "tools": null,
144 + "expected": {
145 + "reasoning": "plan",
146 + "content": "Answer: 天气很好"
147 + }
148 + },
149 + {
150 + "template": "gemma4",
151 + "prefix": "",
152 + "text": "<|channel>thought\nunterminated reasoning",
153 + "tools": null,
154 + "expected": {
155 + "reasoning": "unterminated reasoning"
156 + }
157 + },
158 + {
159 + "template": "gemma4",
160 + "prefix": "",
161 + "text": "Before <|tool_call>call:f{x:<|\"|>a, b<|\"|>,y:[1,2.5,-3],z:{w:true}}<tool_call|> after",
162 + "tools": null,
163 + "expected": {
164 + "content": "after",
165 + "tool_calls": [
166 + {
167 + "name": "f",
168 + "arguments": {
169 + "x": "a, b",
170 + "y": [
171 + 1,
172 + 2.5,
173 + -3
174 + ],
175 + "z": {
176 + "w": true
177 + }
178 + }
179 + }
180 + ]
181 + }
182 + },
183 + {
184 + "template": "gemma4",
185 + "prefix": "",
186 + "text": "<|tool_call>call:f{}<tool_call|><|tool_call>call:g{n:1}<tool_call|>",
187 + "tools": null,
188 + "expected": {
189 + "tool_calls": [
190 + {
191 + "name": "f",
192 + "arguments": {}
193 + },
194 + {
195 + "name": "g",
196 + "arguments": {
197 + "n": 1
198 + }
199 + }
200 + ]
201 + }
202 + },
203 + {
204 + "template": "gemma4",
205 + "prefix": "",
206 + "text": "<|tool_call>call:f{a:<|\"|>unterminated",
207 + "tools": null,
208 + "expected": {
209 + "error": "ValueError"
210 + }
211 + },
212 + {
213 + "template": "gemma4",
214 + "prefix": "",
215 + "text": "text <|tool_call> not a call",
216 + "tools": null,
217 + "expected": {
218 + "content": "text <|tool_call> not a call"
219 + }
220 + },
221 + {
222 + "template": "gemma4",
223 + "prefix": "",
224 + "text": "<|tool_call>call:f{a:1}",
225 + "tools": null,
226 + "expected": {
227 + "tool_calls": [
228 + {
229 + "name": "f",
230 + "arguments": {
231 + "a": 1
232 + }
233 + }
234 + ]
235 + }
236 + },
237 + {
238 + "template": "gemma4",
239 + "prefix": "<|turn>user\nhi<turn|>\n<|turn>model\n",
240 + "text": "Hi!",
241 + "tools": null,
242 + "expected": {
243 + "content": "Hi!"
244 + }
245 + },
246 + {
247 + "template": "gemma4",
248 + "prefix": "<|turn>model\n<|tool_call>call:f{}<tool_call|><|tool_response>response:f{}<tool_response|><|channel>thought\n",
249 + "text": "Done.<channel|>It is sunny.",
250 + "tools": null,
251 + "expected": {
252 + "reasoning": "Done.",
253 + "content": "It is sunny."
254 + }
255 + },
256 + {
257 + "template": "gemma4",
258 + "prefix": "<|turn>model\n<|channel>thought\n<channel|>",
259 + "text": "Plain answer",
260 + "tools": null,
261 + "expected": {
262 + "content": "Plain answer"
263 + }
264 + },
265 + {
266 + "template": "invoke",
267 + "prefix": "",
268 + "text": "to=self<|msg|>think<|pause|>to=user<|msg|>A<|pause|>to=note<|msg|> B <|end|>",
269 + "tools": null,
270 + "expected": {
271 + "reasoning": "think",
272 + "content": "AB"
273 + }
274 + },
275 + {
276 + "template": "invoke",
277 + "prefix": "",
278 + "text": "noise <invoke name=\"get\" id=\"1\"><param name=\"city\">\"Paris\"</param><param name=\"n\">3</param><param name=\"raw\">not json</param></invoke>",
279 + "tools": null,
280 + "expected": {}
281 + },
282 + {
283 + "template": "invoke",
284 + "prefix": "",
285 + "text": "<invoke name=\"get\"><param name=\"k\">1</param><param name=\"k\">2</param></invoke>",
286 + "tools": null,
287 + "expected": {
288 + "tool_calls": [
289 + {
290 + "name": "get",
291 + "arguments": {
292 + "k": 2
293 + }
294 + }
295 + ]
296 + }
297 + },
298 + {
299 + "template": "invoke",
300 + "prefix": "",
301 + "text": "bare text only",
302 + "tools": null,
303 + "expected": {}
304 + },
305 + {
306 + "template": "invoke",
307 + "prefix": "",
308 + "text": "to=user<|msg|>unterminated content",
309 + "tools": null,
310 + "expected": {
311 + "content": "unterminated content"
312 + }
313 + },
314 + {
315 + "template": "invoke",
316 + "prefix": "<|begin|>user<|end|><|begin|>assistant",
317 + "text": "to=user<|msg|>hi<|end|>",
318 + "tools": null,
319 + "expected": {
320 + "content": "hi"
321 + }
322 + },
323 + {
324 + "template": "kv_tools",
325 + "prefix": "",
326 + "text": "<tool_call>\n<function=set_alarm>\nhour: 7\nratio: 0.5\nenabled: 1\nlabel: 7\nmaybe: None\n</tool_call>",
327 + "tools": [
328 + {
329 + "type": "function",
330 + "function": {
331 + "name": "set_alarm",
332 + "parameters": {
333 + "type": "object",
334 + "properties": {
335 + "hour": {
336 + "type": "integer"
337 + },
338 + "ratio": {
339 + "type": "number"
340 + },
341 + "enabled": {
342 + "type": "boolean"
343 + },
344 + "label": {
345 + "type": "string"
346 + },
347 + "maybe": {
348 + "anyOf": [
349 + {
350 + "type": "integer"
351 + },
352 + {
353 + "type": "null"
354 + }
355 + ]
356 + }
357 + }
358 + }
359 + }
360 + }
361 + ],
362 + "expected": {
363 + "tool_calls": [
364 + {
365 + "name": "set_alarm",
366 + "arguments": {
367 + "hour": 7,
368 + "ratio": 0.5,
369 + "enabled": true,
370 + "label": "7",
371 + "maybe": null
372 + }
373 + }
374 + ]
375 + }
376 + },
377 + {
378 + "template": "kv_tools",
379 + "prefix": "",
380 + "text": "<tool_call>\n<function=set_alarm>\nhour: seven\nratio: 1e3\nenabled: nope\n</tool_call>",
381 + "tools": [
382 + {
383 + "type": "function",
384 + "function": {
385 + "name": "set_alarm",
386 + "parameters": {
387 + "type": "object",
388 + "properties": {
389 + "hour": {
390 + "type": "integer"
391 + },
392 + "ratio": {
393 + "type": "number"
394 + },
395 + "enabled": {
396 + "type": "boolean"
397 + },
398 + "label": {
399 + "type": "string"
400 + },
401 + "maybe": {
402 + "anyOf": [
403 + {
404 + "type": "integer"
405 + },
406 + {
407 + "type": "null"
408 + }
409 + ]
410 + }
411 + }
412 + }
413 + }
414 + }
415 + ],
416 + "expected": {
417 + "tool_calls": [
418 + {
419 + "name": "set_alarm",
420 + "arguments": {
421 + "hour": "seven",
422 + "ratio": 1000,
423 + "enabled": "nope"
424 + }
425 + }
426 + ]
427 + }
428 + },
429 + {
430 + "template": "kv_tools",
431 + "prefix": "<|im_start|>assistant\n<think>\n",
432 + "text": "\n reasoning \n</think>\n\nanswer<|im_end|>",
433 + "tools": null,
434 + "expected": {
435 + "reasoning": "reasoning",
436 + "content": "answer"
437 + }
438 + }
439 + ]
440 +}
rust/src/parser/src/unified/hf/fixtures/generate_differential.py new
+264
@@ -0,0 +1,264 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Regenerate `differential.json` from Transformers `parse_response`.
4 +
5 +Requires Transformers with `transformers.utils.chat_parsing` (>= 5.13); the
6 +expected values in the checked-in file were produced at commit 6d43ab4008.
7 +
8 + python generate_differential.py > differential.json
9 +"""
10 +
11 +import json
12 +
13 +from transformers.utils.chat_parsing import parse_response
14 +
15 +GEMMA4 = {
16 + "defaults": {"role": "assistant"},
17 + "start_anchor": ["<|turn>model\n", "<tool_response|>"],
18 + "fields": {
19 + "thinking": {
20 + "open": "<|channel>thought\n",
21 + "close": "<channel|>",
22 + "content": "text",
23 + },
24 + "tool_calls": {
25 + "open_pattern": r"<\|tool_call>call:(?P<name>\w+)",
26 + "close": "<tool_call|>",
27 + "repeats": True,
28 + "content": "json",
29 + "content_args": {
30 + "unquoted_keys": True,
31 + "string_delims": [['<|"|>', '<|"|>']],
32 + },
33 + "transform": {
34 + "type": "function",
35 + "function": {"name": "{name}", "arguments": "{content}"},
36 + },
37 + },
38 + "content": {
39 + "close": ["<turn|>", "<|tool_response>", "<eos>"],
40 + "content": "text",
41 + },
42 + },
43 +}
44 +
45 +INVOKE = {
46 + "defaults": {"role": "assistant"},
47 + "start_anchor": "<|begin|>assistant",
48 + "fields": {
49 + "reasoning_content": {
50 + "open_pattern": r"to=self<\|msg\|>",
51 + "close": "<|pause|>",
52 + "content": "text",
53 + },
54 + "tool_calls": {
55 + "open_pattern": r'<invoke\b[^>]*?\bname="(?P<name>[^"]+)">',
56 + "close": "</invoke>",
57 + "repeats": True,
58 + "content": "xml-inline",
59 + "content_args": {
60 + "tag_pattern": (
61 + r'<param\b[^>]*?\bname="(?P<key>[^"]+)"[^>]*?>'
62 + r"(?P<value>.*?)</param>"
63 + ),
64 + "value_parser": {"name": "json", "args": {"allow_non_json": True}},
65 + },
66 + "transform": {
67 + "type": "function",
68 + "function": {"name": "{name}", "arguments": "{content}"},
69 + },
70 + },
71 + "content": {
72 + "open_pattern": r"to=(?:user|note)<\|msg\|>",
73 + "close": ["<|end|>", "<|pause|>"],
74 + "repeats": True,
75 + "join": "",
76 + "content": "text",
77 + },
78 + },
79 +}
80 +
81 +KV_TOOLS = {
82 + "start_anchor": "<|im_start|>assistant\n",
83 + "fields": {
84 + "thinking": {"open": "<think>", "close": "</think>"},
85 + "tool_calls": {
86 + "open_pattern": r"<tool_call>\s*<function=(?P<name>\w+)>\n",
87 + "close": "</tool_call>",
88 + "repeats": True,
89 + "content": "kv-lines",
90 + "transform": {
91 + "type": "function",
92 + "function": {"name": "{name}", "arguments": "{content}"},
93 + },
94 + },
95 + "content": {"close": "<|im_end|>"},
96 + },
97 +}
98 +
99 +ALARM_TOOLS = [
100 + {
101 + "type": "function",
102 + "function": {
103 + "name": "set_alarm",
104 + "parameters": {
105 + "type": "object",
106 + "properties": {
107 + "hour": {"type": "integer"},
108 + "ratio": {"type": "number"},
109 + "enabled": {"type": "boolean"},
110 + "label": {"type": "string"},
111 + "maybe": {"anyOf": [{"type": "integer"}, {"type": "null"}]},
112 + },
113 + },
114 + },
115 + }
116 +]
117 +
118 +CASES = [
119 + ("gemma4", GEMMA4, "", "Hello there.", None),
120 + ("gemma4", GEMMA4, "", " \n Hello\n\n", None),
121 + (
122 + "gemma4",
123 + GEMMA4,
124 + "",
125 + "<|channel>thought\n plan\n<channel|>\n\nAnswer: 天气很好 ",
126 + None,
127 + ),
128 + ("gemma4", GEMMA4, "", "<|channel>thought\nunterminated reasoning", None),
129 + (
130 + "gemma4",
131 + GEMMA4,
132 + "",
133 + 'Before <|tool_call>call:f{x:<|"|>a, b<|"|>,y:[1,2.5,-3],z:{w:true}}'
134 + "<tool_call|> after",
135 + None,
136 + ),
137 + (
138 + "gemma4",
139 + GEMMA4,
140 + "",
141 + "<|tool_call>call:f{}<tool_call|><|tool_call>call:g{n:1}<tool_call|>",
142 + None,
143 + ),
144 + ("gemma4", GEMMA4, "", '<|tool_call>call:f{a:<|"|>unterminated', None),
145 + ("gemma4", GEMMA4, "", "text <|tool_call> not a call", None),
146 + ("gemma4", GEMMA4, "", "<|tool_call>call:f{a:1}", None),
147 + ("gemma4", GEMMA4, "<|turn>user\nhi<turn|>\n<|turn>model\n", "Hi!", None),
148 + (
149 + "gemma4",
150 + GEMMA4,
151 + "<|turn>model\n<|tool_call>call:f{}<tool_call|><|tool_response>response:f{}<tool_response|><|channel>thought\n",
152 + "Done.<channel|>It is sunny.",
153 + None,
154 + ),
155 + (
156 + "gemma4",
157 + GEMMA4,
158 + "<|turn>model\n<|channel>thought\n<channel|>",
159 + "Plain answer",
160 + None,
161 + ),
162 + (
163 + "invoke",
164 + INVOKE,
165 + "",
166 + "to=self<|msg|>think<|pause|>to=user<|msg|>A<|pause|>to=note<|msg|> B <|end|>",
167 + None,
168 + ),
169 + (
170 + "invoke",
171 + INVOKE,
172 + "",
173 + 'noise <invoke name="get" id="1"><param name="city">"Paris"</param>'
174 + '<param name="n">3</param><param name="raw">not json</param></invoke>',
175 + None,
176 + ),
177 + (
178 + "invoke",
179 + INVOKE,
180 + "",
181 + '<invoke name="get"><param name="k">1</param>'
182 + '<param name="k">2</param></invoke>',
183 + None,
184 + ),
185 + ("invoke", INVOKE, "", "bare text only", None),
186 + ("invoke", INVOKE, "", "to=user<|msg|>unterminated content", None),
187 + (
188 + "invoke",
189 + INVOKE,
190 + "<|begin|>user<|end|><|begin|>assistant",
191 + "to=user<|msg|>hi<|end|>",
192 + None,
193 + ),
194 + (
195 + "kv_tools",
196 + KV_TOOLS,
197 + "",
198 + "<tool_call>\n<function=set_alarm>\nhour: 7\nratio: 0.5\nenabled: 1\n"
199 + "label: 7\nmaybe: None\n</tool_call>",
200 + ALARM_TOOLS,
201 + ),
202 + (
203 + "kv_tools",
204 + KV_TOOLS,
205 + "",
206 + "<tool_call>\n<function=set_alarm>\nhour: seven\nratio: 1e3\n"
207 + "enabled: nope\n</tool_call>",
208 + ALARM_TOOLS,
209 + ),
210 + (
211 + "kv_tools",
212 + KV_TOOLS,
213 + "<|im_start|>assistant\n<think>\n",
214 + "\n reasoning \n</think>\n\nanswer<|im_end|>",
215 + None,
216 + ),
217 +]
218 +
219 +
220 +def normalize(message):
221 + out = {}
222 + reasoning = message.get("thinking", message.get("reasoning_content"))
223 + if reasoning:
224 + out["reasoning"] = reasoning
225 + if message.get("content"):
226 + out["content"] = message["content"]
227 + calls = [
228 + {"name": call["function"]["name"], "arguments": call["function"]["arguments"]}
229 + for call in message.get("tool_calls", [])
230 + ]
231 + if calls:
232 + out["tool_calls"] = calls
233 + return out
234 +
235 +
236 +def main():
237 + templates = {}
238 + cases = []
239 + for template_name, template, prefix, text, tools in CASES:
240 + templates[template_name] = template
241 + try:
242 + expected = normalize(
243 + parse_response(text, template, prefix=prefix, tools=tools)
244 + )
245 + except Exception as error: # noqa: BLE001
246 + expected = {"error": type(error).__name__}
247 + cases.append(
248 + {
249 + "template": template_name,
250 + "prefix": prefix,
251 + "text": text,
252 + "tools": tools,
253 + "expected": expected,
254 + }
255 + )
256 + print(
257 + json.dumps(
258 + {"templates": templates, "cases": cases}, indent=2, ensure_ascii=False
259 + )
260 + )
261 +
262 +
263 +if __name__ == "__main__":
264 + main()
rust/src/parser/src/unified/hf/mod.rs new
+53
@@ -0,0 +1,53 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Unified parser driven by a checkpoint's Hugging Face `response_template`.
5 +//!
6 +//! The renderer executes a checkpoint's `chat_template`; this parser executes its
7 +//! `response_template`, the declarative output-protocol description introduced by
8 +//! Transformers `chat_parsing` (`ResponseParser`).
9 +//!
10 +//! The template language and its value semantics (content parsers, transforms,
11 +//! tool-schema coercion) follow Transformers at
12 +//! <https://github.com/huggingface/transformers/tree/6d43ab4008/src/transformers/utils/chat_parsing>.
13 +//! The streaming executor is native: it emits ordered [`UnifiedParserEvent`]s
14 +//! instead of Transformers' region events and aggregated message dict.
15 +//!
16 +//! [`UnifiedParserEvent`]: super::UnifiedParserEvent
17 +
18 +mod content;
19 +mod parser;
20 +mod pattern;
21 +mod spec;
22 +mod template;
23 +mod transform;
24 +
25 +#[cfg(test)]
26 +mod tests;
27 +
28 +pub use parser::HfUnifiedParser;
29 +pub use template::ResponseTemplate;
30 +use thiserror::Error;
31 +use thiserror_ext::Macro;
32 +
33 +/// Result alias for response-template operations.
34 +pub type Result<T> = std::result::Result<T, HfTemplateError>;
35 +
36 +/// Errors produced while loading a `response_template`.
37 +///
38 +/// Failures while parsing generated output are
39 +/// [`UnifiedParserError::ParsingFailed`](super::UnifiedParserError::ParsingFailed), as for
40 +/// the other unified parsers.
41 +#[derive(Debug, Clone, Error, Macro)]
42 +#[thiserror_ext(macro(path = "crate::unified::hf", mangle))]
43 +pub enum HfTemplateError {
44 + /// The template violates the Transformers `response_template` schema.
45 + #[error("invalid response_template: {message}")]
46 + Invalid { message: String },
47 + /// The template is valid but uses a feature this implementation does not support.
48 + #[error("unsupported response_template: {message}")]
49 + Unsupported { message: String },
50 + /// The model provides no template.
51 + #[error("the model's tokenizer_config.json provides no response_template")]
52 + Missing,
53 +}
rust/src/parser/src/unified/hf/parser.rs new
+730
@@ -0,0 +1,730 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Streaming executor for a compiled [`ResponseTemplate`].
5 +//!
6 +//! Follows `ResponseParser` in `response_parser.py`: a flat region state
7 +//! machine that, outside explicit regions, watches every explicit open plus the
8 +//! implicit region's close, and inside an explicit region watches only that
9 +//! region's close. Instead of region events and an aggregated dict, it emits
10 +//! ordered unified parser events:
11 +//!
12 +//! - text and reasoning stream as they arrive, with `strip` applied so that the
13 +//! concatenated deltas equal Transformers' stripped value. For a field without
14 +//! `repeats`, stripping spans all its occurrences: an interrupted field (text,
15 +//! tool call, more text) streams every occurrence, where Transformers keeps only
16 +//! the last;
17 +//! - a tool call starts at its opener when the opener captures the function
18 +//! name, and its arguments are emitted when the region closes.
19 +
20 +use std::mem::take;
21 +use std::sync::Arc;
22 +
23 +use serde::Deserialize;
24 +use serde_json::{Map, Value};
25 +use thiserror_ext::AsReport as _;
26 +use vllm_tokenizer::{DecodedText, DynTokenizer, TokenAnchor, TokenAttribution};
27 +use winnow::error::{ErrMode, ModalResult};
28 +use winnow::stream::{Partial, Stream};
29 +
30 +use super::pattern::{Captures, Resolution};
31 +use super::template::{
32 + FieldName, Region, RegionKind, Repeat, ResponseTemplate, TextRegion, TextRole, ToolCallRegion,
33 + Watch, WatchKind, WatchSet,
34 +};
35 +use crate::tool::{Tool, ToolCallDelta, ToolSchemas};
36 +use crate::unified::{
37 + Result, UnifiedParser, UnifiedParserError, UnifiedParserOutput, parsing_failed,
38 +};
39 +use crate::utils::{incomplete, parse_buffered_event, safe_text_len_mul};
40 +
41 +/// Prompt tail windows, in tokens, searched for the start anchor before
42 +/// decoding the whole prompt.
43 +const PROMPT_WINDOWS: [usize; 3] = [64, 512, 4096];
44 +
45 +/// Unified parser executing a checkpoint's `response_template`.
46 +///
47 +/// Original Python implementation:
48 +/// <https://github.com/huggingface/transformers/blob/6d43ab4008/src/transformers/utils/chat_parsing/response_parser.py>
49 +pub struct HfUnifiedParser {
50 + template: Arc<ResponseTemplate>,
51 + tool_schemas: ToolSchemas,
52 + tokenizer: DynTokenizer,
53 + buffer: DecodedText,
54 + mode: HfMode,
55 + /// Whether each region has closed an occurrence.
56 + closed: Vec<bool>,
57 + /// Strip state per region: per occurrence with `repeats`, per stream otherwise.
58 + strip: Vec<StripState>,
59 + emitted_tool_count: usize,
60 +}
61 +
62 +/// Parser mode: the region currently receiving text.
63 +enum HfMode {
64 + /// No implicit field is declared: text outside regions is discarded.
65 + Discard,
66 + /// The implicit region, opened lazily on its first byte.
67 + Implicit(Occurrence),
68 + /// An explicit region, opened by its open boundary.
69 + Explicit(Occurrence),
70 +}
71 +
72 +/// One occurrence of a region.
73 +struct Occurrence {
74 + region: usize,
75 + /// Whether the occurrence has content to close (`_opened`).
76 + opened: bool,
77 + captures: Captures,
78 + /// Opener text, returned by [`UnifiedParser::reset`].
79 + raw_open: String,
80 + /// Buffered body of a tool-call region.
81 + body: String,
82 + /// Index of the tool call started at the opener, if any.
83 + active_tool_index: Option<usize>,
84 + /// `join` separator still to emit before this occurrence's content.
85 + separator: Option<String>,
86 +}
87 +
88 +/// Strip-compatible streaming state of a text or reasoning occurrence.
89 +#[derive(Default)]
90 +struct StripState {
91 + enabled: bool,
92 + /// Whether a non-whitespace character has been seen.
93 + started: bool,
94 + /// Trailing whitespace held until more text arrives or the region closes.
95 + held: DecodedText,
96 +}
97 +
98 +/// One parsed event of the buffered input.
99 +enum HfEvent {
100 + /// Text routed into the current region.
101 + Text,
102 + /// A boundary of the current mode's watch list.
103 + Boundary { watch: Watch, captures: Captures },
104 +}
105 +
106 +/// A committable boundary match at the current position.
107 +struct BoundaryMatch {
108 + watch: Watch,
109 + len: usize,
110 + captures: Captures,
111 +}
112 +
113 +impl BoundaryMatch {
114 + /// Preference order of `_scan`: longest first, opens before closes, then by
115 + /// field name.
116 + fn key(&self, template: &ResponseTemplate) -> (std::cmp::Reverse<usize>, bool, FieldName) {
117 + (
118 + std::cmp::Reverse(self.len),
119 + self.watch.kind == WatchKind::Close,
120 + template.regions[self.watch.region].name,
121 + )
122 + }
123 +}
124 +
125 +/// How the event loop treats its input.
126 +#[derive(Clone, Copy)]
127 +enum Phase {
128 + /// Prompt text after the start anchor: state transitions only, no events.
129 + Prefill,
130 + /// Generated text; more may follow.
131 + Stream,
132 + /// Generated text at the end of the stream.
133 + Eof,
134 +}
135 +
136 +impl Phase {
137 + fn emits(self) -> bool {
138 + !matches!(self, Self::Prefill)
139 + }
140 +}
141 +
142 +impl HfUnifiedParser {
143 + /// Create a parser for one request.
144 + pub fn new(template: Arc<ResponseTemplate>, tools: &[Tool], tokenizer: DynTokenizer) -> Self {
145 + let mut parser = Self {
146 + closed: vec![false; template.regions.len()],
147 + strip: template.regions.iter().map(StripState::new).collect(),
148 + mode: HfMode::Discard,
149 + template,
150 + tool_schemas: ToolSchemas::from_tools(tools),
151 + tokenizer,
152 + buffer: DecodedText::default(),
153 + emitted_tool_count: 0,
154 + };
155 + parser.reset_to_implicit();
156 + parser
157 + }
158 +
159 + /// Clear all per-stream state.
160 + fn reset_state(&mut self) {
161 + self.buffer.clear();
162 + self.closed.fill(false);
163 + self.strip = self.template.regions.iter().map(StripState::new).collect();
164 + self.emitted_tool_count = 0;
165 + self.reset_to_implicit();
166 + }
167 +
168 + /// Return to the implicit region, or discard text when there is none.
169 + fn reset_to_implicit(&mut self) {
170 + self.mode = match self.template.implicit {
171 + Some(region) => {
172 + HfMode::Implicit(self.occurrence(region, false, Captures::new(), String::new()))
173 + }
174 + None => HfMode::Discard,
175 + };
176 + }
177 +
178 + /// Start a new occurrence of `region`.
179 + fn occurrence(
180 + &mut self,
181 + region: usize,
182 + opened: bool,
183 + captures: Captures,
184 + raw_open: String,
185 + ) -> Occurrence {
186 + let spec = &self.template.regions[region];
187 + let mut separator = None;
188 + if let RegionKind::Text(text) = &spec.kind {
189 + if let Repeat::Join(join) = &text.repeat
190 + && !join.is_empty()
191 + && self.closed[region]
192 + {
193 + separator = Some(join.clone());
194 + }
195 + if text.repeat != Repeat::Once {
196 + self.strip[region] = StripState::new(spec);
197 + }
198 + }
199 + Occurrence {
200 + region,
201 + opened,
202 + captures,
203 + raw_open,
204 + body: String::new(),
205 + active_tool_index: None,
206 + separator,
207 + }
208 + }
209 +
210 + /// Parse buffered input until more is needed.
211 + fn drive(&mut self, output: &mut UnifiedParserOutput, phase: Phase) -> Result<()> {
212 + let template = Arc::clone(&self.template);
213 + loop {
214 + let watch = match &self.mode {
215 + HfMode::Explicit(occurrence) => &template.regions[occurrence.region].close_watch,
216 + HfMode::Discard | HfMode::Implicit(_) => &template.idle_watch,
217 + };
218 + let eof = matches!(phase, Phase::Eof);
219 + let Some((step, consumed_len)) = parse_buffered_event(&self.buffer.text, |input| {
220 + parse_next_event(input, &template, watch, eof)
221 + })?
222 + else {
223 + return Ok(());
224 + };
225 + let piece = self.buffer.drain_prefix(consumed_len);
226 + match step {
227 + HfEvent::Text => self.route(piece, output, phase),
228 + HfEvent::Boundary { watch, captures } => {
229 + self.close_current(output, phase)?;
230 + match watch.kind {
231 + WatchKind::Open => {
232 + self.open_explicit(watch.region, captures, piece.text, output, phase)
233 + }
234 + // The implicit region's close: start a fresh implicit occurrence.
235 + WatchKind::Close => {}
236 + }
237 + }
238 + }
239 + }
240 + }
241 +
242 + /// Route a text piece into the current region.
243 + fn route(&mut self, piece: DecodedText, output: &mut UnifiedParserOutput, phase: Phase) {
244 + let (occurrence, region) = match &mut self.mode {
245 + HfMode::Discard => return,
246 + HfMode::Implicit(occurrence) | HfMode::Explicit(occurrence) => {
247 + let region = &self.template.regions[occurrence.region];
248 + (occurrence, region)
249 + }
250 + };
251 + let RegionKind::Text(text) = &region.kind else {
252 + if !piece.text.is_empty() {
253 + occurrence.opened = true;
254 + occurrence.body.push_str(&piece.text);
255 + }
256 + return;
257 + };
258 + let reasoning = text.role == TextRole::Reasoning;
259 + if piece.text.is_empty() {
260 + // Zero-width tokens still count as reasoning.
261 + if reasoning && phase.emits() {
262 + output.push_reasoning(piece);
263 + }
264 + return;
265 + }
266 + occurrence.opened = true;
267 +
268 + let strip = &mut self.strip[occurrence.region];
269 + if !phase.emits() {
270 + // Prompt text is never emitted; it only decides whether later
271 + // leading whitespace is still stripped.
272 + strip.started |= !piece.text.trim_start().is_empty();
273 + return;
274 + }
275 + let (visible, dropped) = strip.feed(piece);
276 + if reasoning {
277 + push_dropped_reasoning(output, dropped);
278 + }
279 + if visible.text.is_empty() {
280 + return;
281 + }
282 + if let Some(separator) = occurrence.separator.take() {
283 + push_text(output, text.role, DecodedText::unattributed(separator));
284 + }
285 + push_text(output, text.role, visible);
286 + }
287 +
288 + /// Open an explicit region.
289 + fn open_explicit(
290 + &mut self,
291 + region: usize,
292 + captures: Captures,
293 + raw_open: String,
294 + output: &mut UnifiedParserOutput,
295 + phase: Phase,
296 + ) {
297 + let mut occurrence = self.occurrence(region, true, captures, raw_open);
298 + let opener_name = match &self.template.regions[region].kind {
299 + RegionKind::ToolCalls(ToolCallRegion {
300 + name_group: Some(group),
301 + ..
302 + }) => occurrence.captures.get(group).and_then(Value::as_str).map(str::to_string),
303 + _ => None,
304 + };
305 + if let Some(name) = opener_name
306 + && phase.emits()
307 + {
308 + let tool_index = self.allocate_tool_index();
309 + occurrence.active_tool_index = Some(tool_index);
310 + output.push_call(ToolCallDelta {
311 + tool_index,
312 + name: Some(name),
313 + arguments: String::new(),
314 + });
315 + }
316 + self.mode = HfMode::Explicit(occurrence);
317 + }
318 +
319 + /// Close the current region and reset to the implicit region.
320 + ///
321 + /// Skipped (aside from the reset) when the current region never opened --
322 + /// avoids vacuous open/close pairs at every explicit boundary.
323 + fn close_current(&mut self, output: &mut UnifiedParserOutput, phase: Phase) -> Result<()> {
324 + let template = Arc::clone(&self.template);
325 + let mode = std::mem::replace(&mut self.mode, HfMode::Discard);
326 + let occurrence = match mode {
327 + HfMode::Implicit(occurrence) | HfMode::Explicit(occurrence) if occurrence.opened => {
328 + Some(occurrence)
329 + }
330 + _ => None,
331 + };
332 + // Without `repeats`, trailing whitespace stays held in case the field
333 + // continues; it is dropped at the end of the stream.
334 + let held = occurrence
335 + .as_ref()
336 + .filter(|occurrence| {
337 + matches!(&template.regions[occurrence.region].kind,
338 + RegionKind::Text(text) if text.repeat != Repeat::Once)
339 + })
340 + .map(|occurrence| take(&mut self.strip[occurrence.region].held));
341 + if let Some(occurrence) = &occurrence {
342 + self.closed[occurrence.region] = true;
343 + }
344 + self.reset_to_implicit();
345 + // Regions closed inside the prompt contribute nothing to the output.
346 + let Some(occurrence) = occurrence.filter(|_| phase.emits()) else {
347 + return Ok(());
348 + };
349 +
350 + let region = &template.regions[occurrence.region];
351 + match &region.kind {
352 + RegionKind::Text(text) => {
353 + if text.role == TextRole::Reasoning
354 + && let Some(held) = held
355 + {
356 + push_dropped_reasoning(output, held);
357 + }
358 + // An empty occurrence still joins: `previous + join + ""`.
359 + if let Some(separator) = occurrence.separator {
360 + push_text(output, text.role, DecodedText::unattributed(separator));
361 + }
362 + }
363 + RegionKind::ToolCalls(calls) => {
364 + let parsed = calls.content.parse(&occurrence.body)?;
365 + let value = match &calls.transform {
366 + Some(transform) => {
367 + transform.apply(region.name.as_str(), parsed, &occurrence.captures)?
368 + }
369 + None => parsed,
370 + };
371 + self.emit_calls(value, occurrence.active_tool_index, output)?;
372 + }
373 + }
374 + Ok(())
375 + }
376 +
377 + /// Emit the tool calls of a closed tool-call region.
378 + // TODO: stream arguments incrementally instead of once at region close; the
379 + // dialects (e.g. Gemma 4's unquoted keys and `<|"|>` strings, `xml-inline`)
380 + // need incremental conversion rather than raw passthrough.
381 + fn emit_calls(
382 + &mut self,
383 + value: Value,
384 + started: Option<usize>,
385 + output: &mut UnifiedParserOutput,
386 + ) -> Result<()> {
387 + let calls = match value {
388 + Value::Array(calls) => calls,
389 + call => vec![call],
390 + };
391 + for (position, call) in calls.into_iter().enumerate() {
392 + let (name, arguments) = call_parts(call, &self.tool_schemas)?;
393 + let (tool_index, name) = match (position, started) {
394 + (0, Some(tool_index)) => (tool_index, None),
395 + _ => (self.allocate_tool_index(), Some(name)),
396 + };
397 + output.push_call(ToolCallDelta {
398 + tool_index,
399 + name,
400 + arguments,
401 + });
402 + }
403 + Ok(())
404 + }
405 +
406 + fn allocate_tool_index(&mut self) -> usize {
407 + let tool_index = self.emitted_tool_count;
408 + self.emitted_tool_count += 1;
409 + tool_index
410 + }
411 +
412 + /// Return the prompt text after the last start-anchor match, if any.
413 + ///
414 + /// Searches geometrically growing tail windows before decoding the whole
415 + /// prompt. A decoded suffix can differ from the full decode only in its first
416 + /// character, so a match is accepted only after that character unless the
417 + /// window is the whole prompt.
418 + fn prompt_remainder(&self, prompt_token_ids: &[u32]) -> Result<Option<String>> {
419 + let len = prompt_token_ids.len();
420 + let windows = PROMPT_WINDOWS.into_iter().filter(|window| *window < len).chain([len]);
421 + for window in windows {
422 + let text = self.tokenizer.decode(&prompt_token_ids[len - window..], false).map_err(
423 + |error| parsing_failed!("failed to decode prompt: {}", error.as_report()),
424 + )?;
425 + let whole = window == len;
426 + if let Some((start, end)) = self.template.last_start_anchor(&text) {
427 + let first_char = text.chars().next().map_or(0, char::len_utf8);
428 + if whole || start >= first_char {
429 + return Ok(Some(text[end..].to_string()));
430 + }
431 + }
432 + }
433 + Ok(None)
434 + }
435 +}
436 +
437 +impl StripState {
438 + fn new(region: &Region) -> Self {
439 + Self {
440 + enabled: matches!(
441 + region.kind,
442 + RegionKind::Text(TextRegion { strip: true, .. })
443 + ),
444 + ..Self::default()
445 + }
446 + }
447 +
448 + /// Split a piece into its visible part and whitespace dropped by `strip`.
449 + ///
450 + /// Leading whitespace is dropped until the first non-whitespace character;
451 + /// trailing whitespace is held until more non-whitespace text arrives (then
452 + /// released) or the region closes (then dropped).
453 + fn feed(&mut self, mut piece: DecodedText) -> (DecodedText, DecodedText) {
454 + if !self.enabled {
455 + return (piece, DecodedText::default());
456 + }
457 + let mut dropped = DecodedText::default();
458 + if !self.started {
459 + let lead = piece.text.len() - piece.text.trim_start().len();
460 + if lead == piece.text.len() {
461 + return (DecodedText::default(), piece);
462 + }
463 + dropped = piece.drain_prefix(lead);
464 + self.started = true;
465 + }
466 + let body_len = piece.text.trim_end().len();
467 + if body_len == 0 {
468 + self.held.append(piece);
469 + return (DecodedText::default(), dropped);
470 + }
471 + let head = piece.drain_prefix(body_len);
472 + let mut visible = take(&mut self.held);
473 + visible.append(head);
474 + self.held = piece;
475 + (visible, dropped)
476 + }
477 +}
478 +
479 +/// Push visible text of a text region.
480 +fn push_text(output: &mut UnifiedParserOutput, role: TextRole, piece: DecodedText) {
481 + match role {
482 + TextRole::Content => output.push_text(piece.text),
483 + TextRole::Reasoning => output.push_reasoning(piece),
484 + }
485 +}
486 +
487 +/// Keep the tokens of stripped reasoning whitespace in the reasoning count.
488 +fn push_dropped_reasoning(output: &mut UnifiedParserOutput, dropped: DecodedText) {
489 + if dropped.attributions.is_empty() {
490 + return;
491 + }
492 + output.push_reasoning(DecodedText {
493 + text: String::new(),
494 + attributions: dropped
495 + .attributions
496 + .into_iter()
497 + .map(|attribution| TokenAttribution {
498 + token_id: attribution.token_id,
499 + anchor: TokenAnchor::ZeroWidth { byte_offset: 0 },
500 + })
501 + .collect(),
502 + });
503 +}
504 +
505 +/// One tool call produced by a tool-call region, after its transform.
506 +#[derive(Deserialize)]
507 +#[serde(untagged)]
508 +enum ToolCallValue {
509 + /// The OpenAI shape `{"type": "function", "function": {...}}`.
510 + Wrapped { function: FunctionValue },
511 + /// The bare `{"name", "arguments"}` shape.
512 + Bare(FunctionValue),
513 +}
514 +
515 +#[derive(Deserialize)]
516 +struct FunctionValue {
517 + name: String,
518 + /// Missing or `null` arguments become `{}`.
519 + #[serde(default)]
520 + arguments: Option<ArgumentsValue>,
521 +}
522 +
523 +#[derive(Deserialize)]
524 +#[serde(untagged)]
525 +enum ArgumentsValue {
526 + /// An argument object, typed by the tool schema.
527 + Object(Map<String, Value>),
528 + /// Pre-serialized or non-JSON arguments (e.g. an `allow_non_json`
529 + /// fallback), passed through unchanged.
530 + Text(String),
531 + /// Any other JSON value, serialized as is.
532 + Other(Value),
533 +}
534 +
535 +/// Extract the function name and serialized arguments of one tool-call value.
536 +///
537 +/// String argument values are converted by the calling tool's parameter
538 +/// schema, as for the other tool parsers.
539 +fn call_parts(call: Value, tool_schemas: &ToolSchemas) -> Result<(String, String)> {
540 + let (ToolCallValue::Wrapped { function } | ToolCallValue::Bare(function)) =
541 + ToolCallValue::deserialize(&call).map_err(|_| {
542 + parsing_failed!(
543 + "tool call must be {{\"function\": {{\"name\": ..., \"arguments\": ...}}}} or \
544 + {{\"name\": ..., \"arguments\": ...}} with a string name, got {call}"
545 + )
546 + })?;
547 + let FunctionValue { name, arguments } = function;
548 + let arguments = match arguments {
549 + None => "{}".to_string(),
550 + Some(ArgumentsValue::Text(arguments)) => arguments,
551 + Some(ArgumentsValue::Object(mut arguments)) => {
552 + convert_text_arguments(tool_schemas, &name, &mut arguments);
553 + serialize_arguments(&arguments)?
554 + }
555 + Some(ArgumentsValue::Other(arguments)) => serialize_arguments(&arguments)?,
556 + };
557 + Ok((name, arguments))
558 +}
559 +
560 +/// Convert the argument values a content parser left as raw text by the tool
561 +/// schema: top-level strings, and string lists collected from duplicate keys
562 +/// (`merge_duplicates`).
563 +///
564 +/// Deeper values are the content parser's own JSON and are kept, as in
565 +/// Transformers' `_coerce_tool_calls`.
566 +fn convert_text_arguments(
567 + tool_schemas: &ToolSchemas,
568 + function_name: &str,
569 + arguments: &mut Map<String, Value>,
570 +) {
571 + for (name, value) in arguments.iter_mut() {
572 + let values = match value {
573 + Value::Array(items) => items.as_mut_slice(),
574 + value => std::slice::from_mut(value),
575 + };
576 + for value in values {
577 + if let Value::String(text) = value {
578 + *value = tool_schemas.convert_param_with_schema(function_name, name, take(text));
579 + }
580 + }
581 + }
582 +}
583 +
584 +/// Serialize tool-call arguments to JSON text.
585 +fn serialize_arguments(arguments: &impl serde::Serialize) -> Result<String> {
586 + serde_json::to_string(arguments).map_err(|error| {
587 + parsing_failed!("failed to serialize tool arguments: {}", error.as_report())
588 + })
589 +}
590 +
591 +/// Parse one event: safe text before the next marker, or a boundary at it.
592 +fn parse_next_event(
593 + input: &mut Partial<&str>,
594 + template: &ResponseTemplate,
595 + watch: &WatchSet,
596 + eof: bool,
597 +) -> ModalResult<HfEvent> {
598 + let markers: Vec<&str> = watch.markers.iter().map(String::as_str).collect();
599 + match safe_text_len_mul(input, &markers) {
600 + Ok(_) => return Ok(HfEvent::Text),
601 + // A marker, complete or partial, starts here (or the input is empty).
602 + Err(ErrMode::Incomplete(_)) => {}
603 + Err(error) => return Err(error),
604 + }
605 +
606 + // Any pending boundary blocks the position; otherwise the preferred match
607 + // wins. At the end of the stream, a partial marker matches nothing and is
608 + // released as text one character at a time.
609 + let text = **input;
610 + let mut best: Option<BoundaryMatch> = None;
611 + for &watch in &watch.boundaries {
612 + match template.boundary(watch).resolve(text, eof) {
613 + Resolution::Pending => return incomplete(),
614 + Resolution::NoMatch => {}
615 + Resolution::Matched { len, captures } => {
616 + let candidate = BoundaryMatch {
617 + watch,
618 + len,
619 + captures,
620 + };
621 + if best.as_ref().is_none_or(|best| candidate.key(template) < best.key(template)) {
622 + best = Some(candidate);
623 + }
624 + }
625 + }
626 + }
627 +
628 + match best {
629 + Some(BoundaryMatch {
630 + watch,
631 + len,
632 + captures,
633 + }) => {
634 + input.next_slice(len);
635 + Ok(HfEvent::Boundary { watch, captures })
636 + }
637 + None => {
638 + // Not a boundary: the marker's first character is plain text.
639 + let len = text.chars().next().map_or(0, char::len_utf8);
640 + input.next_slice(len);
641 + Ok(HfEvent::Text)
642 + }
643 + }
644 +}
645 +
646 +impl UnifiedParser for HfUnifiedParser {
647 + fn create(_tools: &[Tool], _tokenizer: DynTokenizer) -> Result<Box<dyn UnifiedParser>>
648 + where
649 + Self: Sized + 'static,
650 + {
651 + Err(UnifiedParserError::NoNamedConstructor {
652 + parser: "hf",
653 + built_from: "the checkpoint's response_template",
654 + })
655 + }
656 +
657 + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
658 + self.reset_state();
659 + if prompt_token_ids.is_empty() {
660 + return Ok(());
661 + }
662 + let Some(remainder) = self.prompt_remainder(prompt_token_ids)? else {
663 + self.template.warn_missing_anchor_once();
664 + return Ok(());
665 + };
666 + // A partial boundary at the end of the prompt stays buffered, so the
667 + // generated text can complete it.
668 + self.buffer = DecodedText::unattributed(remainder);
669 + self.drive(&mut UnifiedParserOutput::default(), Phase::Prefill)
670 + }
671 +
672 + fn preserve_special_tokens(&self) -> bool {
673 + // Templates spell boundaries with special tokens and expect decoding
674 + // with them kept.
675 + true
676 + }
677 +
678 + // TODO: derive a structural-tag output grammar from literal-bounded templates.
679 +
680 + fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()> {
681 + self.buffer.append(delta);
682 + self.drive(output, Phase::Stream)
683 + }
684 +
685 + fn finish(&mut self) -> Result<UnifiedParserOutput> {
686 + let mut output = UnifiedParserOutput::default();
687 + self.drive(&mut output, Phase::Eof)?;
688 + // Trailing zero-width tokens carry no text for the step loop to consume.
689 + let rest = self.buffer.take();
690 + self.route(rest, &mut output, Phase::Eof);
691 + self.close_current(&mut output, Phase::Eof)?;
692 + for (region, strip) in self.template.regions.iter().zip(&mut self.strip) {
693 + let held = take(&mut strip.held);
694 + if matches!(&region.kind, RegionKind::Text(text) if text.role == TextRole::Reasoning) {
695 + push_dropped_reasoning(&mut output, held);
696 + }
697 + }
698 +
699 + let missing: Vec<_> = self
700 + .template
701 + .regions
702 + .iter()
703 + .zip(&self.closed)
704 + .filter(|(region, closed)| !region.optional && !**closed)
705 + .map(|(region, _)| region.name.as_str())
706 + .collect();
707 + if !missing.is_empty() {
708 + return Err(parsing_failed!(
709 + "Required response_template fields missing from parsed output: {missing:?}"
710 + ));
711 + }
712 +
713 + self.reset_state();
714 + Ok(output)
715 + }
716 +
717 + fn reset(&mut self) -> String {
718 + let mut raw = String::new();
719 + if let HfMode::Implicit(occurrence) | HfMode::Explicit(occurrence) =
720 + std::mem::replace(&mut self.mode, HfMode::Discard)
721 + {
722 + raw.push_str(&occurrence.raw_open);
723 + raw.push_str(&occurrence.body);
724 + raw.push_str(&self.strip[occurrence.region].held.text);
725 + }
726 + raw.push_str(&self.buffer.take().text);
727 + self.reset_state();
728 + raw
729 + }
730 +}
rust/src/parser/src/unified/hf/pattern.rs new
+403
@@ -0,0 +1,403 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Streaming resolution of `open_pattern` / `close_pattern` boundaries.
5 +//!
6 +//! Transformers relies on the Python `regex` module's `partial=True` search to
7 +//! decide whether a match at the end of the buffer is final, could still appear,
8 +//! or is impossible. The Rust `regex` crate has no partial-match API, so this
9 +//! module answers that question by stepping a `regex-automata` DFA, and uses a
10 +//! `meta::Regex` for the actual match span and captures.
11 +
12 +use regex_automata::dfa::{Automaton, StartKind, dense};
13 +use regex_automata::meta::Regex;
14 +use regex_automata::nfa::thompson::{self, WhichCaptures};
15 +use regex_automata::util::primitives::StateID;
16 +use regex_automata::{Anchored, Input, MatchKind, PatternID};
17 +use regex_syntax::hir::literal::{ExtractKind, Extractor};
18 +use regex_syntax::hir::{Hir, HirKind};
19 +use serde_json::{Map, Value};
20 +use thiserror_ext::AsReport as _;
21 +
22 +use super::{Result, unsupported};
23 +
24 +/// Upper bound for the memory of one boundary DFA.
25 +const DFA_SIZE_LIMIT: usize = 16 << 20;
26 +
27 +/// Named capture groups of a boundary match, as string values: the scope a
28 +/// transform template reads them from.
29 +pub(super) type Captures = Map<String, Value>;
30 +
31 +/// Outcome of resolving a boundary at a candidate position.
32 +#[derive(Debug, Clone, PartialEq, Eq)]
33 +pub(super) enum Resolution {
34 + /// The boundary matches `len` bytes; more input cannot change the match.
35 + Matched { len: usize, captures: Captures },
36 + /// More input could still produce or change a match.
37 + Pending,
38 + /// No match starts here, whatever input follows.
39 + NoMatch,
40 +}
41 +
42 +/// A regex boundary that can be resolved against a growing buffer.
43 +#[derive(Debug, Clone)]
44 +pub(super) struct StreamingPattern {
45 + /// Finite set of non-empty literals, one of which starts every match.
46 + prefixes: Vec<String>,
47 + /// Anchored leftmost-first DFA of the pattern: decides whether the
48 + /// leftmost-first match is final. Unicode word boundaries are handled
49 + /// heuristically, quitting on non-ASCII bytes.
50 + first: dense::DFA<Vec<u32>>,
51 + /// Anchored DFA with every look-around assertion removed and
52 + /// `MatchKind::All`, used when `first` quits. Relaxing an assertion only
53 + /// enlarges the language, so once this DFA is dead no continuation can
54 + /// produce a later-ending match of the real pattern. `MatchKind::All` is
55 + /// required for that argument: leftmost-first pruning could discard the
56 + /// thread the real pattern takes when an assertion fails (`a\b|ab` on `ab`).
57 + fallback: Option<dense::DFA<Vec<u32>>>,
58 + /// The pattern itself, for the match span and named captures.
59 + exact: Regex,
60 +}
61 +
62 +/// Result of walking a DFA over the available bytes.
63 +enum Walk {
64 + /// No continuation can change the result.
65 + Final,
66 + /// More input could still change the result.
67 + Alive,
68 + /// The DFA gave up (non-ASCII byte near a Unicode word boundary).
69 + Quit,
70 +}
71 +
72 +impl StreamingPattern {
73 + /// Compile a boundary pattern in Python `regex` syntax, with `re.DOTALL`.
74 + ///
75 + /// Streaming resolution only starts at occurrences of the pattern's literal
76 + /// prefixes, so every match must start with one of a finite set of non-empty
77 + /// literals.
78 + pub fn new(scope: &str, pattern: &str) -> Result<Self> {
79 + let hir = regex_syntax::ParserBuilder::new()
80 + .dot_matches_new_line(true)
81 + .build()
82 + .parse(pattern)
83 + .map_err(|error| {
84 + unsupported!(
85 + "{scope}: regex {pattern:?} is not supported by the Rust regex engine: {}",
86 + error.as_report()
87 + )
88 + })?;
89 +
90 + let prefixes = literal_prefixes(&hir).ok_or_else(|| {
91 + unsupported!(
92 + "{scope}: pattern {pattern:?} must start with a literal prefix for streaming \
93 + matching (e.g. `<tool_call>` in `<tool_call>(?P<name>\\w+)`)"
94 + )
95 + })?;
96 +
97 + let first = build_dfa(scope, pattern, &hir, MatchKind::LeftmostFirst)?;
98 + let fallback = hir
99 + .properties()
100 + .look_set()
101 + .contains_word_unicode()
102 + .then(|| build_dfa(scope, pattern, &relax_assertions(&hir), MatchKind::All))
103 + .transpose()?;
104 + let exact = Regex::builder().build_from_hir(&hir).map_err(|error| {
105 + unsupported!(
106 + "{scope}: failed to compile regex {pattern:?}: {}",
107 + error.as_report()
108 + )
109 + })?;
110 +
111 + Ok(Self {
112 + prefixes,
113 + first,
114 + fallback,
115 + exact,
116 + })
117 + }
118 +
119 + /// Literals, one of which starts every match.
120 + pub fn prefixes(&self) -> &[String] {
121 + &self.prefixes
122 + }
123 +
124 + /// Named capture groups of the pattern.
125 + pub fn group_names(&self) -> impl Iterator<Item = &str> {
126 + self.exact.group_info().pattern_names(PatternID::ZERO).flatten()
127 + }
128 +
129 + /// Resolve a match starting at the beginning of `text`.
130 + ///
131 + /// `eof` means no more input will arrive.
132 + pub fn resolve(&self, text: &str, eof: bool) -> Resolution {
133 + let walk = match walk(&self.first, text) {
134 + Walk::Quit => walk(
135 + self.fallback.as_ref().expect("quits imply a fallback"),
136 + text,
137 + ),
138 + walk => walk,
139 + };
140 + if matches!(walk, Walk::Alive) && !eof {
141 + return Resolution::Pending;
142 + }
143 +
144 + let input = Input::new(text).anchored(Anchored::Yes);
145 + let mut captures = self.exact.create_captures();
146 + self.exact.search_captures(&input, &mut captures);
147 + let Some(found) = captures.get_match() else {
148 + return Resolution::NoMatch;
149 + };
150 + let names = self.exact.group_info().pattern_names(PatternID::ZERO);
151 + let captures = names
152 + .enumerate()
153 + .filter_map(|(index, name)| {
154 + let span = captures.get_group(index)?;
155 + Some((
156 + name?.to_string(),
157 + Value::String(text[span.range()].to_string()),
158 + ))
159 + })
160 + .collect();
161 + Resolution::Matched {
162 + len: found.end(),
163 + captures,
164 + }
165 + }
166 +}
167 +
168 +/// Walk `dfa` from an anchored start over `text`.
169 +///
170 +/// The walk is final once the DFA is dead, or once it is in a match state whose
171 +/// transitions are all dead: that removes the one byte of holdback that delayed
172 +/// DFA match reporting would otherwise add. A quit transition counts as live.
173 +fn walk(dfa: &dense::DFA<Vec<u32>>, text: &str) -> Walk {
174 + let input = Input::new(text).anchored(Anchored::Yes);
175 + let Ok(mut state) = dfa.start_state_forward(&input) else {
176 + return Walk::Quit;
177 + };
178 + for byte in text.bytes() {
179 + state = dfa.next_state(state, byte);
180 + if dfa.is_quit_state(state) {
181 + return Walk::Quit;
182 + }
183 + if dfa.is_dead_state(state) || is_terminal_match(dfa, state) {
184 + return Walk::Final;
185 + }
186 + }
187 + Walk::Alive
188 +}
189 +
190 +/// Return whether `state` reports a match and cannot continue.
191 +fn is_terminal_match(dfa: &dense::DFA<Vec<u32>>, state: StateID) -> bool {
192 + dfa.is_match_state(state)
193 + && (0..=u8::MAX).all(|byte| dfa.is_dead_state(dfa.next_state(state, byte)))
194 +}
195 +
196 +/// Build an anchored dense DFA for `hir`.
197 +fn build_dfa(
198 + scope: &str,
199 + pattern: &str,
200 + hir: &Hir,
201 + kind: MatchKind,
202 +) -> Result<dense::DFA<Vec<u32>>> {
203 + let nfa = thompson::Compiler::new()
204 + .configure(thompson::Config::new().which_captures(WhichCaptures::None))
205 + .build_from_hir(hir)
206 + .map_err(|error| {
207 + unsupported!(
208 + "{scope}: failed to compile regex {pattern:?}: {}",
209 + error.as_report()
210 + )
211 + })?;
212 + dense::Builder::new()
213 + .configure(
214 + dense::Config::new()
215 + .match_kind(kind)
216 + .start_kind(StartKind::Anchored)
217 + .unicode_word_boundary(true)
218 + .dfa_size_limit(Some(DFA_SIZE_LIMIT))
219 + .determinize_size_limit(Some(DFA_SIZE_LIMIT)),
220 + )
221 + .build_from_nfa(&nfa)
222 + .map_err(|error| {
223 + unsupported!(
224 + "{scope}: regex {pattern:?} cannot be matched incrementally: {}",
225 + error.as_report()
226 + )
227 + })
228 +}
229 +
230 +/// Replace every look-around assertion with the empty regex.
231 +fn relax_assertions(hir: &Hir) -> Hir {
232 + match hir.kind() {
233 + HirKind::Look(_) => Hir::empty(),
234 + HirKind::Repetition(repetition) => {
235 + let mut repetition = repetition.clone();
236 + repetition.sub = Box::new(relax_assertions(&repetition.sub));
237 + Hir::repetition(repetition)
238 + }
239 + HirKind::Capture(capture) => {
240 + let mut capture = capture.clone();
241 + capture.sub = Box::new(relax_assertions(&capture.sub));
242 + Hir::capture(capture)
243 + }
244 + HirKind::Concat(subs) => Hir::concat(subs.iter().map(relax_assertions).collect()),
245 + HirKind::Alternation(subs) => Hir::alternation(subs.iter().map(relax_assertions).collect()),
246 + HirKind::Empty | HirKind::Literal(_) | HirKind::Class(_) => hir.clone(),
247 + }
248 +}
249 +
250 +/// Extract the finite set of non-empty literal prefixes starting every match.
251 +fn literal_prefixes(hir: &Hir) -> Option<Vec<String>> {
252 + let seq = Extractor::new().kind(ExtractKind::Prefix).extract(hir);
253 + let mut prefixes: Vec<String> = Vec::new();
254 + for literal in seq.literals()? {
255 + let bytes = literal.as_bytes();
256 + // Truncated inexact literals may end inside a UTF-8 sequence; any valid
257 + // prefix of a prefix is still a prefix.
258 + let valid = match std::str::from_utf8(bytes) {
259 + Ok(text) => text,
260 + Err(error) => std::str::from_utf8(&bytes[..error.valid_up_to()]).ok()?,
261 + };
262 + if valid.is_empty() {
263 + return None;
264 + }
265 + if !prefixes.iter().any(|prefix| prefix == valid) {
266 + prefixes.push(valid.to_string());
267 + }
268 + }
269 + (!prefixes.is_empty()).then_some(prefixes)
270 +}
271 +
272 +#[cfg(test)]
273 +mod tests {
274 + use super::*;
275 +
276 + const MUSE_INVOKE: &str = r#"<atem:invoke\b[^>]*?\bname="(?P<name>[^"]+)">"#;
277 + const GEMMA4_CALL: &str = r"<\|tool_call>call:(?P<name>\w+)";
278 + const GPT_OSS_CALL: &str =
279 + r"<\|channel\|>commentary to=functions\.(?P<name>\w+).*?<\|message\|>";
280 +
281 + fn pattern(source: &str) -> StreamingPattern {
282 + StreamingPattern::new("test", source).unwrap()
283 + }
284 +
285 + fn matched(len: usize, name: &str) -> Resolution {
286 + Resolution::Matched {
287 + len,
288 + captures: Captures::from_iter([("name".to_string(), name.into())]),
289 + }
290 + }
291 +
292 + #[test]
293 + fn extracts_literal_prefixes() {
294 + assert_eq!(pattern(GEMMA4_CALL).prefixes(), ["<|tool_call>call:"]);
295 + assert_eq!(pattern(MUSE_INVOKE).prefixes(), ["<atem:invoke"]);
296 + assert_eq!(
297 + pattern(r"to=(?:user|commentary)<\|message\|>").prefixes(),
298 + ["to=user<|message|>", "to=commentary<|message|>"]
299 + );
300 + assert_eq!(
301 + pattern(r"\n?</response>").prefixes(),
302 + ["\n</response>", "</response>"]
303 + );
304 + for source in [r"(?:^|<think>\s*)", r"(?:<\|m\|>)?[^<]*<\|c\|>", r".*x"] {
305 + let result = StreamingPattern::new("test", source);
306 + assert!(
307 + matches!(
308 + result,
309 + Err(super::super::HfTemplateError::Unsupported { .. })
310 + ),
311 + "{source}"
312 + );
313 + }
314 + }
315 +
316 + #[test]
317 + fn word_boundaries_follow_unicode_semantics() {
318 + let muse = pattern(MUSE_INVOKE);
319 + assert_eq!(
320 + muse.resolve(r#"<atem:invoke name="get">rest"#, false),
321 + matched(24, "get")
322 + );
323 + let unicode_attr = r#"<atem:invoke title="天气" name="获取">rest"#;
324 + assert_eq!(
325 + muse.resolve(unicode_attr, false),
326 + matched(unicode_attr.len() - 4, "获取")
327 + );
328 + assert_eq!(
329 + muse.resolve(r#"<atem:invoke name="get"#, false),
330 + Resolution::Pending
331 + );
332 + assert_eq!(
333 + muse.resolve(r#"<atem:invoker name="x">r"#, false),
334 + Resolution::NoMatch
335 + );
336 + assert_eq!(
337 + muse.resolve(r#"<atem:invoke xname="x">r"#, false),
338 + Resolution::NoMatch
339 + );
340 + assert_eq!(
341 + muse.resolve(r#"<atem:invoke é name="x">r"#, false),
342 + matched(25, "x")
343 + );
344 + assert_eq!(
345 + muse.resolve(r#"<atem:invoke éname="x">r"#, false),
346 + Resolution::NoMatch
347 + );
348 + assert_eq!(
349 + muse.resolve("<atem:invoke 使用标签", false),
350 + Resolution::Pending
351 + );
352 + assert_eq!(
353 + muse.resolve("<atem:invoke 使用标签", true),
354 + Resolution::NoMatch
355 + );
356 + assert_eq!(
357 + muse.resolve("<atem:invoke 使用> 正文", false),
358 + Resolution::NoMatch
359 + );
360 +
361 + let alternation = pattern(r"a\b|ab");
362 + assert_eq!(
363 + alternation.resolve("ab!", true),
364 + Resolution::Matched {
365 + len: 2,
366 + captures: Captures::new()
367 + }
368 + );
369 + }
370 +
371 + #[test]
372 + fn greedy_and_lazy_tails_commit_like_transformers() {
373 + let gemma4 = pattern(GEMMA4_CALL);
374 + assert_eq!(
375 + gemma4.resolve("<|tool_call>call:get_wea", false),
376 + Resolution::Pending
377 + );
378 + assert_eq!(
379 + gemma4.resolve("<|tool_call>call:get_weather{", false),
380 + matched(28, "get_weather")
381 + );
382 + assert_eq!(
383 + gemma4.resolve("<|tool_call>call:天气{", false),
384 + matched(23, "天气")
385 + );
386 + assert_eq!(
387 + gemma4.resolve("<|tool_call>call:{", false),
388 + Resolution::NoMatch
389 + );
390 + assert_eq!(
391 + gemma4.resolve("<|tool_call>call:get", true),
392 + matched(20, "get")
393 + );
394 +
395 + let gpt_oss = pattern(GPT_OSS_CALL);
396 + let opener = "<|channel|>commentary to=functions.get_weather <|constrain|>json<|message|>";
397 + assert_eq!(gpt_oss.resolve(opener, false), Resolution::Pending);
398 + assert_eq!(
399 + gpt_oss.resolve(&format!("{opener}{{\"a\""), false),
400 + matched(opener.len(), "get_weather")
401 + );
402 + }
403 +}
rust/src/parser/src/unified/hf/spec.rs new
+202
@@ -0,0 +1,202 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Serde mirror of the `response_template` JSON.
5 +//!
6 +//! Key sets and defaults follow `response_templates.py`; semantic validation
7 +//! happens when compiling into [`super::ResponseTemplate`].
8 +
9 +use serde::Deserialize;
10 +use serde::de::DeserializeOwned;
11 +use serde_json::{Map, Value};
12 +use serde_with::serde_as;
13 +use thiserror_ext::AsReport as _;
14 +
15 +use super::{Result, invalid};
16 +
17 +/// Top-level `response_template` object.
18 +#[serde_as]
19 +#[derive(Debug, Clone, Deserialize)]
20 +#[serde(deny_unknown_fields)]
21 +pub(super) struct TemplateSpec {
22 + #[serde(default = "default_version")]
23 + version: u64,
24 + /// Constant keys of the Transformers output dict (e.g. `role`). Unused by the
25 + /// event-based executor, but validated as part of the schema.
26 + #[serde(default, rename = "defaults")]
27 + _defaults: Map<String, Value>,
28 + /// Field specs in declaration order.
29 + #[serde_as(as = "serde_with::Map<_, _>")]
30 + pub fields: Vec<(String, FieldSpec)>,
31 + start_anchor: Option<Literals>,
32 + start_anchor_pattern: Option<String>,
33 +}
34 +
35 +/// One entry of `fields`.
36 +#[derive(Debug, Clone, Deserialize)]
37 +#[serde(deny_unknown_fields)]
38 +pub(super) struct FieldSpec {
39 + open: Option<Literals>,
40 + open_pattern: Option<String>,
41 + close: Option<Literals>,
42 + close_pattern: Option<String>,
43 + #[serde(default)]
44 + pub content: ContentKind,
45 + /// Arguments of the `content` parser; their keys depend on the parser.
46 + #[serde(default)]
47 + pub content_args: Map<String, Value>,
48 + #[serde(default)]
49 + pub repeats: bool,
50 + pub join: Option<String>,
51 + #[serde(default = "default_optional")]
52 + pub optional: bool,
53 + /// A free-form JSON template, compiled into a [`super::transform::Transform`].
54 + pub transform: Option<Value>,
55 + #[serde(default)]
56 + pub transform_each: bool,
57 +}
58 +
59 +/// Name of a content parser.
60 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
61 +#[serde(rename_all = "kebab-case")]
62 +pub(super) enum ContentKind {
63 + #[default]
64 + Text,
65 + Int,
66 + Float,
67 + Bool,
68 + Json,
69 + XmlInline,
70 + KvLines,
71 +}
72 +
73 +/// A literal anchor: one string, or a list of alternatives.
74 +#[derive(Debug, Clone, Deserialize)]
75 +#[serde(untagged)]
76 +enum Literals {
77 + One(String),
78 + Many(Vec<String>),
79 +}
80 +
81 +/// An anchor after resolving its literal-or-pattern form.
82 +#[derive(Debug, Clone, PartialEq, Eq)]
83 +pub(super) enum AnchorSpec<'a> {
84 + /// Deduplicated literals in first-seen order.
85 + Literals(Vec<String>),
86 + /// A regex in Python `regex` syntax.
87 + Pattern(&'a str),
88 +}
89 +
90 +impl TemplateSpec {
91 + /// Parse the top-level template object.
92 + pub fn from_value(value: &Value) -> Result<Self> {
93 + let spec: Self = deserialize(value, "response_template")?;
94 + if spec.version != 1 {
95 + return Err(invalid!(
96 + "unsupported response_template version: {}",
97 + spec.version
98 + ));
99 + }
100 + if spec.fields.is_empty() {
101 + return Err(invalid!(
102 + "response_template.fields must be a non-empty dict"
103 + ));
104 + }
105 + Ok(spec)
106 + }
107 +
108 + /// The required start anchor.
109 + pub fn start_anchor(&self) -> Result<AnchorSpec<'_>> {
110 + anchor_spec(
111 + "response_template",
112 + (self.start_anchor.as_ref(), "start_anchor"),
113 + (self.start_anchor_pattern.as_deref(), "start_anchor_pattern"),
114 + )?
115 + .ok_or_else(|| {
116 + invalid!("response_template must define 'start_anchor' or 'start_anchor_pattern'.")
117 + })
118 + }
119 +}
120 +
121 +impl FieldSpec {
122 + /// The open anchor; `None` for the implicit field.
123 + pub fn open(&self, scope: &str) -> Result<Option<AnchorSpec<'_>>> {
124 + anchor_spec(
125 + scope,
126 + (self.open.as_ref(), "open"),
127 + (self.open_pattern.as_deref(), "open_pattern"),
128 + )
129 + }
130 +
131 + /// The close anchor; `None` when the field runs to the end of the stream.
132 + pub fn close(&self, scope: &str) -> Result<Option<AnchorSpec<'_>>> {
133 + anchor_spec(
134 + scope,
135 + (self.close.as_ref(), "close"),
136 + (self.close_pattern.as_deref(), "close_pattern"),
137 + )
138 + }
139 +}
140 +
141 +/// Deserialize `value`, reporting `context` and the JSON path of any error.
142 +pub(super) fn deserialize<T: DeserializeOwned>(value: &Value, context: &str) -> Result<T> {
143 + serde_path_to_error::deserialize(value).map_err(|error| {
144 + let path = error.path().to_string();
145 + let error = error.into_inner();
146 + if path == "." {
147 + invalid!("{context}: {}", error.as_report())
148 + } else {
149 + invalid!("{context}.{path}: {}", error.as_report())
150 + }
151 + })
152 +}
153 +
154 +/// Resolve an anchor given in literal form or regex form.
155 +///
156 +/// Mirrors `_compile_anchor`: both forms at once is an error, literal lists must be
157 +/// non-empty and contain no empty strings, and duplicates are dropped in first-seen
158 +/// order.
159 +fn anchor_spec<'a>(
160 + scope: &str,
161 + (literals, literal_key): (Option<&Literals>, &str),
162 + (pattern, pattern_key): (Option<&'a str>, &str),
163 +) -> Result<Option<AnchorSpec<'a>>> {
164 + match (literals, pattern) {
165 + (Some(_), Some(_)) => Err(invalid!(
166 + "{scope}: cannot specify both '{literal_key}' and '{pattern_key}'"
167 + )),
168 + (Some(literals), None) => {
169 + let raw = match literals {
170 + Literals::One(literal) => std::slice::from_ref(literal),
171 + Literals::Many(literals) => literals.as_slice(),
172 + };
173 + if raw.is_empty() {
174 + return Err(invalid!(
175 + "{scope}: '{literal_key}' list must contain at least one literal"
176 + ));
177 + }
178 + if raw.iter().any(String::is_empty) {
179 + return Err(invalid!(
180 + "{scope}: '{literal_key}' literals cannot be empty strings"
181 + ));
182 + }
183 + let mut deduped: Vec<String> = Vec::with_capacity(raw.len());
184 + for literal in raw {
185 + if !deduped.contains(literal) {
186 + deduped.push(literal.clone());
187 + }
188 + }
189 + Ok(Some(AnchorSpec::Literals(deduped)))
190 + }
191 + (None, Some(pattern)) => Ok(Some(AnchorSpec::Pattern(pattern))),
192 + (None, None) => Ok(None),
193 + }
194 +}
195 +
196 +fn default_version() -> u64 {
197 + 1
198 +}
199 +
200 +fn default_optional() -> bool {
201 + true
202 +}
rust/src/parser/src/unified/hf/template.rs new
+481
@@ -0,0 +1,481 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Compiled `response_template`.
5 +//!
6 +//! Validation follows `response_templates.py`; the compiled form additionally
7 +//! maps fields to parser roles and precomputes each state's boundary markers.
8 +
9 +use std::fmt;
10 +use std::sync::atomic::{AtomicBool, Ordering};
11 +
12 +use regex_automata::meta::Regex;
13 +use serde_json::Value;
14 +
15 +use super::content::{ContentParser, TextArgs, compile_regex};
16 +use super::pattern::{Captures, Resolution, StreamingPattern};
17 +use super::spec::{AnchorSpec, FieldSpec, TemplateSpec};
18 +use super::transform::{FieldTransform, Transform};
19 +use super::{Result, invalid, unsupported};
20 +
21 +/// A compiled `response_template`, shared by all requests of one model.
22 +#[derive(Debug)]
23 +pub struct ResponseTemplate {
24 + /// Matches the start of the current assistant turn in the prompt.
25 + start_anchor: Regex,
26 + pub(super) regions: Vec<Region>,
27 + /// The field without an opener: the implicit-open / leftover sink.
28 + pub(super) implicit: Option<usize>,
29 + /// Boundaries watched outside explicit regions: every explicit open plus the
30 + /// implicit region's close.
31 + pub(super) idle_watch: WatchSet,
32 + warned_missing_anchor: AtomicBool,
33 +}
34 +
35 +/// One compiled field.
36 +#[derive(Debug)]
37 +pub(super) struct Region {
38 + pub name: FieldName,
39 + /// `None`: the implicit region.
40 + pub open: Option<Boundary>,
41 + /// `None`: the region runs to the end of the stream.
42 + pub close: Option<Boundary>,
43 + /// Boundaries watched inside this region: its close, if any.
44 + pub close_watch: WatchSet,
45 + pub optional: bool,
46 + pub kind: RegionKind,
47 +}
48 +
49 +/// A field this parser can report, in the lexical order of its name, which
50 +/// Transformers uses to break ties between boundaries.
51 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
52 +pub(super) enum FieldName {
53 + Content,
54 + ReasoningContent,
55 + Thinking,
56 + ToolCalls,
57 +}
58 +
59 +/// How a region's content is reported.
60 +#[derive(Debug)]
61 +pub(super) enum RegionKind {
62 + /// Streamed as it arrives.
63 + Text(TextRegion),
64 + /// Buffered, and parsed into tool calls at close.
65 + ToolCalls(ToolCallRegion),
66 +}
67 +
68 +#[derive(Debug)]
69 +pub(super) struct TextRegion {
70 + pub role: TextRole,
71 + /// Whether the value is stripped (`content_args.strip`).
72 + pub strip: bool,
73 + pub repeat: Repeat,
74 +}
75 +
76 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77 +pub(super) enum TextRole {
78 + Content,
79 + Reasoning,
80 +}
81 +
82 +/// How repeated occurrences of a text field combine.
83 +#[derive(Debug, Clone, PartialEq, Eq)]
84 +pub(super) enum Repeat {
85 + /// No `repeats`: occurrences stream as one value.
86 + Once,
87 + /// `repeats` without `join`: each occurrence is its own value.
88 + Each,
89 + /// `repeats` with `join`: occurrences are joined by the separator.
90 + Join(String),
91 +}
92 +
93 +#[derive(Debug)]
94 +pub(super) struct ToolCallRegion {
95 + pub content: ContentParser,
96 + pub transform: Option<FieldTransform>,
97 + /// Opener capture group that provides the function name, allowing the tool
98 + /// call to start before its arguments are complete.
99 + pub name_group: Option<String>,
100 +}
101 +
102 +/// A region delimiter.
103 +#[derive(Debug)]
104 +pub(super) enum Boundary {
105 + /// Literal alternatives, longest first.
106 + Literals(Vec<String>),
107 + Pattern(Box<StreamingPattern>),
108 +}
109 +
110 +/// One boundary the executor watches for in a given state.
111 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112 +pub(super) struct Watch {
113 + pub region: usize,
114 + pub kind: WatchKind,
115 +}
116 +
117 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118 +pub(super) enum WatchKind {
119 + Open,
120 + Close,
121 +}
122 +
123 +/// The boundaries watched in one parser mode.
124 +#[derive(Debug, Default)]
125 +pub(super) struct WatchSet {
126 + pub boundaries: Vec<Watch>,
127 + /// Literals, deduplicated, whose occurrences are the only positions any of
128 + /// the boundaries can match at.
129 + pub markers: Vec<String>,
130 +}
131 +
132 +impl ResponseTemplate {
133 + /// Compile the `response_template` object from `tokenizer_config.json`.
134 + pub fn from_json(value: &Value) -> Result<Self> {
135 + let spec = TemplateSpec::from_value(value)?;
136 +
137 + let regions = spec
138 + .fields
139 + .iter()
140 + .enumerate()
141 + .map(|(index, (name, field))| Region::compile(index, name, field))
142 + .collect::<Result<Vec<_>>>()?;
143 +
144 + // A field without an open anchor is the implicit-open / leftover sink; at most one is allowed.
145 + let implicit_fields: Vec<_> =
146 + regions.iter().enumerate().filter(|(_, region)| region.open.is_none()).collect();
147 + if implicit_fields.len() > 1 {
148 + let names: Vec<_> =
149 + implicit_fields.iter().map(|(_, region)| region.name.as_str()).collect();
150 + return Err(invalid!(
151 + "At most one field may omit 'open'/'open_pattern' (that field becomes the \
152 + implicit-open / leftover sink). Found: {}",
153 + names.join(", ")
154 + ));
155 + }
156 + let implicit = implicit_fields.first().map(|(index, _)| *index);
157 +
158 + let start_anchor = match spec.start_anchor()? {
159 + AnchorSpec::Literals(mut literals) => {
160 + // Sort longest-first so alternation prefers the longer alternative when both could match.
161 + literals.sort_by_key(|literal| std::cmp::Reverse(literal.len()));
162 + let alternation: Vec<_> =
163 + literals.iter().map(|literal| regex_syntax::escape(literal)).collect();
164 + compile_regex("response_template", &alternation.join("|"))?
165 + }
166 + AnchorSpec::Pattern(pattern) => compile_regex("response_template", pattern)?,
167 + };
168 +
169 + let opens = regions.iter().enumerate().filter_map(|(region, spec)| {
170 + let watch = Watch {
171 + region,
172 + kind: WatchKind::Open,
173 + };
174 + Some((watch, spec.open.as_ref()?))
175 + });
176 + let implicit_close = implicit.and_then(|region| {
177 + let watch = Watch {
178 + region,
179 + kind: WatchKind::Close,
180 + };
181 + Some((watch, regions[region].close.as_ref()?))
182 + });
183 + let idle_watch = WatchSet::new(opens.chain(implicit_close));
184 +
185 + Ok(Self {
186 + start_anchor,
187 + regions,
188 + implicit,
189 + idle_watch,
190 + warned_missing_anchor: AtomicBool::new(false),
191 + })
192 + }
193 +
194 + /// Return the start and end offsets of the last start-anchor match in
195 + /// `prompt`.
196 + pub(super) fn last_start_anchor(&self, prompt: &str) -> Option<(usize, usize)> {
197 + self.start_anchor
198 + .find_iter(prompt)
199 + .last()
200 + .map(|found| (found.start(), found.end()))
201 + }
202 +
203 + /// Log once per template that the prompt carried no start anchor.
204 + pub(super) fn warn_missing_anchor_once(&self) {
205 + if !self.warned_missing_anchor.swap(true, Ordering::Relaxed) {
206 + tracing::warn!(
207 + "response_template start anchor not found in the prompt; parsing starts from the \
208 + template's initial state (the chat template may not match the checkpoint's \
209 + response_template)"
210 + );
211 + }
212 + }
213 +
214 + /// The boundary of a watch entry.
215 + pub(super) fn boundary(&self, watch: Watch) -> &Boundary {
216 + let region = &self.regions[watch.region];
217 + match watch.kind {
218 + WatchKind::Open => region.open.as_ref(),
219 + WatchKind::Close => region.close.as_ref(),
220 + }
221 + .expect("watched boundaries exist")
222 + }
223 +}
224 +
225 +impl Region {
226 + /// Validate the spec of the field at `index` and compile it.
227 + fn compile(index: usize, name: &str, field: &FieldSpec) -> Result<Self> {
228 + let scope = format!("Field '{name}'");
229 + let open = field.open(&scope)?.map(|spec| Boundary::compile(&scope, spec)).transpose()?;
230 + let close = field.close(&scope)?.map(|spec| Boundary::compile(&scope, spec)).transpose()?;
231 +
232 + let content = ContentParser::new(&scope, field.content, &field.content_args)?;
233 + let repeat = match (field.repeats, &field.join) {
234 + (false, None) => Repeat::Once,
235 + (true, None) => Repeat::Each,
236 + (true, Some(join)) => Repeat::Join(join.clone()),
237 + (false, Some(_)) => return Err(invalid!("{scope}: 'join' requires 'repeats': true")),
238 + };
239 + let transform = match (&field.transform, field.transform_each) {
240 + (Some(template), each) => Some(FieldTransform {
241 + template: Transform::compile(&scope, template)?,
242 + each,
243 + }),
244 + (None, false) => None,
245 + (None, true) => {
246 + return Err(invalid!(
247 + "{scope}: transform_each is set but no transform was provided"
248 + ));
249 + }
250 + };
251 +
252 + let captured: Vec<&str> =
253 + [&open, &close].into_iter().flatten().flat_map(Boundary::group_names).collect();
254 + match &transform {
255 + None if !captured.is_empty() => {
256 + // Named captures only reach the output through a transform, so flag any that would be silently dropped.
257 + return Err(invalid!(
258 + "{scope}: open_pattern/close_pattern declares named group(s) {captured:?}, but \
259 + the field has no 'transform'. Named captures are only surfaced through a \
260 + 'transform' template (where they appear alongside 'content'). Either add a \
261 + 'transform' that uses the captures, or remove the named groups from the pattern."
262 + ));
263 + }
264 + // Without `transform_each`, the scope is the opener's captures plus `content`;
265 + // any other placeholder fails on every match.
266 + Some(FieldTransform {
267 + template,
268 + each: false,
269 + }) => {
270 + let open_groups: Vec<&str> = open.iter().flat_map(Boundary::group_names).collect();
271 + if let Some(root) = template
272 + .placeholder_roots()
273 + .find(|root| *root != "content" && !open_groups.contains(root))
274 + {
275 + return Err(invalid!(
276 + "{scope}: transform placeholder '{{{root}}}' is neither 'content' nor a \
277 + named group of open_pattern"
278 + ));
279 + }
280 + }
281 + _ => {}
282 + }
283 +
284 + let name = FieldName::parse(&scope, name)?;
285 + let kind = match name.text_role() {
286 + Some(role) => {
287 + let strip = match (content, &transform) {
288 + (ContentParser::Text(TextArgs { strip }), None) => strip,
289 + _ => {
290 + return Err(unsupported!(
291 + "{scope}: text and reasoning fields must use the 'text' content \
292 + parser without a transform"
293 + ));
294 + }
295 + };
296 + RegionKind::Text(TextRegion {
297 + role,
298 + strip,
299 + repeat,
300 + })
301 + }
302 + None => {
303 + if matches!(repeat, Repeat::Join(_)) {
304 + return Err(unsupported!(
305 + "{scope}: 'join' requires each match to parse to a string"
306 + ));
307 + }
308 + let name_group = name_group(transform.as_ref(), open.as_ref());
309 + RegionKind::ToolCalls(ToolCallRegion {
310 + content,
311 + transform,
312 + name_group,
313 + })
314 + }
315 + };
316 +
317 + let close_watch = WatchSet::new(close.as_ref().map(|close| {
318 + let watch = Watch {
319 + region: index,
320 + kind: WatchKind::Close,
321 + };
322 + (watch, close)
323 + }));
324 +
325 + Ok(Self {
326 + name,
327 + open,
328 + close,
329 + close_watch,
330 + optional: field.optional,
331 + kind,
332 + })
333 + }
334 +}
335 +
336 +/// The opener capture group that a non-`transform_each` transform uses as the
337 +/// function name, if any.
338 +fn name_group(transform: Option<&FieldTransform>, open: Option<&Boundary>) -> Option<String> {
339 + let (
340 + FieldTransform {
341 + template,
342 + each: false,
343 + },
344 + open,
345 + ) = (transform?, open?)
346 + else {
347 + return None;
348 + };
349 + let path = template
350 + .placeholder_at(&["function", "name"])
351 + .or_else(|| template.placeholder_at(&["name"]))?;
352 + match path {
353 + [root] if open.group_names().any(|group| group == root) => Some(root.clone()),
354 + _ => None,
355 + }
356 +}
357 +
358 +impl FieldName {
359 + /// Map a field name to a reportable field.
360 + fn parse(scope: &str, name: &str) -> Result<Self> {
361 + Ok(match name {
362 + "content" => Self::Content,
363 + "reasoning_content" => Self::ReasoningContent,
364 + "thinking" => Self::Thinking,
365 + "tool_calls" => Self::ToolCalls,
366 + _ => {
367 + return Err(unsupported!(
368 + "{scope}: only 'content', 'reasoning_content'/'thinking', and 'tool_calls' \
369 + fields can be reported"
370 + ));
371 + }
372 + })
373 + }
374 +
375 + /// The role of a text field; `None` for `tool_calls`.
376 + fn text_role(self) -> Option<TextRole> {
377 + match self {
378 + Self::Content => Some(TextRole::Content),
379 + Self::ReasoningContent | Self::Thinking => Some(TextRole::Reasoning),
380 + Self::ToolCalls => None,
381 + }
382 + }
383 +
384 + pub fn as_str(self) -> &'static str {
385 + match self {
386 + Self::Content => "content",
387 + Self::ReasoningContent => "reasoning_content",
388 + Self::Thinking => "thinking",
389 + Self::ToolCalls => "tool_calls",
390 + }
391 + }
392 +}
393 +
394 +impl fmt::Display for FieldName {
395 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
396 + f.write_str(self.as_str())
397 + }
398 +}
399 +
400 +impl Boundary {
401 + /// Compile an anchor into a boundary.
402 + fn compile(scope: &str, spec: AnchorSpec<'_>) -> Result<Self> {
403 + Ok(match spec {
404 + AnchorSpec::Literals(mut literals) => {
405 + // Sort longest-first so the longer alternative wins when both match.
406 + literals.sort_by_key(|literal| std::cmp::Reverse(literal.len()));
407 + Self::Literals(literals)
408 + }
409 + AnchorSpec::Pattern(pattern) => {
410 + Self::Pattern(Box::new(StreamingPattern::new(scope, pattern)?))
411 + }
412 + })
413 + }
414 +
415 + /// Literals whose occurrences are the only positions this boundary can match at.
416 + pub fn markers(&self) -> &[String] {
417 + match self {
418 + Self::Literals(literals) => literals,
419 + Self::Pattern(pattern) => pattern.prefixes(),
420 + }
421 + }
422 +
423 + /// Named capture groups.
424 + pub fn group_names(&self) -> impl Iterator<Item = &str> {
425 + match self {
426 + Self::Literals(_) => None,
427 + Self::Pattern(pattern) => Some(pattern.group_names()),
428 + }
429 + .into_iter()
430 + .flatten()
431 + }
432 +
433 + /// Resolve this boundary at the start of `text`.
434 + pub fn resolve(&self, text: &str, eof: bool) -> Resolution {
435 + let markers = self.markers();
436 + // A marker the available text is a proper prefix of: more input decides.
437 + let partial = !eof
438 + && markers
439 + .iter()
440 + .any(|marker| marker.len() > text.len() && marker.starts_with(text));
441 + match self {
442 + Self::Literals(literals) => {
443 + if partial {
444 + return Resolution::Pending;
445 + }
446 + match literals.iter().find(|literal| text.starts_with(literal.as_str())) {
447 + Some(literal) => Resolution::Matched {
448 + len: literal.len(),
449 + captures: Captures::new(),
450 + },
451 + None => Resolution::NoMatch,
452 + }
453 + }
454 + Self::Pattern(pattern) => {
455 + if markers.iter().any(|marker| text.starts_with(marker.as_str())) {
456 + pattern.resolve(text, eof)
457 + } else if partial {
458 + Resolution::Pending
459 + } else {
460 + Resolution::NoMatch
461 + }
462 + }
463 + }
464 + }
465 +}
466 +
467 +impl WatchSet {
468 + /// Collect the watched boundaries and their deduplicated markers.
469 + fn new<'a>(entries: impl IntoIterator<Item = (Watch, &'a Boundary)>) -> Self {
470 + let mut set = Self::default();
471 + for (watch, boundary) in entries {
472 + set.boundaries.push(watch);
473 + for marker in boundary.markers() {
474 + if !set.markers.contains(marker) {
475 + set.markers.push(marker.clone());
476 + }
477 + }
478 + }
479 + set
480 + }
481 +}
rust/src/parser/src/unified/hf/tests.rs new
+933
@@ -0,0 +1,933 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Executor tests.
5 +//!
6 +//! Template fixtures and expected values are ported from Transformers
7 +//! `tests/utils/test_chat_parsing.py` at 6d43ab4008, with the expected output
8 +//! dict re-expressed as aggregated unified parser events (`thinking` becomes
9 +//! `reasoning`).
10 +
11 +use std::collections::BTreeMap;
12 +use std::sync::Arc;
13 +
14 +use serde_json::{Value, json};
15 +use vllm_tokenizer::test_utils::TestTokenizer;
16 +use vllm_tokenizer::{DecodedText, TokenAnchor, TokenAttribution, Tokenizer as _};
17 +
18 +use super::{HfTemplateError, HfUnifiedParser, ResponseTemplate};
19 +use crate::tool::Tool;
20 +use crate::tool::test_utils::split_by_chars;
21 +use crate::unified::{UnifiedParser, UnifiedParserError, UnifiedParserEvent, UnifiedParserOutput};
22 +
23 +fn cohere_template() -> Value {
24 + json!({
25 + "defaults": {"role": "assistant"},
26 + "start_anchor": "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>",
27 + "fields": {
28 + "content": {"open": "<|START_RESPONSE|>", "close": "<|END_RESPONSE|>", "content": "text"},
29 + "thinking": {"open": "<|START_THINKING|>", "close": "<|END_THINKING|>", "content": "text"},
30 + "tool_calls": {
31 + "open": "<|START_ACTION|>",
32 + "close": "<|END_ACTION|>",
33 + "content": "json",
34 + "transform_each": true,
35 + "transform": {"type": "function", "function": {"name": "{tool_name}", "arguments": "{parameters}"}},
36 + },
37 + },
38 + })
39 +}
40 +
41 +fn gpt_oss_template() -> Value {
42 + json!({
43 + "defaults": {"role": "assistant"},
44 + "start_anchor": "<|start|>assistant",
45 + "fields": {
46 + "thinking": {"open": "<|channel|>analysis<|message|>", "close": "<|end|>", "content": "text"},
47 + "content": {"open": "<|channel|>final<|message|>", "close": "<|end|>", "content": "text"},
48 + "tool_calls": {
49 + "open_pattern": r"<\|channel\|>commentary to=functions\.(?P<name>\w+).*?<\|message\|>",
50 + "close": "<|call|>",
51 + "repeats": true,
52 + "content": "json",
53 + "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
54 + },
55 + },
56 + })
57 +}
58 +
59 +fn smollm_template() -> Value {
60 + json!({
61 + "defaults": {"role": "assistant"},
62 + "start_anchor": "<|im_start|>assistant\n",
63 + "fields": {
64 + "thinking": {"open": "<think>", "close": "</think>", "content": "text"},
65 + "tool_calls": {
66 + "open": "<tool_call>",
67 + "close": "</tool_call>",
68 + "repeats": true,
69 + "content": "json",
70 + "transform": {"type": "function", "function": "{content}"},
71 + },
72 + "content": {"close": "<|im_end|>", "content": "text"},
73 + },
74 + })
75 +}
76 +
77 +fn qwen3_template() -> Value {
78 + json!({
79 + "defaults": {"role": "assistant"},
80 + "start_anchor": "<|im_start|>assistant\n",
81 + "fields": {
82 + "thinking": {"open": "<think>", "close": "</think>", "content": "text"},
83 + "tool_calls": {
84 + "open_pattern": r"<tool_call>\s*<function=(?P<name>\w+)>",
85 + "close": "</tool_call>",
86 + "repeats": true,
87 + "content": "xml-inline",
88 + "content_args": {
89 + "tag_pattern": r"<parameter=(?P<key>\w+)>\s*(?P<value>.*?)\s*</parameter>",
90 + "value_parser": {"name": "json", "args": {"allow_non_json": true}},
91 + },
92 + "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
93 + },
94 + },
95 + })
96 +}
97 +
98 +/// Identical to the template shipped in the Gemma 4 IT checkpoints.
99 +fn gemma4_template() -> Value {
100 + json!({
101 + "defaults": {"role": "assistant"},
102 + "start_anchor": ["<|turn>model\n", "<tool_response|>"],
103 + "fields": {
104 + "thinking": {"open": "<|channel>thought\n", "close": "<channel|>", "content": "text"},
105 + "tool_calls": {
106 + "open_pattern": r"<\|tool_call>call:(?P<name>\w+)",
107 + "close": "<tool_call|>",
108 + "repeats": true,
109 + "content": "json",
110 + "content_args": {"unquoted_keys": true, "string_delims": [["<|\"|>", "<|\"|>"]]},
111 + "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
112 + },
113 + "content": {"close": ["<turn|>", "<|tool_response>", "<eos>"], "content": "text"},
114 + },
115 + })
116 +}
117 +
118 +/// A routed-message protocol with XML-style invocations: no implicit field
119 +/// (text outside regions is discarded), Unicode word boundaries and lazy
120 +/// quantifiers in the opener, `xml-inline` arguments, and joined content.
121 +fn invoke_template() -> Value {
122 + json!({
123 + "defaults": {"role": "assistant"},
124 + "start_anchor": "<|begin|>assistant",
125 + "fields": {
126 + "reasoning_content": {"open_pattern": r"to=self<\|msg\|>", "close": "<|pause|>", "content": "text"},
127 + "tool_calls": {
128 + "open_pattern": r#"<invoke\b[^>]*?\bname="(?P<name>[^"]+)">"#,
129 + "close": "</invoke>",
130 + "repeats": true,
131 + "content": "xml-inline",
132 + "content_args": {
133 + "tag_pattern": r#"<param\b[^>]*?\bname="(?P<key>[^"]+)"[^>]*?>(?P<value>.*?)</param>"#,
134 + "value_parser": {"name": "json", "args": {"allow_non_json": true}},
135 + },
136 + "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
137 + },
138 + "content": {
139 + "open_pattern": r"to=(?:user|note)<\|msg\|>",
140 + "close": ["<|end|>", "<|pause|>"],
141 + "repeats": true,
142 + "join": "",
143 + "content": "text",
144 + },
145 + },
146 + })
147 +}
148 +
149 +fn compile(template: Value) -> Arc<ResponseTemplate> {
150 + Arc::new(ResponseTemplate::from_json(&template).unwrap())
151 +}
152 +
153 +fn tokenizer() -> Arc<TestTokenizer> {
154 + Arc::new(TestTokenizer::new())
155 +}
156 +
157 +/// Parse `chunks` after initializing from `prompt`, returning all events.
158 +fn parse_events(
159 + template: &Arc<ResponseTemplate>,
160 + tools: &[Tool],
161 + prompt: &str,
162 + chunks: &[&str],
163 +) -> Result<Vec<UnifiedParserEvent>, UnifiedParserError> {
164 + let tokenizer = tokenizer();
165 + let prompt_ids = tokenizer.encode(prompt, false).unwrap();
166 + let mut parser = HfUnifiedParser::new(template.clone(), tools, tokenizer);
167 + parser.initialize(&prompt_ids)?;
168 + let mut output = UnifiedParserOutput::default();
169 + for chunk in chunks {
170 + parser.parse_into(DecodedText::unattributed(*chunk), &mut output)?;
171 + }
172 + output.append(parser.finish()?);
173 + Ok(output.events)
174 +}
175 +
176 +/// Aggregate events into the shape of the Transformers output dict.
177 +fn message(events: &[UnifiedParserEvent]) -> Value {
178 + let mut reasoning = String::new();
179 + let mut content = String::new();
180 + let mut calls: BTreeMap<usize, (String, String)> = BTreeMap::new();
181 + for event in events {
182 + match event {
183 + UnifiedParserEvent::Text(text) => content.push_str(text),
184 + UnifiedParserEvent::Reasoning(piece) => reasoning.push_str(&piece.text),
185 + UnifiedParserEvent::ToolCall(delta) => {
186 + let call = calls.entry(delta.tool_index).or_default();
187 + if let Some(name) = &delta.name {
188 + call.0.clone_from(name);
189 + }
190 + call.1.push_str(&delta.arguments);
191 + }
192 + }
193 + }
194 + let mut out = serde_json::Map::new();
195 + if !reasoning.is_empty() {
196 + out.insert("reasoning".to_string(), reasoning.into());
197 + }
198 + if !content.is_empty() {
199 + out.insert("content".to_string(), content.into());
200 + }
201 + if !calls.is_empty() {
202 + let calls = calls
203 + .into_values()
204 + .map(|(name, arguments)| {
205 + let arguments =
206 + serde_json::from_str(&arguments).unwrap_or(Value::String(arguments));
207 + json!({"name": name, "arguments": arguments})
208 + })
209 + .collect();
210 + out.insert("tool_calls".to_string(), Value::Array(calls));
211 + }
212 + Value::Object(out)
213 +}
214 +
215 +fn parse(template: Value, text: &str) -> Value {
216 + message(&parse_events(&compile(template), &[], "", &[text]).unwrap())
217 +}
218 +
219 +/// Split `text` at pseudo-random char boundaries (xorshift, seeded).
220 +fn random_chunks(text: &str, seed: u64) -> Vec<&str> {
221 + let mut state = seed | 1;
222 + let mut next = move || {
223 + state ^= state << 13;
224 + state ^= state >> 7;
225 + state ^= state << 17;
226 + state
227 + };
228 + let mut chunks = Vec::new();
229 + let mut start = 0;
230 + for (index, _) in text.char_indices().skip(1) {
231 + if next() % 3 == 0 {
232 + chunks.push(&text[start..index]);
233 + start = index;
234 + }
235 + }
236 + chunks.push(&text[start..]);
237 + chunks
238 +}
239 +
240 +#[test]
241 +fn upstream_fixtures_match_transformers_values() {
242 + let cases = [
243 + (
244 + cohere_template(),
245 + "<|START_THINKING|>I should call a tool.<|END_THINKING|><|START_ACTION|>[\n {\"tool_call_id\": \"0\", \"tool_name\": \"simple_tool\", \"parameters\": {\"temperature_format\": \"Celsius\"}}\n]<|END_ACTION|><|END_OF_TURN_TOKEN|>",
246 + json!({
247 + "reasoning": "I should call a tool.",
248 + "tool_calls": [{"name": "simple_tool", "arguments": {"temperature_format": "Celsius"}}],
249 + }),
250 + ),
251 + (
252 + gpt_oss_template(),
253 + "<|channel|>analysis<|message|>We will call function get_current_weather.<|end|><|start|>assistant<|channel|>commentary to=functions.get_current_weather <|constrain|>json<|message|>{\n \"location\": \"San Francisco, CA\"\n}",
254 + json!({
255 + "reasoning": "We will call function get_current_weather.",
256 + "tool_calls": [{"name": "get_current_weather", "arguments": {"location": "San Francisco, CA"}}],
257 + }),
258 + ),
259 + (
260 + gpt_oss_template(),
261 + "<|channel|>analysis<|message|>User asks a simple math question: 2+2 = 4. Provide answer.<|end|><|start|>assistant<|channel|>final<|message|>2",
262 + json!({
263 + "reasoning": "User asks a simple math question: 2+2 = 4. Provide answer.",
264 + "content": "2",
265 + }),
266 + ),
267 + (
268 + smollm_template(),
269 + "<think>\nOkay, let me greet them.\n</think>\n\n<tool_call>{\"name\": \"greet_user\", \"arguments\": {\"greeting\": \"Hello!\"}}</tool_call>",
270 + json!({
271 + "reasoning": "Okay, let me greet them.",
272 + "tool_calls": [{"name": "greet_user", "arguments": {"greeting": "Hello!"}}],
273 + }),
274 + ),
275 + (
276 + smollm_template(),
277 + "<tool_call>{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}</tool_call>",
278 + json!({"tool_calls": [{"name": "get_weather", "arguments": {"city": "Paris"}}]}),
279 + ),
280 + (
281 + smollm_template(),
282 + "<think>\nOkay, gravity.</think>\nSome content about gravity goes here!",
283 + json!({"reasoning": "Okay, gravity.", "content": "Some content about gravity goes here!"}),
284 + ),
285 + (
286 + qwen3_template(),
287 + "<tool_call>\n<function=get_weather>\n<parameter=locations>\n[{\"country\": \"France\", \"city\": \"Paris\"}]\n</parameter>\n<parameter=temp_units>\ncelsius\n</parameter>\n</function>\n</tool_call>",
288 + json!({
289 + "tool_calls": [{
290 + "name": "get_weather",
291 + "arguments": {"locations": [{"country": "France", "city": "Paris"}], "temp_units": "celsius"},
292 + }],
293 + }),
294 + ),
295 + (
296 + gemma4_template(),
297 + "<|channel>thought\nThe user is asking for the temperature.<channel|><|tool_call>call:get_current_temperature{detail_level:0,location:<|\"|>Paris, France<|\"|>,unit:<|\"|>celsius<|\"|>}<tool_call|><|tool_response>",
298 + json!({
299 + "reasoning": "The user is asking for the temperature.",
300 + "tool_calls": [{
301 + "name": "get_current_temperature",
302 + "arguments": {"detail_level": 0, "location": "Paris, France", "unit": "celsius"},
303 + }],
304 + }),
305 + ),
306 + (
307 + gemma4_template(),
308 + "<|channel>thought\nLet me call the tool.<channel|><|tool_call>call:foo{bool_value:true,list_value:[<|\"|>foo<|\"|>,<|\"|>bar<|\"|>],null_value:null,number_value:1,string_value:<|\"|>foo<|\"|>,struct_value:{foo:<|\"|>bar<|\"|>}}<tool_call|>",
309 + json!({
310 + "reasoning": "Let me call the tool.",
311 + "tool_calls": [{
312 + "name": "foo",
313 + "arguments": {
314 + "bool_value": true,
315 + "list_value": ["foo", "bar"],
316 + "null_value": null,
317 + "number_value": 1,
318 + "string_value": "foo",
319 + "struct_value": {"foo": "bar"},
320 + },
321 + }],
322 + }),
323 + ),
324 + ];
325 + for (template, text, expected) in cases {
326 + assert_eq!(parse(template, text), expected, "{text}");
327 + }
328 +}
329 +
330 +/// Upstream fixtures whose patterns have no literal prefix are rejected at load.
331 +#[test]
332 +fn patterns_without_literal_prefix_are_unsupported() {
333 + let ernie = json!({
334 + "start_anchor": "Assistant:",
335 + "fields": {"thinking": {"open_pattern": r"(?:^|<think>\s*)", "close": "</think>"}},
336 + });
337 + let inkling = json!({
338 + "start_anchor": "<|message_model|>",
339 + "fields": {"content": {"open_pattern": r"(?:<\|message_model\|>)?[^<]*<\|content_text\|>", "close": "<|end_message|>"}},
340 + });
341 + for template in [ernie, inkling] {
342 + assert!(matches!(
343 + ResponseTemplate::from_json(&template),
344 + Err(HfTemplateError::Unsupported { .. })
345 + ));
346 + }
347 +}
348 +
349 +#[test]
350 +fn streaming_matches_whole_string_for_every_chunking() {
351 + let fixtures = [
352 + (
353 + cohere_template(),
354 + "<|START_THINKING|>I should call a tool.<|END_THINKING|><|START_ACTION|>[{\"tool_call_id\": \"0\", \"tool_name\": \"simple_tool\", \"parameters\": {\"a\": 1}}]<|END_ACTION|>",
355 + ),
356 + (
357 + gpt_oss_template(),
358 + "<|channel|>analysis<|message|>thinking chunk<|end|><|channel|>final<|message|>done text",
359 + ),
360 + (
361 + gpt_oss_template(),
362 + "<|channel|>analysis<|message|>Let me check.<|end|><|start|>assistant<|channel|>commentary to=functions.get_current_weather <|constrain|>json<|message|>{\"location\": \"San Francisco, CA\"}<|call|>",
363 + ),
364 + (
365 + smollm_template(),
366 + "<think>thinking</think>\n<tool_call>{\"name\": \"fn\", \"arguments\": {\"x\": 1}}</tool_call>",
367 + ),
368 + (
369 + qwen3_template(),
370 + "<think>short thought</think>\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>",
371 + ),
372 + (
373 + gemma4_template(),
374 + "<|channel>thought\nhi 天气<channel|>前言 <|tool_call>call:foo{a:1,b:<|\"|>bar<|\"|>}<tool_call|> after",
375 + ),
376 + (
377 + invoke_template(),
378 + "header to=self<|msg|>plan it<|pause|>to=user<|msg|>Hello <|pause|><invoke id=\"1\" name=\"get\"><param name=\"city\">\"Paris\"</param><param name=\"days\">3</param></invoke>to=note<|msg|>world<|end|>",
379 + ),
380 + ];
381 + for (template, text) in fixtures {
382 + let template = compile(template);
383 + let expected = message(&parse_events(&template, &[], "", &[text]).unwrap());
384 + let mut chunkings: Vec<Vec<&str>> =
385 + [1, 2, 3, 5, 7, 13, 31].map(|step| split_by_chars(text, step)).into();
386 + chunkings.extend((0..30).map(|seed| random_chunks(text, 0xC0DE_5EED + seed)));
387 + for chunks in chunkings {
388 + assert_eq!(chunks.concat(), text);
389 + let actual = message(&parse_events(&template, &[], "", &chunks).unwrap());
390 + assert_eq!(actual, expected, "{chunks:?}");
391 + }
392 + }
393 +}
394 +
395 +#[test]
396 +fn gemma4_streams_text_and_starts_calls_at_the_opener() {
397 + let template = compile(gemma4_template());
398 + let chunks = [
399 + "<|channel>thought\n",
400 + "Check ",
401 + "the weather. ",
402 + "<channel|>Sure",
403 + ". <|tool_call>call:get_weather",
404 + "{location:<|\"|>Paris<|\"|>}",
405 + "<tool_call|>",
406 + ];
407 + let tokenizer = tokenizer();
408 + let mut parser = HfUnifiedParser::new(template, &[], tokenizer);
409 + parser.initialize(&[]).unwrap();
410 + let steps: Vec<_> = chunks
411 + .iter()
412 + .map(|chunk| {
413 + let mut output = UnifiedParserOutput::default();
414 + parser.parse_into(DecodedText::unattributed(*chunk), &mut output).unwrap();
415 + (*chunk, output.events)
416 + })
417 + .collect();
418 + expect_test::expect![[r#"
419 + [
420 + (
421 + "<|channel>thought\n",
422 + [],
423 + ),
424 + (
425 + "Check ",
426 + [
427 + Reasoning(
428 + DecodedText {
429 + text: "Check",
430 + attributions: [],
431 + },
432 + ),
433 + ],
434 + ),
435 + (
436 + "the weather. ",
437 + [
438 + Reasoning(
439 + DecodedText {
440 + text: " the weather.",
441 + attributions: [],
442 + },
443 + ),
444 + ],
445 + ),
446 + (
447 + "<channel|>Sure",
448 + [
449 + Text(
450 + "Sure",
451 + ),
452 + ],
453 + ),
454 + (
455 + ". <|tool_call>call:get_weather",
456 + [
457 + Text(
458 + ".",
459 + ),
460 + ],
461 + ),
462 + (
463 + "{location:<|\"|>Paris<|\"|>}",
464 + [
465 + ToolCall(
466 + ToolCallDelta {
467 + tool_index: 0,
468 + name: Some(
469 + "get_weather",
470 + ),
471 + arguments: "",
472 + },
473 + ),
474 + ],
475 + ),
476 + (
477 + "<tool_call|>",
478 + [
479 + ToolCall(
480 + ToolCallDelta {
481 + tool_index: 0,
482 + name: None,
483 + arguments: "{\"location\":\"Paris\"}",
484 + },
485 + ),
486 + ],
487 + ),
488 + ]
489 + "#]]
490 + .assert_debug_eq(&steps);
491 +}
492 +
493 +#[test]
494 +fn invoke_protocol_discards_unrouted_text_and_joins_content() {
495 + let text = "stray to=self<|msg|> plan <|pause|>to=user<|msg|>Hello, <|pause|>between<invoke name=\"get\"><param name=\"n\">x</param></invoke>to=note<|msg|>world<|end|>tail";
496 + expect_test::expect![[r#"
497 + Object {
498 + "reasoning": String("plan"),
499 + "content": String("Hello,world"),
500 + "tool_calls": Array [
501 + Object {
502 + "name": String("get"),
503 + "arguments": Object {
504 + "n": String("x"),
505 + },
506 + },
507 + ],
508 + }
509 + "#]]
510 + .assert_debug_eq(&parse(invoke_template(), text));
511 +
512 + // Unicode word boundaries: `é` is a word character, so `éname=` is not the `name` attribute.
513 + let text = "<invoke title=\"天气\" name=\"获取\"><param name=\"城市\">北京</param></invoke><invoke éname=\"x\">";
514 + assert_eq!(
515 + parse(invoke_template(), text),
516 + json!({"tool_calls": [{"name": "获取", "arguments": {"城市": "北京"}}]})
517 + );
518 +}
519 +
520 +#[test]
521 +fn join_concatenates_repeated_matches() {
522 + let template = json!({
523 + "defaults": {"role": "assistant"},
524 + "start_anchor": "<|assistant|>",
525 + "fields": {
526 + "thinking": {"open": "<think>", "close": "</think>", "repeats": true, "join": " "},
527 + "content": {"repeats": true, "join": " "},
528 + },
529 + });
530 + assert_eq!(
531 + parse(
532 + template.clone(),
533 + "<think>first</think>middle<think>second</think>done"
534 + ),
535 + json!({"reasoning": "first second", "content": "middle done"})
536 + );
537 + assert_eq!(
538 + parse(template, "<think>only</think>"),
539 + json!({"reasoning": "only"})
540 + );
541 +}
542 +
543 +#[test]
544 +fn transforms_with_dotted_paths() {
545 + let template = json!({
546 + "start_anchor": "<|assistant|>",
547 + "fields": {
548 + "tool_calls": {
549 + "open": "<actions>",
550 + "close": "</actions>",
551 + "content": "json",
552 + "transform_each": true,
553 + "transform": {"type": "function", "function": {"name": "{fn.name}", "arguments": "{fn.args}"}},
554 + },
555 + },
556 + });
557 + assert_eq!(
558 + parse(
559 + template,
560 + r#"<actions>[{"fn": {"name": "a", "args": {"x": 1}}}, {"fn": {"name": "b", "args": {}}}]</actions>"#
561 + ),
562 + json!({"tool_calls": [{"name": "a", "arguments": {"x": 1}}, {"name": "b", "arguments": {}}]})
563 + );
564 +}
565 +
566 +#[test]
567 +fn literal_boundaries() {
568 + let template = json!({
569 + "start_anchor": "<|assistant|>",
570 + "fields": {"content": {"open": ["<a>", "<bb>"], "close": ["</a>", "</bb>"]}},
571 + });
572 + for (opener, closer) in [("<a>", "</a>"), ("<bb>", "</bb>"), ("<a>", "</bb>")] {
573 + assert_eq!(
574 + parse(template.clone(), &format!("{opener}hi{closer}")),
575 + json!({"content": "hi"})
576 + );
577 + }
578 +
579 + // A literal that is a strict prefix of another in the same list defers at the buffer edge.
580 + let template = compile(json!({
581 + "start_anchor": "<|assistant|>",
582 + "fields": {"content": {"open": "<x>", "close": ["END", "ENDX"]}},
583 + }));
584 + let events = parse_events(&template, &[], "", &["<x>hiEND", " more"]).unwrap();
585 + assert_eq!(message(&events), json!({"content": "hi"}));
586 +
587 + // A field without a close runs to the end of the stream.
588 + let template =
589 + json!({"start_anchor": "<|assistant|>", "fields": {"content": {"open": "<resp>"}}});
590 + assert_eq!(
591 + parse(template, "<resp>hello world"),
592 + json!({"content": "hello world"})
593 + );
594 +}
595 +
596 +#[test]
597 +fn required_field_missing_fails_at_finish() {
598 + let template = compile(json!({
599 + "start_anchor": "<|assistant|>",
600 + "fields": {"content": {"open": "<response>", "close": "</response>", "optional": false}},
601 + }));
602 + let error = parse_events(&template, &[], "", &["no response here"]).unwrap_err();
603 + expect_test::expect![[r#"
604 + ParsingFailed {
605 + message: "Required response_template fields missing from parsed output: [\"content\"]",
606 + }
607 + "#]]
608 + .assert_debug_eq(&error);
609 +}
610 +
611 +#[test]
612 +fn prefix_sets_the_initial_state() {
613 + let template = compile(qwen3_template());
614 +
615 + // The prompt ends inside the thinking block; the prefilled newline is stripped.
616 + let prompt = "<|im_start|>system\nYou are helpful<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n";
617 + let events = parse_events(&template, &[], prompt, &["Let me think...</think>"]).unwrap();
618 + assert_eq!(message(&events), json!({"reasoning": "Let me think..."}));
619 +
620 + // Only the slice after the last anchor matters.
621 + let prompt = "<|im_start|>assistant\nEarlier reply<|im_end|>\n<|im_start|>user\nFollowup<|im_end|>\n<|im_start|>assistant\n<think>\n";
622 + let events = parse_events(&template, &[], prompt, &["done</think>"]).unwrap();
623 + assert_eq!(message(&events), json!({"reasoning": "done"}));
624 +
625 + // A prefix ending mid-delimiter is completed by the generated text.
626 + let events = parse_events(
627 + &template,
628 + &[],
629 + "<|im_start|>assistant\n<thi",
630 + &["nk>real body</think>"],
631 + )
632 + .unwrap();
633 + assert_eq!(message(&events), json!({"reasoning": "real body"}));
634 +
635 + // Gemma 4 continues inside the same turn after a tool response.
636 + let template = compile(gemma4_template());
637 + let prompt = "<|turn>model\n<|tool_call>call:f{}<tool_call|><|tool_response>response:f{}<tool_response|><|channel>thought\n";
638 + let events = parse_events(&template, &[], prompt, &["Done.<channel|>It is sunny."]).unwrap();
639 + assert_eq!(
640 + message(&events),
641 + json!({"reasoning": "Done.", "content": "It is sunny."})
642 + );
643 +
644 + // Prompt text is never emitted, but it counts for leading-whitespace stripping.
645 + let events = parse_events(&template, &[], "<|turn>model\nSure,", &[" here", " it is"]).unwrap();
646 + assert_eq!(message(&events), json!({"content": " here it is"}));
647 +}
648 +
649 +/// Diverges from Transformers, which runs the whole prompt through the parser
650 +/// when the start anchor is absent.
651 +#[test]
652 +fn missing_start_anchor_starts_from_the_initial_state() {
653 + let template = compile(smollm_template());
654 + let events = parse_events(&template, &[], "<think>\n", &["hi</think>"]).unwrap();
655 + assert_eq!(message(&events), json!({"content": "hi</think>"}));
656 +}
657 +
658 +#[test]
659 +fn tools_convert_string_arguments() {
660 + let template = compile(json!({
661 + "start_anchor": "<|im_start|>assistant\n",
662 + "fields": {
663 + "tool_calls": {
664 + "open_pattern": r"<tool_call>\s*<function=(?P<name>\w+)>",
665 + "close": "</tool_call>",
666 + "repeats": true,
667 + "content": "xml-inline",
668 + "content_args": {
669 + "tag_pattern": r"<parameter=(?P<key>\w+)>\s*(?P<value>.*?)\s*</parameter>",
670 + "merge_duplicates": true,
671 + },
672 + "transform": {"type": "function", "function": {"name": "{name}", "arguments": "{content}"}},
673 + },
674 + },
675 + }));
676 + let tools = [Tool {
677 + name: "set_alarm".to_string(),
678 + description: None,
679 + parameters: json!({
680 + "type": "object",
681 + "properties": {
682 + "hour": {"type": "integer"},
683 + "enabled": {"type": "boolean"},
684 + "label": {"type": "string"},
685 + "weekday": {"type": "integer"},
686 + },
687 + }),
688 + strict: None,
689 + }];
690 + let text = "<tool_call>\n<function=set_alarm>\n<parameter=hour>\n7\n</parameter>\n<parameter=enabled>\ntrue\n</parameter>\n<parameter=label>\nwake up\n</parameter>\n<parameter=weekday>1</parameter><parameter=weekday>2</parameter>\n</function>\n</tool_call>";
691 + assert_eq!(
692 + message(&parse_events(&template, &tools, "", &[text]).unwrap()),
693 + json!({"tool_calls": [{"name": "set_alarm", "arguments": {"hour": 7, "enabled": true, "label": "wake up", "weekday": [1, 2]}}]})
694 + );
695 + // Without tools, values stay strings.
696 + assert_eq!(
697 + message(&parse_events(&template, &[], "", &[text]).unwrap()),
698 + json!({"tool_calls": [{"name": "set_alarm", "arguments": {"hour": "7", "enabled": "true", "label": "wake up", "weekday": ["1", "2"]}}]})
699 + );
700 +}
701 +
702 +#[test]
703 +fn stripped_reasoning_whitespace_keeps_its_tokens() {
704 + let template = compile(gemma4_template());
705 + let mut parser = HfUnifiedParser::new(template, &[], tokenizer());
706 + parser.initialize(&[]).unwrap();
707 + let text = "<|channel>thought\n \n plan \n<channel|>";
708 + let mut output = UnifiedParserOutput::default();
709 + for (index, c) in text.chars().enumerate() {
710 + let piece = DecodedText {
711 + text: c.to_string(),
712 + attributions: [TokenAttribution {
713 + token_id: u32::try_from(index).unwrap(),
714 + anchor: TokenAnchor::Visible { byte_offset: 0 },
715 + }]
716 + .into_iter()
717 + .collect(),
718 + };
719 + parser.parse_into(piece, &mut output).unwrap();
720 + }
721 + output.append(parser.finish().unwrap());
722 + let (reasoning, tokens) =
723 + output
724 + .events
725 + .iter()
726 + .fold((String::new(), 0), |(text, tokens), event| match event {
727 + UnifiedParserEvent::Reasoning(piece) => {
728 + (text + &piece.text, tokens + piece.attributions.len())
729 + }
730 + _ => (text, tokens),
731 + });
732 + assert_eq!(reasoning, "plan");
733 + // Every character between the markers is a reasoning token, stripped or not.
734 + assert_eq!(tokens, " \n plan \n".chars().count());
735 +}
736 +
737 +#[test]
738 +fn reset_returns_uncommitted_text() {
739 + let template = compile(gemma4_template());
740 + let mut parser = HfUnifiedParser::new(template, &[], tokenizer());
741 + parser.initialize(&[]).unwrap();
742 + let mut output = UnifiedParserOutput::default();
743 + parser
744 + .parse_into(
745 + DecodedText::unattributed("Hi <|tool_call>call:f{a:<|\"|>x"),
746 + &mut output,
747 + )
748 + .unwrap();
749 + assert_eq!(parser.reset(), "<|tool_call>call:f{a:<|\"|>x");
750 +}
751 +
752 +#[test]
753 +fn malformed_tool_arguments_fail_the_parser() {
754 + let template = compile(gemma4_template());
755 + let error =
756 + parse_events(&template, &[], "", &["<|tool_call>call:f{a:}<tool_call|>"]).unwrap_err();
757 + assert!(
758 + matches!(error, UnifiedParserError::ParsingFailed { .. }),
759 + "{error:?}"
760 + );
761 +}
762 +
763 +#[test]
764 +fn template_validation_errors() {
765 + let error = |template: Value| ResponseTemplate::from_json(&template).unwrap_err();
766 + expect_test::expect![[r#"
767 + [
768 + Invalid {
769 + message: "unsupported response_template version: 2",
770 + },
771 + Invalid {
772 + message: "At most one field may omit 'open'/'open_pattern' (that field becomes the implicit-open / leftover sink). Found: content, thinking",
773 + },
774 + Invalid {
775 + message: "response_template must define 'start_anchor' or 'start_anchor_pattern'.",
776 + },
777 + Invalid {
778 + message: "Field 'content': 'join' requires 'repeats': true",
779 + },
780 + Invalid {
781 + message: "Field 'tool_calls': open_pattern/close_pattern declares named group(s) [\"name\"], but the field has no 'transform'. Named captures are only surfaced through a 'transform' template (where they appear alongside 'content'). Either add a 'transform' that uses the captures, or remove the named groups from the pattern.",
782 + },
783 + Invalid {
784 + message: "Field 'tool_calls': transform string \"name: {content}\" mixes a {placeholder} with literal text. Use either a whole-string placeholder (e.g. \"{content}\") or a plain literal; string interpolation is not supported.",
785 + },
786 + Invalid {
787 + message: "Field 'content': 'open' literals cannot be empty strings",
788 + },
789 + Unsupported {
790 + message: "Field 'count': only 'content', 'reasoning_content'/'thinking', and 'tool_calls' fields can be reported",
791 + },
792 + Unsupported {
793 + message: "Field 'thinking': text and reasoning fields must use the 'text' content parser without a transform",
794 + },
795 + Invalid {
796 + message: "response_template.fields.content.content: unknown variant `yaml`, expected one of `text`, `int`, `float`, `bool`, `json`, `xml-inline`, `kv-lines`",
797 + },
798 + Invalid {
799 + message: "response_template.fields.content.repeats: invalid type: string \"yes\", expected a boolean",
800 + },
801 + Invalid {
802 + message: "Field 'content': cannot specify both 'open' and 'open_pattern'",
803 + },
804 + Invalid {
805 + message: "Field 'tool_calls': transform_each is set but no transform was provided",
806 + },
807 + Unsupported {
808 + message: "Field 'tool_calls': 'join' requires each match to parse to a string",
809 + },
810 + Invalid {
811 + message: "Field 'tool_calls': transform placeholder '{id}' is neither 'content' nor a named group of open_pattern",
812 + },
813 + ]
814 + "#]]
815 + .assert_debug_eq(&[
816 + error(json!({"version": 2, "start_anchor": "a", "fields": {"content": {}}})),
817 + error(json!({"start_anchor": "a", "fields": {"content": {}, "thinking": {}}})),
818 + error(json!({"fields": {"content": {}}})),
819 + error(json!({"start_anchor": "a", "fields": {"content": {"open": "<x>", "join": ""}}})),
820 + error(json!({"start_anchor": "a", "fields": {"tool_calls": {"open_pattern": r"<t (?P<name>\w+)>", "close": "</t>"}}})),
821 + error(json!({"start_anchor": "a", "fields": {"tool_calls": {"open": "<t>", "transform": {"label": "name: {content}"}}}})),
822 + error(json!({"start_anchor": "a", "fields": {"content": {"open": [""]}}})),
823 + error(json!({"start_anchor": "a", "fields": {"count": {"open": "<n>", "content": "int"}}})),
824 + error(json!({"start_anchor": "a", "fields": {"thinking": {"open": "<n>", "content": "json"}}})),
825 + error(json!({"start_anchor": "a", "fields": {"content": {"content": "yaml"}}})),
826 + error(json!({"start_anchor": "a", "fields": {"content": {"repeats": "yes"}}})),
827 + error(json!({"start_anchor": "a", "fields": {"content": {"open": "<x>", "open_pattern": "<y>"}}})),
828 + error(json!({"start_anchor": "a", "fields": {"tool_calls": {"open": "<t>", "transform_each": true}}})),
829 + error(json!({"start_anchor": "a", "fields": {"tool_calls": {"open": "<t>", "repeats": true, "join": ""}}})),
830 + error(json!({
831 + "start_anchor": "a",
832 + "fields": {"tool_calls": {
833 + "open_pattern": r"<t (?P<name>\w+)>",
834 + "close_pattern": r"</t (?P<id>\d+)>",
835 + "content": "json",
836 + "transform": {"function": {"name": "{name}", "arguments": "{content}", "id": "{id}"}},
837 + }},
838 + })),
839 + ]);
840 +}
841 +
842 +/// Cases where the Rust parser intentionally differs from Transformers, keyed by
843 +/// generated text.
844 +const DIVERGENCES: &[(&str, &str)] = &[
845 + (
846 + // A field without `repeats` keeps only its last occurrence in Transformers;
847 + // streamed text cannot be retracted, so every occurrence is reported.
848 + r#"Before <|tool_call>call:f{x:<|"|>a, b<|"|>,y:[1,2.5,-3],z:{w:true}}<tool_call|> after"#,
849 + r#"{"content": "Before after", "tool_calls": [{"name": "f", "arguments": {"x": "a, b", "y": [1, 2.5, -3], "z": {"w": true}}}]}"#,
850 + ),
851 + (
852 + // Arguments use the tool parsers' shared conversion, which keeps a JSON
853 + // number's spelling; Transformers turns `1e3` into the integer 1000.
854 + "<tool_call>\n<function=set_alarm>\nhour: seven\nratio: 1e3\nenabled: nope\n</tool_call>",
855 + r#"{"tool_calls": [{"name": "set_alarm", "arguments": {"hour": "seven", "ratio": 1000.0, "enabled": "nope"}}]}"#,
856 + ),
857 +];
858 +
859 +/// Compare against `fixtures/differential.json`, generated from Transformers
860 +/// `parse_response` by `fixtures/generate_differential.py`.
861 +#[test]
862 +fn differential_against_transformers() {
863 + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
864 + .join("src/unified/hf/fixtures/differential.json");
865 + let corpus: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
866 + let templates: BTreeMap<&str, Arc<ResponseTemplate>> = corpus["templates"]
867 + .as_object()
868 + .unwrap()
869 + .iter()
870 + .map(|(name, template)| {
871 + (
872 + name.as_str(),
873 + Arc::new(ResponseTemplate::from_json(template).unwrap()),
874 + )
875 + })
876 + .collect();
877 +
878 + for case in corpus["cases"].as_array().unwrap() {
879 + let template = &templates[case["template"].as_str().unwrap()];
880 + let prefix = case["prefix"].as_str().unwrap();
881 + let text = case["text"].as_str().unwrap();
882 + let tools: Vec<Tool> = case["tools"]
883 + .as_array()
884 + .into_iter()
885 + .flatten()
886 + .map(|tool| Tool {
887 + name: tool["function"]["name"].as_str().unwrap().to_string(),
888 + description: None,
889 + parameters: tool["function"]["parameters"].clone(),
890 + strict: None,
891 + })
892 + .collect();
893 + let expected = match DIVERGENCES.iter().find(|(divergent, _)| *divergent == text) {
894 + Some((_, expected)) => serde_json::from_str(expected).unwrap(),
895 + None => case["expected"].clone(),
896 + };
897 +
898 + for chunks in [vec![text], split_by_chars(text, 1)] {
899 + let actual = parse_events(template, &tools, prefix, &chunks);
900 + match (&actual, expected.get("error")) {
901 + (Err(_), Some(_)) => {}
902 + (Ok(events), None) => assert_eq!(message(events), expected, "{text:?}"),
903 + _ => panic!("{text:?}: expected {expected}, got {actual:?}"),
904 + }
905 + }
906 + }
907 +}
908 +
909 +#[test]
910 +fn tool_call_value_shapes() {
911 + // No transform: the parsed JSON is the tool call itself.
912 + let template = compile(json!({
913 + "start_anchor": "<|assistant|>",
914 + "fields": {"tool_calls": {"open": "<tool_call>", "close": "</tool_call>", "content": "json"}},
915 + }));
916 + let parse =
917 + |text: &str| parse_events(&template, &[], "", &[text]).map(|events| message(&events));
918 +
919 + assert_eq!(
920 + parse(r#"<tool_call>{"type": "function", "function": {"name": "f", "arguments": {"a": 1}}}</tool_call>"#).unwrap(),
921 + json!({"tool_calls": [{"name": "f", "arguments": {"a": 1}}]})
922 + );
923 + assert_eq!(
924 + parse(r#"<tool_call>[{"name": "f", "arguments": null}, {"name": "g", "arguments": "raw"}]</tool_call>"#).unwrap(),
925 + json!({"tool_calls": [{"name": "f", "arguments": {}}, {"name": "g", "arguments": "raw"}]})
926 + );
927 + expect_test::expect![[r#"
928 + ParsingFailed {
929 + message: "tool call must be {\"function\": {\"name\": ..., \"arguments\": ...}} or {\"name\": ..., \"arguments\": ...} with a string name, got {\"name\":1}",
930 + }
931 + "#]]
932 + .assert_debug_eq(&parse(r#"<tool_call>{"name": 1}</tool_call>"#).unwrap_err());
933 +}
rust/src/parser/src/unified/hf/transform.rs new
+263
@@ -0,0 +1,263 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Transform templates: restructure a parsed region value into the output shape.
5 +//!
6 +//! Port of `_apply_transform`, `validate_transform_strings`, and `process_field`
7 +//! from `content_parsers.py`.
8 +
9 +use std::sync::LazyLock;
10 +
11 +use regex_automata::meta::Regex;
12 +use serde_json::{Map, Value};
13 +
14 +use super::pattern::Captures;
15 +use super::{Result, invalid};
16 +use crate::unified::{self, parsing_failed};
17 +
18 +/// `\{(\w+(?:\.\w+)*)\}`: a whole-string placeholder such as `{content}` or
19 +/// `{content.args}`.
20 +static PLACEHOLDER: LazyLock<Regex> =
21 + LazyLock::new(|| Regex::new(r"\{\w+(?:\.\w+)*\}").expect("valid regex"));
22 +
23 +/// A compiled transform template.
24 +#[derive(Debug, Clone, PartialEq)]
25 +pub(super) enum Transform {
26 + Object(Vec<(String, Transform)>),
27 + Array(Vec<Transform>),
28 + /// A dotted placeholder path, e.g. `["content", "args"]`.
29 + Placeholder(Vec<String>),
30 + /// Any other JSON value, copied verbatim.
31 + Literal(Value),
32 +}
33 +
34 +impl Transform {
35 + /// Compile a transform template, rejecting any string that mixes a
36 + /// `{name}` placeholder with literal text.
37 + pub fn compile(scope: &str, template: &Value) -> Result<Self> {
38 + Ok(match template {
39 + Value::Object(object) => Self::Object(
40 + object
41 + .iter()
42 + .map(|(key, value)| Ok((key.clone(), Self::compile(scope, value)?)))
43 + .collect::<Result<_>>()?,
44 + ),
45 + Value::Array(values) => Self::Array(
46 + values.iter().map(|value| Self::compile(scope, value)).collect::<Result<_>>()?,
47 + ),
48 + Value::String(string) => match PLACEHOLDER.find(string) {
49 + Some(found) if found.range() == (0..string.len()) => Self::Placeholder(
50 + string[1..string.len() - 1].split('.').map(str::to_string).collect(),
51 + ),
52 + Some(_) => {
53 + return Err(invalid!(
54 + "{scope}: transform string {string:?} mixes a {{placeholder}} with literal text. \
55 + Use either a whole-string placeholder (e.g. \"{{content}}\") or a plain literal; \
56 + string interpolation is not supported."
57 + ));
58 + }
59 + None => Self::Literal(template.clone()),
60 + },
61 + other => Self::Literal(other.clone()),
62 + })
63 + }
64 +
65 + /// Return the placeholder path found at `key_path` inside this template, if any.
66 + pub fn placeholder_at(&self, key_path: &[&str]) -> Option<&[String]> {
67 + match (self, key_path) {
68 + (Self::Placeholder(path), []) => Some(path),
69 + (Self::Object(entries), [key, rest @ ..]) => entries
70 + .iter()
71 + .find(|(entry, _)| entry == key)
72 + .and_then(|(_, transform)| transform.placeholder_at(rest)),
73 + _ => None,
74 + }
75 + }
76 +
77 + /// Root names of every placeholder in this template.
78 + pub fn placeholder_roots(&self) -> Box<dyn Iterator<Item = &str> + '_> {
79 + match self {
80 + Self::Object(entries) => {
81 + Box::new(entries.iter().flat_map(|(_, transform)| transform.placeholder_roots()))
82 + }
83 + Self::Array(transforms) => {
84 + Box::new(transforms.iter().flat_map(Self::placeholder_roots))
85 + }
86 + Self::Placeholder(path) => Box::new(path.first().map(String::as_str).into_iter()),
87 + Self::Literal(_) => Box::new(std::iter::empty()),
88 + }
89 + }
90 +
91 + /// Recursively instantiate this template against `scope`.
92 + pub fn apply(&self, scope: &Map<String, Value>) -> unified::Result<Value> {
93 + Ok(match self {
94 + Self::Object(entries) => Value::Object(
95 + entries
96 + .iter()
97 + .map(|(key, transform)| Ok((key.clone(), transform.apply(scope)?)))
98 + .collect::<unified::Result<_>>()?,
99 + ),
100 + Self::Array(transforms) => Value::Array(
101 + transforms
102 + .iter()
103 + .map(|transform| transform.apply(scope))
104 + .collect::<unified::Result<_>>()?,
105 + ),
106 + Self::Literal(value) => value.clone(),
107 + Self::Placeholder(path) => {
108 + let dotted = path.join(".");
109 + let (root, keys) = path.split_first().expect("placeholder path is non-empty");
110 + let Some(mut value) = scope.get(root) else {
111 + let available: Vec<_> = scope.keys().collect();
112 + return Err(parsing_failed!(
113 + "transform placeholder '{{{dotted}}}' is not defined. Available: {available:?}"
114 + ));
115 + };
116 + for key in keys {
117 + let Some(object) = value.as_object() else {
118 + return Err(parsing_failed!(
119 + "transform placeholder '{{{dotted}}}' cannot index into {} at '{key}'",
120 + json_type_name(value)
121 + ));
122 + };
123 + let Some(next) = object.get(key) else {
124 + let available: Vec<_> = object.keys().collect();
125 + return Err(parsing_failed!(
126 + "transform placeholder '{{{dotted}}}' is missing key '{key}'. Available: {available:?}"
127 + ));
128 + };
129 + value = next;
130 + }
131 + value.clone()
132 + }
133 + })
134 + }
135 +}
136 +
137 +/// A field's transform: applied to the parsed value, or to each of its elements.
138 +#[derive(Debug, Clone, PartialEq)]
139 +pub(super) struct FieldTransform {
140 + pub template: Transform,
141 + /// `transform_each`: the parsed content must be a list of dicts, and the
142 + /// template is applied to each element (with the element's keys unpacked into
143 + /// the template scope, alongside any regex captures).
144 + pub each: bool,
145 +}
146 +
147 +impl FieldTransform {
148 + /// Apply the transform to a parsed region value.
149 + pub fn apply(
150 + &self,
151 + field_name: &str,
152 + parsed: Value,
153 + captures: &Captures,
154 + ) -> unified::Result<Value> {
155 + if !self.each {
156 + let mut scope = captures.clone();
157 + scope.insert("content".to_string(), parsed);
158 + return self.template.apply(&scope);
159 + }
160 +
161 + let Value::Array(items) = parsed else {
162 + return Err(parsing_failed!(
163 + "Field '{field_name}': transform_each requires the parsed content to be an array, got {}.",
164 + json_type_name(&parsed)
165 + ));
166 + };
167 + items
168 + .into_iter()
169 + .map(|item| {
170 + let Value::Object(item) = item else {
171 + return Err(parsing_failed!(
172 + "Field '{field_name}': transform_each requires each array element to be an object, got {}.",
173 + json_type_name(&item)
174 + ));
175 + };
176 + let mut scope = captures.clone();
177 + scope.extend(item);
178 + self.template.apply(&scope)
179 + })
180 + .collect::<unified::Result<_>>()
181 + .map(Value::Array)
182 + }
183 +}
184 +
185 +/// JSON type name of a value, for error messages.
186 +fn json_type_name(value: &Value) -> &'static str {
187 + match value {
188 + Value::Null => "null",
189 + Value::Bool(_) => "boolean",
190 + Value::Number(_) => "number",
191 + Value::String(_) => "string",
192 + Value::Array(_) => "array",
193 + Value::Object(_) => "object",
194 + }
195 +}
196 +
197 +#[cfg(test)]
198 +mod tests {
199 + use serde_json::json;
200 +
201 + use super::*;
202 +
203 + fn scope(value: Value) -> Map<String, Value> {
204 + value.as_object().unwrap().clone()
205 + }
206 +
207 + #[test]
208 + fn dotted_placeholders_descend_into_objects() {
209 + let transform = Transform::compile(
210 + "test",
211 + &json!({"type": "function", "function": {"name": "{content.name}", "arguments": "{content.args}"}}),
212 + )
213 + .unwrap();
214 + let value = transform
215 + .apply(&scope(json!({"content": {"name": "f", "args": {"a": 1}}})))
216 + .unwrap();
217 + assert_eq!(
218 + value,
219 + json!({"type": "function", "function": {"name": "f", "arguments": {"a": 1}}})
220 + );
221 + assert_eq!(
222 + transform.placeholder_at(&["function", "name"]),
223 + Some(["content".to_string(), "name".to_string()].as_slice())
224 + );
225 + }
226 +
227 + #[test]
228 + fn placeholder_errors_match_transformers() {
229 + let apply = |template: Value, scope_value: Value| {
230 + Transform::compile("test", &template)
231 + .unwrap()
232 + .apply(&scope(scope_value))
233 + .unwrap_err()
234 + };
235 + expect_test::expect![[r#"
236 + ParsingFailed {
237 + message: "transform placeholder '{name}' is not defined. Available: [\"content\"]",
238 + }
239 + "#]]
240 + .assert_debug_eq(&apply(json!("{name}"), json!({"content": 1})));
241 + expect_test::expect![[r#"
242 + ParsingFailed {
243 + message: "transform placeholder '{content.a}' cannot index into number at 'a'",
244 + }
245 + "#]]
246 + .assert_debug_eq(&apply(json!("{content.a}"), json!({"content": 1})));
247 + expect_test::expect![[r#"
248 + ParsingFailed {
249 + message: "transform placeholder '{content.b}' is missing key 'b'. Available: [\"a\"]",
250 + }
251 + "#]]
252 + .assert_debug_eq(&apply(json!("{content.b}"), json!({"content": {"a": 1}})));
253 + }
254 +
255 + #[test]
256 + fn interpolation_is_rejected_at_load() {
257 + assert!(Transform::compile("test", &json!({"name": "fn_{name}"})).is_err());
258 + assert_eq!(
259 + Transform::compile("test", &json!("no placeholder")).unwrap(),
260 + Transform::Literal(json!("no placeholder"))
261 + );
262 + }
263 +}
rust/src/parser/src/unified/mod.rs
+9 -2
@@ -5,12 +5,14 @@
5
6 mod combined;
7 mod gemma4;
8 +mod hf;
9 mod hy;
10 mod inkling;
11 mod kimi_k3;
12
13 pub use combined::CombinedParser;
14 pub use gemma4::Gemma4UnifiedParser;
15 +pub use hf::{HfTemplateError, HfUnifiedParser, ResponseTemplate};
16 pub use hy::{HyV3UnifiedParser, HyV4UnifiedParser};
17 pub use inkling::InklingUnifiedParser;
18 pub use kimi_k3::{KimiK3StructuralTagBuilder, KimiK3UnifiedParser};
@@ -271,8 +273,13 @@ pub trait UnifiedParser: Send {
273 #[derive(Debug, Error, Macro)]
274 #[thiserror_ext(macro(path = "crate::unified", mangle))]
275 pub enum UnifiedParserError {
274 - #[error("combined parser is constructed from split parser instances")]
275 - CombinedParserConstructor,
276 + /// The parser is built from inputs other than tools and a tokenizer, so
277 + /// [`UnifiedParser::create`] cannot construct it.
278 + #[error("the `{parser}` unified parser is built from {built_from}, not by name")]
279 + NoNamedConstructor {
280 + parser: &'static str,
281 + built_from: &'static str,
282 + },
283 #[error("tokenizer is missing unified parser token `{token}`")]
284 MissingToken { token: String },
285 #[error("unified parser parsing failed: {message}")]
rust/src/text/src/backend/hf/config.rs
+3
@@ -23,6 +23,9 @@ pub struct HfTokenizerConfig {
23 /// containing "Tiktoken" which can be used as a hint for backend
24 /// selection.
25 pub tokenizer_class: Option<String>,
26 + /// The Hugging Face `response_template`: a declarative description of the
27 + /// model's output protocol, executed by the `hf` unified parser.
28 + pub response_template: Option<serde_json::Value>,
29 }
30
31 /// Hugging Face named special tokens may be serialized as a string or an