[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 = ""