Files
vLLM/tests/parser/engine/test_glm47_moe.py
T
2026-10-12 21:28:35 +00:00

231 lines
8.0 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Reasoning-boundary tests for the GLM-4.7 parser.
GLM can transition straight from reasoning into a tool call without ever
emitting ``</think>``, so ``<tool_call>`` is an implicit reasoning
terminator. These cover that path plus the multi-turn prompts where an
earlier turn's markers must not be read as the current turn's state. The
streaming tests check that long tool arguments stream the same JSON as a
non-streaming parse, without converting the whole argument on every delta.
"""
import dataclasses
import pytest
from tests.parser.engine.conftest import make_mock_tokenizer
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionToolsParam,
)
from vllm.parser.glm47_moe import (
THINK_END,
THINK_START,
TOOL_CALL_END,
TOOL_CALL_START,
Glm47MoeParser,
_glm47_arg_value_extender,
glm47_moe_config,
)
THINK_S, THINK_E = 1000, 1001
TOOL_S, TOOL_E = 1002, 1003
ASSISTANT, OBSERVATION, USER = 1004, 1005, 1006
TEXT = 42 # stand-in for an ordinary reasoning/content token
VOCAB = {
THINK_START: THINK_S,
THINK_END: THINK_E,
TOOL_CALL_START: TOOL_S,
TOOL_CALL_END: TOOL_E,
"<|assistant|>": ASSISTANT,
"<|observation|>": OBSERVATION,
"<|user|>": USER,
}
@pytest.fixture
def parser():
return Glm47MoeParser(make_mock_tokenizer(VOCAB))
@pytest.fixture
def no_thinking_parser():
return Glm47MoeParser(
make_mock_tokenizer(VOCAB),
chat_template_kwargs={"thinking": False},
)
class TestIsReasoningEnd:
def test_open_reasoning(self, parser):
assert not parser.is_reasoning_end([THINK_S, TEXT])
def test_think_end(self, parser):
assert parser.is_reasoning_end([THINK_S, TEXT, THINK_E, TEXT])
def test_tool_call_without_think_end(self, parser):
"""Reasoning -> tool call with no ``</think>`` still ends reasoning."""
assert parser.is_reasoning_end([THINK_S, TEXT, TOOL_S, TEXT])
def test_previous_turn_tool_call_ignored(self, parser):
"""A finished tool call from an earlier turn says nothing about the
turn currently being generated."""
prompt = [THINK_S, TEXT, TOOL_S, TEXT, TOOL_E, OBSERVATION, TEXT, ASSISTANT]
assert not parser.is_reasoning_end(prompt)
def test_previous_turn_think_end_ignored(self, parser):
prompt = [THINK_S, TEXT, THINK_E, TEXT, USER, TEXT, ASSISTANT]
assert not parser.is_reasoning_end(prompt)
def test_empty_input(self, parser):
assert not parser.is_reasoning_end([])
def test_thinking_disabled(self, no_thinking_parser):
assert no_thinking_parser.is_reasoning_end([THINK_S, TEXT])
class TestExtractContentIds:
def test_think_end_wins_over_later_tool_call(self, parser):
"""Content between ``</think>`` and a tool call must survive."""
ids = [THINK_S, TEXT, THINK_E, 20, 21, TOOL_S, 30, TOOL_E]
assert parser.extract_content_ids(ids) == [20, 21, TOOL_S, 30, TOOL_E]
def test_every_tool_call_after_think_end_kept(self, parser):
ids = [THINK_E, 20, TOOL_S, 30, TOOL_E, 21, TOOL_S, 31, TOOL_E]
assert parser.extract_content_ids(ids) == ids[1:]
def test_falls_back_to_tool_call(self, parser):
"""Without ``</think>``, content starts at the opener itself so the
tool parser still receives a well-formed call."""
ids = [THINK_S, TEXT, TOOL_S, 30, TOOL_E]
assert parser.extract_content_ids(ids) == [TOOL_S, 30, TOOL_E]
def test_falls_back_to_first_tool_call_of_turn(self, parser):
ids = [THINK_S, TEXT, TOOL_S, 30, TOOL_E, TOOL_S, 31, TOOL_E]
assert parser.extract_content_ids(ids) == ids[2:]
def test_previous_turn_tool_call_ignored(self, parser):
ids = [TOOL_S, 30, TOOL_E, OBSERVATION, TEXT, ASSISTANT, THINK_S, TEXT]
assert parser.extract_content_ids(ids) == ids
def test_no_markers_returns_input_ids(self, parser):
assert parser.extract_content_ids([20, 21]) == [20, 21]
def test_thinking_disabled(self, no_thinking_parser):
ids = [THINK_S, TEXT, TOOL_S]
assert no_thinking_parser.extract_content_ids(ids) == ids
WRITE_TOOL = ChatCompletionToolsParam(
type="function",
function={
"name": "Write",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string"},
"line_count": {"type": "integer"},
"content": {"type": "string"},
},
},
},
)
CALL_HEAD = (
f"{THINK_END}{TOOL_CALL_START}Write\n"
"<arg_key>file_path</arg_key><arg_value>/repo/app.py</arg_value>\n"
"<arg_key>line_count</arg_key><arg_value>120</arg_value>\n"
"<arg_key>content</arg_key><arg_value>"
)
CALL_TAIL = f"</arg_value>\n{TOOL_CALL_END}"
# Escapes, non-ASCII, and tag-like text that must stay inside the value.
CONTENT_LINE = 'if a < b > c: print("x\\y") # </div> </arg_ <arg_key>\t\u00e9\n'
def _pieces(text: str, size: int) -> list[str]:
return [text[i : i + size] for i in range(0, len(text), size)]
def _stream_arguments(parser, request, chunks: list[str]) -> str:
args = []
for i, chunk in enumerate(chunks):
delta = parser.parse_delta(chunk, [], request, finished=i == len(chunks) - 1)
for tool_call in (delta and delta.tool_calls) or []:
if tool_call.function and tool_call.function.arguments:
args.append(tool_call.function.arguments)
return "".join(args)
class TestStreamedToolArguments:
@pytest.mark.parametrize(
"tools, line_count",
[([], '"120"'), ([WRITE_TOOL], "120")],
ids=["no_schema", "schema"],
)
def test_long_call_streams_non_streaming_arguments(
self, mock_request, tools, line_count
):
mock_request.tools = tools
text = CALL_HEAD + CONTENT_LINE * 400 + CALL_TAIL
streamed = _stream_arguments(
Glm47MoeParser(make_mock_tokenizer(VOCAB)), mock_request, _pieces(text, 7)
)
result = Glm47MoeParser(make_mock_tokenizer(VOCAB)).extract_tool_calls(
text, mock_request
)
assert streamed == result.tool_calls[0].function.arguments
assert f'"line_count": {line_count},' in streamed
def test_conversions_do_not_grow_with_argument_length(self, mock_request):
"""The open value streams without re-running the converter."""
mock_request.tools = [WRITE_TOOL]
def streaming_conversions(lines: int) -> int:
calls = 0
base = glm47_moe_config()
def counting_converter(raw_args: str, partial: bool) -> str:
nonlocal calls
if partial:
calls += 1
return base.arg_converter(raw_args, partial)
parser = Glm47MoeParser(
make_mock_tokenizer(VOCAB),
parser_engine_config=dataclasses.replace(
base, arg_converter=counting_converter
),
)
chunks = [
*_pieces(CALL_HEAD, 4),
*_pieces(CONTENT_LINE * lines, 4),
*_pieces(CALL_TAIL, 4),
]
_stream_arguments(parser, mock_request, chunks)
return calls
assert streaming_conversions(lines=40) == streaming_conversions(lines=400)
def test_value_extender_stops_at_split_value_end(self):
extend = _glm47_arg_value_extender("<arg_key>k</arg_key><arg_value>x")
assert [extend(d) for d in ['a"\n', "</arg_", "value>"]] == [
'a\\"\\n',
"</arg_",
None,
]
@pytest.mark.parametrize(
"raw_args",
[
"<arg_key>k</arg_key><arg_value>x</arg_value>",
"<arg_key>k</arg_key><arg_value>1</arg_value>"
"<arg_key>j</arg_key><arg_value>2</arg_value>"
"<arg_key>k</arg_key><arg_value>3",
],
ids=["closed_value", "repeated_key_not_last"],
)
def test_no_value_extender_unless_open_value_is_last(self, raw_args):
assert _glm47_arg_value_extender(raw_args) is None