[Bugfix][Frontend][gpt-oss] Recover raw tail when Harmony parser ends non-terminal (#47379)

yzong-rh committed Jul 4, 2026 at 10:46 UTC 0cd6f767e36f78598c2a932478b59c2f78897ff3
4 files changed +145 -83
tests/entrypoints/unit_tests/test_context.py
+22 -13
@@ -74,7 +74,7 @@ class FakeHarmonyParser(HarmonyParser):
74 self.reasoning_parser = None
75 self.tool_parser = None
76 self._chunk_results: list[ChunkResult] = []
77 - self._flush_results: list[Segment | None] = []
77 + self._flush_results: list[list[Segment]] = []
78 self.processed_chunks: list[list[int]] = []
79
80 def enqueue_chunk_result(
@@ -89,7 +89,7 @@ class FakeHarmonyParser(HarmonyParser):
89 )
90 )
91
92 - def enqueue_flush_result(self, segment: Segment | None) -> None:
92 + def enqueue_flush_result(self, segment: list[Segment]) -> None:
93 self._flush_results.append(segment)
94
95 def process_chunk(self, token_ids) -> ChunkResult:
@@ -98,10 +98,10 @@ class FakeHarmonyParser(HarmonyParser):
98 return self._chunk_results.pop(0)
99 return ChunkResult(segments=[], reasoning_token_count=0)
100
101 - def flush(self) -> Segment | None:
101 + def flush(self) -> list[Segment]:
102 if self._flush_results:
103 return self._flush_results.pop(0)
104 - return None
104 + return []
105
106
107 def make_harmony_context(
@@ -598,13 +598,21 @@ async def test_streaming_message_synchronization():
598 content=[TextContent(text=response_text)],
599 recipient=Role.USER,
600 )
601 - flush_segment = Segment(
602 - channel="commentary",
603 - recipient=None,
604 - delta="",
605 - completed_message=message,
606 - )
607 - parser.enqueue_flush_result(flush_segment)
601 + flush_segments = [
602 + Segment(
603 + channel="final",
604 + recipient=None,
605 + delta=response_text,
606 + completed_message=None,
607 + ),
608 + Segment(
609 + channel="final",
610 + recipient=None,
611 + delta="",
612 + completed_message=message,
613 + ),
614 + ]
615 + parser.enqueue_flush_result(flush_segments)
616
617 # Create another output to trigger synchronization via flush()
618 context.append_output(
@@ -618,8 +626,9 @@ async def test_streaming_message_synchronization():
626 assert context.num_init_messages == 1
627 assert context._messages[2].content[0].text == response_text
628 assert context.last_append_flush_status is True
621 - assert len(context.last_append_segments) == 1
622 - assert context.last_append_segments[0].completed_message is message
629 + assert len(context.last_append_segments) == 2
630 + assert context.last_append_segments[-2].delta == response_text
631 + assert context.last_append_segments[-1].completed_message is message
632
633
634 def test_turn_metrics_copy_and_reset():
tests/parser/test_harmony.py
+70 -34
@@ -7,7 +7,6 @@ from collections.abc import Sequence
7 import pytest
8 from openai_harmony import (
9 Conversation,
10 - HarmonyError,
10 Message,
11 RenderConversationConfig,
12 Role,
@@ -51,6 +50,15 @@ def chat_request():
50 )
51
52
53 +@pytest.fixture
54 +def malformed_msgs_str() -> list[str]:
55 + return [
56 + "<|channel|>analysis<|message|>thinking<|end|>",
57 + "<|start|>assistant<|channel|>commentary<|message|>thinking<|end|>",
58 + '<|start|>assistant<|channel|>final {"answer": "hi"}<|return|>',
59 + ]
60 +
61 +
62 def encode_output(harmony_str: str) -> list[int]:
63 return get_encoding().encode(harmony_str, allowed_special="all")
64
@@ -131,13 +139,22 @@ def tool_call_entries(delta_message) -> list[tuple[int, str | None, str | None]]
139 ]
140
141
142 +def assert_parser_is_reset(harmony_parser: HarmonyParser):
143 + assert harmony_parser._parser is None
144 + assert harmony_parser._num_processed_messages == 0
145 + assert harmony_parser._current_message_tokens == []
146 +
147 +
148 class TestFlush:
149 def test_flush(self, harmony_parser):
150 harmony_parser.process_chunk(
151 encode_output("<|channel|>analysis<|message|>Think")
152 )
153
140 - flushed = harmony_parser.flush()
154 + flushed_segments = harmony_parser.flush()
155 + assert flushed_segments is not None
156 + assert len(flushed_segments) == 1
157 + flushed = flushed_segments[0]
158
159 assert flushed is not None
160 assert flushed.channel == "analysis"
@@ -145,15 +162,27 @@ class TestFlush:
162 assert flushed.delta == ""
163 assert flushed.completed_message is not None
164 assert get_text(flushed.completed_message) == "Think"
148 - assert harmony_parser._parser is None
165 + assert_parser_is_reset(harmony_parser)
166
150 - def test_flush_raises_and_resets_on_non_terminal_eos(self, harmony_parser):
151 - harmony_parser.process_chunk(encode_output("<|channel|>analysis"))
167 + def test_flush_recovers_invalid_output(self, harmony_parser, malformed_msgs_str):
168 + for msg_str in malformed_msgs_str[:-1]:
169 + chunk = harmony_parser.process_chunk(encode_output(msg_str))
170 + assert "".join(segment.delta for segment in chunk.segments) == "thinking"
171
153 - with pytest.raises(HarmonyError):
154 - harmony_parser.flush()
172 + last_msg_str = malformed_msgs_str[-1]
173 + harmony_parser.process_chunk(encode_output(last_msg_str))
174 + flushed_segments = harmony_parser.flush()
175 + assert len(flushed_segments) == 2
176 + delta_segment = flushed_segments[0]
177 + message_segment = flushed_segments[1]
178
156 - assert harmony_parser._parser is None
179 + assert delta_segment.channel == "final"
180 + assert delta_segment.recipient is None
181 + assert delta_segment.delta == last_msg_str
182 + assert message_segment.channel == "final"
183 + assert message_segment.recipient is None
184 + assert get_text(message_segment.completed_message) == last_msg_str
185 + assert_parser_is_reset(harmony_parser)
186
187
188 class TestParse:
@@ -364,7 +393,7 @@ class TestParse:
393 assert reasoning is None
394 assert content == "I'm in the middle of answering"
395 assert tool_calls is None
367 - assert harmony_parser._parser is None
396 + assert_parser_is_reset(harmony_parser)
397
398 def test_interrupted_reasoning_first_message(self, harmony_parser, chat_request):
399 reasoning, content, tool_calls = harmony_parser.parse(
@@ -378,7 +407,7 @@ class TestParse:
407 assert reasoning == "I'm in the middle of thinking"
408 assert content is None
409 assert tool_calls is None
381 - assert harmony_parser._parser is None
410 + assert_parser_is_reset(harmony_parser)
411
412 def test_truncated_output(self, harmony_parser, chat_request):
413 reasoning, content, tool_calls = harmony_parser.parse(
@@ -394,24 +423,23 @@ class TestParse:
423 assert reasoning == "I'm thinking."
424 assert content == "I'm in the middle of answering"
425 assert tool_calls is None
397 - assert harmony_parser._parser is None
426 + assert_parser_is_reset(harmony_parser)
427
399 - def test_malformed_final_recovers_raw_content(self, harmony_parser, chat_request):
400 - raw_output = (
401 - "<|channel|>analysis<|message|>thinking<|end|>"
402 - '<|start|>assistant<|channel|>final {"answer": "hi"}<|return|>'
403 - )
428 + def test_malformed_msgs_recovers_raw_content(
429 + self, harmony_parser, chat_request, malformed_msgs_str
430 + ):
431 + combined_output = "".join(malformed_msgs_str)
432
433 reasoning, content, tool_calls = harmony_parser.parse(
406 - raw_output,
434 + "",
435 chat_request,
408 - model_output_token_ids=encode_output(raw_output),
436 + model_output_token_ids=encode_output(combined_output),
437 )
438
411 - assert content == raw_output
412 - assert reasoning is None
439 + assert reasoning == "thinking"
440 + assert content == "thinking\n" + malformed_msgs_str[-1]
441 assert tool_calls is None
414 - assert harmony_parser._parser is None
442 + assert_parser_is_reset(harmony_parser)
443
444 @pytest.mark.parametrize(
445 ("harmony_str", "expected_content"),
@@ -489,7 +517,7 @@ class TestParseDelta:
517 assert second_delta is not None
518 assert second_delta.content == "Answer"
519 assert second_delta.reasoning is None
492 - assert parser._parser is None
520 + assert_parser_is_reset(parser)
521
522 def test_multi_token(self, gpt_oss_tokenizer, chat_request):
523 parser = HarmonyParser(gpt_oss_tokenizer)
@@ -506,25 +534,33 @@ class TestParseDelta:
534 assert delta.reasoning is None
535 assert not delta.tool_calls
536
509 - def test_malformed_final_recovers_raw_content(
510 - self, gpt_oss_tokenizer, chat_request
537 + def test_malformed_msgs_recovers_raw_content(
538 + self, gpt_oss_tokenizer, chat_request, malformed_msgs_str
539 ):
540 parser = HarmonyParser(gpt_oss_tokenizer)
541
514 - delta = parser.parse_delta(
515 - delta_text='final {"answer": "hi"}',
516 - delta_token_ids=encode_output(
517 - '<|channel|>final {"answer": "hi"}<|return|>'
518 - ),
542 + for msg_str in malformed_msgs_str[:-1]:
543 + delta = parser.parse_delta(
544 + delta_text="",
545 + delta_token_ids=encode_output(msg_str),
546 + request=chat_request,
547 + finished=False,
548 + )
549 + assert delta.reasoning or delta.content == "thinking"
550 + assert not delta.tool_calls
551 +
552 + last_delta = parser.parse_delta(
553 + delta_text="",
554 + delta_token_ids=encode_output(malformed_msgs_str[-1]),
555 request=chat_request,
556 finished=True,
557 )
558
523 - assert delta is not None
524 - assert delta.content == 'final {"answer": "hi"}'
525 - assert delta.reasoning is None
526 - assert not delta.tool_calls
527 - assert parser._parser is None
559 + assert last_delta is not None
560 + assert last_delta.content == malformed_msgs_str[-1]
561 + assert last_delta.reasoning is None
562 + assert not last_delta.tool_calls
563 + assert_parser_is_reset(parser)
564
565 @pytest.mark.parametrize("tool_channel", ["commentary", "analysis"])
566 def test_tool_call_split_across_deltas(
vllm/entrypoints/openai/responses/context.py
+6 -6
@@ -18,7 +18,7 @@ from openai.types.responses.response_output_item import McpCall
18 from openai.types.responses.response_output_message import ResponseOutputMessage
19 from openai.types.responses.response_output_text import ResponseOutputText
20 from openai.types.responses.tool import Mcp
21 -from openai_harmony import Author, HarmonyError, Message, Role, TextContent
21 +from openai_harmony import Author, Message, Role, TextContent
22
23 from vllm import envs
24 from vllm.entrypoints.chat_utils import (
@@ -616,7 +616,7 @@ class HarmonyContext(ConversationContext):
616 self.num_tool_output_tokens = 0
617
618 self.last_append_segments: list[Segment] = []
619 - self.last_append_flush_status: bool | HarmonyError = False
619 + self.last_append_flush_status: bool = False
620
621 # Turn tracking - replaces multiple individual tracking variables
622 self.current_turn_metrics = TurnMetrics()
@@ -643,10 +643,10 @@ class HarmonyContext(ConversationContext):
643
644 if output.finished:
645 self.finish_reason = output.outputs[0].finish_reason
646 - flushed = self.response_parser.flush()
647 - if flushed is not None:
648 - segments.append(flushed)
649 - self.last_append_flush_status = flushed is not None
646 + flushed_segments = self.response_parser.flush()
647 + if flushed_segments:
648 + segments.extend(flushed_segments)
649 + self.last_append_flush_status = len(flushed_segments) > 0
650 self.all_turn_metrics.append(self.current_turn_metrics.copy())
651 self.current_turn_metrics.reset()
652
vllm/parser/harmony.py
+47 -30
@@ -9,7 +9,7 @@ from dataclasses import dataclass
9 from enum import Enum, auto
10 from typing import TYPE_CHECKING, NamedTuple
11
12 -from openai_harmony import HarmonyError
12 +from openai_harmony import HarmonyError, Message, Role
13
14 from vllm.entrypoints.chat_utils import make_tool_call_id
15 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
@@ -91,6 +91,9 @@ class HarmonyParser(DelegatingParser):
91 self._next_tool_call_index = 0
92 self._num_processed_messages = 0
93
94 + # For error recovery
95 + self._current_message_tokens: list[int] = []
96 +
97 @property
98 def _harmony_parser(self) -> StreamableParser:
99 """Lazily initializes the Harmony parser."""
@@ -107,32 +110,48 @@ class HarmonyParser(DelegatingParser):
110 self._num_processed_messages += 1
111 return msg
112
110 - def flush(self) -> Segment | None:
113 + def flush(self) -> list[Segment]:
114 + segments: list[Segment] = []
115 try:
116 self._harmony_parser.process_eos()
117 msg = self._poll_completed_message()
118 except HarmonyError:
119 logger.warning(
120 "Harmony parser ended in a non-terminal state; returning the "
117 - "raw unparsed output. This usually indicates a malformed "
118 - "assistant turn, e.g. a 'final' channel missing the "
119 - "<|message|> delimiter."
121 + "recovered raw output."
122 )
121 - raise
122 - finally:
123 - # Reset to the initial assistant-parser state for the next turn.
124 - self._parser = None
125 - self._num_processed_messages = 0
123
127 - if msg is None:
128 - return None
124 + final_channel = "final"
125 + text = self.model_tokenizer.decode(self._current_message_tokens)
126 + segments.append(
127 + Segment(
128 + channel=final_channel,
129 + recipient=None,
130 + delta=text,
131 + completed_message=None,
132 + )
133 + )
134 + msg = Message.from_role_and_content(Role.ASSISTANT, text).with_channel(
135 + final_channel
136 + )
137 +
138 + # Reset to the initial assistant-parser state for the next turn.
139 + self._parser = None
140 + self._num_processed_messages = 0
141 + self._current_message_tokens.clear()
142
130 - return Segment(
131 - channel=msg.channel,
132 - recipient=msg.recipient,
133 - delta="",
134 - completed_message=msg,
143 + if msg is None:
144 + return segments
145 +
146 + segments.append(
147 + Segment(
148 + channel=msg.channel,
149 + recipient=msg.recipient,
150 + delta="",
151 + completed_message=msg,
152 + )
153 )
154 + return segments
155
156 def parse(
157 self,
@@ -147,12 +166,9 @@ class HarmonyParser(DelegatingParser):
166 Callers must decide whether to surface them.
167 """
168 result = self.process_chunk(model_output_token_ids)
150 - try:
151 - flushed_segment = self.flush()
152 - except HarmonyError:
153 - return None, model_output, None
154 - if flushed_segment is not None:
155 - result.segments.append(flushed_segment)
169 + flushed_segments = self.flush()
170 + if flushed_segments:
171 + result.segments.extend(flushed_segments)
172
173 reasoning_parts: list[str] = []
174 content_parts: list[str] = []
@@ -209,13 +225,9 @@ class HarmonyParser(DelegatingParser):
225 )
226 result = self.process_chunk(delta_token_ids)
227 if finished:
212 - try:
213 - flushed_segment = self.flush()
214 - except HarmonyError:
215 - self._next_tool_call_index = 0
216 - return DeltaMessage(content=delta_text)
217 - if flushed_segment is not None:
218 - result.segments.append(flushed_segment)
228 + flushed_segments = self.flush()
229 + if flushed_segments:
230 + result.segments.extend(flushed_segments)
231 combined_content = ""
232 combined_reasoning = ""
233 tool_messages: list[DeltaToolCall] = []
@@ -298,6 +310,11 @@ class HarmonyParser(DelegatingParser):
310 delta = self._harmony_parser.last_content_delta or ""
311 completed_message = self._poll_completed_message()
312
313 + if completed_message is not None:
314 + self._current_message_tokens.clear()
315 + else:
316 + self._current_message_tokens.append(token_id)
317 +
318 if channel == "analysis" or (
319 channel == "commentary" and recipient is not None
320 ):