[Frontend] Preserve bare Inkling text in Python and Rust parsers (#50403)

Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Jul 31, 2026 at 03:25 UTC 629a938a928476a167efc6428bf2fbd43b1dc888
4 files changed +144 -18
rust/src/parser/src/unified/inkling.rs
+85 -16
@@ -61,7 +61,7 @@ enum InklingEvent {
61 TextStart,
62 ReasoningStart,
63 MessageStart,
64 - Header,
64 + Header { len: usize },
65 ToolJsonStart,
66 ToolJsonHeader { name: String },
67 ToolJsonArgs { len: usize, complete: bool },
@@ -72,7 +72,9 @@ enum InklingEvent {
72 enum InklingMode {
73 #[default]
74 Idle,
75 - MessageHeader,
75 + MessageHeader {
76 + pending: String,
77 + },
78 Text,
79 Reasoning,
80 ToolJsonHeader,
@@ -117,7 +119,9 @@ impl InklingUnifiedParser {
119 self.mode = InklingMode::Idle;
120 for token_id in prompt_token_ids.iter().rev().copied() {
121 if token_id == self.message_model_token_id {
120 - self.mode = InklingMode::MessageHeader;
122 + self.mode = InklingMode::MessageHeader {
123 + pending: String::new(),
124 + };
125 return;
126 }
127 if token_id == self.content_thinking_token_id {
@@ -140,8 +144,19 @@ impl InklingUnifiedParser {
144 InklingEvent::Reasoning { len } => {
145 output.push_reasoning(self.buffer[..len].to_string());
146 }
143 - InklingEvent::MessageStart => self.mode = InklingMode::MessageHeader,
144 - InklingEvent::Header => {}
147 + InklingEvent::MessageStart => {
148 + self.mode = InklingMode::MessageHeader {
149 + pending: String::new(),
150 + };
151 + }
152 + InklingEvent::Header { len } => {
153 + let InklingMode::MessageHeader { pending } = &mut self.mode else {
154 + return Err(parsing_failed!(
155 + "Inkling header text outside a message header"
156 + ));
157 + };
158 + pending.push_str(&self.buffer[..len]);
159 + }
160 InklingEvent::TextStart => self.mode = InklingMode::Text,
161 InklingEvent::ReasoningStart => self.mode = InklingMode::Reasoning,
162 InklingEvent::ToolJsonStart => self.mode = InklingMode::ToolJsonHeader,
@@ -174,7 +189,10 @@ impl InklingUnifiedParser {
189 }
190 }
191 InklingEvent::BlockEnd => {
177 - self.mode = InklingMode::Idle;
192 + let mode = std::mem::take(&mut self.mode);
193 + if let InklingMode::MessageHeader { pending } = mode {
194 + output.push_text(pending);
195 + }
196 self.active_tool_index = None;
197 }
198 }
@@ -182,10 +200,14 @@ impl InklingUnifiedParser {
200 }
201
202 fn reset(&mut self) -> String {
185 - self.mode = InklingMode::Idle;
203 + let mut uncommitted = match std::mem::take(&mut self.mode) {
204 + InklingMode::MessageHeader { pending } => pending,
205 + _ => String::new(),
206 + };
207 self.active_tool_index = None;
208 self.emitted_tool_count = 0;
188 - std::mem::take(&mut self.buffer)
209 + uncommitted.push_str(&std::mem::take(&mut self.buffer));
210 + uncommitted
211 }
212 }
213
@@ -225,11 +247,15 @@ impl UnifiedParser for InklingUnifiedParser {
247 fn finish(&mut self) -> Result<UnifiedParserOutput> {
248 let mut output = UnifiedParserOutput::default();
249
228 - match &self.mode {
250 + match &mut self.mode {
251 InklingMode::Idle | InklingMode::Text => {
252 output.push_text(std::mem::take(&mut self.buffer))
253 }
232 - InklingMode::MessageHeader => self.buffer.clear(),
254 + InklingMode::MessageHeader { pending } => {
255 + pending.push_str(&self.buffer);
256 + self.buffer.clear();
257 + output.push_text(std::mem::take(pending));
258 + }
259 InklingMode::Reasoning => output.push_reasoning(std::mem::take(&mut self.buffer)),
260 InklingMode::ToolJsonHeader
261 | InklingMode::ToolJsonArgs { .. }
@@ -254,7 +280,7 @@ fn parse_next_inkling_event(
280 ) -> ModalResult<InklingEvent> {
281 match mode {
282 InklingMode::Idle => parse_idle_event(input),
257 - InklingMode::MessageHeader => parse_message_header_event(input),
283 + InklingMode::MessageHeader { .. } => parse_message_header_event(input),
284 InklingMode::Text => parse_text_event(input),
285 InklingMode::Reasoning => parse_reasoning_event(input),
286 InklingMode::ToolJsonHeader => parse_tool_json_header_event(input),
@@ -346,7 +372,7 @@ fn safe_idle_text_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEven
372
373 /// Parse safe header text before the next Inkling marker.
374 fn safe_header_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
349 - safe_text_len_mul(input, IDLE_MARKERS).map(|_| InklingEvent::Header)
375 + safe_text_len_mul(input, IDLE_MARKERS).map(|len| InklingEvent::Header { len })
376 }
377
378 /// Parse safe text before the end of a Inkling text block.
@@ -672,15 +698,58 @@ mod tests {
698 let mut parser = test_parser();
699 parser.initialize(&[200001]).unwrap();
700
701 + let mut output = parser.parse_chunk("get_").unwrap();
702 + output.append(
703 + parser
704 + .parse_complete(concat!(
705 + "weather<|content_invoke_tool_json|>",
706 + "{\"name\":\"get_weather\",\"args\":{}}<|end_message|>"
707 + ))
708 + .unwrap(),
709 + );
710 +
711 + assert!(output.normal_text().is_empty());
712 + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather"));
713 + }
714 +
715 + #[test]
716 + fn inkling_initialize_model_opener_flushes_bare_text_at_finish() {
717 + let mut parser = test_parser();
718 + parser.initialize(&[200001]).unwrap();
719 +
720 + let mut output = parser.parse_chunk("plain ").unwrap();
721 + output.append(parser.parse_chunk("answer").unwrap());
722 + assert!(output.normal_text().is_empty());
723 +
724 + output.append(parser.finish().unwrap());
725 +
726 + assert_eq!(output.normal_text(), "plain answer");
727 + }
728 +
729 + #[test]
730 + fn inkling_initialize_model_opener_flushes_bare_text_before_end_marker() {
731 + let mut parser = test_parser();
732 + parser.initialize(&[200001]).unwrap();
733 +
734 let output = parser
735 .parse_complete(concat!(
677 - "get_weather<|content_invoke_tool_json|>",
678 - "{\"name\":\"get_weather\",\"args\":{}}<|end_message|>"
736 + "plain answer",
737 + "<|end_message|>",
738 + "<|content_model_end_sampling|>"
739 ))
740 .unwrap();
741
682 - assert!(output.normal_text().is_empty());
683 - assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather"));
742 + assert_eq!(output.normal_text(), "plain answer");
743 + }
744 +
745 + #[test]
746 + fn inkling_reset_returns_pending_message_header() {
747 + let mut parser = test_parser();
748 + parser.initialize(&[200001]).unwrap();
749 + parser.parse_chunk("plain ").unwrap();
750 + parser.parse_chunk("answer").unwrap();
751 +
752 + assert_eq!(parser.reset(), "plain answer");
753 }
754
755 #[test]
tests/parser/engine/test_inkling.py
+29
@@ -178,6 +178,13 @@ class TestArgConverter:
178
179
180 class TestNonStreaming:
181 + @pytest.mark.parametrize("suffix", ["", END_MESSAGE, END_SAMPLING])
182 + def test_bare_text_after_model_opener(self, parser, mock_request, suffix):
183 + reasoning, content, tools = parser.parse(f"hello world{suffix}", mock_request)
184 + assert reasoning is None
185 + assert content == "hello world"
186 + assert tools is None
187 +
188 def test_plain_text(self, parser, mock_request):
189 reasoning, content, tools = parser.parse(
190 f"{TEXT_START}hello world{END_MESSAGE}", mock_request
@@ -426,6 +433,28 @@ class TestPromptSeededState:
433 assert delta.content is None
434 assert delta.tool_calls[0].function.name == "get_weather"
435
436 + def test_generation_prompt_header_flushes_bare_text_at_finish(
437 + self, parser, mock_request
438 + ):
439 + prompt_token_ids = [_TML_VOCAB[END_MESSAGE], _TML_VOCAB[MSG_MODEL]]
440 + first = parser.parse_delta(
441 + "plain ",
442 + [ord(char) for char in "plain "],
443 + mock_request,
444 + prompt_token_ids=prompt_token_ids,
445 + finished=False,
446 + )
447 + assert first is None
448 +
449 + second = parser.parse_delta(
450 + "answer",
451 + [ord(char) for char in "answer"],
452 + mock_request,
453 + finished=True,
454 + )
455 + assert second is not None
456 + assert second.content == "plain answer"
457 +
458
459 class TestToolCallFiltering:
460 """Inkling equivalents of the generic tool-call-filtering replay tests
vllm/parser/engine/streaming_parser_engine.py
+29 -1
@@ -185,6 +185,7 @@ class StreamingParserEngine:
185 # implicit-reasoning-end (content returns None).
186 self._scanner.reset()
187 self._lexer.reset()
188 + self._message_header_buffer = ""
189 self._reset_args_state()
190
191 def feed(
@@ -270,6 +271,15 @@ class StreamingParserEngine:
271 )
272 self.state = ParserState.CONTENT
273 elif self.state == ParserState.MESSAGE_HEADER:
274 + if self._message_header_buffer:
275 + events.append(
276 + SemanticEvent(
277 + EventType.TEXT_CHUNK,
278 + value=self._message_header_buffer,
279 + tool_index=self.tool_index,
280 + )
281 + )
282 + self._message_header_buffer = ""
283 self.state = ParserState.CONTENT
284
285 return events
@@ -311,6 +321,7 @@ class StreamingParserEngine:
321 if self.skip_tool_parsing and terminal in self._tool_terminals:
322 if self.state == ParserState.MESSAGE_HEADER:
323 self.state = ParserState.CONTENT
324 + self._message_header_buffer = ""
325 return [
326 SemanticEvent(
327 EventType.TEXT_CHUNK,
@@ -345,6 +356,9 @@ class StreamingParserEngine:
356 return self._apply_transition(transition, value)
357
358 def _emit_for_state(self, text: str) -> list[SemanticEvent]:
359 + if self.state == ParserState.MESSAGE_HEADER:
360 + self._message_header_buffer += text
361 + return []
362 if self.state == ParserState.TOOL_ARGS:
363 if self.config.tool_args_json:
364 return self._feed_args_text(text)
@@ -371,6 +385,8 @@ class StreamingParserEngine:
385 value: str,
386 ) -> list[SemanticEvent]:
387 events: list[SemanticEvent] = []
388 + previous_state = self.state
389 + message_header = ""
390
391 if (
392 self.state == ParserState.TOOL_ARGS
@@ -386,15 +402,27 @@ class StreamingParserEngine:
402 )
403 self._args_buffer = ""
404
405 + if previous_state == ParserState.MESSAGE_HEADER:
406 + message_header = self._message_header_buffer
407 + self._message_header_buffer = ""
408 +
409 self.state = transition.next_state
410
411 for event_type in transition.events:
412 if event_type == EventType.TOOL_CALL_START:
413 self.tool_index += 1
414 + event_value = (
415 + message_header
416 + if previous_state == ParserState.MESSAGE_HEADER
417 + and event_type == EventType.TEXT_CHUNK
418 + else value
419 + )
420 + if event_type == EventType.TEXT_CHUNK and not event_value:
421 + continue
422 events.append(
423 SemanticEvent(
424 event_type,
397 - value=value,
425 + value=event_value,
426 tool_index=self.tool_index,
427 )
428 )
vllm/parser/inkling.py
+1 -1
@@ -268,7 +268,7 @@ def inkling_config() -> ParserEngineConfig:
268 )
269 transitions[(ParserState.MESSAGE_HEADER, end)] = Transition(
270 ParserState.CONTENT,
271 - (),
271 + (EventType.TEXT_CHUNK,),
272 )
273
274 return ParserEngineConfig(