diff --git a/tests/entrypoints/openai/responses/test_harmony.py b/tests/entrypoints/openai/responses/test_harmony.py
index 2c70b06d8129..574f33a8f22b 100644
--- a/tests/entrypoints/openai/responses/test_harmony.py
+++ b/tests/entrypoints/openai/responses/test_harmony.py
@@ -13,7 +13,7 @@
import pytest
import pytest_asyncio
import requests
-from openai import InternalServerError, NotFoundError, OpenAI
+from openai import NotFoundError, OpenAI
from openai_harmony import Message
from tests.utils import RemoteOpenAIServer
@@ -368,8 +368,12 @@ async def test_streaming_types(
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
+@pytest.mark.parametrize("tool_choice", ["auto", "required"])
async def test_function_calling_with_streaming_types(
- pairs_of_event_types: dict[str, str], client: OpenAI, model_name: str
+ pairs_of_event_types: dict[str, str],
+ client: OpenAI,
+ model_name: str,
+ tool_choice: str,
):
"""Streaming event nesting for function-calling responses."""
@@ -382,6 +386,7 @@ def _has_function_events(evts: list) -> bool:
validate_events=_has_function_events,
input=[{"role": "user", "content": "What's the weather like in Paris today?"}],
tools=[GET_WEATHER_SCHEMA],
+ tool_choice=tool_choice,
temperature=0.0,
)
@@ -558,7 +563,8 @@ async def test_reasoning_item(client: OpenAI, model_name: str):
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
-async def test_function_calling(client: OpenAI, model_name: str):
+@pytest.mark.parametrize("tool_choice", ["auto", "required"])
+async def test_function_calling(client: OpenAI, model_name: str, tool_choice: str):
tools = [GET_WEATHER_SCHEMA]
response = await retry_for_tool_call(
@@ -567,8 +573,9 @@ async def test_function_calling(client: OpenAI, model_name: str):
expected_tool_type="function_call",
input="What's the weather like in Paris today?",
tools=tools,
+ tool_choice=tool_choice,
temperature=0.0,
- extra_body={"request_id": "test_function_calling_non_resp"},
+ extra_body={"request_id": f"test_function_calling_non_resp_{tool_choice}"},
)
assert response.status == "completed"
assert has_output_type(response, "function_call"), (
@@ -610,7 +617,10 @@ async def test_function_calling(client: OpenAI, model_name: str):
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
-async def test_function_calling_multi_turn(client: OpenAI, model_name: str):
+@pytest.mark.parametrize("tool_choice", ["auto", "required"])
+async def test_function_calling_multi_turn(
+ client: OpenAI, model_name: str, tool_choice: str
+):
"""Multi-tool, multi-turn function calling with retry at API level."""
tools = [
{
@@ -635,6 +645,7 @@ async def test_function_calling_multi_turn(client: OpenAI, model_name: str):
expected_tool_type="function_call",
input="Help me plan a trip to a random place. And tell me the weather there.",
tools=tools,
+ tool_choice=tool_choice,
temperature=0.0,
)
assert response.status == "completed"
@@ -659,6 +670,7 @@ async def test_function_calling_multi_turn(client: OpenAI, model_name: str):
}
],
tools=tools,
+ tool_choice=tool_choice,
previous_response_id=response.id,
temperature=0.0,
)
@@ -695,20 +707,6 @@ async def test_function_calling_multi_turn(client: OpenAI, model_name: str):
)
-@pytest.mark.asyncio
-@pytest.mark.parametrize("model_name", [MODEL_NAME])
-async def test_function_calling_required(client: OpenAI, model_name: str):
- tools = [GET_WEATHER_SCHEMA]
-
- with pytest.raises(InternalServerError):
- await client.responses.create(
- model=model_name,
- input="What's the weather like in Paris today?",
- tools=tools,
- tool_choice="required",
- )
-
-
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
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):
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
-async def test_function_calling_full_history(client: OpenAI, model_name: str):
+@pytest.mark.parametrize("tool_choice", ["auto", "required"])
+async def test_function_calling_full_history(
+ client: OpenAI, model_name: str, tool_choice: str
+):
tools = [GET_WEATHER_SCHEMA]
input_messages = [
@@ -739,6 +740,7 @@ async def test_function_calling_full_history(client: OpenAI, model_name: str):
expected_tool_type="function_call",
input=input_messages,
tools=tools,
+ tool_choice=tool_choice,
temperature=0.0,
)
assert response.status == "completed"
@@ -772,7 +774,10 @@ async def test_function_calling_full_history(client: OpenAI, model_name: str):
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
-async def test_function_calling_with_stream(client: OpenAI, model_name: str):
+@pytest.mark.parametrize("tool_choice", ["auto", "required"])
+async def test_function_calling_with_stream(
+ client: OpenAI, model_name: str, tool_choice: str
+):
"""Function calling via streaming, with retry for non-determinism."""
tools = [GET_WEATHER_SCHEMA]
input_list = [
@@ -792,6 +797,7 @@ def _has_function_call(evts: list) -> bool:
validate_events=_has_function_call,
input=input_list,
tools=tools,
+ tool_choice=tool_choice,
temperature=0.0,
)
@@ -853,8 +859,9 @@ def _has_function_call(evts: list) -> bool:
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
+@pytest.mark.parametrize("tool_choice", ["auto", "required"])
async def test_function_calling_no_code_interpreter_events(
- client: OpenAI, model_name: str
+ client: OpenAI, model_name: str, tool_choice: str
):
"""Verify that function calls don't trigger code_interpreter events.
@@ -880,6 +887,7 @@ def _has_function_call(evts: list) -> bool:
validate_events=_has_function_call,
input=input_list,
tools=tools,
+ tool_choice=tool_choice,
temperature=0.0,
)
@@ -1048,8 +1056,9 @@ async def test_output_messages_enabled(client: OpenAI, model_name: str, server):
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
+@pytest.mark.parametrize("tool_choice", ["auto", "required"])
async def test_function_call_with_previous_input_messages(
- client: OpenAI, model_name: str
+ client: OpenAI, model_name: str, tool_choice: str
):
"""Multi-turn function calling using previous_input_messages."""
tools = [
@@ -1074,6 +1083,7 @@ async def test_function_call_with_previous_input_messages(
expected_tool_type="function_call",
input="What is the horoscope for Aquarius today?",
tools=tools,
+ tool_choice=tool_choice,
temperature=0.0,
extra_body={"enable_response_messages": True},
max_output_tokens=1000,
diff --git a/tests/parser/test_harmony.py b/tests/parser/test_harmony.py
index 3e9cff64aa13..028bbed012dc 100644
--- a/tests/parser/test_harmony.py
+++ b/tests/parser/test_harmony.py
@@ -3,6 +3,7 @@
import json
from collections.abc import Sequence
+from typing import Any, Literal
import pytest
from openai_harmony import (
@@ -12,14 +13,18 @@
Role,
)
from transformers import AutoTokenizer
+from xgrammar import Grammar
+from xgrammar.testing import _is_grammar_accept_string
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.engine.protocol import FunctionCall
from vllm.entrypoints.openai.parser.harmony_utils import (
get_encoding,
)
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.parser.harmony import HarmonyParser
from vllm.parser.parser_manager import ParserManager
+from vllm.sampling_params import StructuredOutputsParams
REASONING_MODEL_NAME = "openai/gpt-oss-20b"
@@ -857,3 +862,335 @@ def test_multi_boundary(self, harmony_parser):
("analysis", "One"),
("final", "Two"),
]
+
+
+class TestAdjustRequest:
+ REQUEST_TEXT = "Hello"
+ TOOL_TYPE = "function"
+ TOOL_1_NAME = "get_user_location"
+ TOOL_2_NAME = "get_weather"
+ TOOLS = [
+ {
+ "name": TOOL_1_NAME,
+ "parameters": {
+ "type": "object",
+ "properties": {},
+ "required": [],
+ },
+ },
+ {
+ "name": TOOL_2_NAME,
+ "parameters": {
+ "type": "object",
+ "properties": {"city": {"type": "string"}},
+ "required": ["city"],
+ },
+ },
+ ]
+ OUTPUT_SCHEMA = {
+ "type": "object",
+ "properties": {"answer": {"type": "string"}},
+ "required": ["answer"],
+ }
+
+ ANALYSIS = "<|channel|>analysis<|message|>analysis message<|end|><|start|>assistant"
+ COMMENTARY = (
+ "<|channel|>commentary<|message|>commentary message<|end|><|start|>assistant"
+ )
+ TOOL_CALL_1 = (
+ ANALYSIS + f"<|channel|>commentary to=functions.{TOOL_1_NAME} json<|message|>"
+ "{}<|call|>"
+ )
+ TOOL_CALL_2 = (
+ ANALYSIS + f"<|channel|>commentary to=functions.{TOOL_2_NAME} json<|message|>"
+ '{"city": "Tokyo"}<|call|>'
+ )
+ FINAL_JSON_SCHEMA = (
+ ANALYSIS
+ + '<|channel|>final <|constrain|>json<|message|>{"answer": "Tokyo"}<|end|>'
+ )
+ FINAL_JSON_OBJECT = (
+ ANALYSIS
+ + '<|channel|>final <|constrain|>json<|message|>{"city": "Tokyo"}<|end|>'
+ )
+ FINAL_TEXT_ONLY = ANALYSIS + "<|channel|>final<|message|>any<|end|>"
+ FINAL_REGEX = ANALYSIS + "<|channel|>final<|message|>regex<|end|>"
+ FINAL_CHOICE = ANALYSIS + "<|channel|>final<|message|>choice1<|end|>"
+ FINAL_GRAMMAR = ANALYSIS + "<|channel|>final<|message|>grammar<|end|>"
+ FINAL_STRUCTURAL_TAG = ANALYSIS + "<|channel|>final<|message|>tag content<|end|>"
+ ADMISSION_SAMPLES = (
+ "COMMENTARY",
+ "TOOL_CALL_1",
+ "TOOL_CALL_2",
+ "FINAL_JSON_SCHEMA",
+ "FINAL_JSON_OBJECT",
+ "FINAL_TEXT_ONLY",
+ "FINAL_REGEX",
+ "FINAL_CHOICE",
+ "FINAL_GRAMMAR",
+ "FINAL_STRUCTURAL_TAG",
+ )
+
+ @staticmethod
+ def _build_request(
+ request_kind: Literal["chat", "responses"],
+ tool_choice: str = "none",
+ strict_tools: bool = False,
+ response_format_type: str | None = None,
+ structured_outputs: StructuredOutputsParams | None = None,
+ ) -> ChatCompletionRequest | ResponsesRequest:
+ data: dict[str, Any] = {
+ "model": REASONING_MODEL_NAME,
+ }
+ if request_kind == "chat":
+ data["messages"] = [
+ {
+ "role": "user",
+ "content": TestAdjustRequest.REQUEST_TEXT,
+ }
+ ]
+ else:
+ data["input"] = TestAdjustRequest.REQUEST_TEXT
+
+ if request_kind == "chat":
+ data["tools"] = [
+ {
+ "type": TestAdjustRequest.TOOL_TYPE,
+ "function": {"strict": strict_tools, **tool_def},
+ }
+ for tool_def in TestAdjustRequest.TOOLS
+ ]
+ data["tool_choice"] = (
+ {
+ "type": TestAdjustRequest.TOOL_TYPE,
+ "function": {"name": TestAdjustRequest.TOOL_2_NAME},
+ }
+ if tool_choice == "named"
+ else tool_choice
+ )
+ else:
+ data["tools"] = [
+ {
+ "type": TestAdjustRequest.TOOL_TYPE,
+ "strict": strict_tools,
+ **tool_def,
+ }
+ for tool_def in TestAdjustRequest.TOOLS
+ ]
+ data["tool_choice"] = (
+ {
+ "type": TestAdjustRequest.TOOL_TYPE,
+ "name": TestAdjustRequest.TOOL_2_NAME,
+ }
+ if tool_choice == "named"
+ else tool_choice
+ )
+
+ if response_format_type == "json_schema":
+ schema_format = {
+ "name": "answer_format",
+ "schema": TestAdjustRequest.OUTPUT_SCHEMA,
+ "strict": True,
+ }
+ if request_kind == "chat":
+ data["response_format"] = {
+ "type": "json_schema",
+ "json_schema": schema_format,
+ }
+ else:
+ data["text"] = {
+ "format": {
+ "type": "json_schema",
+ **schema_format,
+ }
+ }
+ elif response_format_type == "json_object":
+ if request_kind == "chat":
+ data["response_format"] = {"type": "json_object"}
+ else:
+ data["text"] = {"format": {"type": "json_object"}}
+
+ if structured_outputs is not None:
+ data["structured_outputs"] = structured_outputs
+
+ if request_kind == "chat":
+ return ChatCompletionRequest.model_validate(data)
+ return ResponsesRequest.model_validate(data)
+
+ @staticmethod
+ def _assert_format_cleared(
+ adjusted_request: ChatCompletionRequest | ResponsesRequest,
+ ) -> None:
+ if isinstance(adjusted_request, ResponsesRequest):
+ assert adjusted_request.text is None or adjusted_request.text.format is None
+ else:
+ assert adjusted_request.response_format is None
+
+ structured_outputs = adjusted_request.structured_outputs
+ assert structured_outputs is not None
+ assert structured_outputs.structural_tag is not None
+ assert structured_outputs.all_non_structural_tag_constraints_none()
+
+ @classmethod
+ def _assert_structured_outputs_admission(
+ cls,
+ adjusted_request: ChatCompletionRequest | ResponsesRequest,
+ expected_admission: Sequence[str],
+ ) -> None:
+ structured_outputs = adjusted_request.structured_outputs
+ assert structured_outputs is not None
+ assert structured_outputs.structural_tag is not None
+ assert structured_outputs.all_non_structural_tag_constraints_none()
+
+ grammar = Grammar.from_structural_tag(structured_outputs.structural_tag)
+ expected_admission_set = set(expected_admission)
+
+ for sample_name in cls.ADMISSION_SAMPLES:
+ admitted = _is_grammar_accept_string(
+ grammar,
+ getattr(cls, sample_name),
+ require_termination=False,
+ )
+ should_admit = sample_name in expected_admission_set
+ assert admitted is should_admit, (
+ f"Expected structured_outputs admission for {sample_name} "
+ f"to be {should_admit}, got {admitted}."
+ )
+
+ @pytest.mark.parametrize("request_kind", ["chat", "responses"])
+ @pytest.mark.parametrize(
+ ("request_kwargs", "expected_admission"),
+ [
+ (
+ {"tool_choice": "auto", "strict_tools": True},
+ [
+ "COMMENTARY",
+ "TOOL_CALL_1",
+ "TOOL_CALL_2",
+ "FINAL_JSON_SCHEMA",
+ "FINAL_JSON_OBJECT",
+ "FINAL_TEXT_ONLY",
+ "FINAL_REGEX",
+ "FINAL_CHOICE",
+ "FINAL_GRAMMAR",
+ "FINAL_STRUCTURAL_TAG",
+ ],
+ ),
+ (
+ {"tool_choice": "required"},
+ ["COMMENTARY", "TOOL_CALL_1", "TOOL_CALL_2"],
+ ),
+ (
+ {"tool_choice": "named"},
+ ["COMMENTARY", "TOOL_CALL_2"],
+ ),
+ (
+ {"response_format_type": "json_schema"},
+ ["FINAL_JSON_SCHEMA"],
+ ),
+ (
+ {"response_format_type": "json_object"},
+ ["FINAL_JSON_SCHEMA", "FINAL_JSON_OBJECT"],
+ ),
+ (
+ {"structured_outputs": StructuredOutputsParams(json=OUTPUT_SCHEMA)},
+ ["FINAL_JSON_SCHEMA"],
+ ),
+ (
+ {"structured_outputs": StructuredOutputsParams(json_object=True)},
+ ["FINAL_JSON_SCHEMA", "FINAL_JSON_OBJECT"],
+ ),
+ (
+ {"structured_outputs": StructuredOutputsParams(regex=r"regex")},
+ ["FINAL_REGEX"],
+ ),
+ (
+ {
+ "structured_outputs": StructuredOutputsParams(
+ choice=["choice1", "choice2"]
+ )
+ },
+ ["FINAL_CHOICE"],
+ ),
+ (
+ {
+ "structured_outputs": StructuredOutputsParams(
+ grammar='root ::= "grammar"'
+ )
+ },
+ ["FINAL_GRAMMAR"],
+ ),
+ (
+ {
+ "structured_outputs": StructuredOutputsParams(
+ structural_tag=json.dumps(
+ {
+ "type": "structural_tag",
+ "format": {
+ "type": "json_schema",
+ "json_schema": OUTPUT_SCHEMA,
+ },
+ }
+ )
+ )
+ },
+ ["FINAL_JSON_SCHEMA"],
+ ),
+ (
+ {
+ "structured_outputs": StructuredOutputsParams(
+ structural_tag=json.dumps(
+ {
+ "type": "structural_tag",
+ "structures": [
+ {
+ "begin": "",
+ "schema": {"type": "object"},
+ "end": "",
+ }
+ ],
+ "triggers": [""],
+ }
+ )
+ )
+ },
+ [
+ # Legacy triggered tags allow free text until a trigger, so
+ # unconstrained final-channel payloads are also admitted.
+ "FINAL_TEXT_ONLY",
+ "FINAL_REGEX",
+ "FINAL_CHOICE",
+ "FINAL_GRAMMAR",
+ "FINAL_STRUCTURAL_TAG",
+ ],
+ ),
+ ],
+ ids=[
+ "tool_auto_strict",
+ "tool_required",
+ "tool_named",
+ "response_format_json_schema",
+ "response_format_json_object",
+ "structured_outputs_json",
+ "structured_outputs_json_object",
+ "structured_outputs_regex",
+ "structured_outputs_choice",
+ "structured_outputs_grammar",
+ "structured_outputs_structural_tag_modern",
+ "structured_outputs_structural_tag_legacy",
+ ],
+ )
+ def test_adjust_request(
+ self,
+ harmony_parser,
+ request_kind,
+ request_kwargs,
+ expected_admission,
+ ):
+ request = self._build_request(request_kind, **request_kwargs)
+ adjusted_request = harmony_parser.adjust_request(request)
+ self._assert_format_cleared(adjusted_request)
+ self._assert_structured_outputs_admission(
+ adjusted_request,
+ expected_admission,
+ )
diff --git a/tests/reasoning/test_gptoss_reasoning_parser.py b/tests/reasoning/test_gptoss_reasoning_parser.py
index a6f815b6ae5c..e615bc7d73a5 100644
--- a/tests/reasoning/test_gptoss_reasoning_parser.py
+++ b/tests/reasoning/test_gptoss_reasoning_parser.py
@@ -1,351 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
-import json
from unittest.mock import Mock
-import pytest
-from transformers import AutoTokenizer
+from vllm.reasoning.gptoss_reasoning_parser import GptOssReasoningParser
-from vllm.entrypoints.mcp.tool_server import ToolServer
-from vllm.reasoning import ReasoningParser
-from vllm.reasoning.gptoss_reasoning_parser import (
- GptOssReasoningParser,
- from_builtin_tool_to_tag,
- no_func_reasoning_tag,
-)
-REASONING_MODEL_NAME = "openai/gpt-oss-120b"
-
-
-@pytest.fixture(scope="module")
-def gpt_oss_tokenizer():
- return AutoTokenizer.from_pretrained(REASONING_MODEL_NAME)
-
-
-USER_MESSAGE_START = "<|start|>user<|message|>"
-REASONING_SECTION_START = "<|end|><|start|>assistant<|channel|>analysis<|message|>"
-END = "<|end|>"
-ASSISTANT_START = "<|start|>assistant"
-ASSISTANT_CONTENT_START_PREFIX = END + ASSISTANT_START + "<|channel|>final"
-ASSISTANT_CONTENT_START_SUFFIX = "<|message|>"
-ASSISTANT_CONTENT_START = (
- ASSISTANT_CONTENT_START_PREFIX + ASSISTANT_CONTENT_START_SUFFIX
-)
-
-BASIC_CONTENT = {
- "output": REASONING_SECTION_START
- + "This is reasoning"
- + ASSISTANT_CONTENT_START
- + "This is the rest",
- "is_reasoning_end": True,
-}
-
-BASIC_REASONING_ONLY = {
- "output": REASONING_SECTION_START + "This is reasoning" + "<|end|>",
- "is_reasoning_end": False,
-}
-BASIC_NO_REASONING_NO_ASSISTANT = {
- "output": USER_MESSAGE_START + "This is a user message",
- "is_reasoning_end": False,
-}
-
-# Edge-case where the model omits the assistant tag entirely.
-BASIC_NO_REASONING_ASSISTANT = {
- "output": USER_MESSAGE_START + "This is a user message<|end|><|channel|>final",
- "is_reasoning_end": True,
-}
-
-COMPLEX_CONTENT_INCOMPLETE_PREFIX_ONLY = {
- "output": REASONING_SECTION_START
- + "This is reasoning"
- + ASSISTANT_CONTENT_START_PREFIX,
- "is_reasoning_end": False,
-}
-
-COMPLEX_CONTENT_SUFFIX_ONLY = {
- "output": REASONING_SECTION_START
- + "This is reasoning"
- + ASSISTANT_CONTENT_START_SUFFIX,
- "is_reasoning_end": False,
-}
-
-COMPLEX_CONTENT_1_NO_SUFFIX = {
- "output": REASONING_SECTION_START
- + "This is reasoning"
- + ASSISTANT_CONTENT_START_PREFIX
- + "<|constrain|> JSON ",
- "is_reasoning_end": False,
-}
-
-COMPLEX_CONTENT_1 = {
- "output": REASONING_SECTION_START
- + "This is reasoning"
- + ASSISTANT_CONTENT_START_PREFIX
- + "<|constrain|> JSON "
- + ASSISTANT_CONTENT_START_SUFFIX,
- "is_reasoning_end": True,
-}
-
-COMPLEX_CONTENT_1_WITH_CONTENT = {
- "output": REASONING_SECTION_START
- + "This is reasoning"
- + ASSISTANT_CONTENT_START_PREFIX
- + "<|constrain|> JSON "
- + ASSISTANT_CONTENT_START_SUFFIX
- + "This is the rest",
- "is_reasoning_end": True,
-}
-
-COMPLEX_CONTENT_2 = {
- "output": REASONING_SECTION_START
- + "This is reasoning"
- + ASSISTANT_CONTENT_START_PREFIX
- + "<|constrain|>ReplyAction "
- + ASSISTANT_CONTENT_START_SUFFIX
- + "This is the rest",
- "is_reasoning_end": True,
-}
-
-MULTI_TURN_CONTENT = {
- "output": USER_MESSAGE_START
- + "1st turn user message"
- + REASONING_SECTION_START
- + "1st turn reasoning"
- + ASSISTANT_CONTENT_START
- + "1st turn response"
- + END
- + USER_MESSAGE_START
- + "2nd turn user message"
- + END
- + ASSISTANT_START,
- "is_reasoning_end": False,
-}
-TEST_CASES = [
- BASIC_CONTENT,
- BASIC_REASONING_ONLY,
- COMPLEX_CONTENT_INCOMPLETE_PREFIX_ONLY,
- COMPLEX_CONTENT_SUFFIX_ONLY,
- COMPLEX_CONTENT_1_NO_SUFFIX,
- COMPLEX_CONTENT_1,
- COMPLEX_CONTENT_1_WITH_CONTENT,
- COMPLEX_CONTENT_2,
- MULTI_TURN_CONTENT,
-]
-
-
-@pytest.mark.parametrize(
- "output, is_reasoning_end",
- [(t["output"], t["is_reasoning_end"]) for t in TEST_CASES],
-)
-def test_gptoss_is_reasoning_end(
- output,
- is_reasoning_end,
- gpt_oss_tokenizer,
-):
- output = gpt_oss_tokenizer.tokenize(output)
- parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer)
-
- # Test is_reasoning_end
- output_ids = gpt_oss_tokenizer.convert_tokens_to_ids(output)
- actual_is_reasoning_end = parser.is_reasoning_end(output_ids)
- assert is_reasoning_end == actual_is_reasoning_end
-
-
-class TestGptOssStructuralTags:
- """Test cases for GptOssReasoningParser structural tag functionality."""
-
- @pytest.fixture
- def mock_tokenizer(self):
- """Create a mock tokenizer for testing."""
- tokenizer = Mock()
- tokenizer.encode = Mock(return_value=[1, 2, 3, 4, 5])
- tokenizer.get_vocab = Mock(return_value={"<|end|>": 6})
- return tokenizer
-
- @pytest.fixture
- def reasoning_parser(self, mock_tokenizer):
- """Create a GptOssReasoningParser instance."""
- return GptOssReasoningParser(mock_tokenizer)
-
- def test_prepare_structured_tag_no_tool_server(self, reasoning_parser):
- """Test prepare_structured_tag with no tool server."""
- result = reasoning_parser.prepare_structured_tag(None, None)
- expected = json.dumps(no_func_reasoning_tag)
-
- assert result == expected
-
- # Verify the structure is correct
- parsed = json.loads(result)
- assert parsed["type"] == "structural_tag"
- assert parsed["format"]["type"] == "triggered_tags"
- assert len(parsed["format"]["tags"]) == 1
- assert parsed["format"]["tags"][0]["begin"] == "<|channel|>analysis<|message|>"
- assert parsed["format"]["triggers"] == ["<|channel|>analysis"]
-
- def test_prepare_structured_tag_with_original_tag(self, reasoning_parser):
- """Test prepare_structured_tag when original_tag is provided."""
- original_tag = '{"custom": "tag"}'
- result = reasoning_parser.prepare_structured_tag(original_tag, None)
-
- # Should return the original tag unchanged
- assert result == original_tag
-
- def test_from_builtin_tool_to_tag(self):
- """Test from_builtin_tool_to_tag function."""
- tags = from_builtin_tool_to_tag("python")
-
- assert len(tags) == 2
- assert tags[0]["begin"] == "<|channel|>commentary to=python"
- assert tags[0]["content"]["type"] == "any_text"
- assert tags[0]["end"] == "<|end|>"
-
- assert tags[1]["begin"] == "<|channel|>analysis to=python"
- assert tags[1]["content"]["type"] == "any_text"
- assert tags[1]["end"] == "<|end|>"
-
- @pytest.mark.parametrize(
- "tools",
- [
- [],
- ["browser"],
- ["python"],
- ["container"],
- ["browser", "python"],
- ["browser", "container"],
- ["python", "container"],
- ["browser", "python", "container"],
- ],
- )
- def test_json_validity_comprehensive(self, reasoning_parser, tools):
- """Test JSON validity across all possible tool combinations."""
- tool_server = Mock(spec=ToolServer)
- tool_server.has_tool = Mock(side_effect=lambda tool: tool in tools)
-
- result = reasoning_parser.prepare_structured_tag(None, tool_server)
- parsed_result = json.loads(result)
-
- assert parsed_result["type"] == "structural_tag"
- assert "format" in parsed_result
- assert "tags" in parsed_result["format"]
- assert "triggers" in parsed_result["format"]
-
- # Tag count should be: 1 (analysis) + 2 * len(tools)
- expected_tag_count = 1 + (2 * len(tools))
- assert len(parsed_result["format"]["tags"]) == expected_tag_count
-
- # Verify triggers are correctly configured
- expected_triggers = ["<|channel|>analysis"]
- if tools:
- expected_triggers.append("<|channel|>commentary to=")
- assert set(parsed_result["format"]["triggers"]) == set(expected_triggers)
-
- def test_no_cross_request_state_pollution(self, reasoning_parser):
- """Test that sequential calls with different tool servers produce
- independent results, guarding against shared mutable state
- (e.g. missing deepcopy in tag_with_builtin_funcs)."""
- tool_server_1 = Mock(spec=ToolServer)
- tool_server_1.has_tool = Mock(side_effect=lambda tool: tool == "python")
-
- tool_server_2 = Mock(spec=ToolServer)
- tool_server_2.has_tool = Mock(side_effect=lambda tool: tool == "browser")
-
- result_1 = reasoning_parser.prepare_structured_tag(None, tool_server_1)
- result_2 = reasoning_parser.prepare_structured_tag(None, tool_server_2)
-
- tags_1 = [tag["begin"] for tag in json.loads(result_1)["format"]["tags"]]
- tags_2 = [tag["begin"] for tag in json.loads(result_2)["format"]["tags"]]
-
- assert "<|channel|>commentary to=python" in tags_1
- assert "<|channel|>commentary to=browser" not in tags_1
-
- assert "<|channel|>commentary to=browser" in tags_2
- assert "<|channel|>commentary to=python" not in tags_2
-
- def test_tag_format_consistency(self, reasoning_parser):
- """Test that all generated tags follow consistent format,
- catching malformed tags from from_builtin_tool_to_tag."""
- tool_server = Mock(spec=ToolServer)
- tool_server.has_tool = Mock(
- side_effect=lambda tool: tool in ["python", "browser"]
- )
-
- result = reasoning_parser.prepare_structured_tag(None, tool_server)
- parsed_result = json.loads(result)
-
- for tag in parsed_result["format"]["tags"]:
- assert "begin" in tag
- assert "content" in tag
- assert "end" in tag
- assert tag["content"]["type"] == "any_text"
- assert tag["end"] == "<|end|>"
- assert tag["begin"].startswith("<|channel|>")
-
-
-@pytest.mark.parametrize(
- "output, is_reasoning_end",
- [(t["output"], t["is_reasoning_end"]) for t in TEST_CASES],
-)
-def test_gptoss_is_reasoning_end_streaming(
- output,
- is_reasoning_end,
- gpt_oss_tokenizer,
-):
- """Streaming override must agree with is_reasoning_end for all cases."""
- tokens = gpt_oss_tokenizer.tokenize(output)
- parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer)
- output_ids = gpt_oss_tokenizer.convert_tokens_to_ids(tokens)
- delta_ids = output_ids[-1:] if output_ids else []
- actual = parser.is_reasoning_end_streaming(output_ids, delta_ids)
- assert is_reasoning_end == actual
-
-
-@pytest.mark.parametrize(
- "output, is_reasoning_end",
- [(t["output"], t["is_reasoning_end"]) for t in TEST_CASES],
-)
-def test_gptoss_is_reasoning_end_streaming_long_prefix(
- output,
- is_reasoning_end,
- gpt_oss_tokenizer,
-):
- """Windowing must produce correct results even with a long prefix."""
- tokens = gpt_oss_tokenizer.tokenize(output)
- parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer)
- output_ids = gpt_oss_tokenizer.convert_tokens_to_ids(tokens)
- # Prepend 10k dummy reasoning tokens to simulate a long generation
- long_prefix = [1] * 10_000
- padded_ids = long_prefix + list(output_ids)
- delta_ids = output_ids[-1:] if output_ids else []
- actual = parser.is_reasoning_end_streaming(padded_ids, delta_ids)
- assert is_reasoning_end == actual
-
-
-@pytest.mark.parametrize(
- "output, is_reasoning_end",
- [(t["output"], t["is_reasoning_end"]) for t in TEST_CASES],
-)
-def test_gptoss_is_reasoning_end_streaming_large_delta(
- output,
- is_reasoning_end,
- gpt_oss_tokenizer,
-):
- """Simulate speculative decoding where the entire test sequence arrives
- as a single large delta appended after a long prefix. The window must
- expand to cover delta_ids so the end pattern is never missed."""
- tokens = gpt_oss_tokenizer.tokenize(output)
- parser: ReasoningParser = GptOssReasoningParser(gpt_oss_tokenizer)
- output_ids = gpt_oss_tokenizer.convert_tokens_to_ids(tokens)
- long_prefix = [1] * 10_000
- padded_ids = long_prefix + list(output_ids)
- # delta_ids = the entire test sequence (as if accepted in one spec step)
- delta_ids = list(output_ids)
- actual = parser.is_reasoning_end_streaming(padded_ids, delta_ids)
- assert is_reasoning_end == actual
-
-
-def test_gptoss_is_reasoning_end_streaming_signature(gpt_oss_tokenizer):
- """Verify the method is callable with the expected signature."""
- parser = GptOssReasoningParser(gpt_oss_tokenizer)
- result = parser.is_reasoning_end_streaming([], [])
- assert result is False
+def test_gptoss_reasoning_ended_is_true():
+ parser = GptOssReasoningParser(Mock())
+ assert parser.is_reasoning_end([]) is True
+ assert parser.is_reasoning_end_streaming([], []) is True
diff --git a/tests/tool_parsers/test_structural_tag_registry.py b/tests/tool_parsers/test_structural_tag_registry.py
index 58672cdb5de0..b84dfad0923c 100644
--- a/tests/tool_parsers/test_structural_tag_registry.py
+++ b/tests/tool_parsers/test_structural_tag_registry.py
@@ -33,7 +33,7 @@
SUPPORTED_STRUCTURAL_TAG_MODELS,
VLLM_BUILTIN_STRUCTURAL_TAG_MODELS,
XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS,
- _get_function_parameters,
+ get_function_parameters,
get_model_structural_tag,
)
@@ -530,7 +530,7 @@ def test_get_function_parameters_relaxes_function_strict_false():
strict=False,
)
- assert _get_function_parameters(function) is True
+ assert get_function_parameters(function) is True
def _k3_tools_with_root_defs() -> list[ChatCompletionToolsParam]:
diff --git a/vllm/entrypoints/openai/responses/serving.py b/vllm/entrypoints/openai/responses/serving.py
index 598d9fe7a885..344ce0a209c1 100644
--- a/vllm/entrypoints/openai/responses/serving.py
+++ b/vllm/entrypoints/openai/responses/serving.py
@@ -749,11 +749,14 @@ def _make_request_with_harmony(
request: ResponsesRequest,
prev_response: ResponsesResponse | None,
):
- if request.tool_choice not in ("auto", "none"):
- raise NotImplementedError(
- "Only 'auto' or 'none' tool_choice is supported "
- "in response API with Harmony"
- )
+ if self.parser is not None:
+ # HarmonyParser doesn't need chat_template_kwargs
+ # TODO: Unify adjust_request() call with non-harmony branch
+ self.parser(
+ self.renderer.get_tokenizer(),
+ request.tools,
+ model_config=self.model_config,
+ ).adjust_request(request=request)
arrival_time = time.time()
messages = self._construct_input_messages_with_harmony(request, prev_response)
diff --git a/vllm/parser/harmony.py b/vllm/parser/harmony.py
index 1442246f139c..3cc493b65923 100644
--- a/vllm/parser/harmony.py
+++ b/vllm/parser/harmony.py
@@ -5,14 +5,31 @@
import json
from collections.abc import Sequence
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from enum import Enum, auto
from typing import TYPE_CHECKING, NamedTuple
from openai_harmony import HarmonyError, Message, Role
+from xgrammar import StructuralTag
+from xgrammar.openai_tool_call_schema import BuiltinToolParam, FunctionToolParam
+from xgrammar.structural_tag import (
+ AnyTextFormat,
+ ConstStringFormat,
+ Format,
+ GrammarFormat,
+ JSONSchemaFormat,
+ OptionalFormat,
+ OrFormat,
+ RegexFormat,
+ SequenceFormat,
+ TagFormat,
+ TriggeredTagsFormat,
+)
from vllm.entrypoints.chat_utils import make_tool_call_id
-from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
+from vllm.entrypoints.openai.chat_completion.protocol import (
+ ChatCompletionRequest,
+)
from vllm.entrypoints.openai.engine.protocol import (
DeltaFunctionCall,
DeltaMessage,
@@ -28,7 +45,13 @@
from vllm.logger import init_logger
from vllm.parser.abstract_parser import DelegatingParser
from vllm.reasoning.gptoss_reasoning_parser import GptOssReasoningParser
+from vllm.sampling_params import StructuredOutputsParams
from vllm.tool_parsers.gptoss_tool_parser import GptOssToolParser
+from vllm.tool_parsers.structural_tag_registry import (
+ SimplifiedToolChoice,
+ get_function_parameters,
+ register_vllm_structural_tag,
+)
if TYPE_CHECKING:
from openai_harmony import Message, StreamableParser
@@ -346,6 +369,12 @@ def process_chunk(self, token_ids: Sequence[int]) -> ChunkResult:
reasoning_token_count=reasoning_token_count,
)
+ def adjust_request(
+ self, request: ChatCompletionRequest | ResponsesRequest
+ ) -> ChatCompletionRequest | ResponsesRequest:
+ request = _adjust_output_format(request)
+ return super().adjust_request(request)
+
@staticmethod
def _normalize_recipient(recipient: str | None) -> str | None:
"""Remove constrained formats misparsed into recipients by older Harmony."""
@@ -356,3 +385,202 @@ def _normalize_recipient(recipient: str | None) -> str | None:
if constrain_index == -1:
return recipient
return recipient[:constrain_index].rstrip() or None
+
+
+# Harmomy's stop tokens are <|return|>, <|call|>, <|endoftext|>
+# <|return|> is represented as "" since it's the default stop token, which xgrammar
+# disallows under constraints, leading to bad or infinite generation.
+# StreamableParser doesn't consider <|endoftext|> as a message end, so it's excluded
+# TODO: Remove <|call|> once #50595 lands.
+_END_TAG = ["<|end|>", "<|call|>", ""]
+_FINAL_BEGIN = "<|channel|>final{constrain}<|message|>"
+_TOOL_CALL_CHANNELS = [
+ "<|channel|>commentary",
+ "<|channel|>analysis",
+ "<|channel|>final",
+]
+_FUNCTION_CALL_BEGINS = [
+ "to=functions.{name} {channel} json<|message|>",
+ "to=functions.{name} {channel} <|constrain|>json<|message|>",
+ "{channel} to=functions.{name} json<|message|>",
+ "{channel} to=functions.{name} <|constrain|>json<|message|>",
+]
+_JSON_CONTENT = JSONSchemaFormat(json_schema={"type": "object"})
+_ANY_CONTENT = AnyTextFormat()
+
+
+def _assemble_tag(
+ allow_analysis: bool, allow_commentary: bool, content: Format
+) -> StructuralTag:
+ tags = []
+ if allow_analysis:
+ analysis_tag = OptionalFormat(
+ content=SequenceFormat(
+ elements=[
+ TagFormat(
+ begin="<|channel|>analysis<|message|>",
+ content=_ANY_CONTENT,
+ end="<|end|>",
+ ),
+ ConstStringFormat(value="<|start|>assistant"),
+ ]
+ )
+ )
+ tags.append(analysis_tag)
+
+ if allow_commentary:
+ commentary_tag = OptionalFormat(
+ content=SequenceFormat(
+ elements=[
+ TagFormat(
+ begin="<|channel|>commentary<|message|>",
+ content=_ANY_CONTENT,
+ end="<|end|>",
+ ),
+ ConstStringFormat(value="<|start|>assistant"),
+ ]
+ )
+ )
+ tags.append(commentary_tag)
+
+ tags.append(content)
+
+ return StructuralTag(format=SequenceFormat(elements=tags))
+
+
+@register_vllm_structural_tag("harmony")
+def get_harmony_structural_tag(
+ tools: list[FunctionToolParam],
+ builtin_tools: list[BuiltinToolParam],
+ tool_choice: SimplifiedToolChoice,
+ reasoning: bool,
+) -> StructuralTag:
+ # reasoning always enabled for Harmony
+ del reasoning
+
+ if builtin_tools:
+ # Fallback for built-in tools
+ tags = [
+ TagFormat(
+ begin="to=",
+ content=AnyTextFormat(excludes=["<|start|>"]),
+ end=_END_TAG,
+ )
+ ]
+ tags.extend(
+ TagFormat(
+ begin=channel + " to=",
+ content=AnyTextFormat(excludes=["<|start|>", "<|channel|>"]),
+ end=_END_TAG,
+ )
+ for channel in _TOOL_CALL_CHANNELS
+ )
+ else:
+ tags = [
+ TagFormat(
+ begin=pattern.format(name=tool.function.name, channel=channel),
+ content=JSONSchemaFormat(
+ json_schema=get_function_parameters(tool.function)
+ ),
+ end=_END_TAG,
+ )
+ for tool in tools
+ for pattern in _FUNCTION_CALL_BEGINS
+ for channel in _TOOL_CALL_CHANNELS
+ ]
+
+ if tool_choice == "auto":
+ tags.append(
+ TagFormat(
+ begin=_FINAL_BEGIN.format(constrain=" <|constrain|>json"),
+ content=_ANY_CONTENT,
+ end=_END_TAG,
+ )
+ )
+ tags.append(
+ TagFormat(
+ begin=_FINAL_BEGIN.format(constrain=""),
+ content=_ANY_CONTENT,
+ end=_END_TAG,
+ )
+ )
+
+ return _assemble_tag(
+ allow_analysis=True, allow_commentary=True, content=OrFormat(elements=tags)
+ )
+
+
+def _params_to_final_content(params: StructuredOutputsParams) -> Format | None:
+ """Map StructuredOutputsParams in a XGrammar Format."""
+ if params.json_object:
+ return _JSON_CONTENT
+ if params.json is not None:
+ schema = params.json
+ if isinstance(schema, str):
+ schema = json.loads(schema)
+ return JSONSchemaFormat(json_schema=schema)
+ if params.regex is not None:
+ return RegexFormat(pattern=params.regex)
+ if params.choice is not None:
+ return OrFormat(
+ elements=[ConstStringFormat(value=choice) for choice in params.choice]
+ )
+ if params.grammar is not None:
+ return GrammarFormat(grammar=params.grammar)
+ if params.structural_tag is not None:
+ s_tag = json.loads(params.structural_tag)
+ if "structures" in s_tag:
+ # LegacyStructuralTagResponseFormat
+ return TriggeredTagsFormat(
+ triggers=s_tag["triggers"],
+ tags=[
+ TagFormat(
+ begin=structure["begin"],
+ content=JSONSchemaFormat(json_schema=structure["schema"]),
+ end=structure["end"],
+ )
+ for structure in s_tag["structures"]
+ ],
+ )
+ # StructuralTagResponseFormat
+ return StructuralTag.model_validate(s_tag).format
+ return None
+
+
+def _adjust_output_format(
+ request: ChatCompletionRequest | ResponsesRequest,
+) -> ChatCompletionRequest | ResponsesRequest:
+ """Canonicalize request constraints into a reasoning-aware StructuralTag."""
+ params = request.extract_structured_outputs()
+ if params is None:
+ return request
+
+ final_content = _params_to_final_content(params)
+ if final_content is None:
+ return request
+
+ if isinstance(final_content, JSONSchemaFormat):
+ begin = _FINAL_BEGIN.format(constrain=" <|constrain|>json")
+ else:
+ begin = _FINAL_BEGIN.format(constrain="")
+
+ structural_tag = _assemble_tag(
+ allow_analysis=True,
+ allow_commentary=False,
+ content=TagFormat(begin=begin, content=final_content, end=_END_TAG),
+ )
+
+ request.structured_outputs = replace(
+ params,
+ json=None,
+ regex=None,
+ choice=None,
+ grammar=None,
+ json_object=None,
+ structural_tag=json.dumps(structural_tag.model_dump()),
+ )
+ if isinstance(request, ResponsesRequest):
+ request.text = None
+ else:
+ request.response_format = None
+ return request
diff --git a/vllm/reasoning/gptoss_reasoning_parser.py b/vllm/reasoning/gptoss_reasoning_parser.py
index d7bdca829126..b846768f4f35 100644
--- a/vllm/reasoning/gptoss_reasoning_parser.py
+++ b/vllm/reasoning/gptoss_reasoning_parser.py
@@ -1,65 +1,17 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
-import json
from collections.abc import Iterable, Sequence
from typing import TYPE_CHECKING
from transformers import PreTrainedTokenizerBase
-from vllm.entrypoints.mcp.tool_server import ToolServer
from vllm.entrypoints.openai.engine.protocol import DeltaMessage
-from vllm.logger import init_logger
from vllm.reasoning import ReasoningParser
if TYPE_CHECKING:
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
-logger = init_logger(__name__)
-
-no_func_reasoning_tag = {
- "type": "structural_tag",
- "format": {
- "type": "triggered_tags",
- "tags": [
- {
- "begin": "<|channel|>analysis<|message|>",
- "content": {"type": "any_text"},
- "end": "<|end|>",
- }
- ],
- "triggers": ["<|channel|>analysis"],
- "stop_after_first": False,
- },
-}
-
-
-def from_builtin_tool_to_tag(tool: str) -> list[dict]:
- tag = [
- {
- "begin": f"<|channel|>commentary to={tool}",
- "content": {"type": "any_text"},
- "end": "<|end|>",
- },
- {
- "begin": f"<|channel|>analysis to={tool}",
- "content": {"type": "any_text"},
- "end": "<|end|>",
- },
- ]
- return tag
-
-
-def tag_with_builtin_funcs(no_func_reasoning_tag, builtin_tool_list: list[str]) -> dict:
- import copy
-
- new_tag = copy.deepcopy(no_func_reasoning_tag)
- new_tag["format"]["triggers"].append("<|channel|>commentary to=")
-
- for tool in builtin_tool_list:
- new_tag["format"]["tags"].extend(from_builtin_tool_to_tag(tool))
- return new_tag
-
class GptOssReasoningParser(ReasoningParser):
"""
@@ -71,64 +23,14 @@ class GptOssReasoningParser(ReasoningParser):
def __init__(self, tokenizer: PreTrainedTokenizerBase, *args, **kwargs):
super().__init__(tokenizer, *args, **kwargs)
- # The model can output some special tokens between "final" and "<|message|>"
- # So we need to look for both sequences to determine the end of reasoning.
- self.reasoning_end_token_ids_prefix = self.model_tokenizer.encode(
- "<|channel|>final"
- )
- self.reasoning_end_token_ids_suffix = self.model_tokenizer.encode("<|message|>")
- # We also need to check for the <|end|> token to avoid false positives from
- # previous messages in multi-turn conversations.
- self.eom_token_id = self.vocab["<|end|>"]
- self.reasoning_max_num_between_tokens = 20
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
- end_token_ids_prefix = self.reasoning_end_token_ids_prefix
- end_token_ids_suffix = self.reasoning_end_token_ids_suffix
- assert len(end_token_ids_prefix) > 0, "reasoning_end_token_ids_prefix is empty"
- assert len(end_token_ids_suffix) > 0, "reasoning_end_token_ids_suffix is empty"
- # Check if the end sequence is present in the input_ids.
- # We search from the end of input_ids to find the last match.
- for i in range(len(input_ids) - len(end_token_ids_prefix), -1, -1):
- if input_ids[i] == self.eom_token_id:
- # We looped backwards far enough to find the end of a previous message,
- # which means we have searched the entirety of the current message
- # and can exit early without searching further back into prior
- # messages of the conversation.
- return False
- if input_ids[i : i + len(end_token_ids_prefix)] == end_token_ids_prefix:
- # We have found the prefix, now we look for the suffix after the prefix.
- suffix_start = i + len(end_token_ids_prefix)
- for j in range(
- suffix_start, len(input_ids) - len(end_token_ids_suffix) + 1
- ):
- if j - suffix_start >= self.reasoning_max_num_between_tokens:
- break
- if (
- input_ids[j : j + len(end_token_ids_suffix)]
- == end_token_ids_suffix
- ):
- return True
- return False
+ return True
def is_reasoning_end_streaming(
self, input_ids: Sequence[int], delta_ids: Iterable[int]
) -> bool:
- # The pattern window covers the end-of-reasoning marker itself.
- # We add len(delta_ids) so that under speculative decoding (where
- # a single step can accept many tokens) the entire accepted chunk
- # is always inside the scan region.
- delta_ids = tuple(delta_ids)
- pattern_len = (
- len(self.reasoning_end_token_ids_prefix)
- + self.reasoning_max_num_between_tokens
- + len(self.reasoning_end_token_ids_suffix)
- )
- window = pattern_len + len(delta_ids)
- n = len(input_ids)
- if n <= window:
- return self.is_reasoning_end(input_ids)
- return self.is_reasoning_end(input_ids[n - window :])
+ return True
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
raise NotImplementedError(
@@ -159,33 +61,3 @@ def extract_reasoning(
"GptOssReasoningParser only provides boundary detection. "
"Use HarmonyParser for output parsing."
)
-
- # This function prepares the structural tag to format reasoning output
- def prepare_structured_tag(
- self, original_tag: str | None, tool_server: ToolServer | None
- ) -> str | None:
- if original_tag is None:
- if tool_server is None:
- return json.dumps(no_func_reasoning_tag)
- else:
- builtin_tool_list: list[str] = []
- if tool_server.has_tool("browser"):
- builtin_tool_list.append("browser")
- if tool_server.has_tool("python"):
- builtin_tool_list.append("python")
- if tool_server.has_tool("container"):
- builtin_tool_list.append("container")
-
- if len(builtin_tool_list) > 0:
- logger.info("Builtin_tool_list: %s", builtin_tool_list)
- func_tag = json.dumps(
- tag_with_builtin_funcs(no_func_reasoning_tag, builtin_tool_list)
- )
- else:
- logger.info("Builtin_tool_list is empty")
- func_tag = json.dumps(no_func_reasoning_tag)
-
- return func_tag
- else:
- # There is potential risk for appending the tag to the original tag
- return original_tag
diff --git a/vllm/renderers/online_renderer.py b/vllm/renderers/online_renderer.py
index 995527654fe8..4d3b9911355a 100644
--- a/vllm/renderers/online_renderer.py
+++ b/vllm/renderers/online_renderer.py
@@ -201,6 +201,15 @@ async def render_chat(
)
else:
# For GPT-OSS.
+ if self.parser is not None:
+ # HarmonyParser doesn't need chat_template_kwargs
+ # TODO: Unify adjust_request() call with non-harmony branch
+ self.parser(
+ self.renderer.get_tokenizer(),
+ request.tools,
+ model_config=self.model_config,
+ ).adjust_request(request=request)
+
should_include_tools = tool_dicts is not None
conversation, engine_inputs = self._make_request_with_harmony(
request, should_include_tools
diff --git a/vllm/tool_parsers/gptoss_tool_parser.py b/vllm/tool_parsers/gptoss_tool_parser.py
index 6857e6bbe728..7321c2f049cb 100644
--- a/vllm/tool_parsers/gptoss_tool_parser.py
+++ b/vllm/tool_parsers/gptoss_tool_parser.py
@@ -22,6 +22,8 @@ class GptOssToolParser(ToolParser):
capability declaration via HarmonyParser.tool_parser_cls.
"""
+ structural_tag_model = "harmony"
+
def __init__(self, tokenizer: "TokenizerLike", tools: list[Tool] | None = None):
super().__init__(tokenizer, tools)
diff --git a/vllm/tool_parsers/structural_tag_registry.py b/vllm/tool_parsers/structural_tag_registry.py
index 3423fd304bd8..1f9a651ee46d 100644
--- a/vllm/tool_parsers/structural_tag_registry.py
+++ b/vllm/tool_parsers/structural_tag_registry.py
@@ -65,7 +65,6 @@
"qwen_3_5",
"qwen_3_coder",
"qwen_3",
- "harmony",
"deepseek_v3_2",
"glm_4_7",
"deepseek_v4",
@@ -209,7 +208,7 @@ def _dump_allowed_tool_ref_for_xgrammar(tool_ref: AllowedToolRef) -> AllowedTool
return tool_ref
-def _get_function_parameters(function) -> dict[str, Any] | bool:
+def get_function_parameters(function) -> dict[str, Any] | bool:
if getattr(function, "strict", None) is False:
return True
return function.parameters if function.parameters is not None else True
@@ -230,7 +229,7 @@ def _hermes_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]:
TagFormat(
begin=begin + tool.function.name + arguments_field_prefix,
content=JSONSchemaFormat(
- json_schema=_get_function_parameters(tool.function)
+ json_schema=get_function_parameters(tool.function)
),
end=end,
)
@@ -279,7 +278,7 @@ def _minimax_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]:
TagFormat(
begin=f'\n',
content=JSONSchemaFormat(
- json_schema=_get_function_parameters(tool.function),
+ json_schema=get_function_parameters(tool.function),
style="minimax_xml",
),
end="\n",
@@ -537,7 +536,7 @@ def _k3_arguments_block(parameters: dict[str, Any] | bool) -> Any:
def _k3_call_tag(tool: FunctionToolParam) -> TagFormat:
"""One ``call`` tag: ``<|open|>call tool="N" index=""<|sep|> args``."""
function = tool.function
- parameters = _get_function_parameters(function)
+ parameters = get_function_parameters(function)
begin = f'{_K3_OPEN}call tool="{_k3_escape_attr(function.name)}" index="'
return TagFormat(
begin=begin,