[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,