[Bugfix][GPT-OSS] Fix strict tool-call grammar to accept Harmony renders (#52222)

Signed-off-by: Yifan Zong <yzong@redhat.com> Co-authored-by: Ben Browning <56071+bbrowning@users.noreply.github.com>

yzong-rh committed Aug 26, 2026 at 21:05 UTC 51d1b45182a1e9273ef19d2cbfb62055d3aee20f
2 files changed +111 -81
tests/parser/test_harmony.py
+87 -61
@@ -89,8 +89,11 @@ def encode_output(harmony_str: str) -> list[int]:
89 return get_encoding().encode(harmony_str, allowed_special="all")
90
91
92 -def assistant(content: str, channel: str) -> Message:
93 - return Message.from_role_and_content(Role.ASSISTANT, content).with_channel(channel)
92 +def assistant(content: str, channel: str, content_type: str | None = None) -> Message:
93 + message = Message.from_role_and_content(Role.ASSISTANT, content).with_channel(
94 + channel
95 + )
96 + return message if content_type is None else message.with_content_type(content_type)
97
98
99 def tool_call(
@@ -103,16 +106,14 @@ def tool_call(
106 return message if content_type is None else message.with_content_type(content_type)
107
108
106 -def get_model_output_tokens(
107 - prompt_messages: Sequence[Message],
108 - response_messages: Sequence[Message],
109 -) -> list[int]:
109 +def get_model_output_tokens(response_messages: Sequence[Message]) -> list[int]:
110 enc = get_encoding()
111 + prompt_messages = [Message.from_role_and_content(Role.USER, "x")]
112 # Keep analysis messages when synthesizing model-output-only token sequences
113 # for parser tests; the default render path drops them after a later final turn.
114 config = RenderConversationConfig(auto_drop_analysis=False)
115 prompt_ids = enc.render_conversation_for_completion(
115 - Conversation.from_messages(list(prompt_messages)),
116 + Conversation.from_messages(prompt_messages),
117 Role.ASSISTANT,
118 config=config,
119 )
@@ -124,6 +125,10 @@ def get_model_output_tokens(
125 return full_ids[len(prompt_ids) :]
126
127
128 +def get_model_output_str(response_messages: Sequence[Message]) -> str:
129 + return get_encoding().decode_utf8(get_model_output_tokens(response_messages))
130 +
131 +
132 def get_text(msg: Message) -> str:
133 return msg.content[0].text if msg.content else ""
134
@@ -215,13 +220,12 @@ class TestParse:
220 # Rendered conversation outputs.
221
222 def test_reasoning_only(self, harmony_parser, chat_request):
218 - prompt = [Message.from_role_and_content(Role.USER, "Why?")]
223 response = [assistant("This is reasoning", "analysis")]
224
225 reasoning, content, tool_calls = harmony_parser.parse(
226 "",
227 chat_request,
224 - model_output_token_ids=get_model_output_tokens(prompt, response),
228 + model_output_token_ids=get_model_output_tokens(response),
229 )
230
231 assert reasoning == "This is reasoning"
@@ -229,13 +233,12 @@ class TestParse:
233 assert tool_calls is None
234
235 def test_content_only(self, harmony_parser, chat_request):
232 - prompt = [Message.from_role_and_content(Role.USER, "Hello")]
236 response = [assistant("This is a test", "final")]
237
238 reasoning, content, tool_calls = harmony_parser.parse(
239 "",
240 chat_request,
238 - model_output_token_ids=get_model_output_tokens(prompt, response),
241 + model_output_token_ids=get_model_output_tokens(response),
242 )
243
244 assert reasoning is None
@@ -243,7 +246,6 @@ class TestParse:
246 assert tool_calls is None
247
248 def test_reasoning_and_content(self, harmony_parser, chat_request):
246 - prompt = [Message.from_role_and_content(Role.USER, "What is 2+2?")]
249 response = [
250 assistant("I should think first.", "analysis"),
251 assistant("The answer is 4.", "final"),
@@ -252,7 +254,7 @@ class TestParse:
254 reasoning, content, tool_calls = harmony_parser.parse(
255 "",
256 chat_request,
255 - model_output_token_ids=get_model_output_tokens(prompt, response),
257 + model_output_token_ids=get_model_output_tokens(response),
258 )
259
260 assert reasoning == "I should think first."
@@ -270,15 +272,12 @@ class TestParse:
272 def test_single_tool_call(
273 self, harmony_parser, chat_request, tool_args, tool_channel
274 ):
273 - prompt = [
274 - Message.from_role_and_content(Role.USER, "What is the weather in Tokyo?")
275 - ]
275 response = [tool_call("functions.get_current_weather", tool_args, tool_channel)]
276
277 reasoning, content, tool_calls = harmony_parser.parse(
278 "",
279 chat_request,
281 - model_output_token_ids=get_model_output_tokens(prompt, response),
280 + model_output_token_ids=get_model_output_tokens(response),
281 )
282
283 assert reasoning is None
@@ -288,11 +287,6 @@ class TestParse:
287 ]
288
289 def test_multiple_tool_calls_varied_formats(self, harmony_parser, chat_request):
291 - prompt = [
292 - Message.from_role_and_content(
293 - Role.USER, "What is the weather in Tokyo based on where I'm at?"
294 - )
295 - ]
290 response = [
291 tool_call("functions.get_current_weather", '{"location": "Tokyo"}'),
292 tool_call("functions.get_user_location", '{"location": "Tokyo"}'),
@@ -309,7 +303,7 @@ class TestParse:
303 _, content, tool_calls = harmony_parser.parse(
304 "",
305 chat_request,
312 - model_output_token_ids=get_model_output_tokens(prompt, response),
306 + model_output_token_ids=get_model_output_tokens(response),
307 )
308
309 assert content is None
@@ -323,13 +317,12 @@ class TestParse:
317 ]
318
319 def test_tool_call_bare_recipient(self, harmony_parser, chat_request):
326 - prompt = [Message.from_role_and_content(Role.USER, "Weather?")]
320 response = [tool_call("get_current_weather", '{"location": "Tokyo"}')]
321
322 _, _, tool_calls = harmony_parser.parse(
323 "",
324 chat_request,
332 - model_output_token_ids=get_model_output_tokens(prompt, response),
325 + model_output_token_ids=get_model_output_tokens(response),
326 )
327
328 assert tool_call_tuples(tool_calls) == [
@@ -337,7 +330,6 @@ class TestParse:
330 ]
331
332 def test_multiple_tool_calls_bare_recipients(self, harmony_parser, chat_request):
340 - prompt = [Message.from_role_and_content(Role.USER, "Use both tools.")]
333 response = [
334 tool_call("get_current_weather", '{"location": "Tokyo"}'),
335 tool_call("get_user_location", "{}"),
@@ -346,7 +338,7 @@ class TestParse:
338 _, _, tool_calls = harmony_parser.parse(
339 "",
340 chat_request,
349 - model_output_token_ids=get_model_output_tokens(prompt, response),
341 + model_output_token_ids=get_model_output_tokens(response),
342 )
343
344 assert tool_call_tuples(tool_calls) == [
@@ -355,7 +347,6 @@ class TestParse:
347 ]
348
349 def test_assistant_recipient_not_tool(self, harmony_parser, chat_request):
358 - prompt = [Message.from_role_and_content(Role.USER, "Hello")]
350 response = [
351 tool_call("assistant", "Some tool response", content_type=None),
352 assistant("Here is the answer", "final"),
@@ -364,7 +355,7 @@ class TestParse:
355 reasoning, content, tool_calls = harmony_parser.parse(
356 "",
357 chat_request,
367 - model_output_token_ids=get_model_output_tokens(prompt, response),
358 + model_output_token_ids=get_model_output_tokens(response),
359 )
360
361 assert reasoning is None
@@ -372,13 +363,12 @@ class TestParse:
363 assert tool_calls is None
364
365 def test_tool_call_dotted_name(self, harmony_parser, chat_request):
375 - prompt = [Message.from_role_and_content(Role.USER, "Compute 2+3")]
366 response = [tool_call("math.sum", '{"a": 2, "b": 3}')]
367
368 _, _, tool_calls = harmony_parser.parse(
369 "",
370 chat_request,
381 - model_output_token_ids=get_model_output_tokens(prompt, response),
371 + model_output_token_ids=get_model_output_tokens(response),
372 )
373
374 assert tool_call_tuples(tool_calls) == [
@@ -386,7 +376,6 @@ class TestParse:
376 ]
377
378 def test_tool_calls_with_final_content(self, harmony_parser, chat_request):
389 - prompt = [Message.from_role_and_content(Role.USER, "What is the weather?")]
379 response = [
380 assistant("User asked about the weather.", "analysis"),
381 tool_call("functions.get_current_weather", '{"location": "Tokyo"}'),
@@ -396,7 +385,7 @@ class TestParse:
385 reasoning, content, tool_calls = harmony_parser.parse(
386 "",
387 chat_request,
399 - model_output_token_ids=get_model_output_tokens(prompt, response),
388 + model_output_token_ids=get_model_output_tokens(response),
389 )
390
391 assert reasoning == "User asked about the weather."
@@ -700,12 +689,11 @@ class TestParseDelta:
689 recipient,
690 ):
691 parser = HarmonyParser(gpt_oss_tokenizer)
703 - prompt = [Message.from_role_and_content(Role.USER, "Hello")]
692 response = [tool_call(recipient, "Ignore this", content_type=None)]
693
694 delta = parser.parse_delta(
695 delta_text="",
708 - delta_token_ids=get_model_output_tokens(prompt, response),
696 + delta_token_ids=get_model_output_tokens(response),
697 request=chat_request,
698 finished=False,
699 )
@@ -914,35 +902,61 @@ class TestAdjustRequest:
902 "required": ["answer"],
903 }
904
917 - ANALYSIS = "<|channel|>analysis<|message|>analysis message<|end|><|start|>assistant"
918 - COMMENTARY = (
919 - "<|channel|>commentary<|message|>commentary message<|end|><|start|>assistant"
905 + ANALYSIS = get_model_output_str([assistant("reasoning", "analysis")])
906 + COMMENTARY = get_model_output_str([assistant("commentary", "commentary")])
907 + TOOL_CALL_1_CHANNEL_FIRST = (
908 + ANALYSIS + f"<|start|>assistant<|channel|>commentary to=functions.{TOOL_1_NAME}"
909 + " <|constrain|>json<|message|>{}<|call|>"
910 )
921 - TOOL_CALL_1 = (
922 - ANALYSIS + f"<|channel|>commentary to=functions.{TOOL_1_NAME} json<|message|>"
923 - "{}<|call|>"
911 + TOOL_CALL_1_FUNCTION_FIRST = get_model_output_str(
912 + [
913 + assistant("reasoning", "analysis"),
914 + tool_call(f"functions.{TOOL_1_NAME}", "{}"),
915 + ]
916 )
925 - TOOL_CALL_2 = (
926 - ANALYSIS + f"<|channel|>commentary to=functions.{TOOL_2_NAME} json<|message|>"
927 - '{"city": "Tokyo"}<|call|>'
917 + TOOL_CALL_2_CHANNEL_FIRST = (
918 + ANALYSIS + f"<|start|>assistant<|channel|>commentary to=functions.{TOOL_2_NAME}"
919 + ' <|constrain|>json<|message|>{"city": "Tokyo"}<|call|>'
920 )
929 - FINAL_JSON_SCHEMA = (
930 - ANALYSIS
931 - + '<|channel|>final <|constrain|>json<|message|>{"answer": "Tokyo"}<|end|>'
921 + TOOL_CALL_2_FUNCTION_FIRST = get_model_output_str(
922 + [
923 + assistant("reasoning", "analysis"),
924 + tool_call(f"functions.{TOOL_2_NAME}", '{"city": "Tokyo"}'),
925 + ]
926 )
933 - FINAL_JSON_OBJECT = (
934 - ANALYSIS
935 - + '<|channel|>final <|constrain|>json<|message|>{"city": "Tokyo"}<|end|>'
927 + FINAL_JSON_SCHEMA = get_model_output_str(
928 + [
929 + assistant("reasoning", "analysis"),
930 + assistant('{"answer": "Tokyo"}', "final", "json"),
931 + ]
932 + )
933 + FINAL_JSON_OBJECT = get_model_output_str(
934 + [
935 + assistant("reasoning", "analysis"),
936 + assistant('{"city": "Tokyo"}', "final", "<|constrain|>json"),
937 + ]
938 + )
939 + FINAL_TEXT_ONLY = get_model_output_str(
940 + [assistant("reasoning", "analysis"), assistant("any", "final")]
941 + )
942 + FINAL_REGEX = get_model_output_str(
943 + [assistant("reasoning", "analysis"), assistant("regex", "final")]
944 + )
945 + FINAL_CHOICE = get_model_output_str(
946 + [assistant("reasoning", "analysis"), assistant("choice1", "final")]
947 + )
948 + FINAL_GRAMMAR = get_model_output_str(
949 + [assistant("reasoning", "analysis"), assistant("grammar", "final")]
950 + )
951 + FINAL_STRUCTURAL_TAG = get_model_output_str(
952 + [assistant("reasoning", "analysis"), assistant("tag content", "final")]
953 )
937 - FINAL_TEXT_ONLY = ANALYSIS + "<|channel|>final<|message|>any<|end|>"
938 - FINAL_REGEX = ANALYSIS + "<|channel|>final<|message|>regex<|end|>"
939 - FINAL_CHOICE = ANALYSIS + "<|channel|>final<|message|>choice1<|end|>"
940 - FINAL_GRAMMAR = ANALYSIS + "<|channel|>final<|message|>grammar<|end|>"
941 - FINAL_STRUCTURAL_TAG = ANALYSIS + "<|channel|>final<|message|>tag content<|end|>"
954 ADMISSION_SAMPLES = (
955 "COMMENTARY",
944 - "TOOL_CALL_1",
945 - "TOOL_CALL_2",
956 + "TOOL_CALL_1_CHANNEL_FIRST",
957 + "TOOL_CALL_1_FUNCTION_FIRST",
958 + "TOOL_CALL_2_CHANNEL_FIRST",
959 + "TOOL_CALL_2_FUNCTION_FIRST",
960 "FINAL_JSON_SCHEMA",
961 "FINAL_JSON_OBJECT",
962 "FINAL_TEXT_ONLY",
@@ -1090,8 +1104,10 @@ class TestAdjustRequest:
1104 {"tool_choice": "auto", "strict_tools": True},
1105 [
1106 "COMMENTARY",
1093 - "TOOL_CALL_1",
1094 - "TOOL_CALL_2",
1107 + "TOOL_CALL_1_CHANNEL_FIRST",
1108 + "TOOL_CALL_1_FUNCTION_FIRST",
1109 + "TOOL_CALL_2_CHANNEL_FIRST",
1110 + "TOOL_CALL_2_FUNCTION_FIRST",
1111 "FINAL_JSON_SCHEMA",
1112 "FINAL_JSON_OBJECT",
1113 "FINAL_TEXT_ONLY",
@@ -1103,11 +1119,21 @@ class TestAdjustRequest:
1119 ),
1120 (
1121 {"tool_choice": "required"},
1106 - ["COMMENTARY", "TOOL_CALL_1", "TOOL_CALL_2"],
1122 + [
1123 + "COMMENTARY",
1124 + "TOOL_CALL_1_CHANNEL_FIRST",
1125 + "TOOL_CALL_1_FUNCTION_FIRST",
1126 + "TOOL_CALL_2_CHANNEL_FIRST",
1127 + "TOOL_CALL_2_FUNCTION_FIRST",
1128 + ],
1129 ),
1130 (
1131 {"tool_choice": "named"},
1110 - ["COMMENTARY", "TOOL_CALL_2"],
1132 + [
1133 + "COMMENTARY",
1134 + "TOOL_CALL_2_CHANNEL_FIRST",
1135 + "TOOL_CALL_2_FUNCTION_FIRST",
1136 + ],
1137 ),
1138 (
1139 {"response_format_type": "json_schema"},
vllm/parser/harmony.py
+24 -20
@@ -387,6 +387,7 @@ class HarmonyParser(DelegatingParser):
387 return recipient[:constrain_index].rstrip() or None
388
389
390 +_JSON_CONSTRAINS = [" json", " <|constrain|>json"]
391 # Harmomy's stop tokens are <|return|>, <|call|>, <|endoftext|>
392 # They are represented as "" since xgrammar disallows stop tokens while
393 # under constraints, leading to bad or infinite generations.
@@ -398,10 +399,8 @@ _TOOL_CALL_CHANNELS = [
399 "<|channel|>final",
400 ]
401 _FUNCTION_CALL_BEGINS = [
401 - "to=functions.{name} {channel} json<|message|>",
402 - "to=functions.{name} {channel} <|constrain|>json<|message|>",
403 - "{channel} to=functions.{name} json<|message|>",
404 - "{channel} to=functions.{name} <|constrain|>json<|message|>",
402 + " to=functions.{name}{channel}{constrain}<|message|>",
403 + "{channel} to=functions.{name}{constrain}<|message|>",
404 ]
405 _JSON_CONTENT = JSONSchemaFormat(json_schema={"type": "object"})
406 _ANY_CONTENT = AnyTextFormat()
@@ -476,7 +475,9 @@ def get_harmony_structural_tag(
475 else:
476 tags = [
477 TagFormat(
479 - begin=pattern.format(name=tool.function.name, channel=channel),
478 + begin=pattern.format(
479 + name=tool.function.name, channel=channel, constrain=constrain
480 + ),
481 content=JSONSchemaFormat(
482 json_schema=get_function_parameters(tool.function)
483 ),
@@ -484,23 +485,18 @@ def get_harmony_structural_tag(
485 )
486 for tool in tools
487 for pattern in _FUNCTION_CALL_BEGINS
488 + for constrain in _JSON_CONSTRAINS
489 for channel in _TOOL_CALL_CHANNELS
490 ]
491
492 if tool_choice == "auto":
491 - tags.append(
492 - TagFormat(
493 - begin=_FINAL_BEGIN.format(constrain=" <|constrain|>json"),
494 - content=_ANY_CONTENT,
495 - end=_END_TAG,
496 - )
497 - )
498 - tags.append(
493 + tags.extend(
494 TagFormat(
500 - begin=_FINAL_BEGIN.format(constrain=""),
495 + begin=_FINAL_BEGIN.format(constrain=constrain),
496 content=_ANY_CONTENT,
497 end=_END_TAG,
498 )
499 + for constrain in _JSON_CONSTRAINS + [""]
500 )
501
502 return _assemble_tag(
@@ -558,14 +554,22 @@ def _adjust_output_format(
554 return request
555
556 if isinstance(final_content, JSONSchemaFormat):
561 - begin = _FINAL_BEGIN.format(constrain=" <|constrain|>json")
557 + content = OrFormat(
558 + elements=[
559 + TagFormat(
560 + begin=_FINAL_BEGIN.format(constrain=constrain),
561 + content=final_content,
562 + end=_END_TAG,
563 + )
564 + for constrain in _JSON_CONSTRAINS
565 + ]
566 + )
567 else:
563 - begin = _FINAL_BEGIN.format(constrain="")
564 -
568 + content = TagFormat(
569 + begin=_FINAL_BEGIN.format(constrain=""), content=final_content, end=_END_TAG
570 + )
571 structural_tag = _assemble_tag(
566 - allow_analysis=True,
567 - allow_commentary=False,
568 - content=TagFormat(begin=begin, content=final_content, end=_END_TAG),
572 + allow_analysis=True, allow_commentary=False, content=content
573 )
574
575 request.structured_outputs = replace(