[Model] Engine based plamo3 parser (#56575)

Signed-off-by: Shinichi Hemmi <shemmi@preferred.jp>

Shinichi Hemmi committed Sep 29, 2026 at 14:17 UTC 17cc3cd80e5e1aa648eb89ae4d36fa1468e88550
14 files changed +1311 -5
tests/parser/engine/test_plamo3.py new
+556
@@ -0,0 +1,556 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""PLaMo3 grammar, truncation, and serving-adapter regressions."""
4 +
5 +from __future__ import annotations
6 +
7 +import json
8 +
9 +import pytest
10 +import regex as re
11 +
12 +from tests.parser.engine.conftest import make_mock_tokenizer
13 +from tests.parser.engine.replay_harness import (
14 + DUMMY_TOOLS,
15 + _test_request,
16 + collect_output,
17 + replay_streaming,
18 + replay_with_text_holdback,
19 +)
20 +from vllm.parser.parser_manager import ParserManager
21 +from vllm.parser.plamo3 import (
22 + BEGIN_THINK,
23 + BEGIN_TOOL_ARGUMENTS,
24 + BEGIN_TOOL_NAME,
25 + BEGIN_TOOL_REQUEST,
26 + BEGIN_TOOL_REQUESTS,
27 + END_THINK,
28 + END_TOOL_ARGUMENTS,
29 + END_TOOL_NAME,
30 + END_TOOL_REQUEST,
31 + END_TOOL_REQUESTS,
32 + EOT,
33 + PLAMO_MARKER_TOKENS,
34 + Plamo3Parser,
35 +)
36 +
37 +
38 +def _tool_header(name="weather"):
39 + return (
40 + BEGIN_TOOL_REQUEST
41 + + BEGIN_TOOL_NAME
42 + + name
43 + + END_TOOL_NAME
44 + + BEGIN_TOOL_ARGUMENTS
45 + )
46 +
47 +
48 +def _tool_call(name, arguments):
49 + return _tool_header(name) + arguments + END_TOOL_ARGUMENTS + END_TOOL_REQUEST
50 +
51 +
52 +@pytest.fixture
53 +def mock_tokenizer():
54 + special = [*sorted(PLAMO_MARKER_TOKENS), EOT, "<|plamo:bos|>"]
55 + atoms = [*special, "<|plamo:constrain|>", "<|plamo:msg|>"]
56 + vocab = {token: 100 + i for i, token in enumerate(atoms)}
57 + inverse = {i: token for token, i in vocab.items()}
58 + pattern = re.compile("|".join(map(re.escape, atoms)) + r"|[\s\S]")
59 + tokenizer = make_mock_tokenizer(vocab, special_tokens=special)
60 + tokenizer.encode.side_effect = lambda text, **kwargs: [
61 + vocab[piece] if piece in vocab else 1000 + ord(piece)
62 + for piece in pattern.findall(text)
63 + ]
64 + tokenizer.decode.side_effect = lambda ids, **kwargs: "".join(
65 + inverse[i] if i in inverse else chr(i - 1000) for i in ids
66 + )
67 + return tokenizer
68 +
69 +
70 +@pytest.fixture(params=["direct", "registered"])
71 +def parser_cls(request):
72 + if request.param == "direct":
73 + return Plamo3Parser
74 + return ParserManager.get_parser(
75 + tool_parser_name="plamo3",
76 + reasoning_parser_name="plamo3",
77 + enable_auto_tools=True,
78 + )
79 +
80 +
81 +def _stream(parser, tokenizer, output, chunk_size=1, finished_on_last=False):
82 + tokens = [
83 + (i, tokenizer.decode([i]))
84 + for i in tokenizer.encode(output, add_special_tokens=False)
85 + ]
86 + deltas = replay_streaming(
87 + parser,
88 + tokens,
89 + chunk_size=chunk_size,
90 + finished_on_last=finished_on_last,
91 + tools=DUMMY_TOOLS,
92 + )
93 + if not finished_on_last:
94 + deltas.append(
95 + parser.parse_delta("", [], _test_request(DUMMY_TOOLS), finished=True)
96 + )
97 + return deltas
98 +
99 +
100 +@pytest.mark.parametrize("thinking", [False, True])
101 +@pytest.mark.parametrize("with_markers", [False, True])
102 +def test_reasoning_mode(
103 + parser_cls, mock_tokenizer, mock_request, thinking, with_markers
104 +):
105 + output = (
106 + BEGIN_THINK + "Reasoning." + END_THINK + "Answer."
107 + if with_markers
108 + else "Answer."
109 + )
110 + if thinking:
111 + expected = ("Reasoning.", "Answer.") if with_markers else ("Answer.", "")
112 + else:
113 + expected = ("", output)
114 + kwargs = {"chat_template_kwargs": {"enable_thinking": thinking}}
115 + reasoning, content, calls = parser_cls(mock_tokenizer, **kwargs).parse(
116 + output, mock_request
117 + )
118 + assert (reasoning or "", content or "") == expected
119 + assert not calls
120 + result = collect_output(
121 + _stream(parser_cls(mock_tokenizer, **kwargs), mock_tokenizer, output)
122 + )
123 + assert (result.reasoning, result.content) == expected
124 + assert not result.tool_calls
125 +
126 +
127 +def test_duplicate_think_end_does_not_leak_into_content(
128 + parser_cls, mock_tokenizer, mock_request
129 +):
130 + output = END_THINK + "A" + END_THINK + "B"
131 + reasoning, content, calls = parser_cls(mock_tokenizer).parse(output, mock_request)
132 + assert (reasoning, content) == (None, "AB")
133 + assert not calls
134 +
135 +
136 +def test_content_can_start_a_new_reasoning_block(
137 + parser_cls, mock_tokenizer, mock_request
138 +):
139 + output = END_THINK + "A" + BEGIN_THINK + "R" + END_THINK
140 + reasoning, content, calls = parser_cls(mock_tokenizer).parse(output, mock_request)
141 + assert (reasoning, content) == ("R", "A")
142 + assert not calls
143 +
144 +
145 +@pytest.mark.parametrize("chunk_size", [1, 7, None])
146 +@pytest.mark.parametrize("finished_on_last", [False, True])
147 +@pytest.mark.parametrize(
148 + "closing",
149 + [
150 + "",
151 + END_TOOL_ARGUMENTS + END_TOOL_REQUEST,
152 + END_TOOL_ARGUMENTS + END_TOOL_REQUEST + END_TOOL_REQUESTS + EOT,
153 + ],
154 +)
155 +def test_complete_arguments_survive_missing_closers(
156 + parser_cls, mock_tokenizer, mock_request, chunk_size, finished_on_last, closing
157 +):
158 + output = (
159 + BEGIN_THINK
160 + + "Reasoning. "
161 + + END_THINK
162 + + "Before tools. "
163 + + BEGIN_TOOL_REQUESTS
164 + + _tool_header()
165 + + '{"city":"東京"}'
166 + + closing
167 + )
168 + reasoning, content, calls = parser_cls(mock_tokenizer).parse(
169 + output, mock_request, enable_auto_tools=True
170 + )
171 + assert (reasoning, content) == ("Reasoning. ", "Before tools. ")
172 + assert calls and [(c.name, json.loads(c.arguments)) for c in calls] == [
173 + ("weather", {"city": "東京"})
174 + ]
175 + result = collect_output(
176 + _stream(
177 + parser_cls(mock_tokenizer),
178 + mock_tokenizer,
179 + output,
180 + chunk_size,
181 + finished_on_last,
182 + )
183 + )
184 + assert (result.reasoning, result.content) == (reasoning, content)
185 + assert result.tool_calls == [{"name": "weather", "arguments": '{"city":"東京"}'}]
186 +
187 +
188 +def test_tool_name_cutoffs_do_not_leak_markers(
189 + parser_cls, mock_tokenizer, mock_request
190 +):
191 + header = BEGIN_TOOL_REQUEST + BEGIN_TOOL_NAME + "weather" + END_TOOL_NAME
192 + header_ids = mock_tokenizer.encode(header, add_special_tokens=False)
193 + name_start = len(
194 + mock_tokenizer.encode(
195 + BEGIN_TOOL_REQUEST + BEGIN_TOOL_NAME, add_special_tokens=False
196 + )
197 + )
198 + for end in range(len(header_ids)):
199 + partial = mock_tokenizer.decode(header_ids[:end])
200 + output = END_THINK + "Before tools." + BEGIN_TOOL_REQUESTS + partial
201 + reasoning, content, calls = parser_cls(mock_tokenizer).parse(
202 + output, mock_request, enable_auto_tools=True
203 + )
204 + assert (reasoning, content) == (None, "Before tools."), end
205 + if end <= name_start:
206 + expected_name = ""
207 + else:
208 + name_text = mock_tokenizer.decode(header_ids[name_start:end])
209 + if "<|plamo:" in name_text:
210 + name_text = name_text[: name_text.rfind("<|plamo:")]
211 + expected_name = name_text[: len("weather")]
212 + assert [call.name for call in calls or []] == (
213 + [expected_name] if expected_name else []
214 + ), end
215 + result = collect_output(
216 + _stream(parser_cls(mock_tokenizer), mock_tokenizer, output)
217 + )
218 + assert [call["name"] for call in result.tool_calls] == (
219 + [expected_name] if expected_name else []
220 + ), end
221 + assert all("<" not in call["name"] for call in result.tool_calls), end
222 + assert result.content == "Before tools.", end
223 +
224 +
225 +@pytest.mark.parametrize("chunk_size", [1, 7, None])
226 +@pytest.mark.parametrize("finished_on_last", [False, True])
227 +def test_all_argument_closer_cutoffs_do_not_leak(
228 + parser_cls, mock_tokenizer, mock_request, chunk_size, finished_on_last
229 +):
230 + head = END_THINK + BEGIN_TOOL_REQUESTS + _tool_header()
231 + closer_ids = mock_tokenizer.encode(END_TOOL_ARGUMENTS, add_special_tokens=False)
232 + for end in range(len(closer_ids)):
233 + partial = mock_tokenizer.decode(closer_ids[:end])
234 + output = head + '{"city":"東京"}' + partial
235 + _, _, calls = parser_cls(mock_tokenizer).parse(
236 + output, mock_request, enable_auto_tools=True
237 + )
238 + assert calls and calls[0].arguments == '{"city":"東京"}', end
239 + result = collect_output(
240 + _stream(
241 + parser_cls(mock_tokenizer),
242 + mock_tokenizer,
243 + output,
244 + chunk_size,
245 + finished_on_last,
246 + )
247 + )
248 + assert result.tool_calls == [
249 + {"name": "weather", "arguments": '{"city":"東京"}'}
250 + ], end
251 +
252 +
253 +@pytest.mark.parametrize(
254 + "arguments",
255 + [
256 + "{}",
257 + '{"name":"Alice","arguments":{"x":1},"parameters":true}',
258 + '{"text":"literal <|plamo:end_',
259 + ],
260 +)
261 +def test_arguments_are_not_rewritten(
262 + parser_cls, mock_tokenizer, mock_request, arguments
263 +):
264 + output = END_THINK + BEGIN_TOOL_REQUESTS + _tool_header() + arguments
265 + _, _, calls = parser_cls(mock_tokenizer).parse(
266 + output, mock_request, enable_auto_tools=True
267 + )
268 + assert calls and calls[0].arguments == arguments
269 + result = collect_output(_stream(parser_cls(mock_tokenizer), mock_tokenizer, output))
270 + assert result.tool_calls == [{"name": "weather", "arguments": arguments}]
271 +
272 +
273 +@pytest.mark.parametrize("truncated_second", [False, True])
274 +def test_multiple_calls_keep_indices_ids_and_arguments_separate(
275 + parser_cls, mock_tokenizer, mock_request, truncated_second
276 +):
277 + second = (
278 + BEGIN_TOOL_REQUEST + BEGIN_TOOL_NAME + "clo"
279 + if truncated_second
280 + else _tool_call("clock", '{"timezone":"Asia/Tokyo"}')
281 + )
282 + output = (
283 + END_THINK
284 + + BEGIN_TOOL_REQUESTS
285 + + _tool_call("weather", '{"city":"東京"}')
286 + + second
287 + )
288 + expected = [{"name": "weather", "arguments": '{"city":"東京"}'}]
289 + if truncated_second:
290 + expected.append({"name": "clo", "arguments": "{}"})
291 + else:
292 + output += END_TOOL_REQUESTS
293 + expected.append({"name": "clock", "arguments": '{"timezone":"Asia/Tokyo"}'})
294 + stream_expected = expected
295 + _, content, calls = parser_cls(mock_tokenizer).parse(
296 + output, mock_request, enable_auto_tools=True
297 + )
298 + assert (
299 + calls
300 + and [{"name": c.name, "arguments": c.arguments} for c in calls] == expected
301 + )
302 + deltas = _stream(parser_cls(mock_tokenizer), mock_tokenizer, output)
303 + result = collect_output(deltas)
304 + assert result.tool_calls == stream_expected
305 + assert result.content == (content or "") == ""
306 + announced = [
307 + call for delta in deltas if delta for call in delta.tool_calls or [] if call.id
308 + ]
309 + assert [call.index for call in announced] == list(range(len(expected)))
310 + assert len({call.id for call in announced}) == len(expected)
311 +
312 +
313 +def test_arguments_are_available_before_outer_closer(mock_tokenizer, mock_request):
314 + parser = Plamo3Parser(mock_tokenizer)
315 + output = END_THINK + BEGIN_TOOL_REQUESTS + _tool_call("weather", '{"city":"Tokyo"}')
316 + first = parser.parse_delta(output, [], mock_request, finished=False)
317 + assert collect_output([first]).tool_calls == [
318 + {"name": "weather", "arguments": '{"city":"Tokyo"}'}
319 + ]
320 + assert (
321 + parser.parse_delta(END_TOOL_REQUESTS[:10], [], mock_request, finished=False)
322 + is None
323 + )
324 + assert (
325 + parser.parse_delta(END_TOOL_REQUESTS[10:], [], mock_request, finished=True)
326 + is None
327 + )
328 +
329 +
330 +def test_missing_tool_request_start_does_not_break_streaming(
331 + parser_cls, mock_tokenizer, mock_request
332 +):
333 + output = (
334 + BEGIN_TOOL_REQUESTS
335 + + BEGIN_TOOL_NAME
336 + + "weather"
337 + + END_TOOL_NAME
338 + + BEGIN_TOOL_ARGUMENTS
339 + + "{}"
340 + + END_TOOL_ARGUMENTS
341 + + END_TOOL_REQUESTS
342 + )
343 + delta = parser_cls(mock_tokenizer).parse_delta(
344 + output, [], mock_request, finished=False
345 + )
346 + assert delta is not None
347 + assert collect_output([delta]).tool_calls == [
348 + {"name": "weather", "arguments": "{}"}
349 + ]
350 +
351 +
352 +def test_reusing_parser_drops_pending_names(mock_tokenizer, mock_request):
353 + parser = Plamo3Parser(mock_tokenizer)
354 + parser.parse(
355 + END_THINK
356 + + BEGIN_TOOL_REQUESTS
357 + + BEGIN_TOOL_REQUEST
358 + + BEGIN_TOOL_NAME
359 + + "partial",
360 + mock_request,
361 + )
362 + output = (
363 + END_THINK
364 + + BEGIN_TOOL_REQUESTS
365 + + _tool_call("weather", "{}")
366 + + END_TOOL_REQUESTS
367 + )
368 + _, _, calls = parser.parse(output, mock_request)
369 + assert calls and calls[0].name == "weather"
370 +
371 +
372 +@pytest.mark.parametrize(
373 + "text", ["", "a < b", "literal <", "literal <|plamo:unknown", " text\n"]
374 +)
375 +def test_plain_content_is_preserved(parser_cls, mock_tokenizer, mock_request, text):
376 + output = END_THINK + text + EOT
377 + _, content, calls = parser_cls(mock_tokenizer).parse(output, mock_request)
378 + assert (content or "") == text
379 + assert not calls
380 + result = collect_output(_stream(parser_cls(mock_tokenizer), mock_tokenizer, output))
381 + assert (result.content, result.tool_calls) == (text, [])
382 +
383 +
384 +@pytest.mark.parametrize(
385 + "text, ended",
386 + [
387 + ("", False),
388 + (BEGIN_THINK + "reasoning", False),
389 + (END_THINK + "answer", True),
390 + (END_THINK + "answer" + BEGIN_THINK + "new reasoning", False),
391 + (END_THINK + "answer" + EOT + "assistant", False),
392 + ],
393 +)
394 +def test_reasoning_end_checks_the_latest_turn(mock_tokenizer, text, ended):
395 + parser = Plamo3Parser(mock_tokenizer)
396 + ids = mock_tokenizer.encode(text)
397 + assert parser.is_reasoning_end(ids) == ended
398 + if ended:
399 + assert mock_tokenizer.decode(parser.extract_content_ids(ids)) == "answer"
400 +
401 +
402 +@pytest.mark.parametrize("suffix", ["<|plamo:end_", "<|plamo:end_think"])
403 +def test_partial_end_think_is_consistent_between_streaming_and_non_streaming(
404 + parser_cls, mock_tokenizer, mock_request, suffix
405 +):
406 + output = "visible" + suffix
407 + reasoning, content, calls = parser_cls(mock_tokenizer).parse(output, mock_request)
408 + assert (reasoning, content) == ("visible", None)
409 + assert not calls
410 + result = collect_output(_stream(parser_cls(mock_tokenizer), mock_tokenizer, output))
411 + assert (result.reasoning, result.content, result.tool_calls) == ("visible", "", [])
412 +
413 +
414 +@pytest.mark.parametrize(
415 + "suffix",
416 + [
417 + "<|plamo:begin_",
418 + "<|plamo:begin_tool_name",
419 + "<|plamo:end_tool",
420 + ],
421 +)
422 +def test_unfinished_marker_prefixes_are_stripped(mock_tokenizer, suffix):
423 + parser = Plamo3Parser(mock_tokenizer)
424 + assert parser._strip_unfinished_marker("visible" + suffix) == "visible"
425 +
426 +
427 +@pytest.mark.parametrize("suffix", ["<|plamo:", "<|plamo:b"])
428 +def test_non_token_prefix_is_not_stripped(mock_tokenizer, suffix):
429 + parser = Plamo3Parser(mock_tokenizer)
430 + text = "visible" + suffix
431 + assert parser._strip_unfinished_marker(text) == text
432 +
433 +
434 +def test_complete_markers_are_not_stripped(mock_tokenizer):
435 + parser = Plamo3Parser(mock_tokenizer)
436 + text = "visible" + BEGIN_TOOL_NAME
437 + assert parser._strip_unfinished_marker(text) == text
438 +
439 +
440 +def test_short_prefix_is_not_stripped(mock_tokenizer):
441 + parser = Plamo3Parser(mock_tokenizer)
442 + text = "visible<"
443 + assert parser._strip_unfinished_marker(text) == text
444 +
445 +
446 +def test_tool_block_can_end_reasoning_implicitly(
447 + parser_cls, mock_tokenizer, mock_request
448 +):
449 + output = (
450 + "reasoning"
451 + + BEGIN_TOOL_REQUESTS
452 + + _tool_call("weather", "{}")
453 + + END_TOOL_REQUESTS
454 + )
455 + reasoning, content, calls = parser_cls(mock_tokenizer).parse(
456 + output, mock_request, enable_auto_tools=True
457 + )
458 + assert (reasoning, content) == ("reasoning", None)
459 + assert calls and calls[0].name == "weather"
460 + result = collect_output(_stream(parser_cls(mock_tokenizer), mock_tokenizer, output))
461 + assert (result.reasoning, result.content) == ("reasoning", "")
462 + assert result.tool_calls == [{"name": "weather", "arguments": "{}"}]
463 +
464 +
465 +def test_every_cut_point_matches_non_streaming(
466 + parser_cls, mock_tokenizer, mock_request
467 +):
468 + output = (
469 + BEGIN_THINK
470 + + "Reasoning. "
471 + + END_THINK
472 + + "Before tools. "
473 + + BEGIN_TOOL_REQUESTS
474 + + _tool_call("weather", '{"city":"東京"}')
475 + + END_TOOL_REQUESTS
476 + + EOT
477 + )
478 + ids = mock_tokenizer.encode(output, add_special_tokens=False)
479 + for k in range(1, len(ids) + 1):
480 + prefix = mock_tokenizer.decode(ids[:k])
481 + reasoning, content, calls = parser_cls(mock_tokenizer).parse(
482 + prefix, mock_request, enable_auto_tools=True
483 + )
484 + result = collect_output(
485 + _stream(parser_cls(mock_tokenizer), mock_tokenizer, prefix)
486 + )
487 + assert (result.reasoning, result.content) == (
488 + (reasoning or ""),
489 + (content or ""),
490 + ), k
491 + expected = [{"name": c.name, "arguments": c.arguments} for c in (calls or [])]
492 + assert result.tool_calls == expected, (k, expected, result.tool_calls)
493 +
494 +
495 +@pytest.mark.parametrize(
496 + "choice",
497 + [
498 + "auto",
499 + "required",
500 + "none",
501 + {"type": "function", "function": {"name": "weather"}},
502 + ],
503 +)
504 +def test_tool_choice_uses_the_plamo_format(parser_cls, mock_tokenizer, choice):
505 + request = _test_request(
506 + [
507 + {
508 + "type": "function",
509 + "function": {"name": "weather", "parameters": {"type": "object"}},
510 + },
511 + ]
512 + )
513 + request = type(request).model_validate(
514 + request.model_dump() | {"tool_choice": choice}
515 + )
516 + output = (
517 + END_THINK
518 + + "Before tools."
519 + + BEGIN_TOOL_REQUESTS
520 + + _tool_call("weather", "{}")
521 + + END_TOOL_REQUESTS
522 + )
523 + _, content, calls = parser_cls(mock_tokenizer).parse(
524 + output, request, enable_auto_tools=True
525 + )
526 + assert content == "Before tools."
527 + assert [c.name for c in calls or []] == ([] if choice == "none" else ["weather"])
528 + parser = parser_cls(mock_tokenizer)
529 + delta = parser.parse_delta(output, [], request, finished=True)
530 + result = collect_output([delta])
531 + assert result.content == "Before tools."
532 + assert result.tool_calls == (
533 + [] if choice == "none" else [{"name": "weather", "arguments": "{}"}]
534 + )
535 +
536 +
537 +@pytest.mark.parametrize("delay", [1, 3])
538 +def test_delayed_text_keeps_split_markers_intact(parser_cls, mock_tokenizer, delay):
539 + output = (
540 + BEGIN_THINK
541 + + "reasoning"
542 + + END_THINK
543 + + "content"
544 + + BEGIN_TOOL_REQUESTS
545 + + _tool_call("weather", "{}")
546 + + END_TOOL_REQUESTS
547 + + EOT
548 + )
549 + tokens = [(i, mock_tokenizer.decode([i])) for i in mock_tokenizer.encode(output)]
550 + result = collect_output(
551 + replay_with_text_holdback(
552 + parser_cls(mock_tokenizer), tokens, text_delay=delay, tools=DUMMY_TOOLS
553 + )
554 + )
555 + assert (result.reasoning, result.content) == ("reasoning", "content")
556 + assert result.tool_calls == [{"name": "weather", "arguments": "{}"}]
tests/parser/engine/test_replay.py
+4 -3
@@ -74,9 +74,10 @@ def _discover_parsers() -> list[_ParserInfo]:
74 if cfg.name not in _BUILDERS:
75 missing_builders.append(f"{obj.__name__} (config.name={cfg.name!r})")
76 continue
77 - if cfg.name == "inkling":
78 - # Inkling opts out of token-id terminal matching and has typed
79 - # structural blocks; its replay coverage lives in test_inkling.py.
77 + if cfg.name in ("inkling", "plamo3"):
78 + # Inkling uses typed structural blocks. PLaMo markers can span
79 + # several token IDs. Neither fits this TOOL_END-token harness;
80 + # their direct and delegating coverage lives in dedicated tests.
81 continue
82 if cfg.name == "granite":
83 # Granite has a JSON-array tool body with no TOOL_END terminal, so
tests/parser/engine/trace_builder.py
+67
@@ -28,6 +28,7 @@ from tests.parser.engine.replay_harness import (
28 from vllm.entrypoints.openai.chat_completion.protocol import (
29 ChatCompletionToolsParam,
30 )
31 +from vllm.parser import plamo3
32 from vllm.parser.engine.registered_adapters import (
33 DeepSeekV4Parser,
34 DeepSeekV32Parser,
@@ -40,6 +41,7 @@ from vllm.parser.engine.registered_adapters import (
41 KimiK2Parser,
42 MinimaxM2Parser,
43 NemotronV3Parser,
44 + Plamo3Parser,
45 Qwen3Parser,
46 SeedOssParser,
47 )
@@ -1068,6 +1070,70 @@ def _build_inkling(scenario: Scenario, validate: bool = True) -> Sample:
1070 return sample
1071
1072
1073 +# ── PLaMo3 (literal nested tool-request wrappers) ───────────────────
1074 +
1075 +_PLAMO3_MARKERS = (
1076 + plamo3.BEGIN_THINK,
1077 + plamo3.END_THINK,
1078 + plamo3.BEGIN_TOOL_REQUESTS,
1079 + plamo3.END_TOOL_REQUESTS,
1080 + plamo3.BEGIN_TOOL_REQUEST,
1081 + plamo3.END_TOOL_REQUEST,
1082 + plamo3.BEGIN_TOOL_NAME,
1083 + plamo3.END_TOOL_NAME,
1084 + plamo3.BEGIN_TOOL_ARGUMENTS,
1085 + plamo3.END_TOOL_ARGUMENTS,
1086 + plamo3.EOT,
1087 +)
1088 +_PLAMO3_VOCAB = {marker: 300 + i for i, marker in enumerate(_PLAMO3_MARKERS)}
1089 +
1090 +
1091 +def _plamo3_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
1092 + args = json.dumps(tc.arguments, ensure_ascii=False, separators=(",", ":"))
1093 + return [
1094 + (plamo3.BEGIN_TOOL_REQUEST, True),
1095 + (plamo3.BEGIN_TOOL_NAME, True),
1096 + (tc.name, False),
1097 + (plamo3.END_TOOL_NAME, True),
1098 + (plamo3.BEGIN_TOOL_ARGUMENTS, True),
1099 + (args, False),
1100 + (plamo3.END_TOOL_ARGUMENTS, True),
1101 + (plamo3.END_TOOL_REQUEST, True),
1102 + ]
1103 +
1104 +
1105 +def _plamo3_segments(scenario: Scenario) -> list[tuple[str, bool]]:
1106 + segs: list[tuple[str, bool]] = [
1107 + (plamo3.BEGIN_THINK, True),
1108 + (scenario.reasoning or "", False),
1109 + (plamo3.END_THINK, True),
1110 + ]
1111 + if scenario.content is not None:
1112 + segs.append((scenario.content, False))
1113 + if scenario.tool_calls is not None:
1114 + segs.append((plamo3.BEGIN_TOOL_REQUESTS, True))
1115 + for tool_call in scenario.tool_calls:
1116 + segs.extend(_plamo3_tool_segments(tool_call))
1117 + segs.append((plamo3.END_TOOL_REQUESTS, True))
1118 + return segs
1119 +
1120 +
1121 +def _build_plamo3(scenario: Scenario, validate: bool = True) -> Sample:
1122 + sample = _make_sample(
1123 + sample_id=f"plamo3-{scenario.id}",
1124 + description=scenario.description,
1125 + vocab=_PLAMO3_VOCAB,
1126 + segments=_plamo3_segments(scenario),
1127 + expected_reasoning=scenario.reasoning or "",
1128 + expected_content=_qwen3_expected_content(scenario),
1129 + expected_tool_calls=_expected_tc(scenario),
1130 + tools=_expected_tools(scenario),
1131 + )
1132 + if validate:
1133 + _validate_sample(sample, Plamo3Parser)
1134 + return sample
1135 +
1136 +
1137 # ── Granite (JSON-array tool bodies, no reasoning) ───────────────────────
1138
1139 _GRANITE_VOCAB: dict[str, int] = {
@@ -1167,6 +1233,7 @@ _BUILDERS: dict[str, Any] = {
1233 "kimi_k2": _build_kimi_k2,
1234 "qwen3": _build_qwen3,
1235 "inkling": _build_inkling,
1236 + "plamo3": _build_plamo3,
1237 }
1238
1239
tests/tool_parsers/test_structural_tag_registry.py
+137
@@ -16,6 +16,17 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
16 ChatCompletionToolsParam,
17 )
18 from vllm.parser.abstract_parser import DelegatingParser
19 +from vllm.parser.plamo3 import (
20 + BEGIN_TOOL_ARGUMENTS,
21 + BEGIN_TOOL_NAME,
22 + BEGIN_TOOL_REQUEST,
23 + BEGIN_TOOL_REQUESTS,
24 + END_TOOL_ARGUMENTS,
25 + END_TOOL_NAME,
26 + END_TOOL_REQUEST,
27 + END_TOOL_REQUESTS,
28 + EOT,
29 +)
30 from vllm.tool_parsers.abstract_tool_parser import ToolParser
31 from vllm.tool_parsers.deepseekv3_tool_parser import DeepSeekV3ToolParser
32 from vllm.tool_parsers.deepseekv4_engine_tool_parser import DeepSeekV4EngineToolParser
@@ -30,6 +41,7 @@ from vllm.tool_parsers.kimi_k2_tool_parser import KimiK2ToolParser
41 from vllm.tool_parsers.kimi_k3_tool_parser import KimiK3ToolParser
42 from vllm.tool_parsers.llama_tool_parser import Llama3JsonToolParser
43 from vllm.tool_parsers.minimax_m2_tool_parser import MinimaxM2ToolParser
44 +from vllm.tool_parsers.plamo3_engine_tool_parser import Plamo3EngineToolParser
45 from vllm.tool_parsers.qwen3_engine_tool_parser import Qwen3EngineToolParser
46 from vllm.tool_parsers.structural_tag_registry import (
47 SUPPORTED_STRUCTURAL_TAG_MODELS,
@@ -271,6 +283,131 @@ def test_hermes_required_tool_calls_use_empty_separator():
283 assert tag.format.separator == ""
284
285
286 +def _plamo3_call(name: str, arguments: str) -> str:
287 + return (
288 + BEGIN_TOOL_REQUEST
289 + + BEGIN_TOOL_NAME
290 + + name
291 + + END_TOOL_NAME
292 + + BEGIN_TOOL_ARGUMENTS
293 + + arguments
294 + + END_TOOL_ARGUMENTS
295 + + END_TOOL_REQUEST
296 + )
297 +
298 +
299 +def _plamo3_requests(*calls: str, eot: bool = False) -> str:
300 + return (
301 + BEGIN_TOOL_REQUESTS + "".join(calls) + END_TOOL_REQUESTS + (EOT if eot else "")
302 + )
303 +
304 +
305 +def _plamo3_grammar(tool_choice, tools):
306 + tag = get_model_structural_tag(
307 + model="plamo3",
308 + tools=tools,
309 + tool_choice=tool_choice,
310 + reasoning=False,
311 + )
312 + assert isinstance(tag, StructuralTag)
313 + return Grammar.from_structural_tag(tag)
314 +
315 +
316 +def _plamo3_tools() -> list[ChatCompletionToolsParam]:
317 + return [
318 + ChatCompletionToolsParam(
319 + type="function",
320 + function={
321 + "name": "get_weather",
322 + "strict": True,
323 + "parameters": {
324 + "type": "object",
325 + "properties": {"city": {"type": "string"}},
326 + "required": ["city"],
327 + },
328 + },
329 + ),
330 + ChatCompletionToolsParam(
331 + type="function",
332 + function={
333 + "name": "get_time",
334 + "strict": True,
335 + "parameters": {
336 + "type": "object",
337 + "properties": {"timezone": {"type": "string"}},
338 + "required": ["timezone"],
339 + },
340 + },
341 + ),
342 + ]
343 +
344 +
345 +def test_plamo3_registered_as_vllm_structural_tag_model():
346 + assert "plamo3" in VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
347 + assert Plamo3EngineToolParser.structural_tag_model == "plamo3"
348 + assert Plamo3EngineToolParser.supports_required_and_named is False
349 +
350 +
351 +@pytest.mark.parametrize(
352 + "body",
353 + [
354 + _plamo3_requests(_plamo3_call("get_weather", '{"city":"Tokyo"}')),
355 + _plamo3_requests(
356 + _plamo3_call("get_weather", '{"city":"Tokyo"}'),
357 + _plamo3_call("get_time", '{"timezone":"Asia/Tokyo"}'),
358 + eot=True,
359 + ),
360 + ],
361 +)
362 +def test_plamo3_required_accepts_complete_request_blocks(body: str):
363 + assert _is_grammar_accept_string(_plamo3_grammar("required", _plamo3_tools()), body)
364 +
365 +
366 +@pytest.mark.parametrize(
367 + "body",
368 + [
369 + _plamo3_requests(_plamo3_call("unknown", '{"city":"Tokyo"}')),
370 + _plamo3_requests(_plamo3_call("get_weather", "{}")),
371 + BEGIN_TOOL_REQUESTS + _plamo3_call("get_weather", '{"city":"Tokyo"}'),
372 + ],
373 +)
374 +def test_plamo3_required_rejects_invalid_request_blocks(body: str):
375 + assert not _is_grammar_accept_string(
376 + _plamo3_grammar("required", _plamo3_tools()),
377 + body,
378 + )
379 +
380 +
381 +def test_plamo3_auto_allows_text_or_a_tool_request_block(sample_tools_strict):
382 + grammar = _plamo3_grammar("auto", sample_tools_strict)
383 +
384 + assert _is_grammar_accept_string(grammar, "Plain response")
385 + assert _is_grammar_accept_string(
386 + grammar,
387 + _plamo3_requests(_plamo3_call("get_weather", '{"city":"Tokyo"}')),
388 + )
389 +
390 +
391 +def test_plamo3_forced_stops_after_the_named_tool_call():
392 + tools = _plamo3_tools()
393 + tool_choice = ChatCompletionNamedToolChoiceParam(
394 + function=ChatCompletionNamedFunction(name="get_weather")
395 + )
396 + grammar = _plamo3_grammar(tool_choice, tools)
397 +
398 + assert _is_grammar_accept_string(
399 + grammar,
400 + _plamo3_requests(_plamo3_call("get_weather", '{"city":"Tokyo"}')),
401 + )
402 + assert not _is_grammar_accept_string(
403 + grammar,
404 + _plamo3_requests(
405 + _plamo3_call("get_weather", '{"city":"Tokyo"}'),
406 + _plamo3_call("get_time", '{"timezone":"Asia/Tokyo"}'),
407 + ),
408 + )
409 +
410 +
411 # ---------------------------------------------------------------------------
412 # Kimi K3 (XTML channel format) structural tag
413 # ---------------------------------------------------------------------------
tests/v1/structured_output/test_backend_xgrammar_plamo3.py new
+117
@@ -0,0 +1,117 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from types import SimpleNamespace
5 +
6 +import pytest
7 +import xgrammar as xgr
8 +
9 +from vllm.config import StructuredOutputsConfig, VllmConfig
10 +from vllm.entrypoints.openai.chat_completion.protocol import (
11 + ChatCompletionToolsParam,
12 +)
13 +from vllm.parser.plamo3 import (
14 + BEGIN_TOOL_ARGUMENTS,
15 + BEGIN_TOOL_NAME,
16 + BEGIN_TOOL_REQUEST,
17 + BEGIN_TOOL_REQUESTS,
18 + END_TOOL_ARGUMENTS,
19 + END_TOOL_NAME,
20 + END_TOOL_REQUEST,
21 + END_TOOL_REQUESTS,
22 +)
23 +from vllm.tool_parsers.structural_tag_registry import get_model_structural_tag
24 +from vllm.v1.structured_output.backend_types import StructuredOutputOptions
25 +from vllm.v1.structured_output.backend_xgrammar import XgrammarBackend
26 +
27 +
28 +class Plamo3Tokenizer:
29 + """Minimal stand-in for PLaMo's remote-code PythonBackend tokenizer."""
30 +
31 + eos_token_id = 0
32 +
33 + def __init__(self, tokens: list[str]) -> None:
34 + self._vocab = {token: token_id for token_id, token in enumerate(tokens)}
35 +
36 + def get_vocab(self) -> dict[str, int]:
37 + return self._vocab
38 +
39 + def init_xgrammar(self):
40 + stop_token_ids = [self.eos_token_id]
41 + tokenizer_info = xgr.TokenizerInfo(
42 + encoded_vocab=list(self._vocab),
43 + vocab_type=xgr.VocabType.BYTE_FALLBACK,
44 + vocab_size=len(self._vocab),
45 + stop_token_ids=stop_token_ids,
46 + )
47 + return tokenizer_info, stop_token_ids
48 +
49 +
50 +def make_vllm_config() -> VllmConfig:
51 + config = VllmConfig(
52 + structured_outputs_config=StructuredOutputsConfig(backend="xgrammar"),
53 + )
54 + config.model_config = SimpleNamespace(
55 + hf_config=SimpleNamespace(model_type="plamo3")
56 + )
57 + return config
58 +
59 +
60 +def test_plamo3_python_tokenizer_compiles_and_accepts_structural_tag():
61 + call_tokens = [
62 + BEGIN_TOOL_REQUESTS,
63 + BEGIN_TOOL_REQUEST,
64 + BEGIN_TOOL_NAME,
65 + "get_weather",
66 + END_TOOL_NAME,
67 + BEGIN_TOOL_ARGUMENTS,
68 + '{"city":"Tokyo"}',
69 + END_TOOL_ARGUMENTS,
70 + END_TOOL_REQUEST,
71 + END_TOOL_REQUESTS,
72 + ]
73 + tokenizer = Plamo3Tokenizer(["<eos>", *call_tokens])
74 + backend = XgrammarBackend(
75 + make_vllm_config(),
76 + tokenizer=tokenizer,
77 + vocab_size=len(tokenizer.get_vocab()),
78 + )
79 + tools = [
80 + ChatCompletionToolsParam(
81 + type="function",
82 + function={
83 + "name": "get_weather",
84 + "parameters": {
85 + "type": "object",
86 + "properties": {"city": {"type": "string"}},
87 + "required": ["city"],
88 + },
89 + "strict": True,
90 + },
91 + )
92 + ]
93 + tag = get_model_structural_tag("plamo3", tools, "required", reasoning=False)
94 + assert tag is not None
95 +
96 + grammar = backend.compile_grammar(
97 + StructuredOutputOptions.STRUCTURAL_TAG,
98 + tag.model_dump_json(),
99 + )
100 + token_ids = [tokenizer.get_vocab()[token] for token in call_tokens]
101 + assert grammar.accept_tokens("plamo3", token_ids)
102 +
103 +
104 +@pytest.mark.parametrize("value", ['"', "あ"])
105 +def test_plamo3_grammar_matches_decoded_bytes(value):
106 + pieces = [f"<0x{byte:02X}>" for byte in value.encode("utf-8")]
107 + tokenizer = Plamo3Tokenizer(list(dict.fromkeys(["<eos>", value, *pieces])))
108 + backend = XgrammarBackend(
109 + make_vllm_config(),
110 + tokenizer=tokenizer,
111 + vocab_size=len(tokenizer.get_vocab()),
112 + )
113 + for tokens in ([value], pieces):
114 + grammar = backend.compile_grammar(StructuredOutputOptions.REGEX, value)
115 + assert grammar.accept_tokens(
116 + "plamo3", [tokenizer.get_vocab()[token] for token in tokens]
117 + )
vllm/parser/abstract_parser.py
+10 -1
@@ -38,6 +38,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
38 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
39 from vllm.exceptions import VLLMValidationError
40 from vllm.logger import init_logger
41 +from vllm.parser.engine.adapters import ParserEngineToolAdapter
42 from vllm.parser.metrics import record_tool_parser_invocation
43 from vllm.parser.utils import count_history_tool_calls
44 from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
@@ -128,7 +129,15 @@ class Parser:
129 tokenizer, *args, model_config=model_config, **kwargs
130 )
131 if self.__class__.tool_parser_cls is not None:
131 - self._tool_parser = self.__class__.tool_parser_cls(tokenizer, tools)
132 + # Engine-based adapters take the same construction kwargs as
133 + # the reasoning parser (e.g. chat_template_kwargs for per-request
134 + # thinking toggles); legacy ToolParser classes take none.
135 + if issubclass(self.__class__.tool_parser_cls, ParserEngineToolAdapter):
136 + self._tool_parser = self.__class__.tool_parser_cls(
137 + tokenizer, tools, model_config=model_config, **kwargs
138 + )
139 + else:
140 + self._tool_parser = self.__class__.tool_parser_cls(tokenizer, tools)
141
142 self._engine_based = (
143 self._reasoning_parser is None
vllm/parser/engine/registered_adapters.py
+6
@@ -20,6 +20,7 @@ from vllm.parser.kimi_k2 import KimiK2Parser
20 from vllm.parser.minimax_m2 import MinimaxM2Parser
21 from vllm.parser.mistral import MistralParser
22 from vllm.parser.nemotron_v3 import NemotronV3Parser
23 +from vllm.parser.plamo3 import Plamo3Parser
24 from vllm.parser.qwen3 import Qwen3Parser
25 from vllm.parser.seed_oss import SeedOssParser
26
@@ -92,3 +93,8 @@ from vllm.parser.seed_oss import SeedOssParser
93 MistralParserReasoningAdapter,
94 MistralParserToolAdapter,
95 ) = make_adapters(MistralParser)
96 +
97 +(
98 + Plamo3ParserReasoningAdapter,
99 + Plamo3ParserToolAdapter,
100 +) = make_adapters(Plamo3Parser)
vllm/parser/plamo3.py new
+318
@@ -0,0 +1,318 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from __future__ import annotations
5 +
6 +import functools
7 +import json
8 +from dataclasses import replace
9 +from itertools import groupby
10 +from typing import TYPE_CHECKING
11 +
12 +from vllm.entrypoints.generate.base.protocol import (
13 + DeltaFunctionCall,
14 + DeltaMessage,
15 + DeltaToolCall,
16 +)
17 +from vllm.parser.engine.events import EventType, SemanticEvent
18 +from vllm.parser.engine.parser_engine import ParserEngine
19 +from vllm.parser.engine.parser_engine_config import (
20 + ParserEngineConfig,
21 + ParserState,
22 + Transition,
23 +)
24 +
25 +if TYPE_CHECKING:
26 + from vllm.entrypoints.openai.chat_completion.protocol import (
27 + ChatCompletionRequest,
28 + )
29 + from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
30 + from vllm.tokenizers import TokenizerLike
31 + from vllm.tool_parsers.abstract_tool_parser import Tool
32 +
33 +BEGIN_TOOL_REQUESTS = "<|plamo:begin_tool_requests:plamo|>"
34 +END_TOOL_REQUESTS = "<|plamo:end_tool_requests:plamo|>"
35 +BEGIN_TOOL_REQUEST = "<|plamo:begin_tool_request:plamo|>"
36 +END_TOOL_REQUEST = "<|plamo:end_tool_request:plamo|>"
37 +BEGIN_TOOL_NAME = "<|plamo:begin_tool_name:plamo|>"
38 +END_TOOL_NAME = "<|plamo:end_tool_name:plamo|>"
39 +BEGIN_TOOL_ARGUMENTS = (
40 + "<|plamo:begin_tool_arguments:plamo|><|plamo:constrain|>json<|plamo:msg|>"
41 +)
42 +END_TOOL_ARGUMENTS = "<|plamo:end_tool_arguments:plamo|>"
43 +BEGIN_THINK = "<|plamo:begin_think:plamo|>"
44 +END_THINK = "<|plamo:end_think:plamo|>"
45 +EOT = "<|plamo:tag|>"
46 +
47 +# PLaMo3 control markers are split into multiple tokenizer tokens. These
48 +# atomic pieces must not be treated as unrelated special tokens by the
49 +# parser-engine drop-token handling.
50 +PLAMO_MARKER_TOKENS = frozenset(
51 + {
52 + "<|plamo:begin_",
53 + "<|plamo:end_",
54 + ":plamo|>",
55 + }
56 +)
57 +
58 +
59 +@functools.cache
60 +def plamo3_config(thinking: bool = True) -> ParserEngineConfig:
61 + if thinking:
62 + reasoning_terminals = {
63 + "THINK_START": BEGIN_THINK,
64 + "THINK_END": END_THINK,
65 + }
66 + reasoning_transitions = {
67 + (ParserState.REASONING, "THINK_START"): Transition(
68 + ParserState.REASONING,
69 + ),
70 + (ParserState.CONTENT, "THINK_START"): Transition(
71 + ParserState.REASONING,
72 + (EventType.REASONING_START,),
73 + ),
74 + (ParserState.REASONING, "THINK_END"): Transition(
75 + ParserState.CONTENT,
76 + (EventType.REASONING_END,),
77 + ),
78 + (ParserState.CONTENT, "THINK_END"): Transition(
79 + ParserState.CONTENT,
80 + (),
81 + ),
82 + (ParserState.REASONING, "TOOL_REQUESTS_START"): Transition(
83 + ParserState.TOOL_PREAMBLE,
84 + (EventType.REASONING_END,),
85 + ),
86 + }
87 + initial_state = ParserState.REASONING
88 + else:
89 + reasoning_terminals = {}
90 + reasoning_transitions = {}
91 + initial_state = ParserState.CONTENT
92 +
93 + return ParserEngineConfig(
94 + name="plamo3",
95 + initial_state=initial_state,
96 + terminals={
97 + **reasoning_terminals,
98 + "TOOL_REQUESTS_START": BEGIN_TOOL_REQUESTS,
99 + "TOOL_REQUESTS_END": END_TOOL_REQUESTS,
100 + "TOOL_REQUEST_START": BEGIN_TOOL_REQUEST,
101 + "TOOL_REQUEST_END": END_TOOL_REQUEST,
102 + "TOOL_NAME_START": BEGIN_TOOL_NAME,
103 + "TOOL_NAME_END": END_TOOL_NAME,
104 + "TOOL_ARGS_START": BEGIN_TOOL_ARGUMENTS,
105 + "TOOL_ARGS_END": END_TOOL_ARGUMENTS,
106 + "EOT": EOT,
107 + },
108 + transitions={
109 + **reasoning_transitions,
110 + (ParserState.CONTENT, "TOOL_REQUESTS_START"): Transition(
111 + ParserState.TOOL_PREAMBLE,
112 + ),
113 + (ParserState.TOOL_PREAMBLE, "TOOL_REQUEST_START"): Transition(
114 + ParserState.TOOL_PREAMBLE,
115 + ),
116 + (ParserState.TOOL_PREAMBLE, "TOOL_NAME_START"): Transition(
117 + ParserState.TOOL_NAME,
118 + (EventType.TOOL_CALL_START,),
119 + ),
120 + (ParserState.TOOL_NAME, "TOOL_NAME_END"): Transition(
121 + ParserState.TOOL_PREAMBLE,
122 + ),
123 + (ParserState.TOOL_PREAMBLE, "TOOL_ARGS_START"): Transition(
124 + ParserState.TOOL_ARGS,
125 + ),
126 + (ParserState.TOOL_ARGS, "TOOL_ARGS_END"): Transition(
127 + ParserState.TOOL_BETWEEN,
128 + ),
129 + (ParserState.TOOL_BETWEEN, "TOOL_REQUEST_END"): Transition(
130 + ParserState.TOOL_PREAMBLE,
131 + (EventType.TOOL_CALL_END,),
132 + ),
133 + (ParserState.TOOL_PREAMBLE, "TOOL_REQUESTS_END"): Transition(
134 + ParserState.CONTENT,
135 + ),
136 + # EOT is normally consumed by generation stop handling. Keep it
137 + # in the grammar as well so text-only parsing and explicitly
138 + # supplied stop tokens cannot leak it into user-visible output.
139 + (ParserState.REASONING, "EOT"): Transition(
140 + ParserState.CONTENT,
141 + (EventType.REASONING_END,),
142 + ),
143 + (ParserState.CONTENT, "EOT"): Transition(ParserState.CONTENT),
144 + (ParserState.TOOL_PREAMBLE, "EOT"): Transition(ParserState.CONTENT),
145 + (ParserState.TOOL_NAME, "EOT"): Transition(ParserState.CONTENT),
146 + (ParserState.TOOL_ARGS, "EOT"): Transition(ParserState.CONTENT),
147 + (ParserState.TOOL_BETWEEN, "EOT"): Transition(ParserState.CONTENT),
148 + },
149 + token_id_terminals={"EOT": EOT, **reasoning_terminals},
150 + drop_whitespace_only_content_before_tools=True,
151 + stream_arg_deltas=True,
152 + strip_content_whitespace_with_tools=False,
153 + strip_trailing_reasoning_whitespace=False,
154 + preserve_tokens=PLAMO_MARKER_TOKENS,
155 + tool_args_json=True,
156 + )
157 +
158 +
159 +class Plamo3Parser(ParserEngine):
160 + """PLaMo3 reasoning and tool-call parser backed by ParserEngine."""
161 +
162 + def __init__(
163 + self,
164 + tokenizer: TokenizerLike,
165 + tools: list[Tool] | None = None,
166 + **kwargs,
167 + ) -> None:
168 + # Configure PLaMo parsing and cache its multi-token reasoning markers.
169 + chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
170 + self.thinking_enabled = chat_kwargs.get("enable_thinking", True)
171 + kwargs.setdefault(
172 + "parser_engine_config",
173 + plamo3_config(thinking=self.thinking_enabled),
174 + )
175 + super().__init__(tokenizer, tools, **kwargs)
176 +
177 + self._reasoning_start_token_id_sequence: list[int] = list(
178 + tokenizer.encode(BEGIN_THINK, add_special_tokens=False)
179 + )
180 + self._reasoning_end_token_id_sequence: list[int] = list(
181 + tokenizer.encode(END_THINK, add_special_tokens=False)
182 + )
183 + # Token-ID sequences of every terminal literal, for token-level
184 + # unfinished-marker detection.
185 + self._terminal_token_id_sequences: dict[str, tuple[int, ...]] = {
186 + literal: tuple(tokenizer.encode(literal, add_special_tokens=False))
187 + for literal in self.parser_engine_config.terminal_literals
188 + }
189 +
190 + def is_reasoning_end(self, input_ids: list[int]) -> bool:
191 + # Detect PLaMo reasoning boundaries that span multiple token IDs.
192 + if not self.thinking_enabled:
193 + return True
194 +
195 + start_ids = self._reasoning_start_token_id_sequence
196 + end_ids = self._reasoning_end_token_id_sequence
197 + for i in range(len(input_ids) - 1, -1, -1):
198 + if input_ids[i : i + len(end_ids)] == end_ids:
199 + return True
200 + if input_ids[i : i + len(start_ids)] == start_ids:
201 + return False
202 + if input_ids[i] == self.vocab.get(EOT):
203 + return False
204 + return False
205 +
206 + def extract_content_ids(self, input_ids: list[int]) -> list[int]:
207 + # Extract content after PLaMo's multi-token reasoning end marker.
208 + if not self.thinking_enabled:
209 + return input_ids
210 + end_ids = self._reasoning_end_token_id_sequence
211 + for i in range(len(input_ids) - len(end_ids), -1, -1):
212 + if input_ids[i : i + len(end_ids)] == end_ids:
213 + return input_ids[i + len(end_ids) :]
214 + return input_ids
215 +
216 + def _strip_unfinished_marker(self, value: str | None) -> str | None:
217 + if not value:
218 + return value
219 + marker_start = value.rfind("<|plamo:")
220 + if marker_start < 0:
221 + return value or None
222 + suffix = value[marker_start:]
223 + # Strip a trailing partial marker at the token-ID level.
224 + suffix_ids = tuple(
225 + self.model_tokenizer.encode(suffix, add_special_tokens=False)
226 + )
227 + if any(
228 + len(seq) > len(suffix_ids) and seq[: len(suffix_ids)] == suffix_ids
229 + for seq in self._terminal_token_id_sequences.values()
230 + ):
231 + return value[:marker_start] or None
232 + return value or None
233 +
234 + def _coalesce_finished_tool_events(
235 + self, events: list[SemanticEvent]
236 + ) -> list[SemanticEvent]:
237 + combined = []
238 + for (kind, _), group in groupby(
239 + events, key=lambda event: (event.type, event.tool_index)
240 + ):
241 + chunks = list(group)
242 + if kind not in (EventType.TOOL_NAME, EventType.ARG_VALUE_CHUNK):
243 + combined.extend(chunks)
244 + continue
245 + if kind == EventType.TOOL_NAME and chunks[0].tool_index < 0:
246 + continue
247 + value = "".join(chunk.value for chunk in chunks)
248 + if kind == EventType.TOOL_NAME:
249 + value = self._strip_unfinished_marker(value) or ""
250 + if value:
251 + combined.append(replace(chunks[0], value=value))
252 + return combined
253 +
254 + def _events_to_delta(
255 + self,
256 + events: list[SemanticEvent],
257 + finished: bool = False,
258 + ) -> DeltaMessage | None:
259 + # Coalesce flushed tool fragments before stripping truncated markers.
260 + if not finished:
261 + return super()._events_to_delta(events)
262 +
263 + events = self._coalesce_finished_tool_events(events)
264 + delta = super()._events_to_delta(events, finished=True)
265 + if delta is None:
266 + return None
267 +
268 + delta.reasoning = self._strip_unfinished_marker(delta.reasoning)
269 + if not self.skip_tool_parsing:
270 + delta.content = self._strip_unfinished_marker(delta.content)
271 + # The flush emits the remaining (never-streamed) tool-call
272 + # arguments; an empty remainder must match non-streaming
273 + # extraction (_extract_args_json), which normalizes it to '{}'.
274 + for tc in delta.tool_calls or []:
275 + if tc.function is not None and not (tc.function.arguments or "").strip():
276 + tc.function.arguments = "{}"
277 + if delta.reasoning is None and delta.content is None and not delta.tool_calls:
278 + return None
279 + return delta
280 +
281 + def _handle_arg_chunk(
282 + self,
283 + event: SemanticEvent,
284 + deltas: list[DeltaToolCall],
285 + ) -> None:
286 + # Strip truncated marker suffixes and preserve the first argument delta.
287 + idx = event.tool_index
288 + slot = self._tool_slots[idx]
289 + if (marker_pos := event.value.rfind("<|plamo:")) >= 0:
290 + stripped = event.value[:marker_pos]
291 + try:
292 + json.loads(slot.args + stripped)
293 + except ValueError:
294 + pass
295 + else:
296 + event = replace(event, value=stripped)
297 + name_sent_before = slot.name_sent
298 + super()._handle_arg_chunk(event, deltas)
299 + if event.value and not name_sent_before and slot.name_sent:
300 + deltas.append(
301 + DeltaToolCall(
302 + index=idx,
303 + function=DeltaFunctionCall(arguments=event.value),
304 + )
305 + )
306 +
307 + def _extract_args_json(self, raw_args: str, func_name: str) -> str:
308 + # Return the raw arguments as JSON
309 + # since PLaMo3 generates function names and arguments separately.
310 + return raw_args.strip() or "{}"
311 +
312 + def extract_reasoning(
313 + self,
314 + model_output: str,
315 + request: ChatCompletionRequest | ResponsesRequest,
316 + ) -> tuple[str | None, str | None]:
317 + reasoning, content = super().extract_reasoning(model_output, request)
318 + return self._strip_unfinished_marker(reasoning), content
vllm/reasoning/__init__.py
+4
@@ -136,6 +136,10 @@ _REASONING_PARSERS_TO_REGISTER = {
136 "olmo3_reasoning_parser",
137 "Olmo3ReasoningParser",
138 ),
139 + "plamo3": (
140 + "plamo3_engine_reasoning_parser",
141 + "Plamo3ParserReasoningAdapter",
142 + ),
143 "muse_glimmer": (
144 "muse_glimmer_reasoning_parser",
145 "MuseGlimmerReasoningParser",
vllm/reasoning/plamo3_engine_reasoning_parser.py new
+6
@@ -0,0 +1,6 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from vllm.parser.engine.registered_adapters import Plamo3ParserReasoningAdapter
5 +
6 +__all__ = ["Plamo3ParserReasoningAdapter"]
vllm/tool_parsers/__init__.py
+4
@@ -166,6 +166,10 @@ _TOOL_PARSERS_TO_REGISTER = {
166 "olmo3_tool_parser",
167 "Olmo3PythonicToolParser",
168 ),
169 + "plamo3": (
170 + "plamo3_engine_tool_parser",
171 + "Plamo3EngineToolParser",
172 + ),
173 "muse_glimmer": (
174 "muse_glimmer_tool_parser",
175 "MuseGlimmerToolParser",
vllm/tool_parsers/plamo3_engine_tool_parser.py new
+9
@@ -0,0 +1,9 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from vllm.parser.engine.registered_adapters import Plamo3ParserToolAdapter
5 +
6 +
7 +class Plamo3EngineToolParser(Plamo3ParserToolAdapter): # type: ignore[valid-type, misc]
8 + structural_tag_model = "plamo3"
9 + supports_required_and_named = False
vllm/tool_parsers/structural_tag_registry.py
+65 -1
@@ -73,7 +73,7 @@ XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
73 "deepseek_v4_1",
74 }
75 )
76 -VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset({"hermes", "hy_v4", "kimi_k3"})
76 +VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset({"hermes", "hy_v4", "kimi_k3", "plamo3"})
77 SUPPORTED_STRUCTURAL_TAG_MODELS = (
78 XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS | VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
79 )
@@ -407,6 +407,70 @@ def get_minimax_structural_tag(
407 return StructuralTag(format=suffix_tag)
408
409
410 +_PLAMO3_BEGIN_TOOL_REQUESTS = "<|plamo:begin_tool_requests:plamo|>"
411 +_PLAMO3_END_TOOL_REQUESTS = "<|plamo:end_tool_requests:plamo|>"
412 +_PLAMO3_BEGIN_TOOL_REQUEST = "<|plamo:begin_tool_request:plamo|>"
413 +_PLAMO3_END_TOOL_REQUEST = "<|plamo:end_tool_request:plamo|>"
414 +_PLAMO3_BEGIN_TOOL_NAME = "<|plamo:begin_tool_name:plamo|>"
415 +_PLAMO3_END_TOOL_NAME = "<|plamo:end_tool_name:plamo|>"
416 +_PLAMO3_BEGIN_TOOL_ARGUMENTS = (
417 + "<|plamo:begin_tool_arguments:plamo|><|plamo:constrain|>json<|plamo:msg|>"
418 +)
419 +_PLAMO3_END_TOOL_ARGUMENTS = "<|plamo:end_tool_arguments:plamo|>"
420 +_PLAMO3_EOT = "<|plamo:tag|>"
421 +
422 +
423 +@register_vllm_structural_tag("plamo3")
424 +def get_plamo3_structural_tag(
425 + tools: list[FunctionToolParam],
426 + builtin_tools: list[BuiltinToolParam],
427 + tool_choice: SimplifiedToolChoice,
428 + reasoning: bool,
429 + token_suffix: str = "",
430 +) -> StructuralTag:
431 + """Build PLaMo3's explicit tool-request structural tag."""
432 + del builtin_tools, reasoning, token_suffix
433 +
434 + request_tags = [
435 + TagFormat(
436 + begin=(
437 + _PLAMO3_BEGIN_TOOL_REQUEST
438 + + _PLAMO3_BEGIN_TOOL_NAME
439 + + tool.function.name
440 + + _PLAMO3_END_TOOL_NAME
441 + + _PLAMO3_BEGIN_TOOL_ARGUMENTS
442 + ),
443 + content=JSONSchemaFormat(
444 + json_schema=get_function_parameters(tool.function)
445 + ),
446 + end=_PLAMO3_END_TOOL_ARGUMENTS + _PLAMO3_END_TOOL_REQUEST,
447 + )
448 + for tool in tools
449 + ]
450 + requests_tag = TagFormat(
451 + begin=_PLAMO3_BEGIN_TOOL_REQUESTS,
452 + content=TagsWithSeparatorFormat(
453 + tags=request_tags,
454 + separator="",
455 + at_least_one=True,
456 + stop_after_first=tool_choice == "forced",
457 + ),
458 + end=[
459 + _PLAMO3_END_TOOL_REQUESTS,
460 + _PLAMO3_END_TOOL_REQUESTS + _PLAMO3_EOT,
461 + ],
462 + )
463 +
464 + if tool_choice == "auto":
465 + return StructuralTag(
466 + format=TriggeredTagsFormat(
467 + triggers=[_PLAMO3_BEGIN_TOOL_REQUESTS],
468 + tags=[requests_tag],
469 + )
470 + )
471 + return StructuralTag(format=requests_tag)
472 +
473 +
474 # ---------------------------------------------------------------------------
475 # Kimi K3 (XTML channel format)
476 # ---------------------------------------------------------------------------
vllm/v1/structured_output/backend_xgrammar.py
+8
@@ -38,6 +38,10 @@ class XgrammarBackend(StructuredOutputBackend):
38 self.disable_any_whitespace = (
39 self.vllm_config.structured_outputs_config.disable_any_whitespace
40 )
41 + model_config = self.vllm_config.model_config
42 + is_plamo3 = (
43 + model_config is not None and model_config.hf_config.model_type == "plamo3"
44 + )
45
46 if is_mistral_tokenizer(self.tokenizer):
47 # NOTE: ideally, xgrammar should handle this accordingly.
@@ -57,6 +61,10 @@ class XgrammarBackend(StructuredOutputBackend):
61 stop_token_ids=stop_token_ids,
62 add_prefix_space=True,
63 )
64 + elif is_plamo3 and callable(
65 + init_xgrammar := getattr(self.tokenizer, "init_xgrammar", None)
66 + ):
67 + tokenizer_info, _ = init_xgrammar()
68 else:
69 tokenizer_info = xgr.TokenizerInfo.from_huggingface(
70 self.tokenizer,