[GPT-OSS] Strict tool call and constrained decoding for Harmony (#45560)

Signed-off-by: Yifan Zong <yzong@redhat.com>

yzong-rh committed Aug 1, 2026 at 12:10 UTC dc818c198d3ff50a16f38eba567da006478239c8
10 files changed +632 -511
tests/entrypoints/openai/responses/test_harmony.py
+33 -23
@@ -13,7 +13,7 @@ from typing import Any
13 import pytest
14 import pytest_asyncio
15 import requests
16 -from openai import InternalServerError, NotFoundError, OpenAI
16 +from openai import NotFoundError, OpenAI
17 from openai_harmony import Message
18
19 from tests.utils import RemoteOpenAIServer
@@ -368,8 +368,12 @@ async def test_streaming_types(
368
369 @pytest.mark.asyncio
370 @pytest.mark.parametrize("model_name", [MODEL_NAME])
371 +@pytest.mark.parametrize("tool_choice", ["auto", "required"])
372 async def test_function_calling_with_streaming_types(
372 - pairs_of_event_types: dict[str, str], client: OpenAI, model_name: str
373 + pairs_of_event_types: dict[str, str],
374 + client: OpenAI,
375 + model_name: str,
376 + tool_choice: str,
377 ):
378 """Streaming event nesting for function-calling responses."""
379
@@ -382,6 +386,7 @@ async def test_function_calling_with_streaming_types(
386 validate_events=_has_function_events,
387 input=[{"role": "user", "content": "What's the weather like in Paris today?"}],
388 tools=[GET_WEATHER_SCHEMA],
389 + tool_choice=tool_choice,
390 temperature=0.0,
391 )
392
@@ -558,7 +563,8 @@ async def test_reasoning_item(client: OpenAI, model_name: str):
563
564 @pytest.mark.asyncio
565 @pytest.mark.parametrize("model_name", [MODEL_NAME])
561 -async def test_function_calling(client: OpenAI, model_name: str):
566 +@pytest.mark.parametrize("tool_choice", ["auto", "required"])
567 +async def test_function_calling(client: OpenAI, model_name: str, tool_choice: str):
568 tools = [GET_WEATHER_SCHEMA]
569
570 response = await retry_for_tool_call(
@@ -567,8 +573,9 @@ async def test_function_calling(client: OpenAI, model_name: str):
573 expected_tool_type="function_call",
574 input="What's the weather like in Paris today?",
575 tools=tools,
576 + tool_choice=tool_choice,
577 temperature=0.0,
571 - extra_body={"request_id": "test_function_calling_non_resp"},
578 + extra_body={"request_id": f"test_function_calling_non_resp_{tool_choice}"},
579 )
580 assert response.status == "completed"
581 assert has_output_type(response, "function_call"), (
@@ -610,7 +617,10 @@ async def test_function_calling(client: OpenAI, model_name: str):
617
618 @pytest.mark.asyncio
619 @pytest.mark.parametrize("model_name", [MODEL_NAME])
613 -async def test_function_calling_multi_turn(client: OpenAI, model_name: str):
620 +@pytest.mark.parametrize("tool_choice", ["auto", "required"])
621 +async def test_function_calling_multi_turn(
622 + client: OpenAI, model_name: str, tool_choice: str
623 +):
624 """Multi-tool, multi-turn function calling with retry at API level."""
625 tools = [
626 {
@@ -635,6 +645,7 @@ async def test_function_calling_multi_turn(client: OpenAI, model_name: str):
645 expected_tool_type="function_call",
646 input="Help me plan a trip to a random place. And tell me the weather there.",
647 tools=tools,
648 + tool_choice=tool_choice,
649 temperature=0.0,
650 )
651 assert response.status == "completed"
@@ -659,6 +670,7 @@ async def test_function_calling_multi_turn(client: OpenAI, model_name: str):
670 }
671 ],
672 tools=tools,
673 + tool_choice=tool_choice,
674 previous_response_id=response.id,
675 temperature=0.0,
676 )
@@ -695,20 +707,6 @@ async def test_function_calling_multi_turn(client: OpenAI, model_name: str):
707 )
708
709
698 -@pytest.mark.asyncio
699 -@pytest.mark.parametrize("model_name", [MODEL_NAME])
700 -async def test_function_calling_required(client: OpenAI, model_name: str):
701 - tools = [GET_WEATHER_SCHEMA]
702 -
703 - with pytest.raises(InternalServerError):
704 - await client.responses.create(
705 - model=model_name,
706 - input="What's the weather like in Paris today?",
707 - tools=tools,
708 - tool_choice="required",
709 - )
710 -
711 -
710 @pytest.mark.asyncio
711 @pytest.mark.parametrize("model_name", [MODEL_NAME])
712 async def test_system_message_with_tools(client: OpenAI, model_name: str):
@@ -726,7 +724,10 @@ async def test_system_message_with_tools(client: OpenAI, model_name: str):
724
725 @pytest.mark.asyncio
726 @pytest.mark.parametrize("model_name", [MODEL_NAME])
729 -async def test_function_calling_full_history(client: OpenAI, model_name: str):
727 +@pytest.mark.parametrize("tool_choice", ["auto", "required"])
728 +async def test_function_calling_full_history(
729 + client: OpenAI, model_name: str, tool_choice: str
730 +):
731 tools = [GET_WEATHER_SCHEMA]
732
733 input_messages = [
@@ -739,6 +740,7 @@ async def test_function_calling_full_history(client: OpenAI, model_name: str):
740 expected_tool_type="function_call",
741 input=input_messages,
742 tools=tools,
743 + tool_choice=tool_choice,
744 temperature=0.0,
745 )
746 assert response.status == "completed"
@@ -772,7 +774,10 @@ async def test_function_calling_full_history(client: OpenAI, model_name: str):
774
775 @pytest.mark.asyncio
776 @pytest.mark.parametrize("model_name", [MODEL_NAME])
775 -async def test_function_calling_with_stream(client: OpenAI, model_name: str):
777 +@pytest.mark.parametrize("tool_choice", ["auto", "required"])
778 +async def test_function_calling_with_stream(
779 + client: OpenAI, model_name: str, tool_choice: str
780 +):
781 """Function calling via streaming, with retry for non-determinism."""
782 tools = [GET_WEATHER_SCHEMA]
783 input_list = [
@@ -792,6 +797,7 @@ async def test_function_calling_with_stream(client: OpenAI, model_name: str):
797 validate_events=_has_function_call,
798 input=input_list,
799 tools=tools,
800 + tool_choice=tool_choice,
801 temperature=0.0,
802 )
803
@@ -853,8 +859,9 @@ async def test_function_calling_with_stream(client: OpenAI, model_name: str):
859
860 @pytest.mark.asyncio
861 @pytest.mark.parametrize("model_name", [MODEL_NAME])
862 +@pytest.mark.parametrize("tool_choice", ["auto", "required"])
863 async def test_function_calling_no_code_interpreter_events(
857 - client: OpenAI, model_name: str
864 + client: OpenAI, model_name: str, tool_choice: str
865 ):
866 """Verify that function calls don't trigger code_interpreter events.
867
@@ -880,6 +887,7 @@ async def test_function_calling_no_code_interpreter_events(
887 validate_events=_has_function_call,
888 input=input_list,
889 tools=tools,
890 + tool_choice=tool_choice,
891 temperature=0.0,
892 )
893
@@ -1048,8 +1056,9 @@ async def test_output_messages_enabled(client: OpenAI, model_name: str, server):
1056
1057 @pytest.mark.asyncio
1058 @pytest.mark.parametrize("model_name", [MODEL_NAME])
1059 +@pytest.mark.parametrize("tool_choice", ["auto", "required"])
1060 async def test_function_call_with_previous_input_messages(
1052 - client: OpenAI, model_name: str
1061 + client: OpenAI, model_name: str, tool_choice: str
1062 ):
1063 """Multi-turn function calling using previous_input_messages."""
1064 tools = [
@@ -1074,6 +1083,7 @@ async def test_function_call_with_previous_input_messages(
1083 expected_tool_type="function_call",
1084 input="What is the horoscope for Aquarius today?",
1085 tools=tools,
1086 + tool_choice=tool_choice,
1087 temperature=0.0,
1088 extra_body={"enable_response_messages": True},
1089 max_output_tokens=1000,
tests/parser/test_harmony.py
+337
@@ -3,6 +3,7 @@
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 (
@@ -12,14 +13,18 @@ from openai_harmony import (
13 Role,
14 )
15 from transformers import AutoTokenizer
16 +from xgrammar import Grammar
17 +from xgrammar.testing import _is_grammar_accept_string
18
19 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
20 from vllm.entrypoints.openai.engine.protocol import FunctionCall
21 from vllm.entrypoints.openai.parser.harmony_utils import (
22 get_encoding,
23 )
24 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
25 from vllm.parser.harmony import HarmonyParser
26 from vllm.parser.parser_manager import ParserManager
27 +from vllm.sampling_params import StructuredOutputsParams
28
29 REASONING_MODEL_NAME = "openai/gpt-oss-20b"
30
@@ -857,3 +862,335 @@ class TestProcessChunk:
862 ("analysis", "One"),
863 ("final", "Two"),
864 ]
865 +
866 +
867 +class TestAdjustRequest:
868 + REQUEST_TEXT = "Hello"
869 + TOOL_TYPE = "function"
870 + TOOL_1_NAME = "get_user_location"
871 + TOOL_2_NAME = "get_weather"
872 + TOOLS = [
873 + {
874 + "name": TOOL_1_NAME,
875 + "parameters": {
876 + "type": "object",
877 + "properties": {},
878 + "required": [],
879 + },
880 + },
881 + {
882 + "name": TOOL_2_NAME,
883 + "parameters": {
884 + "type": "object",
885 + "properties": {"city": {"type": "string"}},
886 + "required": ["city"],
887 + },
888 + },
889 + ]
890 + OUTPUT_SCHEMA = {
891 + "type": "object",
892 + "properties": {"answer": {"type": "string"}},
893 + "required": ["answer"],
894 + }
895 +
896 + ANALYSIS = "<|channel|>analysis<|message|>analysis message<|end|><|start|>assistant"
897 + COMMENTARY = (
898 + "<|channel|>commentary<|message|>commentary message<|end|><|start|>assistant"
899 + )
900 + TOOL_CALL_1 = (
901 + ANALYSIS + f"<|channel|>commentary to=functions.{TOOL_1_NAME} json<|message|>"
902 + "{}<|call|>"
903 + )
904 + TOOL_CALL_2 = (
905 + ANALYSIS + f"<|channel|>commentary to=functions.{TOOL_2_NAME} json<|message|>"
906 + '{"city": "Tokyo"}<|call|>'
907 + )
908 + FINAL_JSON_SCHEMA = (
909 + ANALYSIS
910 + + '<|channel|>final <|constrain|>json<|message|>{"answer": "Tokyo"}<|end|>'
911 + )
912 + FINAL_JSON_OBJECT = (
913 + ANALYSIS
914 + + '<|channel|>final <|constrain|>json<|message|>{"city": "Tokyo"}<|end|>'
915 + )
916 + FINAL_TEXT_ONLY = ANALYSIS + "<|channel|>final<|message|>any<|end|>"
917 + FINAL_REGEX = ANALYSIS + "<|channel|>final<|message|>regex<|end|>"
918 + FINAL_CHOICE = ANALYSIS + "<|channel|>final<|message|>choice1<|end|>"
919 + FINAL_GRAMMAR = ANALYSIS + "<|channel|>final<|message|>grammar<|end|>"
920 + FINAL_STRUCTURAL_TAG = ANALYSIS + "<|channel|>final<|message|>tag content<|end|>"
921 + ADMISSION_SAMPLES = (
922 + "COMMENTARY",
923 + "TOOL_CALL_1",
924 + "TOOL_CALL_2",
925 + "FINAL_JSON_SCHEMA",
926 + "FINAL_JSON_OBJECT",
927 + "FINAL_TEXT_ONLY",
928 + "FINAL_REGEX",
929 + "FINAL_CHOICE",
930 + "FINAL_GRAMMAR",
931 + "FINAL_STRUCTURAL_TAG",
932 + )
933 +
934 + @staticmethod
935 + def _build_request(
936 + request_kind: Literal["chat", "responses"],
937 + tool_choice: str = "none",
938 + strict_tools: bool = False,
939 + response_format_type: str | None = None,
940 + structured_outputs: StructuredOutputsParams | None = None,
941 + ) -> ChatCompletionRequest | ResponsesRequest:
942 + data: dict[str, Any] = {
943 + "model": REASONING_MODEL_NAME,
944 + }
945 + if request_kind == "chat":
946 + data["messages"] = [
947 + {
948 + "role": "user",
949 + "content": TestAdjustRequest.REQUEST_TEXT,
950 + }
951 + ]
952 + else:
953 + data["input"] = TestAdjustRequest.REQUEST_TEXT
954 +
955 + if request_kind == "chat":
956 + data["tools"] = [
957 + {
958 + "type": TestAdjustRequest.TOOL_TYPE,
959 + "function": {"strict": strict_tools, **tool_def},
960 + }
961 + for tool_def in TestAdjustRequest.TOOLS
962 + ]
963 + data["tool_choice"] = (
964 + {
965 + "type": TestAdjustRequest.TOOL_TYPE,
966 + "function": {"name": TestAdjustRequest.TOOL_2_NAME},
967 + }
968 + if tool_choice == "named"
969 + else tool_choice
970 + )
971 + else:
972 + data["tools"] = [
973 + {
974 + "type": TestAdjustRequest.TOOL_TYPE,
975 + "strict": strict_tools,
976 + **tool_def,
977 + }
978 + for tool_def in TestAdjustRequest.TOOLS
979 + ]
980 + data["tool_choice"] = (
981 + {
982 + "type": TestAdjustRequest.TOOL_TYPE,
983 + "name": TestAdjustRequest.TOOL_2_NAME,
984 + }
985 + if tool_choice == "named"
986 + else tool_choice
987 + )
988 +
989 + if response_format_type == "json_schema":
990 + schema_format = {
991 + "name": "answer_format",
992 + "schema": TestAdjustRequest.OUTPUT_SCHEMA,
993 + "strict": True,
994 + }
995 + if request_kind == "chat":
996 + data["response_format"] = {
997 + "type": "json_schema",
998 + "json_schema": schema_format,
999 + }
1000 + else:
1001 + data["text"] = {
1002 + "format": {
1003 + "type": "json_schema",
1004 + **schema_format,
1005 + }
1006 + }
1007 + elif response_format_type == "json_object":
1008 + if request_kind == "chat":
1009 + data["response_format"] = {"type": "json_object"}
1010 + else:
1011 + data["text"] = {"format": {"type": "json_object"}}
1012 +
1013 + if structured_outputs is not None:
1014 + data["structured_outputs"] = structured_outputs
1015 +
1016 + if request_kind == "chat":
1017 + return ChatCompletionRequest.model_validate(data)
1018 + return ResponsesRequest.model_validate(data)
1019 +
1020 + @staticmethod
1021 + def _assert_format_cleared(
1022 + adjusted_request: ChatCompletionRequest | ResponsesRequest,
1023 + ) -> None:
1024 + if isinstance(adjusted_request, ResponsesRequest):
1025 + assert adjusted_request.text is None or adjusted_request.text.format is None
1026 + else:
1027 + assert adjusted_request.response_format is None
1028 +
1029 + structured_outputs = adjusted_request.structured_outputs
1030 + assert structured_outputs is not None
1031 + assert structured_outputs.structural_tag is not None
1032 + assert structured_outputs.all_non_structural_tag_constraints_none()
1033 +
1034 + @classmethod
1035 + def _assert_structured_outputs_admission(
1036 + cls,
1037 + adjusted_request: ChatCompletionRequest | ResponsesRequest,
1038 + expected_admission: Sequence[str],
1039 + ) -> None:
1040 + structured_outputs = adjusted_request.structured_outputs
1041 + assert structured_outputs is not None
1042 + assert structured_outputs.structural_tag is not None
1043 + assert structured_outputs.all_non_structural_tag_constraints_none()
1044 +
1045 + grammar = Grammar.from_structural_tag(structured_outputs.structural_tag)
1046 + expected_admission_set = set(expected_admission)
1047 +
1048 + for sample_name in cls.ADMISSION_SAMPLES:
1049 + admitted = _is_grammar_accept_string(
1050 + grammar,
1051 + getattr(cls, sample_name),
1052 + require_termination=False,
1053 + )
1054 + should_admit = sample_name in expected_admission_set
1055 + assert admitted is should_admit, (
1056 + f"Expected structured_outputs admission for {sample_name} "
1057 + f"to be {should_admit}, got {admitted}."
1058 + )
1059 +
1060 + @pytest.mark.parametrize("request_kind", ["chat", "responses"])
1061 + @pytest.mark.parametrize(
1062 + ("request_kwargs", "expected_admission"),
1063 + [
1064 + (
1065 + {"tool_choice": "auto", "strict_tools": True},
1066 + [
1067 + "COMMENTARY",
1068 + "TOOL_CALL_1",
1069 + "TOOL_CALL_2",
1070 + "FINAL_JSON_SCHEMA",
1071 + "FINAL_JSON_OBJECT",
1072 + "FINAL_TEXT_ONLY",
1073 + "FINAL_REGEX",
1074 + "FINAL_CHOICE",
1075 + "FINAL_GRAMMAR",
1076 + "FINAL_STRUCTURAL_TAG",
1077 + ],
1078 + ),
1079 + (
1080 + {"tool_choice": "required"},
1081 + ["COMMENTARY", "TOOL_CALL_1", "TOOL_CALL_2"],
1082 + ),
1083 + (
1084 + {"tool_choice": "named"},
1085 + ["COMMENTARY", "TOOL_CALL_2"],
1086 + ),
1087 + (
1088 + {"response_format_type": "json_schema"},
1089 + ["FINAL_JSON_SCHEMA"],
1090 + ),
1091 + (
1092 + {"response_format_type": "json_object"},
1093 + ["FINAL_JSON_SCHEMA", "FINAL_JSON_OBJECT"],
1094 + ),
1095 + (
1096 + {"structured_outputs": StructuredOutputsParams(json=OUTPUT_SCHEMA)},
1097 + ["FINAL_JSON_SCHEMA"],
1098 + ),
1099 + (
1100 + {"structured_outputs": StructuredOutputsParams(json_object=True)},
1101 + ["FINAL_JSON_SCHEMA", "FINAL_JSON_OBJECT"],
1102 + ),
1103 + (
1104 + {"structured_outputs": StructuredOutputsParams(regex=r"regex")},
1105 + ["FINAL_REGEX"],
1106 + ),
1107 + (
1108 + {
1109 + "structured_outputs": StructuredOutputsParams(
1110 + choice=["choice1", "choice2"]
1111 + )
1112 + },
1113 + ["FINAL_CHOICE"],
1114 + ),
1115 + (
1116 + {
1117 + "structured_outputs": StructuredOutputsParams(
1118 + grammar='root ::= "grammar"'
1119 + )
1120 + },
1121 + ["FINAL_GRAMMAR"],
1122 + ),
1123 + (
1124 + {
1125 + "structured_outputs": StructuredOutputsParams(
1126 + structural_tag=json.dumps(
1127 + {
1128 + "type": "structural_tag",
1129 + "format": {
1130 + "type": "json_schema",
1131 + "json_schema": OUTPUT_SCHEMA,
1132 + },
1133 + }
1134 + )
1135 + )
1136 + },
1137 + ["FINAL_JSON_SCHEMA"],
1138 + ),
1139 + (
1140 + {
1141 + "structured_outputs": StructuredOutputsParams(
1142 + structural_tag=json.dumps(
1143 + {
1144 + "type": "structural_tag",
1145 + "structures": [
1146 + {
1147 + "begin": "<tag>",
1148 + "schema": {"type": "object"},
1149 + "end": "</tag>",
1150 + }
1151 + ],
1152 + "triggers": ["<tag>"],
1153 + }
1154 + )
1155 + )
1156 + },
1157 + [
1158 + # Legacy triggered tags allow free text until a trigger, so
1159 + # unconstrained final-channel payloads are also admitted.
1160 + "FINAL_TEXT_ONLY",
1161 + "FINAL_REGEX",
1162 + "FINAL_CHOICE",
1163 + "FINAL_GRAMMAR",
1164 + "FINAL_STRUCTURAL_TAG",
1165 + ],
1166 + ),
1167 + ],
1168 + ids=[
1169 + "tool_auto_strict",
1170 + "tool_required",
1171 + "tool_named",
1172 + "response_format_json_schema",
1173 + "response_format_json_object",
1174 + "structured_outputs_json",
1175 + "structured_outputs_json_object",
1176 + "structured_outputs_regex",
1177 + "structured_outputs_choice",
1178 + "structured_outputs_grammar",
1179 + "structured_outputs_structural_tag_modern",
1180 + "structured_outputs_structural_tag_legacy",
1181 + ],
1182 + )
1183 + def test_adjust_request(
1184 + self,
1185 + harmony_parser,
1186 + request_kind,
1187 + request_kwargs,
1188 + expected_admission,
1189 + ):
1190 + request = self._build_request(request_kind, **request_kwargs)
1191 + adjusted_request = harmony_parser.adjust_request(request)
1192 + self._assert_format_cleared(adjusted_request)
1193 + self._assert_structured_outputs_admission(
1194 + adjusted_request,
1195 + expected_admission,
1196 + )
tests/reasoning/test_gptoss_reasoning_parser.py
+5 -344
@@ -1,351 +1,12 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -import json
4 from unittest.mock import Mock
5
7 -import pytest
8 -from transformers import AutoTokenizer
6 +from vllm.reasoning.gptoss_reasoning_parser import GptOssReasoningParser
7
10 -from vllm.entrypoints.mcp.tool_server import ToolServer
11 -from vllm.reasoning import ReasoningParser
12 -from vllm.reasoning.gptoss_reasoning_parser import (
13 - GptOssReasoningParser,
14 - from_builtin_tool_to_tag,
15 - no_func_reasoning_tag,
16 -)
8
18 -REASONING_MODEL_NAME = "openai/gpt-oss-120b"
19 -
20 -
21 -@pytest.fixture(scope="module")
22 -def gpt_oss_tokenizer():
23 - return AutoTokenizer.from_pretrained(REASONING_MODEL_NAME)
24 -
25 -
26 -USER_MESSAGE_START = "<|start|>user<|message|>"
27 -REASONING_SECTION_START = "<|end|><|start|>assistant<|channel|>analysis<|message|>"
28 -END = "<|end|>"
29 -ASSISTANT_START = "<|start|>assistant"
30 -ASSISTANT_CONTENT_START_PREFIX = END + ASSISTANT_START + "<|channel|>final"
31 -ASSISTANT_CONTENT_START_SUFFIX = "<|message|>"
32 -ASSISTANT_CONTENT_START = (
33 - ASSISTANT_CONTENT_START_PREFIX + ASSISTANT_CONTENT_START_SUFFIX
34 -)
35 -
36 -BASIC_CONTENT = {
37 - "output": REASONING_SECTION_START
38 - + "This is reasoning"
39 - + ASSISTANT_CONTENT_START
40 - + "This is the rest",
41 - "is_reasoning_end": True,
42 -}
43 -
44 -BASIC_REASONING_ONLY = {
45 - "output": REASONING_SECTION_START + "This is reasoning" + "<|end|>",
46 - "is_reasoning_end": False,
47 -}
48 -BASIC_NO_REASONING_NO_ASSISTANT = {
49 - "output": USER_MESSAGE_START + "This is a user message",
50 - "is_reasoning_end": False,
51 -}
52 -
53 -# Edge-case where the model omits the assistant tag entirely.
54 -BASIC_NO_REASONING_ASSISTANT = {
55 - "output": USER_MESSAGE_START + "This is a user message<|end|><|channel|>final",
56 - "is_reasoning_end": True,
57 -}
58 -
59 -COMPLEX_CONTENT_INCOMPLETE_PREFIX_ONLY = {
60 - "output": REASONING_SECTION_START
61 - + "This is reasoning"
62 - + ASSISTANT_CONTENT_START_PREFIX,
63 - "is_reasoning_end": False,
64 -}
65 -
66 -COMPLEX_CONTENT_SUFFIX_ONLY = {
67 - "output": REASONING_SECTION_START
68 - + "This is reasoning"
69 - + ASSISTANT_CONTENT_START_SUFFIX,
70 - "is_reasoning_end": False,
71 -}
72 -
73 -COMPLEX_CONTENT_1_NO_SUFFIX = {
74 - "output": REASONING_SECTION_START
75 - + "This is reasoning"
76 - + ASSISTANT_CONTENT_START_PREFIX
77 - + "<|constrain|> JSON ",
78 - "is_reasoning_end": False,
79 -}
80 -
81 -COMPLEX_CONTENT_1 = {
82 - "output": REASONING_SECTION_START
83 - + "This is reasoning"
84 - + ASSISTANT_CONTENT_START_PREFIX
85 - + "<|constrain|> JSON "
86 - + ASSISTANT_CONTENT_START_SUFFIX,
87 - "is_reasoning_end": True,
88 -}
89 -
90 -COMPLEX_CONTENT_1_WITH_CONTENT = {
91 - "output": REASONING_SECTION_START
92 - + "This is reasoning"
93 - + ASSISTANT_CONTENT_START_PREFIX
94 - + "<|constrain|> JSON "
95 - + ASSISTANT_CONTENT_START_SUFFIX
96 - + "This is the rest",
97 - "is_reasoning_end": True,
98 -}
99 -
100 -COMPLEX_CONTENT_2 = {
101 - "output": REASONING_SECTION_START
102 - + "This is reasoning"
103 - + ASSISTANT_CONTENT_START_PREFIX
104 - + "<|constrain|>ReplyAction "
105 - + ASSISTANT_CONTENT_START_SUFFIX
106 - + "This is the rest",
107 - "is_reasoning_end": True,
108 -}
109 -
110 -MULTI_TURN_CONTENT = {
111 - "output": USER_MESSAGE_START
112 - + "1st turn user message"
113 - + REASONING_SECTION_START
114 - + "1st turn reasoning"
115 - + ASSISTANT_CONTENT_START
116 - + "1st turn response"
117 - + END
118 - + USER_MESSAGE_START
119 - + "2nd turn user message"
120 - + END
121 - + ASSISTANT_START,
122 - "is_reasoning_end": False,
123 -}
124 -TEST_CASES = [
125 - BASIC_CONTENT,
126 - BASIC_REASONING_ONLY,
127 - COMPLEX_CONTENT_INCOMPLETE_PREFIX_ONLY,
128 - COMPLEX_CONTENT_SUFFIX_ONLY,
129 - COMPLEX_CONTENT_1_NO_SUFFIX,
130 - COMPLEX_CONTENT_1,
131 - COMPLEX_CONTENT_1_WITH_CONTENT,
132 - COMPLEX_CONTENT_2,
133 - MULTI_TURN_CONTENT,
134 -]
135 -
136 -
137 -@pytest.mark.parametrize(
138 - "output, is_reasoning_end",
139 - [(t["output"], t["is_reasoning_end"]) for t in TEST_CASES],
140 -)
141 -def test_gptoss_is_reasoning_end(
142 - output,
143 - is_reasoning_end,
144 - gpt_oss_tokenizer,
145 -):
146 - output = gpt_oss_tokenizer.tokenize(output)
147 - parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer)
148 -
149 - # Test is_reasoning_end
150 - output_ids = gpt_oss_tokenizer.convert_tokens_to_ids(output)
151 - actual_is_reasoning_end = parser.is_reasoning_end(output_ids)
152 - assert is_reasoning_end == actual_is_reasoning_end
153 -
154 -
155 -class TestGptOssStructuralTags:
156 - """Test cases for GptOssReasoningParser structural tag functionality."""
157 -
158 - @pytest.fixture
159 - def mock_tokenizer(self):
160 - """Create a mock tokenizer for testing."""
161 - tokenizer = Mock()
162 - tokenizer.encode = Mock(return_value=[1, 2, 3, 4, 5])
163 - tokenizer.get_vocab = Mock(return_value={"<|end|>": 6})
164 - return tokenizer
165 -
166 - @pytest.fixture
167 - def reasoning_parser(self, mock_tokenizer):
168 - """Create a GptOssReasoningParser instance."""
169 - return GptOssReasoningParser(mock_tokenizer)
170 -
171 - def test_prepare_structured_tag_no_tool_server(self, reasoning_parser):
172 - """Test prepare_structured_tag with no tool server."""
173 - result = reasoning_parser.prepare_structured_tag(None, None)
174 - expected = json.dumps(no_func_reasoning_tag)
175 -
176 - assert result == expected
177 -
178 - # Verify the structure is correct
179 - parsed = json.loads(result)
180 - assert parsed["type"] == "structural_tag"
181 - assert parsed["format"]["type"] == "triggered_tags"
182 - assert len(parsed["format"]["tags"]) == 1
183 - assert parsed["format"]["tags"][0]["begin"] == "<|channel|>analysis<|message|>"
184 - assert parsed["format"]["triggers"] == ["<|channel|>analysis"]
185 -
186 - def test_prepare_structured_tag_with_original_tag(self, reasoning_parser):
187 - """Test prepare_structured_tag when original_tag is provided."""
188 - original_tag = '{"custom": "tag"}'
189 - result = reasoning_parser.prepare_structured_tag(original_tag, None)
190 -
191 - # Should return the original tag unchanged
192 - assert result == original_tag
193 -
194 - def test_from_builtin_tool_to_tag(self):
195 - """Test from_builtin_tool_to_tag function."""
196 - tags = from_builtin_tool_to_tag("python")
197 -
198 - assert len(tags) == 2
199 - assert tags[0]["begin"] == "<|channel|>commentary to=python"
200 - assert tags[0]["content"]["type"] == "any_text"
201 - assert tags[0]["end"] == "<|end|>"
202 -
203 - assert tags[1]["begin"] == "<|channel|>analysis to=python"
204 - assert tags[1]["content"]["type"] == "any_text"
205 - assert tags[1]["end"] == "<|end|>"
206 -
207 - @pytest.mark.parametrize(
208 - "tools",
209 - [
210 - [],
211 - ["browser"],
212 - ["python"],
213 - ["container"],
214 - ["browser", "python"],
215 - ["browser", "container"],
216 - ["python", "container"],
217 - ["browser", "python", "container"],
218 - ],
219 - )
220 - def test_json_validity_comprehensive(self, reasoning_parser, tools):
221 - """Test JSON validity across all possible tool combinations."""
222 - tool_server = Mock(spec=ToolServer)
223 - tool_server.has_tool = Mock(side_effect=lambda tool: tool in tools)
224 -
225 - result = reasoning_parser.prepare_structured_tag(None, tool_server)
226 - parsed_result = json.loads(result)
227 -
228 - assert parsed_result["type"] == "structural_tag"
229 - assert "format" in parsed_result
230 - assert "tags" in parsed_result["format"]
231 - assert "triggers" in parsed_result["format"]
232 -
233 - # Tag count should be: 1 (analysis) + 2 * len(tools)
234 - expected_tag_count = 1 + (2 * len(tools))
235 - assert len(parsed_result["format"]["tags"]) == expected_tag_count
236 -
237 - # Verify triggers are correctly configured
238 - expected_triggers = ["<|channel|>analysis"]
239 - if tools:
240 - expected_triggers.append("<|channel|>commentary to=")
241 - assert set(parsed_result["format"]["triggers"]) == set(expected_triggers)
242 -
243 - def test_no_cross_request_state_pollution(self, reasoning_parser):
244 - """Test that sequential calls with different tool servers produce
245 - independent results, guarding against shared mutable state
246 - (e.g. missing deepcopy in tag_with_builtin_funcs)."""
247 - tool_server_1 = Mock(spec=ToolServer)
248 - tool_server_1.has_tool = Mock(side_effect=lambda tool: tool == "python")
249 -
250 - tool_server_2 = Mock(spec=ToolServer)
251 - tool_server_2.has_tool = Mock(side_effect=lambda tool: tool == "browser")
252 -
253 - result_1 = reasoning_parser.prepare_structured_tag(None, tool_server_1)
254 - result_2 = reasoning_parser.prepare_structured_tag(None, tool_server_2)
255 -
256 - tags_1 = [tag["begin"] for tag in json.loads(result_1)["format"]["tags"]]
257 - tags_2 = [tag["begin"] for tag in json.loads(result_2)["format"]["tags"]]
258 -
259 - assert "<|channel|>commentary to=python" in tags_1
260 - assert "<|channel|>commentary to=browser" not in tags_1
261 -
262 - assert "<|channel|>commentary to=browser" in tags_2
263 - assert "<|channel|>commentary to=python" not in tags_2
264 -
265 - def test_tag_format_consistency(self, reasoning_parser):
266 - """Test that all generated tags follow consistent format,
267 - catching malformed tags from from_builtin_tool_to_tag."""
268 - tool_server = Mock(spec=ToolServer)
269 - tool_server.has_tool = Mock(
270 - side_effect=lambda tool: tool in ["python", "browser"]
271 - )
272 -
273 - result = reasoning_parser.prepare_structured_tag(None, tool_server)
274 - parsed_result = json.loads(result)
275 -
276 - for tag in parsed_result["format"]["tags"]:
277 - assert "begin" in tag
278 - assert "content" in tag
279 - assert "end" in tag
280 - assert tag["content"]["type"] == "any_text"
281 - assert tag["end"] == "<|end|>"
282 - assert tag["begin"].startswith("<|channel|>")
283 -
284 -
285 -@pytest.mark.parametrize(
286 - "output, is_reasoning_end",
287 - [(t["output"], t["is_reasoning_end"]) for t in TEST_CASES],
288 -)
289 -def test_gptoss_is_reasoning_end_streaming(
290 - output,
291 - is_reasoning_end,
292 - gpt_oss_tokenizer,
293 -):
294 - """Streaming override must agree with is_reasoning_end for all cases."""
295 - tokens = gpt_oss_tokenizer.tokenize(output)
296 - parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer)
297 - output_ids = gpt_oss_tokenizer.convert_tokens_to_ids(tokens)
298 - delta_ids = output_ids[-1:] if output_ids else []
299 - actual = parser.is_reasoning_end_streaming(output_ids, delta_ids)
300 - assert is_reasoning_end == actual
301 -
302 -
303 -@pytest.mark.parametrize(
304 - "output, is_reasoning_end",
305 - [(t["output"], t["is_reasoning_end"]) for t in TEST_CASES],
306 -)
307 -def test_gptoss_is_reasoning_end_streaming_long_prefix(
308 - output,
309 - is_reasoning_end,
310 - gpt_oss_tokenizer,
311 -):
312 - """Windowing must produce correct results even with a long prefix."""
313 - tokens = gpt_oss_tokenizer.tokenize(output)
314 - parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer)
315 - output_ids = gpt_oss_tokenizer.convert_tokens_to_ids(tokens)
316 - # Prepend 10k dummy reasoning tokens to simulate a long generation
317 - long_prefix = [1] * 10_000
318 - padded_ids = long_prefix + list(output_ids)
319 - delta_ids = output_ids[-1:] if output_ids else []
320 - actual = parser.is_reasoning_end_streaming(padded_ids, delta_ids)
321 - assert is_reasoning_end == actual
322 -
323 -
324 -@pytest.mark.parametrize(
325 - "output, is_reasoning_end",
326 - [(t["output"], t["is_reasoning_end"]) for t in TEST_CASES],
327 -)
328 -def test_gptoss_is_reasoning_end_streaming_large_delta(
329 - output,
330 - is_reasoning_end,
331 - gpt_oss_tokenizer,
332 -):
333 - """Simulate speculative decoding where the entire test sequence arrives
334 - as a single large delta appended after a long prefix. The window must
335 - expand to cover delta_ids so the end pattern is never missed."""
336 - tokens = gpt_oss_tokenizer.tokenize(output)
337 - parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer)
338 - output_ids = gpt_oss_tokenizer.convert_tokens_to_ids(tokens)
339 - long_prefix = [1] * 10_000
340 - padded_ids = long_prefix + list(output_ids)
341 - # delta_ids = the entire test sequence (as if accepted in one spec step)
342 - delta_ids = list(output_ids)
343 - actual = parser.is_reasoning_end_streaming(padded_ids, delta_ids)
344 - assert is_reasoning_end == actual
345 -
346 -
347 -def test_gptoss_is_reasoning_end_streaming_signature(gpt_oss_tokenizer):
348 - """Verify the method is callable with the expected signature."""
349 - parser = GptOssReasoningParser(gpt_oss_tokenizer)
350 - result = parser.is_reasoning_end_streaming([], [])
351 - assert result is False
9 +def test_gptoss_reasoning_ended_is_true():
10 + parser = GptOssReasoningParser(Mock())
11 + assert parser.is_reasoning_end([]) is True
12 + assert parser.is_reasoning_end_streaming([], []) is True
tests/tool_parsers/test_structural_tag_registry.py
+2 -2
@@ -33,7 +33,7 @@ from vllm.tool_parsers.structural_tag_registry import (
33 SUPPORTED_STRUCTURAL_TAG_MODELS,
34 VLLM_BUILTIN_STRUCTURAL_TAG_MODELS,
35 XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS,
36 - _get_function_parameters,
36 + get_function_parameters,
37 get_model_structural_tag,
38 )
39
@@ -530,7 +530,7 @@ def test_get_function_parameters_relaxes_function_strict_false():
530 strict=False,
531 )
532
533 - assert _get_function_parameters(function) is True
533 + assert get_function_parameters(function) is True
534
535
536 def _k3_tools_with_root_defs() -> list[ChatCompletionToolsParam]:
vllm/entrypoints/openai/responses/serving.py
+8 -5
@@ -749,11 +749,14 @@ class OpenAIServingResponses(GenerateBaseServing):
749 request: ResponsesRequest,
750 prev_response: ResponsesResponse | None,
751 ):
752 - if request.tool_choice not in ("auto", "none"):
753 - raise NotImplementedError(
754 - "Only 'auto' or 'none' tool_choice is supported "
755 - "in response API with Harmony"
756 - )
752 + if self.parser is not None:
753 + # HarmonyParser doesn't need chat_template_kwargs
754 + # TODO: Unify adjust_request() call with non-harmony branch
755 + self.parser(
756 + self.renderer.get_tokenizer(),
757 + request.tools,
758 + model_config=self.model_config,
759 + ).adjust_request(request=request)
760
761 arrival_time = time.time()
762 messages = self._construct_input_messages_with_harmony(request, prev_response)
vllm/parser/harmony.py
+230 -2
@@ -5,14 +5,31 @@ from __future__ import annotations
5
6 import json
7 from collections.abc import Sequence
8 -from dataclasses import dataclass
8 +from dataclasses import dataclass, replace
9 from enum import Enum, auto
10 from typing import TYPE_CHECKING, NamedTuple
11
12 from openai_harmony import HarmonyError, Message, Role
13 +from xgrammar import StructuralTag
14 +from xgrammar.openai_tool_call_schema import BuiltinToolParam, FunctionToolParam
15 +from xgrammar.structural_tag import (
16 + AnyTextFormat,
17 + ConstStringFormat,
18 + Format,
19 + GrammarFormat,
20 + JSONSchemaFormat,
21 + OptionalFormat,
22 + OrFormat,
23 + RegexFormat,
24 + SequenceFormat,
25 + TagFormat,
26 + TriggeredTagsFormat,
27 +)
28
29 from vllm.entrypoints.chat_utils import make_tool_call_id
15 -from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
30 +from vllm.entrypoints.openai.chat_completion.protocol import (
31 + ChatCompletionRequest,
32 +)
33 from vllm.entrypoints.openai.engine.protocol import (
34 DeltaFunctionCall,
35 DeltaMessage,
@@ -28,7 +45,13 @@ from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
45 from vllm.logger import init_logger
46 from vllm.parser.abstract_parser import DelegatingParser
47 from vllm.reasoning.gptoss_reasoning_parser import GptOssReasoningParser
48 +from vllm.sampling_params import StructuredOutputsParams
49 from vllm.tool_parsers.gptoss_tool_parser import GptOssToolParser
50 +from vllm.tool_parsers.structural_tag_registry import (
51 + SimplifiedToolChoice,
52 + get_function_parameters,
53 + register_vllm_structural_tag,
54 +)
55
56 if TYPE_CHECKING:
57 from openai_harmony import Message, StreamableParser
@@ -346,6 +369,12 @@ class HarmonyParser(DelegatingParser):
369 reasoning_token_count=reasoning_token_count,
370 )
371
372 + def adjust_request(
373 + self, request: ChatCompletionRequest | ResponsesRequest
374 + ) -> ChatCompletionRequest | ResponsesRequest:
375 + request = _adjust_output_format(request)
376 + return super().adjust_request(request)
377 +
378 @staticmethod
379 def _normalize_recipient(recipient: str | None) -> str | None:
380 """Remove constrained formats misparsed into recipients by older Harmony."""
@@ -356,3 +385,202 @@ class HarmonyParser(DelegatingParser):
385 if constrain_index == -1:
386 return recipient
387 return recipient[:constrain_index].rstrip() or None
388 +
389 +
390 +# Harmomy's stop tokens are <|return|>, <|call|>, <|endoftext|>
391 +# <|return|> is represented as "" since it's the default stop token, which xgrammar
392 +# disallows under constraints, leading to bad or infinite generation.
393 +# StreamableParser doesn't consider <|endoftext|> as a message end, so it's excluded
394 +# TODO: Remove <|call|> once #50595 lands.
395 +_END_TAG = ["<|end|>", "<|call|>", ""]
396 +_FINAL_BEGIN = "<|channel|>final{constrain}<|message|>"
397 +_TOOL_CALL_CHANNELS = [
398 + "<|channel|>commentary",
399 + "<|channel|>analysis",
400 + "<|channel|>final",
401 +]
402 +_FUNCTION_CALL_BEGINS = [
403 + "to=functions.{name} {channel} json<|message|>",
404 + "to=functions.{name} {channel} <|constrain|>json<|message|>",
405 + "{channel} to=functions.{name} json<|message|>",
406 + "{channel} to=functions.{name} <|constrain|>json<|message|>",
407 +]
408 +_JSON_CONTENT = JSONSchemaFormat(json_schema={"type": "object"})
409 +_ANY_CONTENT = AnyTextFormat()
410 +
411 +
412 +def _assemble_tag(
413 + allow_analysis: bool, allow_commentary: bool, content: Format
414 +) -> StructuralTag:
415 + tags = []
416 + if allow_analysis:
417 + analysis_tag = OptionalFormat(
418 + content=SequenceFormat(
419 + elements=[
420 + TagFormat(
421 + begin="<|channel|>analysis<|message|>",
422 + content=_ANY_CONTENT,
423 + end="<|end|>",
424 + ),
425 + ConstStringFormat(value="<|start|>assistant"),
426 + ]
427 + )
428 + )
429 + tags.append(analysis_tag)
430 +
431 + if allow_commentary:
432 + commentary_tag = OptionalFormat(
433 + content=SequenceFormat(
434 + elements=[
435 + TagFormat(
436 + begin="<|channel|>commentary<|message|>",
437 + content=_ANY_CONTENT,
438 + end="<|end|>",
439 + ),
440 + ConstStringFormat(value="<|start|>assistant"),
441 + ]
442 + )
443 + )
444 + tags.append(commentary_tag)
445 +
446 + tags.append(content)
447 +
448 + return StructuralTag(format=SequenceFormat(elements=tags))
449 +
450 +
451 +@register_vllm_structural_tag("harmony")
452 +def get_harmony_structural_tag(
453 + tools: list[FunctionToolParam],
454 + builtin_tools: list[BuiltinToolParam],
455 + tool_choice: SimplifiedToolChoice,
456 + reasoning: bool,
457 +) -> StructuralTag:
458 + # reasoning always enabled for Harmony
459 + del reasoning
460 +
461 + if builtin_tools:
462 + # Fallback for built-in tools
463 + tags = [
464 + TagFormat(
465 + begin="to=",
466 + content=AnyTextFormat(excludes=["<|start|>"]),
467 + end=_END_TAG,
468 + )
469 + ]
470 + tags.extend(
471 + TagFormat(
472 + begin=channel + " to=",
473 + content=AnyTextFormat(excludes=["<|start|>", "<|channel|>"]),
474 + end=_END_TAG,
475 + )
476 + for channel in _TOOL_CALL_CHANNELS
477 + )
478 + else:
479 + tags = [
480 + TagFormat(
481 + begin=pattern.format(name=tool.function.name, channel=channel),
482 + content=JSONSchemaFormat(
483 + json_schema=get_function_parameters(tool.function)
484 + ),
485 + end=_END_TAG,
486 + )
487 + for tool in tools
488 + for pattern in _FUNCTION_CALL_BEGINS
489 + for channel in _TOOL_CALL_CHANNELS
490 + ]
491 +
492 + if tool_choice == "auto":
493 + tags.append(
494 + TagFormat(
495 + begin=_FINAL_BEGIN.format(constrain=" <|constrain|>json"),
496 + content=_ANY_CONTENT,
497 + end=_END_TAG,
498 + )
499 + )
500 + tags.append(
501 + TagFormat(
502 + begin=_FINAL_BEGIN.format(constrain=""),
503 + content=_ANY_CONTENT,
504 + end=_END_TAG,
505 + )
506 + )
507 +
508 + return _assemble_tag(
509 + allow_analysis=True, allow_commentary=True, content=OrFormat(elements=tags)
510 + )
511 +
512 +
513 +def _params_to_final_content(params: StructuredOutputsParams) -> Format | None:
514 + """Map StructuredOutputsParams in a XGrammar Format."""
515 + if params.json_object:
516 + return _JSON_CONTENT
517 + if params.json is not None:
518 + schema = params.json
519 + if isinstance(schema, str):
520 + schema = json.loads(schema)
521 + return JSONSchemaFormat(json_schema=schema)
522 + if params.regex is not None:
523 + return RegexFormat(pattern=params.regex)
524 + if params.choice is not None:
525 + return OrFormat(
526 + elements=[ConstStringFormat(value=choice) for choice in params.choice]
527 + )
528 + if params.grammar is not None:
529 + return GrammarFormat(grammar=params.grammar)
530 + if params.structural_tag is not None:
531 + s_tag = json.loads(params.structural_tag)
532 + if "structures" in s_tag:
533 + # LegacyStructuralTagResponseFormat
534 + return TriggeredTagsFormat(
535 + triggers=s_tag["triggers"],
536 + tags=[
537 + TagFormat(
538 + begin=structure["begin"],
539 + content=JSONSchemaFormat(json_schema=structure["schema"]),
540 + end=structure["end"],
541 + )
542 + for structure in s_tag["structures"]
543 + ],
544 + )
545 + # StructuralTagResponseFormat
546 + return StructuralTag.model_validate(s_tag).format
547 + return None
548 +
549 +
550 +def _adjust_output_format(
551 + request: ChatCompletionRequest | ResponsesRequest,
552 +) -> ChatCompletionRequest | ResponsesRequest:
553 + """Canonicalize request constraints into a reasoning-aware StructuralTag."""
554 + params = request.extract_structured_outputs()
555 + if params is None:
556 + return request
557 +
558 + final_content = _params_to_final_content(params)
559 + if final_content is None:
560 + return request
561 +
562 + if isinstance(final_content, JSONSchemaFormat):
563 + begin = _FINAL_BEGIN.format(constrain=" <|constrain|>json")
564 + else:
565 + begin = _FINAL_BEGIN.format(constrain="")
566 +
567 + structural_tag = _assemble_tag(
568 + allow_analysis=True,
569 + allow_commentary=False,
570 + content=TagFormat(begin=begin, content=final_content, end=_END_TAG),
571 + )
572 +
573 + request.structured_outputs = replace(
574 + params,
575 + json=None,
576 + regex=None,
577 + choice=None,
578 + grammar=None,
579 + json_object=None,
580 + structural_tag=json.dumps(structural_tag.model_dump()),
581 + )
582 + if isinstance(request, ResponsesRequest):
583 + request.text = None
584 + else:
585 + request.response_format = None
586 + return request
vllm/reasoning/gptoss_reasoning_parser.py
+2 -130
@@ -1,65 +1,17 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -import json
3 from collections.abc import Iterable, Sequence
4 from typing import TYPE_CHECKING
5
6 from transformers import PreTrainedTokenizerBase
7
9 -from vllm.entrypoints.mcp.tool_server import ToolServer
8 from vllm.entrypoints.openai.engine.protocol import DeltaMessage
11 -from vllm.logger import init_logger
9 from vllm.reasoning import ReasoningParser
10
11 if TYPE_CHECKING:
12 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
13 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
14
18 -logger = init_logger(__name__)
19 -
20 -no_func_reasoning_tag = {
21 - "type": "structural_tag",
22 - "format": {
23 - "type": "triggered_tags",
24 - "tags": [
25 - {
26 - "begin": "<|channel|>analysis<|message|>",
27 - "content": {"type": "any_text"},
28 - "end": "<|end|>",
29 - }
30 - ],
31 - "triggers": ["<|channel|>analysis"],
32 - "stop_after_first": False,
33 - },
34 -}
35 -
36 -
37 -def from_builtin_tool_to_tag(tool: str) -> list[dict]:
38 - tag = [
39 - {
40 - "begin": f"<|channel|>commentary to={tool}",
41 - "content": {"type": "any_text"},
42 - "end": "<|end|>",
43 - },
44 - {
45 - "begin": f"<|channel|>analysis to={tool}",
46 - "content": {"type": "any_text"},
47 - "end": "<|end|>",
48 - },
49 - ]
50 - return tag
51 -
52 -
53 -def tag_with_builtin_funcs(no_func_reasoning_tag, builtin_tool_list: list[str]) -> dict:
54 - import copy
55 -
56 - new_tag = copy.deepcopy(no_func_reasoning_tag)
57 - new_tag["format"]["triggers"].append("<|channel|>commentary to=")
58 -
59 - for tool in builtin_tool_list:
60 - new_tag["format"]["tags"].extend(from_builtin_tool_to_tag(tool))
61 - return new_tag
62 -
15
16 class GptOssReasoningParser(ReasoningParser):
17 """
@@ -71,64 +23,14 @@ class GptOssReasoningParser(ReasoningParser):
23
24 def __init__(self, tokenizer: PreTrainedTokenizerBase, *args, **kwargs):
25 super().__init__(tokenizer, *args, **kwargs)
74 - # The model can output some special tokens between "final" and "<|message|>"
75 - # So we need to look for both sequences to determine the end of reasoning.
76 - self.reasoning_end_token_ids_prefix = self.model_tokenizer.encode(
77 - "<|channel|>final"
78 - )
79 - self.reasoning_end_token_ids_suffix = self.model_tokenizer.encode("<|message|>")
80 - # We also need to check for the <|end|> token to avoid false positives from
81 - # previous messages in multi-turn conversations.
82 - self.eom_token_id = self.vocab["<|end|>"]
83 - self.reasoning_max_num_between_tokens = 20
26
27 def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
86 - end_token_ids_prefix = self.reasoning_end_token_ids_prefix
87 - end_token_ids_suffix = self.reasoning_end_token_ids_suffix
88 - assert len(end_token_ids_prefix) > 0, "reasoning_end_token_ids_prefix is empty"
89 - assert len(end_token_ids_suffix) > 0, "reasoning_end_token_ids_suffix is empty"
90 - # Check if the end sequence is present in the input_ids.
91 - # We search from the end of input_ids to find the last match.
92 - for i in range(len(input_ids) - len(end_token_ids_prefix), -1, -1):
93 - if input_ids[i] == self.eom_token_id:
94 - # We looped backwards far enough to find the end of a previous message,
95 - # which means we have searched the entirety of the current message
96 - # and can exit early without searching further back into prior
97 - # messages of the conversation.
98 - return False
99 - if input_ids[i : i + len(end_token_ids_prefix)] == end_token_ids_prefix:
100 - # We have found the prefix, now we look for the suffix after the prefix.
101 - suffix_start = i + len(end_token_ids_prefix)
102 - for j in range(
103 - suffix_start, len(input_ids) - len(end_token_ids_suffix) + 1
104 - ):
105 - if j - suffix_start >= self.reasoning_max_num_between_tokens:
106 - break
107 - if (
108 - input_ids[j : j + len(end_token_ids_suffix)]
109 - == end_token_ids_suffix
110 - ):
111 - return True
112 - return False
28 + return True
29
30 def is_reasoning_end_streaming(
31 self, input_ids: Sequence[int], delta_ids: Iterable[int]
32 ) -> bool:
117 - # The pattern window covers the end-of-reasoning marker itself.
118 - # We add len(delta_ids) so that under speculative decoding (where
119 - # a single step can accept many tokens) the entire accepted chunk
120 - # is always inside the scan region.
121 - delta_ids = tuple(delta_ids)
122 - pattern_len = (
123 - len(self.reasoning_end_token_ids_prefix)
124 - + self.reasoning_max_num_between_tokens
125 - + len(self.reasoning_end_token_ids_suffix)
126 - )
127 - window = pattern_len + len(delta_ids)
128 - n = len(input_ids)
129 - if n <= window:
130 - return self.is_reasoning_end(input_ids)
131 - return self.is_reasoning_end(input_ids[n - window :])
33 + return True
34
35 def extract_content_ids(self, input_ids: list[int]) -> list[int]:
36 raise NotImplementedError(
@@ -159,33 +61,3 @@ class GptOssReasoningParser(ReasoningParser):
61 "GptOssReasoningParser only provides boundary detection. "
62 "Use HarmonyParser for output parsing."
63 )
162 -
163 - # This function prepares the structural tag to format reasoning output
164 - def prepare_structured_tag(
165 - self, original_tag: str | None, tool_server: ToolServer | None
166 - ) -> str | None:
167 - if original_tag is None:
168 - if tool_server is None:
169 - return json.dumps(no_func_reasoning_tag)
170 - else:
171 - builtin_tool_list: list[str] = []
172 - if tool_server.has_tool("browser"):
173 - builtin_tool_list.append("browser")
174 - if tool_server.has_tool("python"):
175 - builtin_tool_list.append("python")
176 - if tool_server.has_tool("container"):
177 - builtin_tool_list.append("container")
178 -
179 - if len(builtin_tool_list) > 0:
180 - logger.info("Builtin_tool_list: %s", builtin_tool_list)
181 - func_tag = json.dumps(
182 - tag_with_builtin_funcs(no_func_reasoning_tag, builtin_tool_list)
183 - )
184 - else:
185 - logger.info("Builtin_tool_list is empty")
186 - func_tag = json.dumps(no_func_reasoning_tag)
187 -
188 - return func_tag
189 - else:
190 - # There is potential risk for appending the tag to the original tag
191 - return original_tag
vllm/renderers/online_renderer.py
+9
@@ -201,6 +201,15 @@ class OnlineRenderer:
201 )
202 else:
203 # For GPT-OSS.
204 + if self.parser is not None:
205 + # HarmonyParser doesn't need chat_template_kwargs
206 + # TODO: Unify adjust_request() call with non-harmony branch
207 + self.parser(
208 + self.renderer.get_tokenizer(),
209 + request.tools,
210 + model_config=self.model_config,
211 + ).adjust_request(request=request)
212 +
213 should_include_tools = tool_dicts is not None
214 conversation, engine_inputs = self._make_request_with_harmony(
215 request, should_include_tools
vllm/tool_parsers/gptoss_tool_parser.py
+2
@@ -22,6 +22,8 @@ class GptOssToolParser(ToolParser):
22 capability declaration via HarmonyParser.tool_parser_cls.
23 """
24
25 + structural_tag_model = "harmony"
26 +
27 def __init__(self, tokenizer: "TokenizerLike", tools: list[Tool] | None = None):
28 super().__init__(tokenizer, tools)
29
vllm/tool_parsers/structural_tag_registry.py
+4 -5
@@ -65,7 +65,6 @@ XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
65 "qwen_3_5",
66 "qwen_3_coder",
67 "qwen_3",
68 - "harmony",
68 "deepseek_v3_2",
69 "glm_4_7",
70 "deepseek_v4",
@@ -209,7 +208,7 @@ def _dump_allowed_tool_ref_for_xgrammar(tool_ref: AllowedToolRef) -> AllowedTool
208 return tool_ref
209
210
212 -def _get_function_parameters(function) -> dict[str, Any] | bool:
211 +def get_function_parameters(function) -> dict[str, Any] | bool:
212 if getattr(function, "strict", None) is False:
213 return True
214 return function.parameters if function.parameters is not None else True
@@ -230,7 +229,7 @@ def _hermes_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]:
229 TagFormat(
230 begin=begin + tool.function.name + arguments_field_prefix,
231 content=JSONSchemaFormat(
233 - json_schema=_get_function_parameters(tool.function)
232 + json_schema=get_function_parameters(tool.function)
233 ),
234 end=end,
235 )
@@ -279,7 +278,7 @@ def _minimax_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]:
278 TagFormat(
279 begin=f'<invoke name="{tool.function.name}">\n',
280 content=JSONSchemaFormat(
282 - json_schema=_get_function_parameters(tool.function),
281 + json_schema=get_function_parameters(tool.function),
282 style="minimax_xml",
283 ),
284 end="</invoke>\n",
@@ -537,7 +536,7 @@ def _k3_arguments_block(parameters: dict[str, Any] | bool) -> Any:
536 def _k3_call_tag(tool: FunctionToolParam) -> TagFormat:
537 """One ``call`` tag: ``<|open|>call tool="N" index="<digits>"<|sep|> args``."""
538 function = tool.function
540 - parameters = _get_function_parameters(function)
539 + parameters = get_function_parameters(function)
540 begin = f'{_K3_OPEN}call tool="{_k3_escape_attr(function.name)}" index="'
541 return TagFormat(
542 begin=begin,