[Model] Engine based plamo3 parser (#56575)
Signed-off-by: Shinichi Hemmi <shemmi@preferred.jp>
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""PLaMo3 grammar, truncation, and serving-adapter regressions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import regex as re
|
||||
|
||||
from tests.parser.engine.conftest import make_mock_tokenizer
|
||||
from tests.parser.engine.replay_harness import (
|
||||
DUMMY_TOOLS,
|
||||
_test_request,
|
||||
collect_output,
|
||||
replay_streaming,
|
||||
replay_with_text_holdback,
|
||||
)
|
||||
from vllm.parser.parser_manager import ParserManager
|
||||
from vllm.parser.plamo3 import (
|
||||
BEGIN_THINK,
|
||||
BEGIN_TOOL_ARGUMENTS,
|
||||
BEGIN_TOOL_NAME,
|
||||
BEGIN_TOOL_REQUEST,
|
||||
BEGIN_TOOL_REQUESTS,
|
||||
END_THINK,
|
||||
END_TOOL_ARGUMENTS,
|
||||
END_TOOL_NAME,
|
||||
END_TOOL_REQUEST,
|
||||
END_TOOL_REQUESTS,
|
||||
EOT,
|
||||
PLAMO_MARKER_TOKENS,
|
||||
Plamo3Parser,
|
||||
)
|
||||
|
||||
|
||||
def _tool_header(name="weather"):
|
||||
return (
|
||||
BEGIN_TOOL_REQUEST
|
||||
+ BEGIN_TOOL_NAME
|
||||
+ name
|
||||
+ END_TOOL_NAME
|
||||
+ BEGIN_TOOL_ARGUMENTS
|
||||
)
|
||||
|
||||
|
||||
def _tool_call(name, arguments):
|
||||
return _tool_header(name) + arguments + END_TOOL_ARGUMENTS + END_TOOL_REQUEST
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tokenizer():
|
||||
special = [*sorted(PLAMO_MARKER_TOKENS), EOT, "<|plamo:bos|>"]
|
||||
atoms = [*special, "<|plamo:constrain|>", "<|plamo:msg|>"]
|
||||
vocab = {token: 100 + i for i, token in enumerate(atoms)}
|
||||
inverse = {i: token for token, i in vocab.items()}
|
||||
pattern = re.compile("|".join(map(re.escape, atoms)) + r"|[\s\S]")
|
||||
tokenizer = make_mock_tokenizer(vocab, special_tokens=special)
|
||||
tokenizer.encode.side_effect = lambda text, **kwargs: [
|
||||
vocab[piece] if piece in vocab else 1000 + ord(piece)
|
||||
for piece in pattern.findall(text)
|
||||
]
|
||||
tokenizer.decode.side_effect = lambda ids, **kwargs: "".join(
|
||||
inverse[i] if i in inverse else chr(i - 1000) for i in ids
|
||||
)
|
||||
return tokenizer
|
||||
|
||||
|
||||
@pytest.fixture(params=["direct", "registered"])
|
||||
def parser_cls(request):
|
||||
if request.param == "direct":
|
||||
return Plamo3Parser
|
||||
return ParserManager.get_parser(
|
||||
tool_parser_name="plamo3",
|
||||
reasoning_parser_name="plamo3",
|
||||
enable_auto_tools=True,
|
||||
)
|
||||
|
||||
|
||||
def _stream(parser, tokenizer, output, chunk_size=1, finished_on_last=False):
|
||||
tokens = [
|
||||
(i, tokenizer.decode([i]))
|
||||
for i in tokenizer.encode(output, add_special_tokens=False)
|
||||
]
|
||||
deltas = replay_streaming(
|
||||
parser,
|
||||
tokens,
|
||||
chunk_size=chunk_size,
|
||||
finished_on_last=finished_on_last,
|
||||
tools=DUMMY_TOOLS,
|
||||
)
|
||||
if not finished_on_last:
|
||||
deltas.append(
|
||||
parser.parse_delta("", [], _test_request(DUMMY_TOOLS), finished=True)
|
||||
)
|
||||
return deltas
|
||||
|
||||
|
||||
@pytest.mark.parametrize("thinking", [False, True])
|
||||
@pytest.mark.parametrize("with_markers", [False, True])
|
||||
def test_reasoning_mode(
|
||||
parser_cls, mock_tokenizer, mock_request, thinking, with_markers
|
||||
):
|
||||
output = (
|
||||
BEGIN_THINK + "Reasoning." + END_THINK + "Answer."
|
||||
if with_markers
|
||||
else "Answer."
|
||||
)
|
||||
if thinking:
|
||||
expected = ("Reasoning.", "Answer.") if with_markers else ("Answer.", "")
|
||||
else:
|
||||
expected = ("", output)
|
||||
kwargs = {"chat_template_kwargs": {"enable_thinking": thinking}}
|
||||
reasoning, content, calls = parser_cls(mock_tokenizer, **kwargs).parse(
|
||||
output, mock_request
|
||||
)
|
||||
assert (reasoning or "", content or "") == expected
|
||||
assert not calls
|
||||
result = collect_output(
|
||||
_stream(parser_cls(mock_tokenizer, **kwargs), mock_tokenizer, output)
|
||||
)
|
||||
assert (result.reasoning, result.content) == expected
|
||||
assert not result.tool_calls
|
||||
|
||||
|
||||
def test_duplicate_think_end_does_not_leak_into_content(
|
||||
parser_cls, mock_tokenizer, mock_request
|
||||
):
|
||||
output = END_THINK + "A" + END_THINK + "B"
|
||||
reasoning, content, calls = parser_cls(mock_tokenizer).parse(output, mock_request)
|
||||
assert (reasoning, content) == (None, "AB")
|
||||
assert not calls
|
||||
|
||||
|
||||
def test_content_can_start_a_new_reasoning_block(
|
||||
parser_cls, mock_tokenizer, mock_request
|
||||
):
|
||||
output = END_THINK + "A" + BEGIN_THINK + "R" + END_THINK
|
||||
reasoning, content, calls = parser_cls(mock_tokenizer).parse(output, mock_request)
|
||||
assert (reasoning, content) == ("R", "A")
|
||||
assert not calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chunk_size", [1, 7, None])
|
||||
@pytest.mark.parametrize("finished_on_last", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"closing",
|
||||
[
|
||||
"",
|
||||
END_TOOL_ARGUMENTS + END_TOOL_REQUEST,
|
||||
END_TOOL_ARGUMENTS + END_TOOL_REQUEST + END_TOOL_REQUESTS + EOT,
|
||||
],
|
||||
)
|
||||
def test_complete_arguments_survive_missing_closers(
|
||||
parser_cls, mock_tokenizer, mock_request, chunk_size, finished_on_last, closing
|
||||
):
|
||||
output = (
|
||||
BEGIN_THINK
|
||||
+ "Reasoning. "
|
||||
+ END_THINK
|
||||
+ "Before tools. "
|
||||
+ BEGIN_TOOL_REQUESTS
|
||||
+ _tool_header()
|
||||
+ '{"city":"東京"}'
|
||||
+ closing
|
||||
)
|
||||
reasoning, content, calls = parser_cls(mock_tokenizer).parse(
|
||||
output, mock_request, enable_auto_tools=True
|
||||
)
|
||||
assert (reasoning, content) == ("Reasoning. ", "Before tools. ")
|
||||
assert calls and [(c.name, json.loads(c.arguments)) for c in calls] == [
|
||||
("weather", {"city": "東京"})
|
||||
]
|
||||
result = collect_output(
|
||||
_stream(
|
||||
parser_cls(mock_tokenizer),
|
||||
mock_tokenizer,
|
||||
output,
|
||||
chunk_size,
|
||||
finished_on_last,
|
||||
)
|
||||
)
|
||||
assert (result.reasoning, result.content) == (reasoning, content)
|
||||
assert result.tool_calls == [{"name": "weather", "arguments": '{"city":"東京"}'}]
|
||||
|
||||
|
||||
def test_tool_name_cutoffs_do_not_leak_markers(
|
||||
parser_cls, mock_tokenizer, mock_request
|
||||
):
|
||||
header = BEGIN_TOOL_REQUEST + BEGIN_TOOL_NAME + "weather" + END_TOOL_NAME
|
||||
header_ids = mock_tokenizer.encode(header, add_special_tokens=False)
|
||||
name_start = len(
|
||||
mock_tokenizer.encode(
|
||||
BEGIN_TOOL_REQUEST + BEGIN_TOOL_NAME, add_special_tokens=False
|
||||
)
|
||||
)
|
||||
for end in range(len(header_ids)):
|
||||
partial = mock_tokenizer.decode(header_ids[:end])
|
||||
output = END_THINK + "Before tools." + BEGIN_TOOL_REQUESTS + partial
|
||||
reasoning, content, calls = parser_cls(mock_tokenizer).parse(
|
||||
output, mock_request, enable_auto_tools=True
|
||||
)
|
||||
assert (reasoning, content) == (None, "Before tools."), end
|
||||
if end <= name_start:
|
||||
expected_name = ""
|
||||
else:
|
||||
name_text = mock_tokenizer.decode(header_ids[name_start:end])
|
||||
if "<|plamo:" in name_text:
|
||||
name_text = name_text[: name_text.rfind("<|plamo:")]
|
||||
expected_name = name_text[: len("weather")]
|
||||
assert [call.name for call in calls or []] == (
|
||||
[expected_name] if expected_name else []
|
||||
), end
|
||||
result = collect_output(
|
||||
_stream(parser_cls(mock_tokenizer), mock_tokenizer, output)
|
||||
)
|
||||
assert [call["name"] for call in result.tool_calls] == (
|
||||
[expected_name] if expected_name else []
|
||||
), end
|
||||
assert all("<" not in call["name"] for call in result.tool_calls), end
|
||||
assert result.content == "Before tools.", end
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chunk_size", [1, 7, None])
|
||||
@pytest.mark.parametrize("finished_on_last", [False, True])
|
||||
def test_all_argument_closer_cutoffs_do_not_leak(
|
||||
parser_cls, mock_tokenizer, mock_request, chunk_size, finished_on_last
|
||||
):
|
||||
head = END_THINK + BEGIN_TOOL_REQUESTS + _tool_header()
|
||||
closer_ids = mock_tokenizer.encode(END_TOOL_ARGUMENTS, add_special_tokens=False)
|
||||
for end in range(len(closer_ids)):
|
||||
partial = mock_tokenizer.decode(closer_ids[:end])
|
||||
output = head + '{"city":"東京"}' + partial
|
||||
_, _, calls = parser_cls(mock_tokenizer).parse(
|
||||
output, mock_request, enable_auto_tools=True
|
||||
)
|
||||
assert calls and calls[0].arguments == '{"city":"東京"}', end
|
||||
result = collect_output(
|
||||
_stream(
|
||||
parser_cls(mock_tokenizer),
|
||||
mock_tokenizer,
|
||||
output,
|
||||
chunk_size,
|
||||
finished_on_last,
|
||||
)
|
||||
)
|
||||
assert result.tool_calls == [
|
||||
{"name": "weather", "arguments": '{"city":"東京"}'}
|
||||
], end
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments",
|
||||
[
|
||||
"{}",
|
||||
'{"name":"Alice","arguments":{"x":1},"parameters":true}',
|
||||
'{"text":"literal <|plamo:end_',
|
||||
],
|
||||
)
|
||||
def test_arguments_are_not_rewritten(
|
||||
parser_cls, mock_tokenizer, mock_request, arguments
|
||||
):
|
||||
output = END_THINK + BEGIN_TOOL_REQUESTS + _tool_header() + arguments
|
||||
_, _, calls = parser_cls(mock_tokenizer).parse(
|
||||
output, mock_request, enable_auto_tools=True
|
||||
)
|
||||
assert calls and calls[0].arguments == arguments
|
||||
result = collect_output(_stream(parser_cls(mock_tokenizer), mock_tokenizer, output))
|
||||
assert result.tool_calls == [{"name": "weather", "arguments": arguments}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("truncated_second", [False, True])
|
||||
def test_multiple_calls_keep_indices_ids_and_arguments_separate(
|
||||
parser_cls, mock_tokenizer, mock_request, truncated_second
|
||||
):
|
||||
second = (
|
||||
BEGIN_TOOL_REQUEST + BEGIN_TOOL_NAME + "clo"
|
||||
if truncated_second
|
||||
else _tool_call("clock", '{"timezone":"Asia/Tokyo"}')
|
||||
)
|
||||
output = (
|
||||
END_THINK
|
||||
+ BEGIN_TOOL_REQUESTS
|
||||
+ _tool_call("weather", '{"city":"東京"}')
|
||||
+ second
|
||||
)
|
||||
expected = [{"name": "weather", "arguments": '{"city":"東京"}'}]
|
||||
if truncated_second:
|
||||
expected.append({"name": "clo", "arguments": "{}"})
|
||||
else:
|
||||
output += END_TOOL_REQUESTS
|
||||
expected.append({"name": "clock", "arguments": '{"timezone":"Asia/Tokyo"}'})
|
||||
stream_expected = expected
|
||||
_, content, calls = parser_cls(mock_tokenizer).parse(
|
||||
output, mock_request, enable_auto_tools=True
|
||||
)
|
||||
assert (
|
||||
calls
|
||||
and [{"name": c.name, "arguments": c.arguments} for c in calls] == expected
|
||||
)
|
||||
deltas = _stream(parser_cls(mock_tokenizer), mock_tokenizer, output)
|
||||
result = collect_output(deltas)
|
||||
assert result.tool_calls == stream_expected
|
||||
assert result.content == (content or "") == ""
|
||||
announced = [
|
||||
call for delta in deltas if delta for call in delta.tool_calls or [] if call.id
|
||||
]
|
||||
assert [call.index for call in announced] == list(range(len(expected)))
|
||||
assert len({call.id for call in announced}) == len(expected)
|
||||
|
||||
|
||||
def test_arguments_are_available_before_outer_closer(mock_tokenizer, mock_request):
|
||||
parser = Plamo3Parser(mock_tokenizer)
|
||||
output = END_THINK + BEGIN_TOOL_REQUESTS + _tool_call("weather", '{"city":"Tokyo"}')
|
||||
first = parser.parse_delta(output, [], mock_request, finished=False)
|
||||
assert collect_output([first]).tool_calls == [
|
||||
{"name": "weather", "arguments": '{"city":"Tokyo"}'}
|
||||
]
|
||||
assert (
|
||||
parser.parse_delta(END_TOOL_REQUESTS[:10], [], mock_request, finished=False)
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
parser.parse_delta(END_TOOL_REQUESTS[10:], [], mock_request, finished=True)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_missing_tool_request_start_does_not_break_streaming(
|
||||
parser_cls, mock_tokenizer, mock_request
|
||||
):
|
||||
output = (
|
||||
BEGIN_TOOL_REQUESTS
|
||||
+ BEGIN_TOOL_NAME
|
||||
+ "weather"
|
||||
+ END_TOOL_NAME
|
||||
+ BEGIN_TOOL_ARGUMENTS
|
||||
+ "{}"
|
||||
+ END_TOOL_ARGUMENTS
|
||||
+ END_TOOL_REQUESTS
|
||||
)
|
||||
delta = parser_cls(mock_tokenizer).parse_delta(
|
||||
output, [], mock_request, finished=False
|
||||
)
|
||||
assert delta is not None
|
||||
assert collect_output([delta]).tool_calls == [
|
||||
{"name": "weather", "arguments": "{}"}
|
||||
]
|
||||
|
||||
|
||||
def test_reusing_parser_drops_pending_names(mock_tokenizer, mock_request):
|
||||
parser = Plamo3Parser(mock_tokenizer)
|
||||
parser.parse(
|
||||
END_THINK
|
||||
+ BEGIN_TOOL_REQUESTS
|
||||
+ BEGIN_TOOL_REQUEST
|
||||
+ BEGIN_TOOL_NAME
|
||||
+ "partial",
|
||||
mock_request,
|
||||
)
|
||||
output = (
|
||||
END_THINK
|
||||
+ BEGIN_TOOL_REQUESTS
|
||||
+ _tool_call("weather", "{}")
|
||||
+ END_TOOL_REQUESTS
|
||||
)
|
||||
_, _, calls = parser.parse(output, mock_request)
|
||||
assert calls and calls[0].name == "weather"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text", ["", "a < b", "literal <", "literal <|plamo:unknown", " text\n"]
|
||||
)
|
||||
def test_plain_content_is_preserved(parser_cls, mock_tokenizer, mock_request, text):
|
||||
output = END_THINK + text + EOT
|
||||
_, content, calls = parser_cls(mock_tokenizer).parse(output, mock_request)
|
||||
assert (content or "") == text
|
||||
assert not calls
|
||||
result = collect_output(_stream(parser_cls(mock_tokenizer), mock_tokenizer, output))
|
||||
assert (result.content, result.tool_calls) == (text, [])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, ended",
|
||||
[
|
||||
("", False),
|
||||
(BEGIN_THINK + "reasoning", False),
|
||||
(END_THINK + "answer", True),
|
||||
(END_THINK + "answer" + BEGIN_THINK + "new reasoning", False),
|
||||
(END_THINK + "answer" + EOT + "assistant", False),
|
||||
],
|
||||
)
|
||||
def test_reasoning_end_checks_the_latest_turn(mock_tokenizer, text, ended):
|
||||
parser = Plamo3Parser(mock_tokenizer)
|
||||
ids = mock_tokenizer.encode(text)
|
||||
assert parser.is_reasoning_end(ids) == ended
|
||||
if ended:
|
||||
assert mock_tokenizer.decode(parser.extract_content_ids(ids)) == "answer"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", ["<|plamo:end_", "<|plamo:end_think"])
|
||||
def test_partial_end_think_is_consistent_between_streaming_and_non_streaming(
|
||||
parser_cls, mock_tokenizer, mock_request, suffix
|
||||
):
|
||||
output = "visible" + suffix
|
||||
reasoning, content, calls = parser_cls(mock_tokenizer).parse(output, mock_request)
|
||||
assert (reasoning, content) == ("visible", None)
|
||||
assert not calls
|
||||
result = collect_output(_stream(parser_cls(mock_tokenizer), mock_tokenizer, output))
|
||||
assert (result.reasoning, result.content, result.tool_calls) == ("visible", "", [])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"suffix",
|
||||
[
|
||||
"<|plamo:begin_",
|
||||
"<|plamo:begin_tool_name",
|
||||
"<|plamo:end_tool",
|
||||
],
|
||||
)
|
||||
def test_unfinished_marker_prefixes_are_stripped(mock_tokenizer, suffix):
|
||||
parser = Plamo3Parser(mock_tokenizer)
|
||||
assert parser._strip_unfinished_marker("visible" + suffix) == "visible"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", ["<|plamo:", "<|plamo:b"])
|
||||
def test_non_token_prefix_is_not_stripped(mock_tokenizer, suffix):
|
||||
parser = Plamo3Parser(mock_tokenizer)
|
||||
text = "visible" + suffix
|
||||
assert parser._strip_unfinished_marker(text) == text
|
||||
|
||||
|
||||
def test_complete_markers_are_not_stripped(mock_tokenizer):
|
||||
parser = Plamo3Parser(mock_tokenizer)
|
||||
text = "visible" + BEGIN_TOOL_NAME
|
||||
assert parser._strip_unfinished_marker(text) == text
|
||||
|
||||
|
||||
def test_short_prefix_is_not_stripped(mock_tokenizer):
|
||||
parser = Plamo3Parser(mock_tokenizer)
|
||||
text = "visible<"
|
||||
assert parser._strip_unfinished_marker(text) == text
|
||||
|
||||
|
||||
def test_tool_block_can_end_reasoning_implicitly(
|
||||
parser_cls, mock_tokenizer, mock_request
|
||||
):
|
||||
output = (
|
||||
"reasoning"
|
||||
+ BEGIN_TOOL_REQUESTS
|
||||
+ _tool_call("weather", "{}")
|
||||
+ END_TOOL_REQUESTS
|
||||
)
|
||||
reasoning, content, calls = parser_cls(mock_tokenizer).parse(
|
||||
output, mock_request, enable_auto_tools=True
|
||||
)
|
||||
assert (reasoning, content) == ("reasoning", None)
|
||||
assert calls and calls[0].name == "weather"
|
||||
result = collect_output(_stream(parser_cls(mock_tokenizer), mock_tokenizer, output))
|
||||
assert (result.reasoning, result.content) == ("reasoning", "")
|
||||
assert result.tool_calls == [{"name": "weather", "arguments": "{}"}]
|
||||
|
||||
|
||||
def test_every_cut_point_matches_non_streaming(
|
||||
parser_cls, mock_tokenizer, mock_request
|
||||
):
|
||||
output = (
|
||||
BEGIN_THINK
|
||||
+ "Reasoning. "
|
||||
+ END_THINK
|
||||
+ "Before tools. "
|
||||
+ BEGIN_TOOL_REQUESTS
|
||||
+ _tool_call("weather", '{"city":"東京"}')
|
||||
+ END_TOOL_REQUESTS
|
||||
+ EOT
|
||||
)
|
||||
ids = mock_tokenizer.encode(output, add_special_tokens=False)
|
||||
for k in range(1, len(ids) + 1):
|
||||
prefix = mock_tokenizer.decode(ids[:k])
|
||||
reasoning, content, calls = parser_cls(mock_tokenizer).parse(
|
||||
prefix, mock_request, enable_auto_tools=True
|
||||
)
|
||||
result = collect_output(
|
||||
_stream(parser_cls(mock_tokenizer), mock_tokenizer, prefix)
|
||||
)
|
||||
assert (result.reasoning, result.content) == (
|
||||
(reasoning or ""),
|
||||
(content or ""),
|
||||
), k
|
||||
expected = [{"name": c.name, "arguments": c.arguments} for c in (calls or [])]
|
||||
assert result.tool_calls == expected, (k, expected, result.tool_calls)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"choice",
|
||||
[
|
||||
"auto",
|
||||
"required",
|
||||
"none",
|
||||
{"type": "function", "function": {"name": "weather"}},
|
||||
],
|
||||
)
|
||||
def test_tool_choice_uses_the_plamo_format(parser_cls, mock_tokenizer, choice):
|
||||
request = _test_request(
|
||||
[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "weather", "parameters": {"type": "object"}},
|
||||
},
|
||||
]
|
||||
)
|
||||
request = type(request).model_validate(
|
||||
request.model_dump() | {"tool_choice": choice}
|
||||
)
|
||||
output = (
|
||||
END_THINK
|
||||
+ "Before tools."
|
||||
+ BEGIN_TOOL_REQUESTS
|
||||
+ _tool_call("weather", "{}")
|
||||
+ END_TOOL_REQUESTS
|
||||
)
|
||||
_, content, calls = parser_cls(mock_tokenizer).parse(
|
||||
output, request, enable_auto_tools=True
|
||||
)
|
||||
assert content == "Before tools."
|
||||
assert [c.name for c in calls or []] == ([] if choice == "none" else ["weather"])
|
||||
parser = parser_cls(mock_tokenizer)
|
||||
delta = parser.parse_delta(output, [], request, finished=True)
|
||||
result = collect_output([delta])
|
||||
assert result.content == "Before tools."
|
||||
assert result.tool_calls == (
|
||||
[] if choice == "none" else [{"name": "weather", "arguments": "{}"}]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delay", [1, 3])
|
||||
def test_delayed_text_keeps_split_markers_intact(parser_cls, mock_tokenizer, delay):
|
||||
output = (
|
||||
BEGIN_THINK
|
||||
+ "reasoning"
|
||||
+ END_THINK
|
||||
+ "content"
|
||||
+ BEGIN_TOOL_REQUESTS
|
||||
+ _tool_call("weather", "{}")
|
||||
+ END_TOOL_REQUESTS
|
||||
+ EOT
|
||||
)
|
||||
tokens = [(i, mock_tokenizer.decode([i])) for i in mock_tokenizer.encode(output)]
|
||||
result = collect_output(
|
||||
replay_with_text_holdback(
|
||||
parser_cls(mock_tokenizer), tokens, text_delay=delay, tools=DUMMY_TOOLS
|
||||
)
|
||||
)
|
||||
assert (result.reasoning, result.content) == ("reasoning", "content")
|
||||
assert result.tool_calls == [{"name": "weather", "arguments": "{}"}]
|
||||
@@ -74,9 +74,10 @@ def _discover_parsers() -> list[_ParserInfo]:
|
||||
if cfg.name not in _BUILDERS:
|
||||
missing_builders.append(f"{obj.__name__} (config.name={cfg.name!r})")
|
||||
continue
|
||||
if cfg.name == "inkling":
|
||||
# Inkling opts out of token-id terminal matching and has typed
|
||||
# structural blocks; its replay coverage lives in test_inkling.py.
|
||||
if cfg.name in ("inkling", "plamo3"):
|
||||
# Inkling uses typed structural blocks. PLaMo markers can span
|
||||
# several token IDs. Neither fits this TOOL_END-token harness;
|
||||
# their direct and delegating coverage lives in dedicated tests.
|
||||
continue
|
||||
if cfg.name == "granite":
|
||||
# Granite has a JSON-array tool body with no TOOL_END terminal, so
|
||||
|
||||
@@ -28,6 +28,7 @@ from tests.parser.engine.replay_harness import (
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
ChatCompletionToolsParam,
|
||||
)
|
||||
from vllm.parser import plamo3
|
||||
from vllm.parser.engine.registered_adapters import (
|
||||
DeepSeekV4Parser,
|
||||
DeepSeekV32Parser,
|
||||
@@ -40,6 +41,7 @@ from vllm.parser.engine.registered_adapters import (
|
||||
KimiK2Parser,
|
||||
MinimaxM2Parser,
|
||||
NemotronV3Parser,
|
||||
Plamo3Parser,
|
||||
Qwen3Parser,
|
||||
SeedOssParser,
|
||||
)
|
||||
@@ -1068,6 +1070,70 @@ def _build_inkling(scenario: Scenario, validate: bool = True) -> Sample:
|
||||
return sample
|
||||
|
||||
|
||||
# ── PLaMo3 (literal nested tool-request wrappers) ───────────────────
|
||||
|
||||
_PLAMO3_MARKERS = (
|
||||
plamo3.BEGIN_THINK,
|
||||
plamo3.END_THINK,
|
||||
plamo3.BEGIN_TOOL_REQUESTS,
|
||||
plamo3.END_TOOL_REQUESTS,
|
||||
plamo3.BEGIN_TOOL_REQUEST,
|
||||
plamo3.END_TOOL_REQUEST,
|
||||
plamo3.BEGIN_TOOL_NAME,
|
||||
plamo3.END_TOOL_NAME,
|
||||
plamo3.BEGIN_TOOL_ARGUMENTS,
|
||||
plamo3.END_TOOL_ARGUMENTS,
|
||||
plamo3.EOT,
|
||||
)
|
||||
_PLAMO3_VOCAB = {marker: 300 + i for i, marker in enumerate(_PLAMO3_MARKERS)}
|
||||
|
||||
|
||||
def _plamo3_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
|
||||
args = json.dumps(tc.arguments, ensure_ascii=False, separators=(",", ":"))
|
||||
return [
|
||||
(plamo3.BEGIN_TOOL_REQUEST, True),
|
||||
(plamo3.BEGIN_TOOL_NAME, True),
|
||||
(tc.name, False),
|
||||
(plamo3.END_TOOL_NAME, True),
|
||||
(plamo3.BEGIN_TOOL_ARGUMENTS, True),
|
||||
(args, False),
|
||||
(plamo3.END_TOOL_ARGUMENTS, True),
|
||||
(plamo3.END_TOOL_REQUEST, True),
|
||||
]
|
||||
|
||||
|
||||
def _plamo3_segments(scenario: Scenario) -> list[tuple[str, bool]]:
|
||||
segs: list[tuple[str, bool]] = [
|
||||
(plamo3.BEGIN_THINK, True),
|
||||
(scenario.reasoning or "", False),
|
||||
(plamo3.END_THINK, True),
|
||||
]
|
||||
if scenario.content is not None:
|
||||
segs.append((scenario.content, False))
|
||||
if scenario.tool_calls is not None:
|
||||
segs.append((plamo3.BEGIN_TOOL_REQUESTS, True))
|
||||
for tool_call in scenario.tool_calls:
|
||||
segs.extend(_plamo3_tool_segments(tool_call))
|
||||
segs.append((plamo3.END_TOOL_REQUESTS, True))
|
||||
return segs
|
||||
|
||||
|
||||
def _build_plamo3(scenario: Scenario, validate: bool = True) -> Sample:
|
||||
sample = _make_sample(
|
||||
sample_id=f"plamo3-{scenario.id}",
|
||||
description=scenario.description,
|
||||
vocab=_PLAMO3_VOCAB,
|
||||
segments=_plamo3_segments(scenario),
|
||||
expected_reasoning=scenario.reasoning or "",
|
||||
expected_content=_qwen3_expected_content(scenario),
|
||||
expected_tool_calls=_expected_tc(scenario),
|
||||
tools=_expected_tools(scenario),
|
||||
)
|
||||
if validate:
|
||||
_validate_sample(sample, Plamo3Parser)
|
||||
return sample
|
||||
|
||||
|
||||
# ── Granite (JSON-array tool bodies, no reasoning) ───────────────────────
|
||||
|
||||
_GRANITE_VOCAB: dict[str, int] = {
|
||||
@@ -1167,6 +1233,7 @@ _BUILDERS: dict[str, Any] = {
|
||||
"kimi_k2": _build_kimi_k2,
|
||||
"qwen3": _build_qwen3,
|
||||
"inkling": _build_inkling,
|
||||
"plamo3": _build_plamo3,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,17 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
ChatCompletionToolsParam,
|
||||
)
|
||||
from vllm.parser.abstract_parser import DelegatingParser
|
||||
from vllm.parser.plamo3 import (
|
||||
BEGIN_TOOL_ARGUMENTS,
|
||||
BEGIN_TOOL_NAME,
|
||||
BEGIN_TOOL_REQUEST,
|
||||
BEGIN_TOOL_REQUESTS,
|
||||
END_TOOL_ARGUMENTS,
|
||||
END_TOOL_NAME,
|
||||
END_TOOL_REQUEST,
|
||||
END_TOOL_REQUESTS,
|
||||
EOT,
|
||||
)
|
||||
from vllm.tool_parsers.abstract_tool_parser import ToolParser
|
||||
from vllm.tool_parsers.deepseekv3_tool_parser import DeepSeekV3ToolParser
|
||||
from vllm.tool_parsers.deepseekv4_engine_tool_parser import DeepSeekV4EngineToolParser
|
||||
@@ -30,6 +41,7 @@ from vllm.tool_parsers.kimi_k2_tool_parser import KimiK2ToolParser
|
||||
from vllm.tool_parsers.kimi_k3_tool_parser import KimiK3ToolParser
|
||||
from vllm.tool_parsers.llama_tool_parser import Llama3JsonToolParser
|
||||
from vllm.tool_parsers.minimax_m2_tool_parser import MinimaxM2ToolParser
|
||||
from vllm.tool_parsers.plamo3_engine_tool_parser import Plamo3EngineToolParser
|
||||
from vllm.tool_parsers.qwen3_engine_tool_parser import Qwen3EngineToolParser
|
||||
from vllm.tool_parsers.structural_tag_registry import (
|
||||
SUPPORTED_STRUCTURAL_TAG_MODELS,
|
||||
@@ -271,6 +283,131 @@ def test_hermes_required_tool_calls_use_empty_separator():
|
||||
assert tag.format.separator == ""
|
||||
|
||||
|
||||
def _plamo3_call(name: str, arguments: str) -> str:
|
||||
return (
|
||||
BEGIN_TOOL_REQUEST
|
||||
+ BEGIN_TOOL_NAME
|
||||
+ name
|
||||
+ END_TOOL_NAME
|
||||
+ BEGIN_TOOL_ARGUMENTS
|
||||
+ arguments
|
||||
+ END_TOOL_ARGUMENTS
|
||||
+ END_TOOL_REQUEST
|
||||
)
|
||||
|
||||
|
||||
def _plamo3_requests(*calls: str, eot: bool = False) -> str:
|
||||
return (
|
||||
BEGIN_TOOL_REQUESTS + "".join(calls) + END_TOOL_REQUESTS + (EOT if eot else "")
|
||||
)
|
||||
|
||||
|
||||
def _plamo3_grammar(tool_choice, tools):
|
||||
tag = get_model_structural_tag(
|
||||
model="plamo3",
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
reasoning=False,
|
||||
)
|
||||
assert isinstance(tag, StructuralTag)
|
||||
return Grammar.from_structural_tag(tag)
|
||||
|
||||
|
||||
def _plamo3_tools() -> list[ChatCompletionToolsParam]:
|
||||
return [
|
||||
ChatCompletionToolsParam(
|
||||
type="function",
|
||||
function={
|
||||
"name": "get_weather",
|
||||
"strict": True,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
),
|
||||
ChatCompletionToolsParam(
|
||||
type="function",
|
||||
function={
|
||||
"name": "get_time",
|
||||
"strict": True,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"timezone": {"type": "string"}},
|
||||
"required": ["timezone"],
|
||||
},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_plamo3_registered_as_vllm_structural_tag_model():
|
||||
assert "plamo3" in VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
|
||||
assert Plamo3EngineToolParser.structural_tag_model == "plamo3"
|
||||
assert Plamo3EngineToolParser.supports_required_and_named is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
_plamo3_requests(_plamo3_call("get_weather", '{"city":"Tokyo"}')),
|
||||
_plamo3_requests(
|
||||
_plamo3_call("get_weather", '{"city":"Tokyo"}'),
|
||||
_plamo3_call("get_time", '{"timezone":"Asia/Tokyo"}'),
|
||||
eot=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_plamo3_required_accepts_complete_request_blocks(body: str):
|
||||
assert _is_grammar_accept_string(_plamo3_grammar("required", _plamo3_tools()), body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
_plamo3_requests(_plamo3_call("unknown", '{"city":"Tokyo"}')),
|
||||
_plamo3_requests(_plamo3_call("get_weather", "{}")),
|
||||
BEGIN_TOOL_REQUESTS + _plamo3_call("get_weather", '{"city":"Tokyo"}'),
|
||||
],
|
||||
)
|
||||
def test_plamo3_required_rejects_invalid_request_blocks(body: str):
|
||||
assert not _is_grammar_accept_string(
|
||||
_plamo3_grammar("required", _plamo3_tools()),
|
||||
body,
|
||||
)
|
||||
|
||||
|
||||
def test_plamo3_auto_allows_text_or_a_tool_request_block(sample_tools_strict):
|
||||
grammar = _plamo3_grammar("auto", sample_tools_strict)
|
||||
|
||||
assert _is_grammar_accept_string(grammar, "Plain response")
|
||||
assert _is_grammar_accept_string(
|
||||
grammar,
|
||||
_plamo3_requests(_plamo3_call("get_weather", '{"city":"Tokyo"}')),
|
||||
)
|
||||
|
||||
|
||||
def test_plamo3_forced_stops_after_the_named_tool_call():
|
||||
tools = _plamo3_tools()
|
||||
tool_choice = ChatCompletionNamedToolChoiceParam(
|
||||
function=ChatCompletionNamedFunction(name="get_weather")
|
||||
)
|
||||
grammar = _plamo3_grammar(tool_choice, tools)
|
||||
|
||||
assert _is_grammar_accept_string(
|
||||
grammar,
|
||||
_plamo3_requests(_plamo3_call("get_weather", '{"city":"Tokyo"}')),
|
||||
)
|
||||
assert not _is_grammar_accept_string(
|
||||
grammar,
|
||||
_plamo3_requests(
|
||||
_plamo3_call("get_weather", '{"city":"Tokyo"}'),
|
||||
_plamo3_call("get_time", '{"timezone":"Asia/Tokyo"}'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kimi K3 (XTML channel format) structural tag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import xgrammar as xgr
|
||||
|
||||
from vllm.config import StructuredOutputsConfig, VllmConfig
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
ChatCompletionToolsParam,
|
||||
)
|
||||
from vllm.parser.plamo3 import (
|
||||
BEGIN_TOOL_ARGUMENTS,
|
||||
BEGIN_TOOL_NAME,
|
||||
BEGIN_TOOL_REQUEST,
|
||||
BEGIN_TOOL_REQUESTS,
|
||||
END_TOOL_ARGUMENTS,
|
||||
END_TOOL_NAME,
|
||||
END_TOOL_REQUEST,
|
||||
END_TOOL_REQUESTS,
|
||||
)
|
||||
from vllm.tool_parsers.structural_tag_registry import get_model_structural_tag
|
||||
from vllm.v1.structured_output.backend_types import StructuredOutputOptions
|
||||
from vllm.v1.structured_output.backend_xgrammar import XgrammarBackend
|
||||
|
||||
|
||||
class Plamo3Tokenizer:
|
||||
"""Minimal stand-in for PLaMo's remote-code PythonBackend tokenizer."""
|
||||
|
||||
eos_token_id = 0
|
||||
|
||||
def __init__(self, tokens: list[str]) -> None:
|
||||
self._vocab = {token: token_id for token_id, token in enumerate(tokens)}
|
||||
|
||||
def get_vocab(self) -> dict[str, int]:
|
||||
return self._vocab
|
||||
|
||||
def init_xgrammar(self):
|
||||
stop_token_ids = [self.eos_token_id]
|
||||
tokenizer_info = xgr.TokenizerInfo(
|
||||
encoded_vocab=list(self._vocab),
|
||||
vocab_type=xgr.VocabType.BYTE_FALLBACK,
|
||||
vocab_size=len(self._vocab),
|
||||
stop_token_ids=stop_token_ids,
|
||||
)
|
||||
return tokenizer_info, stop_token_ids
|
||||
|
||||
|
||||
def make_vllm_config() -> VllmConfig:
|
||||
config = VllmConfig(
|
||||
structured_outputs_config=StructuredOutputsConfig(backend="xgrammar"),
|
||||
)
|
||||
config.model_config = SimpleNamespace(
|
||||
hf_config=SimpleNamespace(model_type="plamo3")
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def test_plamo3_python_tokenizer_compiles_and_accepts_structural_tag():
|
||||
call_tokens = [
|
||||
BEGIN_TOOL_REQUESTS,
|
||||
BEGIN_TOOL_REQUEST,
|
||||
BEGIN_TOOL_NAME,
|
||||
"get_weather",
|
||||
END_TOOL_NAME,
|
||||
BEGIN_TOOL_ARGUMENTS,
|
||||
'{"city":"Tokyo"}',
|
||||
END_TOOL_ARGUMENTS,
|
||||
END_TOOL_REQUEST,
|
||||
END_TOOL_REQUESTS,
|
||||
]
|
||||
tokenizer = Plamo3Tokenizer(["<eos>", *call_tokens])
|
||||
backend = XgrammarBackend(
|
||||
make_vllm_config(),
|
||||
tokenizer=tokenizer,
|
||||
vocab_size=len(tokenizer.get_vocab()),
|
||||
)
|
||||
tools = [
|
||||
ChatCompletionToolsParam(
|
||||
type="function",
|
||||
function={
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
tag = get_model_structural_tag("plamo3", tools, "required", reasoning=False)
|
||||
assert tag is not None
|
||||
|
||||
grammar = backend.compile_grammar(
|
||||
StructuredOutputOptions.STRUCTURAL_TAG,
|
||||
tag.model_dump_json(),
|
||||
)
|
||||
token_ids = [tokenizer.get_vocab()[token] for token in call_tokens]
|
||||
assert grammar.accept_tokens("plamo3", token_ids)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ['"', "あ"])
|
||||
def test_plamo3_grammar_matches_decoded_bytes(value):
|
||||
pieces = [f"<0x{byte:02X}>" for byte in value.encode("utf-8")]
|
||||
tokenizer = Plamo3Tokenizer(list(dict.fromkeys(["<eos>", value, *pieces])))
|
||||
backend = XgrammarBackend(
|
||||
make_vllm_config(),
|
||||
tokenizer=tokenizer,
|
||||
vocab_size=len(tokenizer.get_vocab()),
|
||||
)
|
||||
for tokens in ([value], pieces):
|
||||
grammar = backend.compile_grammar(StructuredOutputOptions.REGEX, value)
|
||||
assert grammar.accept_tokens(
|
||||
"plamo3", [tokenizer.get_vocab()[token] for token in tokens]
|
||||
)
|
||||
@@ -38,6 +38,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
|
||||
from vllm.exceptions import VLLMValidationError
|
||||
from vllm.logger import init_logger
|
||||
from vllm.parser.engine.adapters import ParserEngineToolAdapter
|
||||
from vllm.parser.metrics import record_tool_parser_invocation
|
||||
from vllm.parser.utils import count_history_tool_calls
|
||||
from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
|
||||
@@ -128,7 +129,15 @@ class Parser:
|
||||
tokenizer, *args, model_config=model_config, **kwargs
|
||||
)
|
||||
if self.__class__.tool_parser_cls is not None:
|
||||
self._tool_parser = self.__class__.tool_parser_cls(tokenizer, tools)
|
||||
# Engine-based adapters take the same construction kwargs as
|
||||
# the reasoning parser (e.g. chat_template_kwargs for per-request
|
||||
# thinking toggles); legacy ToolParser classes take none.
|
||||
if issubclass(self.__class__.tool_parser_cls, ParserEngineToolAdapter):
|
||||
self._tool_parser = self.__class__.tool_parser_cls(
|
||||
tokenizer, tools, model_config=model_config, **kwargs
|
||||
)
|
||||
else:
|
||||
self._tool_parser = self.__class__.tool_parser_cls(tokenizer, tools)
|
||||
|
||||
self._engine_based = (
|
||||
self._reasoning_parser is None
|
||||
|
||||
@@ -20,6 +20,7 @@ from vllm.parser.kimi_k2 import KimiK2Parser
|
||||
from vllm.parser.minimax_m2 import MinimaxM2Parser
|
||||
from vllm.parser.mistral import MistralParser
|
||||
from vllm.parser.nemotron_v3 import NemotronV3Parser
|
||||
from vllm.parser.plamo3 import Plamo3Parser
|
||||
from vllm.parser.qwen3 import Qwen3Parser
|
||||
from vllm.parser.seed_oss import SeedOssParser
|
||||
|
||||
@@ -92,3 +93,8 @@ from vllm.parser.seed_oss import SeedOssParser
|
||||
MistralParserReasoningAdapter,
|
||||
MistralParserToolAdapter,
|
||||
) = make_adapters(MistralParser)
|
||||
|
||||
(
|
||||
Plamo3ParserReasoningAdapter,
|
||||
Plamo3ParserToolAdapter,
|
||||
) = make_adapters(Plamo3Parser)
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from itertools import groupby
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm.entrypoints.generate.base.protocol import (
|
||||
DeltaFunctionCall,
|
||||
DeltaMessage,
|
||||
DeltaToolCall,
|
||||
)
|
||||
from vllm.parser.engine.events import EventType, SemanticEvent
|
||||
from vllm.parser.engine.parser_engine import ParserEngine
|
||||
from vllm.parser.engine.parser_engine_config import (
|
||||
ParserEngineConfig,
|
||||
ParserState,
|
||||
Transition,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
ChatCompletionRequest,
|
||||
)
|
||||
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
from vllm.tool_parsers.abstract_tool_parser import Tool
|
||||
|
||||
BEGIN_TOOL_REQUESTS = "<|plamo:begin_tool_requests:plamo|>"
|
||||
END_TOOL_REQUESTS = "<|plamo:end_tool_requests:plamo|>"
|
||||
BEGIN_TOOL_REQUEST = "<|plamo:begin_tool_request:plamo|>"
|
||||
END_TOOL_REQUEST = "<|plamo:end_tool_request:plamo|>"
|
||||
BEGIN_TOOL_NAME = "<|plamo:begin_tool_name:plamo|>"
|
||||
END_TOOL_NAME = "<|plamo:end_tool_name:plamo|>"
|
||||
BEGIN_TOOL_ARGUMENTS = (
|
||||
"<|plamo:begin_tool_arguments:plamo|><|plamo:constrain|>json<|plamo:msg|>"
|
||||
)
|
||||
END_TOOL_ARGUMENTS = "<|plamo:end_tool_arguments:plamo|>"
|
||||
BEGIN_THINK = "<|plamo:begin_think:plamo|>"
|
||||
END_THINK = "<|plamo:end_think:plamo|>"
|
||||
EOT = "<|plamo:tag|>"
|
||||
|
||||
# PLaMo3 control markers are split into multiple tokenizer tokens. These
|
||||
# atomic pieces must not be treated as unrelated special tokens by the
|
||||
# parser-engine drop-token handling.
|
||||
PLAMO_MARKER_TOKENS = frozenset(
|
||||
{
|
||||
"<|plamo:begin_",
|
||||
"<|plamo:end_",
|
||||
":plamo|>",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def plamo3_config(thinking: bool = True) -> ParserEngineConfig:
|
||||
if thinking:
|
||||
reasoning_terminals = {
|
||||
"THINK_START": BEGIN_THINK,
|
||||
"THINK_END": END_THINK,
|
||||
}
|
||||
reasoning_transitions = {
|
||||
(ParserState.REASONING, "THINK_START"): Transition(
|
||||
ParserState.REASONING,
|
||||
),
|
||||
(ParserState.CONTENT, "THINK_START"): Transition(
|
||||
ParserState.REASONING,
|
||||
(EventType.REASONING_START,),
|
||||
),
|
||||
(ParserState.REASONING, "THINK_END"): Transition(
|
||||
ParserState.CONTENT,
|
||||
(EventType.REASONING_END,),
|
||||
),
|
||||
(ParserState.CONTENT, "THINK_END"): Transition(
|
||||
ParserState.CONTENT,
|
||||
(),
|
||||
),
|
||||
(ParserState.REASONING, "TOOL_REQUESTS_START"): Transition(
|
||||
ParserState.TOOL_PREAMBLE,
|
||||
(EventType.REASONING_END,),
|
||||
),
|
||||
}
|
||||
initial_state = ParserState.REASONING
|
||||
else:
|
||||
reasoning_terminals = {}
|
||||
reasoning_transitions = {}
|
||||
initial_state = ParserState.CONTENT
|
||||
|
||||
return ParserEngineConfig(
|
||||
name="plamo3",
|
||||
initial_state=initial_state,
|
||||
terminals={
|
||||
**reasoning_terminals,
|
||||
"TOOL_REQUESTS_START": BEGIN_TOOL_REQUESTS,
|
||||
"TOOL_REQUESTS_END": END_TOOL_REQUESTS,
|
||||
"TOOL_REQUEST_START": BEGIN_TOOL_REQUEST,
|
||||
"TOOL_REQUEST_END": END_TOOL_REQUEST,
|
||||
"TOOL_NAME_START": BEGIN_TOOL_NAME,
|
||||
"TOOL_NAME_END": END_TOOL_NAME,
|
||||
"TOOL_ARGS_START": BEGIN_TOOL_ARGUMENTS,
|
||||
"TOOL_ARGS_END": END_TOOL_ARGUMENTS,
|
||||
"EOT": EOT,
|
||||
},
|
||||
transitions={
|
||||
**reasoning_transitions,
|
||||
(ParserState.CONTENT, "TOOL_REQUESTS_START"): Transition(
|
||||
ParserState.TOOL_PREAMBLE,
|
||||
),
|
||||
(ParserState.TOOL_PREAMBLE, "TOOL_REQUEST_START"): Transition(
|
||||
ParserState.TOOL_PREAMBLE,
|
||||
),
|
||||
(ParserState.TOOL_PREAMBLE, "TOOL_NAME_START"): Transition(
|
||||
ParserState.TOOL_NAME,
|
||||
(EventType.TOOL_CALL_START,),
|
||||
),
|
||||
(ParserState.TOOL_NAME, "TOOL_NAME_END"): Transition(
|
||||
ParserState.TOOL_PREAMBLE,
|
||||
),
|
||||
(ParserState.TOOL_PREAMBLE, "TOOL_ARGS_START"): Transition(
|
||||
ParserState.TOOL_ARGS,
|
||||
),
|
||||
(ParserState.TOOL_ARGS, "TOOL_ARGS_END"): Transition(
|
||||
ParserState.TOOL_BETWEEN,
|
||||
),
|
||||
(ParserState.TOOL_BETWEEN, "TOOL_REQUEST_END"): Transition(
|
||||
ParserState.TOOL_PREAMBLE,
|
||||
(EventType.TOOL_CALL_END,),
|
||||
),
|
||||
(ParserState.TOOL_PREAMBLE, "TOOL_REQUESTS_END"): Transition(
|
||||
ParserState.CONTENT,
|
||||
),
|
||||
# EOT is normally consumed by generation stop handling. Keep it
|
||||
# in the grammar as well so text-only parsing and explicitly
|
||||
# supplied stop tokens cannot leak it into user-visible output.
|
||||
(ParserState.REASONING, "EOT"): Transition(
|
||||
ParserState.CONTENT,
|
||||
(EventType.REASONING_END,),
|
||||
),
|
||||
(ParserState.CONTENT, "EOT"): Transition(ParserState.CONTENT),
|
||||
(ParserState.TOOL_PREAMBLE, "EOT"): Transition(ParserState.CONTENT),
|
||||
(ParserState.TOOL_NAME, "EOT"): Transition(ParserState.CONTENT),
|
||||
(ParserState.TOOL_ARGS, "EOT"): Transition(ParserState.CONTENT),
|
||||
(ParserState.TOOL_BETWEEN, "EOT"): Transition(ParserState.CONTENT),
|
||||
},
|
||||
token_id_terminals={"EOT": EOT, **reasoning_terminals},
|
||||
drop_whitespace_only_content_before_tools=True,
|
||||
stream_arg_deltas=True,
|
||||
strip_content_whitespace_with_tools=False,
|
||||
strip_trailing_reasoning_whitespace=False,
|
||||
preserve_tokens=PLAMO_MARKER_TOKENS,
|
||||
tool_args_json=True,
|
||||
)
|
||||
|
||||
|
||||
class Plamo3Parser(ParserEngine):
|
||||
"""PLaMo3 reasoning and tool-call parser backed by ParserEngine."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tokenizer: TokenizerLike,
|
||||
tools: list[Tool] | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
# Configure PLaMo parsing and cache its multi-token reasoning markers.
|
||||
chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
|
||||
self.thinking_enabled = chat_kwargs.get("enable_thinking", True)
|
||||
kwargs.setdefault(
|
||||
"parser_engine_config",
|
||||
plamo3_config(thinking=self.thinking_enabled),
|
||||
)
|
||||
super().__init__(tokenizer, tools, **kwargs)
|
||||
|
||||
self._reasoning_start_token_id_sequence: list[int] = list(
|
||||
tokenizer.encode(BEGIN_THINK, add_special_tokens=False)
|
||||
)
|
||||
self._reasoning_end_token_id_sequence: list[int] = list(
|
||||
tokenizer.encode(END_THINK, add_special_tokens=False)
|
||||
)
|
||||
# Token-ID sequences of every terminal literal, for token-level
|
||||
# unfinished-marker detection.
|
||||
self._terminal_token_id_sequences: dict[str, tuple[int, ...]] = {
|
||||
literal: tuple(tokenizer.encode(literal, add_special_tokens=False))
|
||||
for literal in self.parser_engine_config.terminal_literals
|
||||
}
|
||||
|
||||
def is_reasoning_end(self, input_ids: list[int]) -> bool:
|
||||
# Detect PLaMo reasoning boundaries that span multiple token IDs.
|
||||
if not self.thinking_enabled:
|
||||
return True
|
||||
|
||||
start_ids = self._reasoning_start_token_id_sequence
|
||||
end_ids = self._reasoning_end_token_id_sequence
|
||||
for i in range(len(input_ids) - 1, -1, -1):
|
||||
if input_ids[i : i + len(end_ids)] == end_ids:
|
||||
return True
|
||||
if input_ids[i : i + len(start_ids)] == start_ids:
|
||||
return False
|
||||
if input_ids[i] == self.vocab.get(EOT):
|
||||
return False
|
||||
return False
|
||||
|
||||
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
|
||||
# Extract content after PLaMo's multi-token reasoning end marker.
|
||||
if not self.thinking_enabled:
|
||||
return input_ids
|
||||
end_ids = self._reasoning_end_token_id_sequence
|
||||
for i in range(len(input_ids) - len(end_ids), -1, -1):
|
||||
if input_ids[i : i + len(end_ids)] == end_ids:
|
||||
return input_ids[i + len(end_ids) :]
|
||||
return input_ids
|
||||
|
||||
def _strip_unfinished_marker(self, value: str | None) -> str | None:
|
||||
if not value:
|
||||
return value
|
||||
marker_start = value.rfind("<|plamo:")
|
||||
if marker_start < 0:
|
||||
return value or None
|
||||
suffix = value[marker_start:]
|
||||
# Strip a trailing partial marker at the token-ID level.
|
||||
suffix_ids = tuple(
|
||||
self.model_tokenizer.encode(suffix, add_special_tokens=False)
|
||||
)
|
||||
if any(
|
||||
len(seq) > len(suffix_ids) and seq[: len(suffix_ids)] == suffix_ids
|
||||
for seq in self._terminal_token_id_sequences.values()
|
||||
):
|
||||
return value[:marker_start] or None
|
||||
return value or None
|
||||
|
||||
def _coalesce_finished_tool_events(
|
||||
self, events: list[SemanticEvent]
|
||||
) -> list[SemanticEvent]:
|
||||
combined = []
|
||||
for (kind, _), group in groupby(
|
||||
events, key=lambda event: (event.type, event.tool_index)
|
||||
):
|
||||
chunks = list(group)
|
||||
if kind not in (EventType.TOOL_NAME, EventType.ARG_VALUE_CHUNK):
|
||||
combined.extend(chunks)
|
||||
continue
|
||||
if kind == EventType.TOOL_NAME and chunks[0].tool_index < 0:
|
||||
continue
|
||||
value = "".join(chunk.value for chunk in chunks)
|
||||
if kind == EventType.TOOL_NAME:
|
||||
value = self._strip_unfinished_marker(value) or ""
|
||||
if value:
|
||||
combined.append(replace(chunks[0], value=value))
|
||||
return combined
|
||||
|
||||
def _events_to_delta(
|
||||
self,
|
||||
events: list[SemanticEvent],
|
||||
finished: bool = False,
|
||||
) -> DeltaMessage | None:
|
||||
# Coalesce flushed tool fragments before stripping truncated markers.
|
||||
if not finished:
|
||||
return super()._events_to_delta(events)
|
||||
|
||||
events = self._coalesce_finished_tool_events(events)
|
||||
delta = super()._events_to_delta(events, finished=True)
|
||||
if delta is None:
|
||||
return None
|
||||
|
||||
delta.reasoning = self._strip_unfinished_marker(delta.reasoning)
|
||||
if not self.skip_tool_parsing:
|
||||
delta.content = self._strip_unfinished_marker(delta.content)
|
||||
# The flush emits the remaining (never-streamed) tool-call
|
||||
# arguments; an empty remainder must match non-streaming
|
||||
# extraction (_extract_args_json), which normalizes it to '{}'.
|
||||
for tc in delta.tool_calls or []:
|
||||
if tc.function is not None and not (tc.function.arguments or "").strip():
|
||||
tc.function.arguments = "{}"
|
||||
if delta.reasoning is None and delta.content is None and not delta.tool_calls:
|
||||
return None
|
||||
return delta
|
||||
|
||||
def _handle_arg_chunk(
|
||||
self,
|
||||
event: SemanticEvent,
|
||||
deltas: list[DeltaToolCall],
|
||||
) -> None:
|
||||
# Strip truncated marker suffixes and preserve the first argument delta.
|
||||
idx = event.tool_index
|
||||
slot = self._tool_slots[idx]
|
||||
if (marker_pos := event.value.rfind("<|plamo:")) >= 0:
|
||||
stripped = event.value[:marker_pos]
|
||||
try:
|
||||
json.loads(slot.args + stripped)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
event = replace(event, value=stripped)
|
||||
name_sent_before = slot.name_sent
|
||||
super()._handle_arg_chunk(event, deltas)
|
||||
if event.value and not name_sent_before and slot.name_sent:
|
||||
deltas.append(
|
||||
DeltaToolCall(
|
||||
index=idx,
|
||||
function=DeltaFunctionCall(arguments=event.value),
|
||||
)
|
||||
)
|
||||
|
||||
def _extract_args_json(self, raw_args: str, func_name: str) -> str:
|
||||
# Return the raw arguments as JSON
|
||||
# since PLaMo3 generates function names and arguments separately.
|
||||
return raw_args.strip() or "{}"
|
||||
|
||||
def extract_reasoning(
|
||||
self,
|
||||
model_output: str,
|
||||
request: ChatCompletionRequest | ResponsesRequest,
|
||||
) -> tuple[str | None, str | None]:
|
||||
reasoning, content = super().extract_reasoning(model_output, request)
|
||||
return self._strip_unfinished_marker(reasoning), content
|
||||
@@ -136,6 +136,10 @@ _REASONING_PARSERS_TO_REGISTER = {
|
||||
"olmo3_reasoning_parser",
|
||||
"Olmo3ReasoningParser",
|
||||
),
|
||||
"plamo3": (
|
||||
"plamo3_engine_reasoning_parser",
|
||||
"Plamo3ParserReasoningAdapter",
|
||||
),
|
||||
"muse_glimmer": (
|
||||
"muse_glimmer_reasoning_parser",
|
||||
"MuseGlimmerReasoningParser",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from vllm.parser.engine.registered_adapters import Plamo3ParserReasoningAdapter
|
||||
|
||||
__all__ = ["Plamo3ParserReasoningAdapter"]
|
||||
@@ -166,6 +166,10 @@ _TOOL_PARSERS_TO_REGISTER = {
|
||||
"olmo3_tool_parser",
|
||||
"Olmo3PythonicToolParser",
|
||||
),
|
||||
"plamo3": (
|
||||
"plamo3_engine_tool_parser",
|
||||
"Plamo3EngineToolParser",
|
||||
),
|
||||
"muse_glimmer": (
|
||||
"muse_glimmer_tool_parser",
|
||||
"MuseGlimmerToolParser",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from vllm.parser.engine.registered_adapters import Plamo3ParserToolAdapter
|
||||
|
||||
|
||||
class Plamo3EngineToolParser(Plamo3ParserToolAdapter): # type: ignore[valid-type, misc]
|
||||
structural_tag_model = "plamo3"
|
||||
supports_required_and_named = False
|
||||
@@ -73,7 +73,7 @@ XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
|
||||
"deepseek_v4_1",
|
||||
}
|
||||
)
|
||||
VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset({"hermes", "hy_v4", "kimi_k3"})
|
||||
VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset({"hermes", "hy_v4", "kimi_k3", "plamo3"})
|
||||
SUPPORTED_STRUCTURAL_TAG_MODELS = (
|
||||
XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS | VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
|
||||
)
|
||||
@@ -407,6 +407,70 @@ def get_minimax_structural_tag(
|
||||
return StructuralTag(format=suffix_tag)
|
||||
|
||||
|
||||
_PLAMO3_BEGIN_TOOL_REQUESTS = "<|plamo:begin_tool_requests:plamo|>"
|
||||
_PLAMO3_END_TOOL_REQUESTS = "<|plamo:end_tool_requests:plamo|>"
|
||||
_PLAMO3_BEGIN_TOOL_REQUEST = "<|plamo:begin_tool_request:plamo|>"
|
||||
_PLAMO3_END_TOOL_REQUEST = "<|plamo:end_tool_request:plamo|>"
|
||||
_PLAMO3_BEGIN_TOOL_NAME = "<|plamo:begin_tool_name:plamo|>"
|
||||
_PLAMO3_END_TOOL_NAME = "<|plamo:end_tool_name:plamo|>"
|
||||
_PLAMO3_BEGIN_TOOL_ARGUMENTS = (
|
||||
"<|plamo:begin_tool_arguments:plamo|><|plamo:constrain|>json<|plamo:msg|>"
|
||||
)
|
||||
_PLAMO3_END_TOOL_ARGUMENTS = "<|plamo:end_tool_arguments:plamo|>"
|
||||
_PLAMO3_EOT = "<|plamo:tag|>"
|
||||
|
||||
|
||||
@register_vllm_structural_tag("plamo3")
|
||||
def get_plamo3_structural_tag(
|
||||
tools: list[FunctionToolParam],
|
||||
builtin_tools: list[BuiltinToolParam],
|
||||
tool_choice: SimplifiedToolChoice,
|
||||
reasoning: bool,
|
||||
token_suffix: str = "",
|
||||
) -> StructuralTag:
|
||||
"""Build PLaMo3's explicit tool-request structural tag."""
|
||||
del builtin_tools, reasoning, token_suffix
|
||||
|
||||
request_tags = [
|
||||
TagFormat(
|
||||
begin=(
|
||||
_PLAMO3_BEGIN_TOOL_REQUEST
|
||||
+ _PLAMO3_BEGIN_TOOL_NAME
|
||||
+ tool.function.name
|
||||
+ _PLAMO3_END_TOOL_NAME
|
||||
+ _PLAMO3_BEGIN_TOOL_ARGUMENTS
|
||||
),
|
||||
content=JSONSchemaFormat(
|
||||
json_schema=get_function_parameters(tool.function)
|
||||
),
|
||||
end=_PLAMO3_END_TOOL_ARGUMENTS + _PLAMO3_END_TOOL_REQUEST,
|
||||
)
|
||||
for tool in tools
|
||||
]
|
||||
requests_tag = TagFormat(
|
||||
begin=_PLAMO3_BEGIN_TOOL_REQUESTS,
|
||||
content=TagsWithSeparatorFormat(
|
||||
tags=request_tags,
|
||||
separator="",
|
||||
at_least_one=True,
|
||||
stop_after_first=tool_choice == "forced",
|
||||
),
|
||||
end=[
|
||||
_PLAMO3_END_TOOL_REQUESTS,
|
||||
_PLAMO3_END_TOOL_REQUESTS + _PLAMO3_EOT,
|
||||
],
|
||||
)
|
||||
|
||||
if tool_choice == "auto":
|
||||
return StructuralTag(
|
||||
format=TriggeredTagsFormat(
|
||||
triggers=[_PLAMO3_BEGIN_TOOL_REQUESTS],
|
||||
tags=[requests_tag],
|
||||
)
|
||||
)
|
||||
return StructuralTag(format=requests_tag)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kimi K3 (XTML channel format)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -38,6 +38,10 @@ class XgrammarBackend(StructuredOutputBackend):
|
||||
self.disable_any_whitespace = (
|
||||
self.vllm_config.structured_outputs_config.disable_any_whitespace
|
||||
)
|
||||
model_config = self.vllm_config.model_config
|
||||
is_plamo3 = (
|
||||
model_config is not None and model_config.hf_config.model_type == "plamo3"
|
||||
)
|
||||
|
||||
if is_mistral_tokenizer(self.tokenizer):
|
||||
# NOTE: ideally, xgrammar should handle this accordingly.
|
||||
@@ -57,6 +61,10 @@ class XgrammarBackend(StructuredOutputBackend):
|
||||
stop_token_ids=stop_token_ids,
|
||||
add_prefix_space=True,
|
||||
)
|
||||
elif is_plamo3 and callable(
|
||||
init_xgrammar := getattr(self.tokenizer, "init_xgrammar", None)
|
||||
):
|
||||
tokenizer_info, _ = init_xgrammar()
|
||||
else:
|
||||
tokenizer_info = xgr.TokenizerInfo.from_huggingface(
|
||||
self.tokenizer,
|
||||
|
||||
Reference in New Issue
Block a user