main
py 1,337 lines 46.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from collections.abc import Sequence
6 from typing import Any, Literal
7
8 import pytest
9 from openai_harmony import (
10 Conversation,
11 Message,
12 RenderConversationConfig,
13 Role,
14 )
15 from transformers import AutoTokenizer, GenerationConfig
16
17 from vllm.config import StructuredOutputsConfig, VllmConfig
18 from vllm.entrypoints.generate.base.protocol import FunctionCall
19 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
20 from vllm.entrypoints.openai.parser.harmony_utils import (
21 get_encoding,
22 )
23 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
24 from vllm.parser.harmony import HarmonyParser
25 from vllm.parser.parser_manager import ParserManager
26 from vllm.sampling_params import StructuredOutputsParams
27 from vllm.v1.structured_output.backend_types import StructuredOutputOptions
28 from vllm.v1.structured_output.backend_xgrammar import XgrammarBackend
29
30 REASONING_MODEL_NAME = "openai/gpt-oss-20b"
31
32
33 @pytest.fixture(scope="module")
34 def gpt_oss_tokenizer():
35 return AutoTokenizer.from_pretrained(REASONING_MODEL_NAME)
36
37
38 @pytest.fixture(scope="module")
39 def gpt_oss_stop_token_ids() -> set[int]:
40 eos_token_id = GenerationConfig.from_pretrained(REASONING_MODEL_NAME).eos_token_id
41 if isinstance(eos_token_id, int):
42 return {eos_token_id}
43 return set(eos_token_id)
44
45
46 @pytest.fixture(scope="module")
47 def xgrammar_backend(gpt_oss_tokenizer) -> XgrammarBackend:
48 vllm_config = VllmConfig(
49 structured_outputs_config=StructuredOutputsConfig(backend="xgrammar")
50 )
51 return XgrammarBackend(
52 vllm_config,
53 tokenizer=gpt_oss_tokenizer,
54 vocab_size=len(gpt_oss_tokenizer),
55 )
56
57
58 @pytest.fixture
59 def harmony_parser(gpt_oss_tokenizer):
60 parser_cls = ParserManager.get_parser(
61 tool_parser_name="openai",
62 reasoning_parser_name="openai_gptoss",
63 enable_auto_tools=True,
64 model_name=REASONING_MODEL_NAME,
65 is_harmony=True,
66 )
67 assert parser_cls is HarmonyParser
68 return parser_cls(gpt_oss_tokenizer)
69
70
71 @pytest.fixture
72 def chat_request():
73 return ChatCompletionRequest(
74 model="openai/gpt-oss-20b",
75 messages=[{"role": "user", "content": "Hello"}],
76 )
77
78
79 @pytest.fixture
80 def malformed_msgs_str() -> list[str]:
81 return [
82 "<|channel|>analysis<|message|>thinking<|end|>",
83 "<|start|>assistant<|channel|>commentary<|message|>thinking<|end|>",
84 '<|start|>assistant<|channel|>final {"answer": "hi"}<|return|>',
85 ]
86
87
88 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, 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(
100 recipient: str,
101 content: str,
102 channel: str = "commentary",
103 content_type: str | None = "json",
104 ) -> Message:
105 message = assistant(content, channel).with_recipient(recipient)
106 return message if content_type is None else message.with_content_type(content_type)
107
108
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(
116 Conversation.from_messages(prompt_messages),
117 Role.ASSISTANT,
118 config=config,
119 )
120 full_ids = enc.render_conversation(
121 Conversation.from_messages([*prompt_messages, *response_messages]),
122 config=config,
123 )
124 assert full_ids[: len(prompt_ids)] == prompt_ids
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
135
136 def tool_call_tuples(tool_calls: list[FunctionCall] | None) -> list[tuple[str, str]]:
137 return [] if tool_calls is None else [(tc.name, tc.arguments) for tc in tool_calls]
138
139
140 def tool_call_headers(delta_message) -> list:
141 if delta_message is None or not delta_message.tool_calls:
142 return []
143 return [
144 tool_call
145 for tool_call in delta_message.tool_calls
146 if tool_call.function and tool_call.function.name
147 ]
148
149
150 def tool_call_payloads(delta_message) -> list:
151 if delta_message is None or not delta_message.tool_calls:
152 return []
153 return [
154 tool_call
155 for tool_call in delta_message.tool_calls
156 if tool_call.function and tool_call.function.arguments
157 ]
158
159
160 def tool_call_entries(delta_message) -> list[tuple[int, str | None, str | None]]:
161 if delta_message is None or not delta_message.tool_calls:
162 return []
163 return [
164 (
165 tool_call.index,
166 tool_call.function.name if tool_call.function else None,
167 tool_call.function.arguments if tool_call.function else None,
168 )
169 for tool_call in delta_message.tool_calls
170 ]
171
172
173 def assert_parser_is_reset(harmony_parser: HarmonyParser):
174 assert harmony_parser._parser is None
175 assert harmony_parser._num_processed_messages == 0
176 assert harmony_parser._current_message_tokens == []
177
178
179 class TestFlush:
180 def test_flush(self, harmony_parser):
181 harmony_parser.process_chunk(
182 encode_output("<|channel|>analysis<|message|>Think")
183 )
184
185 flushed_segments = harmony_parser.flush()
186 assert flushed_segments is not None
187 assert len(flushed_segments) == 1
188 flushed = flushed_segments[0]
189
190 assert flushed is not None
191 assert flushed.channel == "analysis"
192 assert flushed.recipient is None
193 assert flushed.delta == ""
194 assert flushed.completed_message is not None
195 assert get_text(flushed.completed_message) == "Think"
196 assert_parser_is_reset(harmony_parser)
197
198 def test_flush_recovers_invalid_output(self, harmony_parser, malformed_msgs_str):
199 for msg_str in malformed_msgs_str[:-1]:
200 chunk = harmony_parser.process_chunk(encode_output(msg_str))
201 assert "".join(segment.delta for segment in chunk.segments) == "thinking"
202
203 last_msg_str = malformed_msgs_str[-1]
204 harmony_parser.process_chunk(encode_output(last_msg_str))
205 flushed_segments = harmony_parser.flush()
206 assert len(flushed_segments) == 2
207 delta_segment = flushed_segments[0]
208 message_segment = flushed_segments[1]
209
210 assert delta_segment.channel == "final"
211 assert delta_segment.recipient is None
212 assert delta_segment.delta == last_msg_str
213 assert message_segment.channel == "final"
214 assert message_segment.recipient is None
215 assert get_text(message_segment.completed_message) == last_msg_str
216 assert_parser_is_reset(harmony_parser)
217
218
219 class TestParse:
220 # Rendered conversation outputs.
221
222 def test_reasoning_only(self, harmony_parser, chat_request):
223 response = [assistant("This is reasoning", "analysis")]
224
225 reasoning, content, tool_calls = harmony_parser.parse(
226 "",
227 chat_request,
228 model_output_token_ids=get_model_output_tokens(response),
229 )
230
231 assert reasoning == "This is reasoning"
232 assert content is None
233 assert tool_calls is None
234
235 def test_content_only(self, harmony_parser, chat_request):
236 response = [assistant("This is a test", "final")]
237
238 reasoning, content, tool_calls = harmony_parser.parse(
239 "",
240 chat_request,
241 model_output_token_ids=get_model_output_tokens(response),
242 )
243
244 assert reasoning is None
245 assert content == "This is a test"
246 assert tool_calls is None
247
248 def test_reasoning_and_content(self, harmony_parser, chat_request):
249 response = [
250 assistant("I should think first.", "analysis"),
251 assistant("The answer is 4.", "final"),
252 ]
253
254 reasoning, content, tool_calls = harmony_parser.parse(
255 "",
256 chat_request,
257 model_output_token_ids=get_model_output_tokens(response),
258 )
259
260 assert reasoning == "I should think first."
261 assert content == "The answer is 4."
262 assert tool_calls is None
263
264 @pytest.mark.parametrize(
265 "tool_args",
266 [
267 '{"location": "Tokyo"}',
268 '{\n"location": "Tokyo"\n}',
269 ],
270 )
271 @pytest.mark.parametrize("tool_channel", ["commentary", "analysis"])
272 def test_single_tool_call(
273 self, harmony_parser, chat_request, tool_args, tool_channel
274 ):
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,
280 model_output_token_ids=get_model_output_tokens(response),
281 )
282
283 assert reasoning is None
284 assert content is None
285 assert tool_call_tuples(tool_calls) == [
286 ("get_current_weather", json.dumps({"location": "Tokyo"}))
287 ]
288
289 def test_multiple_tool_calls_varied_formats(self, harmony_parser, chat_request):
290 response = [
291 tool_call("functions.get_current_weather", '{"location": "Tokyo"}'),
292 tool_call("functions.get_user_location", '{"location": "Tokyo"}'),
293 tool_call(
294 "functions.no_content_type",
295 '{"location": "Tokyo"}',
296 content_type=None,
297 ),
298 tool_call("functions.not_json_no_content_type", "foo", content_type=None),
299 tool_call("functions.empty_args", "{}"),
300 tool_call("functions.no_args", ""),
301 ]
302
303 _, content, tool_calls = harmony_parser.parse(
304 "",
305 chat_request,
306 model_output_token_ids=get_model_output_tokens(response),
307 )
308
309 assert content is None
310 assert tool_call_tuples(tool_calls) == [
311 ("get_current_weather", json.dumps({"location": "Tokyo"})),
312 ("get_user_location", json.dumps({"location": "Tokyo"})),
313 ("no_content_type", json.dumps({"location": "Tokyo"})),
314 ("not_json_no_content_type", "foo"),
315 ("empty_args", json.dumps({})),
316 ("no_args", ""),
317 ]
318
319 def test_tool_call_bare_recipient(self, harmony_parser, chat_request):
320 response = [tool_call("get_current_weather", '{"location": "Tokyo"}')]
321
322 _, _, tool_calls = harmony_parser.parse(
323 "",
324 chat_request,
325 model_output_token_ids=get_model_output_tokens(response),
326 )
327
328 assert tool_call_tuples(tool_calls) == [
329 ("get_current_weather", json.dumps({"location": "Tokyo"}))
330 ]
331
332 def test_multiple_tool_calls_bare_recipients(self, harmony_parser, chat_request):
333 response = [
334 tool_call("get_current_weather", '{"location": "Tokyo"}'),
335 tool_call("get_user_location", "{}"),
336 ]
337
338 _, _, tool_calls = harmony_parser.parse(
339 "",
340 chat_request,
341 model_output_token_ids=get_model_output_tokens(response),
342 )
343
344 assert tool_call_tuples(tool_calls) == [
345 ("get_current_weather", json.dumps({"location": "Tokyo"})),
346 ("get_user_location", json.dumps({})),
347 ]
348
349 def test_assistant_recipient_not_tool(self, harmony_parser, chat_request):
350 response = [
351 tool_call("assistant", "Some tool response", content_type=None),
352 assistant("Here is the answer", "final"),
353 ]
354
355 reasoning, content, tool_calls = harmony_parser.parse(
356 "",
357 chat_request,
358 model_output_token_ids=get_model_output_tokens(response),
359 )
360
361 assert reasoning is None
362 assert content == "Here is the answer"
363 assert tool_calls is None
364
365 def test_tool_call_dotted_name(self, harmony_parser, chat_request):
366 response = [tool_call("math.sum", '{"a": 2, "b": 3}')]
367
368 _, _, tool_calls = harmony_parser.parse(
369 "",
370 chat_request,
371 model_output_token_ids=get_model_output_tokens(response),
372 )
373
374 assert tool_call_tuples(tool_calls) == [
375 ("math.sum", json.dumps({"a": 2, "b": 3}))
376 ]
377
378 def test_tool_calls_with_final_content(self, harmony_parser, chat_request):
379 response = [
380 assistant("User asked about the weather.", "analysis"),
381 tool_call("functions.get_current_weather", '{"location": "Tokyo"}'),
382 assistant("This tool call will get the weather.", "final"),
383 ]
384
385 reasoning, content, tool_calls = harmony_parser.parse(
386 "",
387 chat_request,
388 model_output_token_ids=get_model_output_tokens(response),
389 )
390
391 assert reasoning == "User asked about the weather."
392 assert content == "This tool call will get the weather."
393 assert tool_call_tuples(tool_calls) == [
394 ("get_current_weather", json.dumps({"location": "Tokyo"}))
395 ]
396
397 # Raw/truncated Harmony output streams.
398
399 def test_interrupted_first_message(self, harmony_parser, chat_request):
400 reasoning, content, tool_calls = harmony_parser.parse(
401 "",
402 chat_request,
403 model_output_token_ids=encode_output(
404 "<|channel|>final<|message|>I'm in the middle of answering"
405 ),
406 )
407
408 assert reasoning is None
409 assert content == "I'm in the middle of answering"
410 assert tool_calls is None
411 assert_parser_is_reset(harmony_parser)
412
413 def test_interrupted_reasoning_first_message(self, harmony_parser, chat_request):
414 reasoning, content, tool_calls = harmony_parser.parse(
415 "",
416 chat_request,
417 model_output_token_ids=encode_output(
418 "<|channel|>analysis<|message|>I'm in the middle of thinking"
419 ),
420 )
421
422 assert reasoning == "I'm in the middle of thinking"
423 assert content is None
424 assert tool_calls is None
425 assert_parser_is_reset(harmony_parser)
426
427 def test_truncated_output(self, harmony_parser, chat_request):
428 reasoning, content, tool_calls = harmony_parser.parse(
429 "",
430 chat_request,
431 model_output_token_ids=encode_output(
432 "<|channel|>analysis<|message|>I'm thinking.<|end|>"
433 "<|start|>assistant<|channel|>final<|message|>"
434 "I'm in the middle of answering"
435 ),
436 )
437
438 assert reasoning == "I'm thinking."
439 assert content == "I'm in the middle of answering"
440 assert tool_calls is None
441 assert_parser_is_reset(harmony_parser)
442
443 def test_malformed_msgs_recovers_raw_content(
444 self, harmony_parser, chat_request, malformed_msgs_str
445 ):
446 combined_output = "".join(malformed_msgs_str)
447
448 reasoning, content, tool_calls = harmony_parser.parse(
449 "",
450 chat_request,
451 model_output_token_ids=encode_output(combined_output),
452 )
453
454 assert reasoning == "thinking"
455 assert content == "thinking\n" + malformed_msgs_str[-1]
456 assert tool_calls is None
457 assert_parser_is_reset(harmony_parser)
458
459 @pytest.mark.parametrize(
460 ("harmony_str", "expected_content"),
461 [
462 (
463 "<|channel|>commentary<|message|>I'll search for that",
464 "I'll search for that",
465 ),
466 (
467 "<|channel|>commentary<|message|>Let me look that up.<|end|>"
468 "<|start|>assistant<|channel|>final<|message|>The answer is 42.<|end|>",
469 "Let me look that up.\nThe answer is 42.",
470 ),
471 ],
472 )
473 def test_commentary_preambles(
474 self,
475 harmony_parser,
476 chat_request,
477 harmony_str,
478 expected_content,
479 ):
480 reasoning, content, tool_calls = harmony_parser.parse(
481 "",
482 chat_request,
483 model_output_token_ids=encode_output(harmony_str),
484 )
485
486 assert reasoning is None
487 assert content == expected_content
488 assert tool_calls is None
489
490 def test_commentary_with_recipient_excluded(self, harmony_parser, chat_request):
491 reasoning, content, tool_calls = harmony_parser.parse(
492 "",
493 chat_request,
494 model_output_token_ids=encode_output(
495 "<|channel|>commentary"
496 "<|message|>Let me check the weather.<|end|>"
497 "<|start|>assistant to=functions.get_weather"
498 "<|channel|>commentary"
499 '<|message|>{"location": "SF"}<|end|>'
500 ),
501 )
502
503 assert reasoning is None
504 assert content == "Let me check the weather."
505 assert tool_call_tuples(tool_calls) == [
506 ("get_weather", json.dumps({"location": "SF"}))
507 ]
508
509
510 class TestParseDelta:
511 def test_basic(self, gpt_oss_tokenizer, chat_request):
512 parser = HarmonyParser(gpt_oss_tokenizer)
513
514 first_delta = parser.parse_delta(
515 delta_text="",
516 delta_token_ids=encode_output("<|channel|>analysis<|message|>Thinking"),
517 request=chat_request,
518 finished=False,
519 )
520 second_delta = parser.parse_delta(
521 delta_text="",
522 delta_token_ids=encode_output(
523 "<|end|><|start|>assistant<|channel|>final<|message|>Answer"
524 ),
525 request=chat_request,
526 finished=True,
527 )
528
529 assert first_delta is not None
530 assert first_delta.reasoning == "Thinking"
531 assert first_delta.content is None
532 assert second_delta is not None
533 assert second_delta.content == "Answer"
534 assert second_delta.reasoning is None
535 assert_parser_is_reset(parser)
536
537 def test_multi_token(self, gpt_oss_tokenizer, chat_request):
538 parser = HarmonyParser(gpt_oss_tokenizer)
539
540 delta = parser.parse_delta(
541 delta_text="",
542 delta_token_ids=encode_output("<|channel|>final<|message|>Hello, world!"),
543 request=chat_request,
544 finished=False,
545 )
546
547 assert delta is not None
548 assert delta.content == "Hello, world!"
549 assert delta.reasoning is None
550 assert not delta.tool_calls
551
552 def test_malformed_msgs_recovers_raw_content(
553 self, gpt_oss_tokenizer, chat_request, malformed_msgs_str
554 ):
555 parser = HarmonyParser(gpt_oss_tokenizer)
556
557 for msg_str in malformed_msgs_str[:-1]:
558 delta = parser.parse_delta(
559 delta_text="",
560 delta_token_ids=encode_output(msg_str),
561 request=chat_request,
562 finished=False,
563 )
564 assert delta.reasoning or delta.content == "thinking"
565 assert not delta.tool_calls
566
567 last_delta = parser.parse_delta(
568 delta_text="",
569 delta_token_ids=encode_output(malformed_msgs_str[-1]),
570 request=chat_request,
571 finished=True,
572 )
573
574 assert last_delta is not None
575 assert last_delta.content == malformed_msgs_str[-1]
576 assert last_delta.reasoning is None
577 assert not last_delta.tool_calls
578 assert_parser_is_reset(parser)
579
580 @pytest.mark.parametrize("tool_channel", ["commentary", "analysis"])
581 def test_tool_call_split_across_deltas(
582 self, gpt_oss_tokenizer, chat_request, tool_channel
583 ):
584 parser = HarmonyParser(gpt_oss_tokenizer)
585
586 first_delta = parser.parse_delta(
587 delta_text="",
588 delta_token_ids=encode_output(
589 "<|channel|>analysis<|message|>Thinking<|end|>"
590 f"<|start|>assistant to=functions.get_weather<|channel|>{tool_channel}"
591 '<|constrain|>json<|message|>{"location": '
592 ),
593 request=chat_request,
594 finished=False,
595 )
596 second_delta = parser.parse_delta(
597 delta_text="",
598 delta_token_ids=encode_output('"Paris"}<|call|>'),
599 request=chat_request,
600 finished=False,
601 )
602
603 assert first_delta is not None
604 assert first_delta.reasoning == "Thinking"
605 assert first_delta.content is None
606 assert tool_call_entries(first_delta) == [
607 (0, "get_weather", '{"location": '),
608 ]
609
610 assert second_delta is not None
611 assert second_delta.reasoning is None
612 assert second_delta.content is None
613 assert tool_call_entries(second_delta) == [(0, None, '"Paris"}')]
614
615 def test_commentary_preamble_streaming(self, gpt_oss_tokenizer, chat_request):
616 parser = HarmonyParser(gpt_oss_tokenizer)
617
618 delta = parser.parse_delta(
619 delta_text="",
620 delta_token_ids=encode_output(
621 "<|channel|>commentary<|message|>I'll search for that"
622 ),
623 request=chat_request,
624 finished=False,
625 )
626
627 assert delta is not None
628 assert delta.content == "I'll search for that"
629 assert delta.reasoning is None
630 assert not delta.tool_calls
631
632 def test_multiple_choices(self, gpt_oss_tokenizer, chat_request):
633 parser_a = HarmonyParser(gpt_oss_tokenizer)
634 parser_b = HarmonyParser(gpt_oss_tokenizer)
635
636 delta_a = parser_a.parse_delta(
637 delta_text="",
638 delta_token_ids=encode_output(
639 "<|channel|>analysis<|message|>Check weather<|end|>"
640 "<|start|>assistant to=functions.get_weather<|channel|>commentary"
641 '<|constrain|>json<|message|>{"location": "Paris"}'
642 ),
643 request=chat_request,
644 finished=False,
645 )
646 delta_b = parser_b.parse_delta(
647 delta_text="",
648 delta_token_ids=encode_output(
649 "<|channel|>analysis<|message|>Check time<|end|>"
650 "<|start|>assistant to=functions.get_time<|channel|>commentary"
651 '<|constrain|>json<|message|>{"timezone": "UTC"}'
652 ),
653 request=chat_request,
654 finished=False,
655 )
656
657 assert [tool.function.name for tool in tool_call_headers(delta_a)] == [
658 "get_weather"
659 ]
660 assert [tool.function.name for tool in tool_call_headers(delta_b)] == [
661 "get_time"
662 ]
663 assert {tool.index for tool in delta_a.tool_calls} == {0}
664 assert {tool.index for tool in delta_b.tool_calls} == {0}
665
666 def test_dotted_function_name(self, gpt_oss_tokenizer, chat_request):
667 parser = HarmonyParser(gpt_oss_tokenizer)
668
669 delta = parser.parse_delta(
670 delta_text="",
671 delta_token_ids=encode_output(
672 "<|channel|>analysis<|message|>Compute this<|end|>"
673 "<|start|>assistant to=math.sum<|channel|>commentary"
674 '<|constrain|>json<|message|>{"a": 2, "b": 3}'
675 ),
676 request=chat_request,
677 finished=False,
678 )
679
680 assert delta is not None
681 assert [tool.function.name for tool in tool_call_headers(delta)] == ["math.sum"]
682 assert {tool.index for tool in delta.tool_calls} == {0}
683
684 @pytest.mark.parametrize("recipient", ["assistant", "browser"])
685 def test_builtin_recipient_skipped(
686 self,
687 gpt_oss_tokenizer,
688 chat_request,
689 recipient,
690 ):
691 parser = HarmonyParser(gpt_oss_tokenizer)
692 response = [tool_call(recipient, "Ignore this", content_type=None)]
693
694 delta = parser.parse_delta(
695 delta_text="",
696 delta_token_ids=get_model_output_tokens(response),
697 request=chat_request,
698 finished=False,
699 )
700
701 assert delta is None
702
703 def test_cross_channel_with_tool(self, gpt_oss_tokenizer, chat_request):
704 parser = HarmonyParser(gpt_oss_tokenizer)
705
706 delta = parser.parse_delta(
707 delta_text="",
708 delta_token_ids=encode_output(
709 "<|channel|>analysis<|message|>Reasoning about query...<|end|>"
710 "<|start|>assistant to=functions.search<|channel|>commentary"
711 '<|constrain|>json<|message|>{"query": "vllm"}<|call|>'
712 "<|start|>assistant<|channel|>final<|message|>Done"
713 ),
714 request=chat_request,
715 finished=False,
716 )
717
718 assert delta is not None
719 assert delta.reasoning == "Reasoning about query..."
720 assert delta.content == "Done"
721 assert tool_call_entries(delta) == [(0, "search", '{"query": "vllm"}')]
722
723 def test_tool_index_across_calls(self, gpt_oss_tokenizer, chat_request):
724 parser = HarmonyParser(gpt_oss_tokenizer)
725
726 first_delta = parser.parse_delta(
727 delta_text="",
728 delta_token_ids=encode_output(
729 "<|channel|>analysis<|message|>Thinking<|end|>"
730 "<|start|>assistant to=functions.get_weather<|channel|>commentary"
731 '<|constrain|>json<|message|>{"location": "Paris"}<|call|>'
732 ),
733 request=chat_request,
734 finished=False,
735 )
736 second_delta = parser.parse_delta(
737 delta_text="",
738 delta_token_ids=encode_output(
739 "<|start|>assistant to=functions.get_time<|channel|>commentary"
740 '<|constrain|>json<|message|>{"timezone": "UTC"}<|call|>'
741 ),
742 request=chat_request,
743 finished=False,
744 )
745
746 assert [tool.index for tool in tool_call_headers(first_delta)] == [0]
747 assert [tool.index for tool in tool_call_headers(second_delta)] == [1]
748 assert [tool.function.name for tool in tool_call_headers(second_delta)] == [
749 "get_time"
750 ]
751
752 def test_multi_tool_interleaved(self, gpt_oss_tokenizer, chat_request):
753 parser = HarmonyParser(gpt_oss_tokenizer)
754
755 first_delta = parser.parse_delta(
756 delta_text="",
757 delta_token_ids=encode_output(
758 "<|channel|>analysis<|message|>Plan<|end|>"
759 "<|start|>assistant to=functions.tool_a<|channel|>commentary"
760 '<|constrain|>json<|message|>{"a": 1}<|call|>'
761 "<|start|>assistant to=functions.tool_b<|channel|>commentary"
762 '<|constrain|>json<|message|>{"b": '
763 ),
764 request=chat_request,
765 finished=False,
766 )
767 second_delta = parser.parse_delta(
768 delta_text="",
769 delta_token_ids=encode_output("2"),
770 request=chat_request,
771 finished=False,
772 )
773 third_delta = parser.parse_delta(
774 delta_text="",
775 delta_token_ids=encode_output(
776 "}<|call|><|start|>assistant<|channel|>final<|message|>Done<|end|>"
777 "<|start|>assistant to=functions.tool_c<|channel|>commentary"
778 '<|constrain|>json<|message|>{"c": 3}'
779 ),
780 request=chat_request,
781 finished=False,
782 )
783
784 assert tool_call_entries(first_delta) == [
785 (0, "tool_a", '{"a": 1}'),
786 (1, "tool_b", '{"b": '),
787 ]
788 assert [tool.index for tool in tool_call_headers(first_delta)] == [0, 1]
789
790 assert second_delta is not None
791 assert tool_call_entries(second_delta) == [(1, None, "2")]
792 assert [tool.index for tool in tool_call_payloads(second_delta)] == [1]
793
794 assert third_delta is not None
795 assert third_delta.content == "Done"
796 assert tool_call_entries(third_delta) == [
797 (1, None, "}"),
798 (2, "tool_c", '{"c": 3}'),
799 ]
800 assert [tool.index for tool in tool_call_headers(third_delta)] == [2]
801
802
803 class TestProcessChunk:
804 def test_empty(self, harmony_parser):
805 result = harmony_parser.process_chunk([])
806 assert result.segments == []
807 assert result.reasoning_token_count == 0
808
809 def test_single_channel(self, harmony_parser):
810 result = harmony_parser.process_chunk(
811 encode_output("<|channel|>final<|message|>Hello")
812 )
813
814 assert [
815 (s.channel, s.recipient, s.delta) for s in result.segments if s.delta
816 ] == [("final", None, "Hello")]
817
818 def test_constrained_output_segment_recipient_normalized(self, harmony_parser):
819 result = harmony_parser.process_chunk(
820 encode_output(
821 '<|channel|>final <|constrain|>json<|message|>{"result":true}<|end|>'
822 )
823 )
824
825 content_segments = [segment for segment in result.segments if segment.delta]
826 assert all(segment.channel == "final" for segment in content_segments)
827 assert all(segment.recipient is None for segment in content_segments)
828 assert (
829 "".join(segment.delta for segment in content_segments) == '{"result":true}'
830 )
831 completed_messages = [
832 segment.completed_message
833 for segment in result.segments
834 if segment.completed_message is not None
835 ]
836 assert len(completed_messages) == 1
837 assert completed_messages[0].recipient is None
838
839 def test_cross_channel(self, harmony_parser):
840 result = harmony_parser.process_chunk(
841 encode_output(
842 "<|channel|>analysis<|message|>Think<|end|>"
843 "<|start|>assistant<|channel|>final<|message|>Answer"
844 )
845 )
846
847 assert [
848 (s.channel, s.recipient, s.delta) for s in result.segments if s.delta
849 ] == [
850 ("analysis", None, "Think"),
851 ("final", None, "Answer"),
852 ]
853
854 def test_multi_boundary(self, harmony_parser):
855 result = harmony_parser.process_chunk(
856 encode_output(
857 "<|channel|>analysis<|message|>One<|end|>"
858 "<|start|>assistant<|channel|>final<|message|>Two<|end|>"
859 )
860 )
861
862 boundary_segments = [
863 segment
864 for segment in result.segments
865 if segment.completed_message is not None
866 ]
867 assert [
868 (segment.completed_message.channel, get_text(segment.completed_message))
869 for segment in boundary_segments
870 ] == [
871 ("analysis", "One"),
872 ("final", "Two"),
873 ]
874
875 def test_malformed_token_does_not_raise(self, harmony_parser):
876 malformed = encode_output(
877 "<|channel|>analysis<|message|>think<|end|><|return|>"
878 "<|start|>assistant<|channel|>final<|message|>answer<|return|>"
879 )
880
881 result = harmony_parser.process_chunk(malformed)
882 assert "".join(s.delta for s in result.segments if s.delta) == "thinkanswer"
883
884
885 class TestCountReasoningTokens:
886 def test_matches_process_chunk(self, harmony_parser, gpt_oss_tokenizer):
887 """Chat usage must report the same count as the Responses API path."""
888 token_ids = get_model_output_tokens(
889 [
890 assistant("Let me check the weather.", "analysis"),
891 tool_call("functions.get_weather", '{"location": "SF"}'),
892 ]
893 )
894 count = harmony_parser.count_reasoning_tokens(token_ids)
895
896 fresh = HarmonyParser(gpt_oss_tokenizer)
897 assert count == fresh.process_chunk(token_ids).reasoning_token_count
898 assert count > 0
899
900 def test_excludes_harmony_control_tokens(self, harmony_parser):
901 text = "Let me think about this."
902 token_ids = get_model_output_tokens(
903 [assistant(text, "analysis"), assistant("Hi", "final")]
904 )
905 expected = len(get_encoding().encode(text))
906 assert harmony_parser.count_reasoning_tokens(token_ids) == expected
907
908 def test_final_only_has_no_reasoning(self, harmony_parser):
909 token_ids = get_model_output_tokens([assistant("Hello", "final")])
910 assert harmony_parser.count_reasoning_tokens(token_ids) == 0
911
912 def test_streaming_reuses_running_total(
913 self, harmony_parser, gpt_oss_tokenizer, chat_request, monkeypatch
914 ):
915 """Called per streamed chunk with all tokens so far; must not replay."""
916 token_ids = get_model_output_tokens(
917 [assistant("Think", "analysis"), assistant("Answer", "final")]
918 )
919 expected = HarmonyParser(gpt_oss_tokenizer).count_reasoning_tokens(token_ids)
920 harmony_parser.parse_delta("", token_ids[:3], chat_request, finished=False)
921
922 def fail():
923 raise AssertionError("count_reasoning_tokens replayed the output")
924
925 monkeypatch.setattr(
926 "vllm.parser.harmony.get_streamable_parser_for_assistant", fail
927 )
928 for end in range(3, len(token_ids)):
929 harmony_parser.process_chunk(token_ids[end : end + 1])
930 harmony_parser.count_reasoning_tokens(token_ids[: end + 1])
931 assert harmony_parser.count_reasoning_tokens(token_ids) == expected
932
933 def test_does_not_disturb_stream_state(self, harmony_parser, chat_request):
934 """Streaming counts after each chunk, so it must not consume parser state."""
935 token_ids = get_model_output_tokens(
936 [assistant("Think", "analysis"), assistant("Answer", "final")]
937 )
938 harmony_parser.count_reasoning_tokens(token_ids)
939
940 reasoning, content, _ = harmony_parser.parse(
941 "", chat_request, model_output_token_ids=token_ids
942 )
943 assert (reasoning, content) == ("Think", "Answer")
944
945
946 class TestAdjustRequest:
947 REQUEST_TEXT = "Hello"
948 TOOL_TYPE = "function"
949 TOOL_1_NAME = "get_user_location"
950 TOOL_2_NAME = "get_weather"
951 TOOLS = [
952 {
953 "name": TOOL_1_NAME,
954 "parameters": {
955 "type": "object",
956 "properties": {},
957 "required": [],
958 },
959 },
960 {
961 "name": TOOL_2_NAME,
962 "parameters": {
963 "type": "object",
964 "properties": {"city": {"type": "string"}},
965 "required": ["city"],
966 },
967 },
968 ]
969 OUTPUT_SCHEMA = {
970 "type": "object",
971 "properties": {"answer": {"type": "string"}},
972 "required": ["answer"],
973 }
974
975 ANALYSIS = get_model_output_str([assistant("reasoning", "analysis")])
976 COMMENTARY = get_model_output_str([assistant("commentary", "commentary")])
977 TOOL_CALL_1_CHANNEL_FIRST = (
978 ANALYSIS + f"<|start|>assistant<|channel|>commentary to=functions.{TOOL_1_NAME}"
979 " <|constrain|>json<|message|>{}<|call|>"
980 )
981 TOOL_CALL_1_FUNCTION_FIRST = get_model_output_str(
982 [
983 assistant("reasoning", "analysis"),
984 tool_call(f"functions.{TOOL_1_NAME}", "{}"),
985 ]
986 )
987 TOOL_CALL_2_CHANNEL_FIRST = (
988 ANALYSIS + f"<|start|>assistant<|channel|>commentary to=functions.{TOOL_2_NAME}"
989 ' <|constrain|>json<|message|>{"city": "Tokyo"}<|call|>'
990 )
991 TOOL_CALL_2_FUNCTION_FIRST = get_model_output_str(
992 [
993 assistant("reasoning", "analysis"),
994 tool_call(f"functions.{TOOL_2_NAME}", '{"city": "Tokyo"}'),
995 ]
996 )
997 FINAL_JSON_SCHEMA = get_model_output_str(
998 [
999 assistant("reasoning", "analysis"),
1000 assistant('{"answer": "Tokyo"}', "final", "json"),
1001 ]
1002 )
1003 FINAL_JSON_OBJECT = get_model_output_str(
1004 [
1005 assistant("reasoning", "analysis"),
1006 assistant('{"city": "Tokyo"}', "final", "<|constrain|>json"),
1007 ]
1008 )
1009 FINAL_TEXT_ONLY = get_model_output_str(
1010 [assistant("reasoning", "analysis"), assistant("any", "final")]
1011 )
1012 FINAL_REGEX = get_model_output_str(
1013 [assistant("reasoning", "analysis"), assistant("regex", "final")]
1014 )
1015 FINAL_CHOICE = get_model_output_str(
1016 [assistant("reasoning", "analysis"), assistant("choice1", "final")]
1017 )
1018 FINAL_GRAMMAR = get_model_output_str(
1019 [assistant("reasoning", "analysis"), assistant("grammar", "final")]
1020 )
1021 FINAL_STRUCTURAL_TAG = get_model_output_str(
1022 [assistant("reasoning", "analysis"), assistant("tag content", "final")]
1023 )
1024 ADMISSION_SAMPLES = (
1025 "COMMENTARY",
1026 "TOOL_CALL_1_CHANNEL_FIRST",
1027 "TOOL_CALL_1_FUNCTION_FIRST",
1028 "TOOL_CALL_2_CHANNEL_FIRST",
1029 "TOOL_CALL_2_FUNCTION_FIRST",
1030 "FINAL_JSON_SCHEMA",
1031 "FINAL_JSON_OBJECT",
1032 "FINAL_TEXT_ONLY",
1033 "FINAL_REGEX",
1034 "FINAL_CHOICE",
1035 "FINAL_GRAMMAR",
1036 "FINAL_STRUCTURAL_TAG",
1037 )
1038
1039 @staticmethod
1040 def _build_request(
1041 request_kind: Literal["chat", "responses"],
1042 tool_choice: str = "none",
1043 strict_tools: bool = False,
1044 response_format_type: str | None = None,
1045 structured_outputs: StructuredOutputsParams | None = None,
1046 ) -> ChatCompletionRequest | ResponsesRequest:
1047 data: dict[str, Any] = {
1048 "model": REASONING_MODEL_NAME,
1049 }
1050 if request_kind == "chat":
1051 data["messages"] = [
1052 {
1053 "role": "user",
1054 "content": TestAdjustRequest.REQUEST_TEXT,
1055 }
1056 ]
1057 else:
1058 data["input"] = TestAdjustRequest.REQUEST_TEXT
1059
1060 if request_kind == "chat":
1061 data["tools"] = [
1062 {
1063 "type": TestAdjustRequest.TOOL_TYPE,
1064 "function": {"strict": strict_tools, **tool_def},
1065 }
1066 for tool_def in TestAdjustRequest.TOOLS
1067 ]
1068 data["tool_choice"] = (
1069 {
1070 "type": TestAdjustRequest.TOOL_TYPE,
1071 "function": {"name": TestAdjustRequest.TOOL_2_NAME},
1072 }
1073 if tool_choice == "named"
1074 else tool_choice
1075 )
1076 else:
1077 data["tools"] = [
1078 {
1079 "type": TestAdjustRequest.TOOL_TYPE,
1080 "strict": strict_tools,
1081 **tool_def,
1082 }
1083 for tool_def in TestAdjustRequest.TOOLS
1084 ]
1085 data["tool_choice"] = (
1086 {
1087 "type": TestAdjustRequest.TOOL_TYPE,
1088 "name": TestAdjustRequest.TOOL_2_NAME,
1089 }
1090 if tool_choice == "named"
1091 else tool_choice
1092 )
1093
1094 if response_format_type == "json_schema":
1095 schema_format = {
1096 "name": "answer_format",
1097 "schema": TestAdjustRequest.OUTPUT_SCHEMA,
1098 "strict": True,
1099 }
1100 if request_kind == "chat":
1101 data["response_format"] = {
1102 "type": "json_schema",
1103 "json_schema": schema_format,
1104 }
1105 else:
1106 data["text"] = {
1107 "format": {
1108 "type": "json_schema",
1109 **schema_format,
1110 }
1111 }
1112 elif response_format_type == "json_object":
1113 if request_kind == "chat":
1114 data["response_format"] = {"type": "json_object"}
1115 else:
1116 data["text"] = {"format": {"type": "json_object"}}
1117
1118 if structured_outputs is not None:
1119 data["structured_outputs"] = structured_outputs
1120
1121 if request_kind == "chat":
1122 return ChatCompletionRequest.model_validate(data)
1123 return ResponsesRequest.model_validate(data)
1124
1125 @staticmethod
1126 def _assert_format_cleared(
1127 adjusted_request: ChatCompletionRequest | ResponsesRequest,
1128 ) -> None:
1129 if isinstance(adjusted_request, ResponsesRequest):
1130 assert adjusted_request.text is None or adjusted_request.text.format is None
1131 else:
1132 assert adjusted_request.response_format is None
1133
1134 structured_outputs = adjusted_request.structured_outputs
1135 assert structured_outputs is not None
1136 assert structured_outputs.structural_tag is not None
1137 assert structured_outputs.all_non_structural_tag_constraints_none()
1138
1139 @classmethod
1140 def _assert_structured_outputs_admission(
1141 cls,
1142 adjusted_request: ChatCompletionRequest | ResponsesRequest,
1143 expected_admission: Sequence[str],
1144 xgrammar_backend: XgrammarBackend,
1145 stop_token_ids: set[int],
1146 ) -> None:
1147 structured_outputs = adjusted_request.structured_outputs
1148 assert structured_outputs is not None
1149 assert structured_outputs.structural_tag is not None
1150 assert structured_outputs.all_non_structural_tag_constraints_none()
1151
1152 grammar = xgrammar_backend.compile_grammar(
1153 StructuredOutputOptions.STRUCTURAL_TAG,
1154 structured_outputs.structural_tag,
1155 stop_token_ids=stop_token_ids,
1156 )
1157 expected_admission_set = set(expected_admission)
1158
1159 for sample_name in cls.ADMISSION_SAMPLES:
1160 tokens = encode_output(getattr(cls, sample_name))
1161 accepted = grammar.validate_tokens(tokens)
1162 admitted = accepted == tokens
1163 should_admit = sample_name in expected_admission_set
1164 assert admitted is should_admit, (
1165 f"Expected structured_outputs admission for {sample_name} "
1166 f"to be {should_admit}, got {admitted}."
1167 )
1168
1169 @pytest.mark.parametrize("request_kind", ["chat", "responses"])
1170 @pytest.mark.parametrize(
1171 ("request_kwargs", "expected_admission"),
1172 [
1173 (
1174 {"tool_choice": "auto", "strict_tools": True},
1175 [
1176 "COMMENTARY",
1177 "TOOL_CALL_1_CHANNEL_FIRST",
1178 "TOOL_CALL_1_FUNCTION_FIRST",
1179 "TOOL_CALL_2_CHANNEL_FIRST",
1180 "TOOL_CALL_2_FUNCTION_FIRST",
1181 "FINAL_JSON_SCHEMA",
1182 "FINAL_JSON_OBJECT",
1183 "FINAL_TEXT_ONLY",
1184 "FINAL_REGEX",
1185 "FINAL_CHOICE",
1186 "FINAL_GRAMMAR",
1187 "FINAL_STRUCTURAL_TAG",
1188 ],
1189 ),
1190 (
1191 {"tool_choice": "required"},
1192 [
1193 "COMMENTARY",
1194 "TOOL_CALL_1_CHANNEL_FIRST",
1195 "TOOL_CALL_1_FUNCTION_FIRST",
1196 "TOOL_CALL_2_CHANNEL_FIRST",
1197 "TOOL_CALL_2_FUNCTION_FIRST",
1198 ],
1199 ),
1200 (
1201 {"tool_choice": "named"},
1202 [
1203 "COMMENTARY",
1204 "TOOL_CALL_2_CHANNEL_FIRST",
1205 "TOOL_CALL_2_FUNCTION_FIRST",
1206 ],
1207 ),
1208 (
1209 {
1210 "tool_choice": "auto",
1211 "strict_tools": True,
1212 "response_format_type": "json_schema",
1213 },
1214 [
1215 "COMMENTARY",
1216 "TOOL_CALL_1_CHANNEL_FIRST",
1217 "TOOL_CALL_1_FUNCTION_FIRST",
1218 "TOOL_CALL_2_CHANNEL_FIRST",
1219 "TOOL_CALL_2_FUNCTION_FIRST",
1220 "FINAL_JSON_SCHEMA",
1221 ],
1222 ),
1223 (
1224 {"response_format_type": "json_schema"},
1225 ["FINAL_JSON_SCHEMA"],
1226 ),
1227 (
1228 {"response_format_type": "json_object"},
1229 ["FINAL_JSON_SCHEMA", "FINAL_JSON_OBJECT"],
1230 ),
1231 (
1232 {"structured_outputs": StructuredOutputsParams(json=OUTPUT_SCHEMA)},
1233 ["FINAL_JSON_SCHEMA"],
1234 ),
1235 (
1236 {"structured_outputs": StructuredOutputsParams(json_object=True)},
1237 ["FINAL_JSON_SCHEMA", "FINAL_JSON_OBJECT"],
1238 ),
1239 (
1240 {"structured_outputs": StructuredOutputsParams(regex=r"regex")},
1241 ["FINAL_REGEX"],
1242 ),
1243 (
1244 {
1245 "structured_outputs": StructuredOutputsParams(
1246 choice=["choice1", "choice2"]
1247 )
1248 },
1249 ["FINAL_CHOICE"],
1250 ),
1251 (
1252 {
1253 "structured_outputs": StructuredOutputsParams(
1254 grammar='root ::= "grammar"'
1255 )
1256 },
1257 ["FINAL_GRAMMAR"],
1258 ),
1259 (
1260 {
1261 "structured_outputs": StructuredOutputsParams(
1262 structural_tag=json.dumps(
1263 {
1264 "type": "structural_tag",
1265 "format": {
1266 "type": "json_schema",
1267 "json_schema": OUTPUT_SCHEMA,
1268 },
1269 }
1270 )
1271 )
1272 },
1273 ["FINAL_JSON_SCHEMA"],
1274 ),
1275 (
1276 {
1277 "structured_outputs": StructuredOutputsParams(
1278 structural_tag=json.dumps(
1279 {
1280 "type": "structural_tag",
1281 "structures": [
1282 {
1283 "begin": "<tag>",
1284 "schema": {"type": "object"},
1285 "end": "</tag>",
1286 }
1287 ],
1288 "triggers": ["<tag>"],
1289 }
1290 )
1291 )
1292 },
1293 [
1294 # Legacy triggered tags allow free text until a trigger, so
1295 # unconstrained final-channel payloads are also admitted.
1296 "FINAL_TEXT_ONLY",
1297 "FINAL_REGEX",
1298 "FINAL_CHOICE",
1299 "FINAL_GRAMMAR",
1300 "FINAL_STRUCTURAL_TAG",
1301 ],
1302 ),
1303 ],
1304 ids=[
1305 "tool_auto_strict",
1306 "tool_required",
1307 "tool_named",
1308 "pr56086_tool_auto_strict_response_format_json_schema",
1309 "response_format_json_schema",
1310 "response_format_json_object",
1311 "structured_outputs_json",
1312 "structured_outputs_json_object",
1313 "structured_outputs_regex",
1314 "structured_outputs_choice",
1315 "structured_outputs_grammar",
1316 "structured_outputs_structural_tag_modern",
1317 "structured_outputs_structural_tag_legacy",
1318 ],
1319 )
1320 def test_adjust_request(
1321 self,
1322 harmony_parser,
1323 xgrammar_backend,
1324 gpt_oss_stop_token_ids,
1325 request_kind,
1326 request_kwargs,
1327 expected_admission,
1328 ):
1329 request = self._build_request(request_kind, **request_kwargs)
1330 adjusted_request = harmony_parser.adjust_request(request)
1331 self._assert_format_cleared(adjusted_request)
1332 self._assert_structured_outputs_admission(
1333 adjusted_request,
1334 expected_admission,
1335 xgrammar_backend,
1336 gpt_oss_stop_token_ids,
1337 )