[Model] Engine based plamo3 parser (#56575)

Signed-off-by: Shinichi Hemmi <shemmi@preferred.jp>
This commit is contained in:
Shinichi Hemmi
2026-09-29 05:17:42 +00:00
committed by GitHub
parent 36768d1bfd
commit 17cc3cd80e
14 changed files with 1311 additions and 5 deletions
+556
View File
@@ -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": "{}"}]
+4 -3
View File
@@ -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
+67
View File
@@ -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]
)
+10 -1
View File
@@ -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)
+318
View File
@@ -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
+4
View File
@@ -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"]
+4
View File
@@ -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
+65 -1
View File
@@ -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,