[Bugfix][Frontend][gpt-oss] Return raw output when Harmony parser ends non-terminal (#47062)

Signed-off-by: Achyuthan Sivasankar <achyuthan.sivasankar@gmail.com>

achyuthan.s committed Jul 1, 2026 at 05:46 UTC 3406e8f83dad17d044d38853f75270c7b636bb95
2 files changed +69 -17
tests/parser/test_harmony.py
+42 -5
@@ -7,6 +7,7 @@ from collections.abc import Sequence
7 import pytest
8 from openai_harmony import (
9 Conversation,
10 + HarmonyError,
11 Message,
12 RenderConversationConfig,
13 Role,
@@ -81,9 +82,8 @@ def get_model_output_tokens(
82 Role.ASSISTANT,
83 config=config,
84 )
84 - full_ids = enc.render_conversation_for_completion(
85 + full_ids = enc.render_conversation(
86 Conversation.from_messages([*prompt_messages, *response_messages]),
86 - Role.ASSISTANT,
87 config=config,
88 )
89 assert full_ids[: len(prompt_ids)] == prompt_ids
@@ -147,12 +147,12 @@ class TestFlush:
147 assert get_text(flushed.completed_message) == "Think"
148 assert harmony_parser._parser is None
149
150 - def test_flush_resets_after_eos_error(self, harmony_parser):
150 + def test_flush_raises_and_resets_on_non_terminal_eos(self, harmony_parser):
151 harmony_parser.process_chunk(encode_output("<|channel|>analysis"))
152
153 - flushed = harmony_parser.flush()
153 + with pytest.raises(HarmonyError):
154 + harmony_parser.flush()
155
155 - assert flushed is None
156 assert harmony_parser._parser is None
157
158
@@ -396,6 +396,23 @@ class TestParse:
396 assert tool_calls is None
397 assert harmony_parser._parser is None
398
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 + )
404 +
405 + reasoning, content, tool_calls = harmony_parser.parse(
406 + raw_output,
407 + chat_request,
408 + model_output_token_ids=encode_output(raw_output),
409 + )
410 +
411 + assert content == raw_output
412 + assert reasoning is None
413 + assert tool_calls is None
414 + assert harmony_parser._parser is None
415 +
416 @pytest.mark.parametrize(
417 ("harmony_str", "expected_content"),
418 [
@@ -489,6 +506,26 @@ class TestParseDelta:
506 assert delta.reasoning is None
507 assert not delta.tool_calls
508
509 + def test_malformed_final_recovers_raw_content(
510 + self, gpt_oss_tokenizer, chat_request
511 + ):
512 + parser = HarmonyParser(gpt_oss_tokenizer)
513 +
514 + delta = parser.parse_delta(
515 + delta_text='final {"answer": "hi"}',
516 + delta_token_ids=encode_output(
517 + '<|channel|>final {"answer": "hi"}<|return|>'
518 + ),
519 + request=chat_request,
520 + finished=True,
521 + )
522 +
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
528 +
529 @pytest.mark.parametrize("tool_channel", ["commentary", "analysis"])
530 def test_tool_call_split_across_deltas(
531 self, gpt_oss_tokenizer, chat_request, tool_channel
vllm/parser/harmony.py
+27 -12
@@ -3,7 +3,6 @@
3
4 from __future__ import annotations
5
6 -import contextlib
6 import json
7 from collections.abc import Sequence
8 from dataclasses import dataclass
@@ -26,6 +25,7 @@ from vllm.entrypoints.openai.parser.harmony_utils import (
25 is_function_recipient,
26 )
27 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
28 +from vllm.logger import init_logger
29 from vllm.parser.abstract_parser import DelegatingParser
30 from vllm.reasoning.gptoss_reasoning_parser import GptOssReasoningParser
31 from vllm.tool_parsers.gptoss_tool_parser import GptOssToolParser
@@ -34,6 +34,9 @@ if TYPE_CHECKING:
34 from openai_harmony import Message, StreamableParser
35
36
37 +logger = init_logger(__name__)
38 +
39 +
40 class _SegmentType(Enum):
41 TOOL = auto()
42 REASONING = auto()
@@ -105,16 +108,21 @@ class HarmonyParser(DelegatingParser):
108 return msg
109
110 def flush(self) -> Segment | None:
108 - msg = None
109 - with contextlib.suppress(HarmonyError):
111 + try:
112 self._harmony_parser.process_eos()
111 - # TODO: Consider reraising
112 -
113 - msg = self._poll_completed_message()
114 -
115 - # Reset to the initial assistant-parser state for the next turn.
116 - self._parser = None
117 - self._num_processed_messages = 0
113 + msg = self._poll_completed_message()
114 + except HarmonyError:
115 + logger.warning(
116 + "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."
120 + )
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
126
127 if msg is None:
128 return None
@@ -139,7 +147,10 @@ class HarmonyParser(DelegatingParser):
147 Callers must decide whether to surface them.
148 """
149 result = self.process_chunk(model_output_token_ids)
142 - flushed_segment = self.flush()
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)
156
@@ -198,7 +209,11 @@ class HarmonyParser(DelegatingParser):
209 )
210 result = self.process_chunk(delta_token_ids)
211 if finished:
201 - flushed_segment = self.flush()
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)
219 combined_content = ""