diff --git a/tests/parser/engine/test_plamo3.py b/tests/parser/engine/test_plamo3.py new file mode 100644 index 0000000000..7b6339cd35 --- /dev/null +++ b/tests/parser/engine/test_plamo3.py @@ -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": "{}"}] diff --git a/tests/parser/engine/test_replay.py b/tests/parser/engine/test_replay.py index cc8fef483f..89791ba29b 100644 --- a/tests/parser/engine/test_replay.py +++ b/tests/parser/engine/test_replay.py @@ -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 diff --git a/tests/parser/engine/trace_builder.py b/tests/parser/engine/trace_builder.py index 5b405e6dae..29b9db3f2b 100644 --- a/tests/parser/engine/trace_builder.py +++ b/tests/parser/engine/trace_builder.py @@ -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, } diff --git a/tests/tool_parsers/test_structural_tag_registry.py b/tests/tool_parsers/test_structural_tag_registry.py index 3221d1fb2c..99b18c6736 100644 --- a/tests/tool_parsers/test_structural_tag_registry.py +++ b/tests/tool_parsers/test_structural_tag_registry.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/v1/structured_output/test_backend_xgrammar_plamo3.py b/tests/v1/structured_output/test_backend_xgrammar_plamo3.py new file mode 100644 index 0000000000..a6506dafaa --- /dev/null +++ b/tests/v1/structured_output/test_backend_xgrammar_plamo3.py @@ -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(["", *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(["", 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] + ) diff --git a/vllm/parser/abstract_parser.py b/vllm/parser/abstract_parser.py index 20f22bd0e3..b15d86fd07 100644 --- a/vllm/parser/abstract_parser.py +++ b/vllm/parser/abstract_parser.py @@ -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 diff --git a/vllm/parser/engine/registered_adapters.py b/vllm/parser/engine/registered_adapters.py index 7353a6016b..7ce123c787 100644 --- a/vllm/parser/engine/registered_adapters.py +++ b/vllm/parser/engine/registered_adapters.py @@ -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) diff --git a/vllm/parser/plamo3.py b/vllm/parser/plamo3.py new file mode 100644 index 0000000000..8f37cc9b56 --- /dev/null +++ b/vllm/parser/plamo3.py @@ -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 diff --git a/vllm/reasoning/__init__.py b/vllm/reasoning/__init__.py index cad835ff1f..f1ab60b6b7 100644 --- a/vllm/reasoning/__init__.py +++ b/vllm/reasoning/__init__.py @@ -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", diff --git a/vllm/reasoning/plamo3_engine_reasoning_parser.py b/vllm/reasoning/plamo3_engine_reasoning_parser.py new file mode 100644 index 0000000000..20037fe08f --- /dev/null +++ b/vllm/reasoning/plamo3_engine_reasoning_parser.py @@ -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"] diff --git a/vllm/tool_parsers/__init__.py b/vllm/tool_parsers/__init__.py index 2515009d0e..90a724858a 100644 --- a/vllm/tool_parsers/__init__.py +++ b/vllm/tool_parsers/__init__.py @@ -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", diff --git a/vllm/tool_parsers/plamo3_engine_tool_parser.py b/vllm/tool_parsers/plamo3_engine_tool_parser.py new file mode 100644 index 0000000000..f64999742e --- /dev/null +++ b/vllm/tool_parsers/plamo3_engine_tool_parser.py @@ -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 diff --git a/vllm/tool_parsers/structural_tag_registry.py b/vllm/tool_parsers/structural_tag_registry.py index f3e5c9ec98..fdb4e4f92a 100644 --- a/vllm/tool_parsers/structural_tag_registry.py +++ b/vllm/tool_parsers/structural_tag_registry.py @@ -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) # --------------------------------------------------------------------------- diff --git a/vllm/v1/structured_output/backend_xgrammar.py b/vllm/v1/structured_output/backend_xgrammar.py index 5d2c2a4985..337b2285f0 100644 --- a/vllm/v1/structured_output/backend_xgrammar.py +++ b/vllm/v1/structured_output/backend_xgrammar.py @@ -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,