main
py 631 lines 21.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Replay tests for engine parsers (holdback, skip-tool-parsing, adapters).
4
5 Replays dynamically built token sequences at different chunk sizes and
6 holdback depths to verify chunk-size invariance and terminal-token hygiene.
7
8 Parser discovery is automatic: any ``ParserEngine`` subclass registered in
9 ``registered_adapters`` that also has a builder in ``trace_builder._BUILDERS``
10 is picked up with zero manual wiring.
11 """
12
13 from __future__ import annotations
14
15 import dataclasses
16 from typing import NamedTuple
17
18 import pytest
19
20 from tests.parser.engine.replay_harness import (
21 DUMMY_TOOLS,
22 MockTokenizer,
23 Sample,
24 _test_request,
25 assert_no_terminal_leakage,
26 assert_parse_output,
27 collect_output,
28 make_mock_tokenizer,
29 parse_non_streaming,
30 replay_streaming,
31 replay_with_text_holdback,
32 )
33 from tests.parser.engine.trace_builder import _BUILDERS, build_samples
34 from vllm.parser.engine import registered_adapters as _adapters_mod
35 from vllm.parser.engine.parser_engine import ParserEngine
36 from vllm.parser.engine.parser_engine_config import ParserState
37 from vllm.parser.mistral import MistralParser
38
39 # ── Parser discovery ─────────────────────────────────────────────────
40
41
42 class _ParserInfo(NamedTuple):
43 parser_cls: type[ParserEngine]
44 name: str
45 samples: tuple
46 terminals: list[str]
47 tool_end: str
48 think_end: str
49 tool_start: str
50
51
52 def _discover_parsers() -> list[_ParserInfo]:
53 """Discover engine parsers from registered_adapters that have test builders.
54
55 Returns one ``_ParserInfo`` per parser, sorted by config name.
56 Raises ``RuntimeError`` if any registered parser lacks a builder.
57 """
58 bare_tok = MockTokenizer(vocab={}, tokens=[])
59 found: list[_ParserInfo] = []
60 missing_builders: list[str] = []
61 for obj in vars(_adapters_mod).values():
62 if not (
63 isinstance(obj, type)
64 and issubclass(obj, ParserEngine)
65 and obj is not ParserEngine
66 ):
67 continue
68 if obj is MistralParser:
69 # Mistral uses brace-balanced JSON tool args with no TOOL_END
70 # token, so it does not fit this TOOL_END-based replay harness.
71 # It is covered by tests/parser/mistral/ instead.
72 continue
73 cfg = obj(bare_tok, None).parser_engine_config
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 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
84 # it does not fit this token-terminal harness; its replay coverage
85 # lives in test_granite.py.
86 continue
87 tool_end = cfg.token_id_terminals.get("TOOL_END")
88 if not tool_end:
89 raise RuntimeError(
90 f"{obj.__name__} config missing 'TOOL_END' in token_id_terminals"
91 )
92 all_vals = cfg.terminal_literals | set(cfg.token_id_terminals.values())
93 found.append(
94 _ParserInfo(
95 parser_cls=obj,
96 name=cfg.name,
97 samples=build_samples(cfg.name),
98 terminals=sorted(v for v in all_vals if len(v) > 1),
99 tool_end=tool_end,
100 think_end=cfg.terminal_literal("THINK_END") or "",
101 tool_start=(
102 cfg.terminal_literal("TOOL_SECTION_START")
103 if (ParserState.CONTENT, "TOOL_SECTION_START") in cfg.transitions
104 else cfg.terminal_literal("TOOL_START")
105 )
106 or "",
107 )
108 )
109 if missing_builders:
110 raise RuntimeError(
111 f"Engine parsers in registered_adapters have no test builder "
112 f"in trace_builder._BUILDERS: {', '.join(missing_builders)}. "
113 f"Add a builder to _BUILDERS for each new parser."
114 )
115 found.sort(key=lambda p: p.name)
116 return found
117
118
119 _PARSERS = _discover_parsers()
120
121
122 def _make_parser(parser_cls: type[ParserEngine], tokenizer, sample: Sample, **extra):
123 kwargs = dict(extra)
124 if sample.chat_template_kwargs:
125 kwargs["chat_template_kwargs"] = sample.chat_template_kwargs
126 return parser_cls(tokenizer, sample.tools, **kwargs)
127
128
129 _ENGINE_PARSERS: dict[str, type[ParserEngine]] = {
130 f"{p.name}_engine": p.parser_cls for p in _PARSERS
131 }
132
133 # ── Parametrize sample lists ─────────────────────────────────────────
134
135 HOLDBACK_CONFIGS = [6, 12, 24]
136
137 _REPLAY_SAMPLES = [(p.parser_cls, s, p.terminals) for p in _PARSERS for s in p.samples]
138
139
140 @pytest.mark.parametrize("holdback", HOLDBACK_CONFIGS, ids=lambda h: f"holdback{h}")
141 @pytest.mark.parametrize("chunk_size", [3, 5, 10], ids=lambda c: f"chunk{c}")
142 @pytest.mark.parametrize(
143 "parser_cls,sample,terminals",
144 _REPLAY_SAMPLES,
145 ids=lambda v: v.id if hasattr(v, "id") else "",
146 )
147 class TestReplayWithHoldback:
148 """Replay all parsers with simulated detokenizer holdback."""
149
150 def test_replay(self, parser_cls, sample, terminals, chunk_size, holdback):
151 tokenizer = make_mock_tokenizer(sample)
152 parser = _make_parser(parser_cls, tokenizer, sample)
153 deltas = replay_streaming(
154 parser,
155 sample.tokens,
156 chunk_size=chunk_size,
157 holdback_chars=holdback,
158 prompt_token_ids=sample.prompt_token_ids,
159 )
160 output = collect_output(deltas)
161
162 assert_parse_output(output, sample)
163 assert_no_terminal_leakage(
164 output,
165 terminals,
166 context=f"chunk_size={chunk_size}, holdback={holdback}",
167 )
168
169
170 TEXT_HOLDBACK_DELAYS = [1, 2, 3]
171
172
173 @pytest.mark.parametrize("delay", TEXT_HOLDBACK_DELAYS, ids=lambda d: f"delay{d}")
174 @pytest.mark.parametrize(
175 "parser_cls,sample,terminals",
176 _REPLAY_SAMPLES,
177 ids=lambda v: v.id if hasattr(v, "id") else "",
178 )
179 class TestTextHoldback:
180 """Replay with production-like text/token-ID misalignment.
181
182 In production the detokenizer sends token IDs immediately but holds
183 back text by N tokens. This exercises the TokenIDScanner deferred
184 terminal path that aligned-holdback tests do not cover.
185 """
186
187 def test_replay(self, parser_cls, sample, terminals, delay):
188 tokenizer = make_mock_tokenizer(sample)
189 parser = _make_parser(parser_cls, tokenizer, sample)
190 deltas = replay_with_text_holdback(
191 parser,
192 sample.tokens,
193 text_delay=delay,
194 prompt_token_ids=sample.prompt_token_ids,
195 )
196 output = collect_output(deltas)
197
198 assert_parse_output(output, sample)
199 assert_no_terminal_leakage(
200 output,
201 terminals,
202 context=f"text_delay={delay}",
203 )
204
205
206 @pytest.mark.parametrize(
207 "chunk_size", [1, 2, 3, 5, 10, 19, 20, None], ids=lambda c: f"chunk{c}"
208 )
209 @pytest.mark.parametrize(
210 "parser_cls,sample,terminals",
211 _REPLAY_SAMPLES,
212 ids=lambda v: v.id if hasattr(v, "id") else "",
213 )
214 class TestReplay:
215 """Replay all parsers at varied chunk sizes without holdback."""
216
217 def test_replay(self, parser_cls, sample, terminals, chunk_size):
218 tokenizer = make_mock_tokenizer(sample)
219 parser = _make_parser(parser_cls, tokenizer, sample)
220 deltas = replay_streaming(
221 parser,
222 sample.tokens,
223 chunk_size=chunk_size,
224 prompt_token_ids=sample.prompt_token_ids,
225 )
226 output = collect_output(deltas)
227
228 assert_parse_output(output, sample)
229 assert_no_terminal_leakage(output, terminals)
230
231
232 _DEFERRAL_SAMPLES = [
233 (p.parser_cls, s, p.tool_end)
234 for p in _PARSERS
235 for s in p.samples
236 if s.expected_tool_calls
237 ]
238
239
240 @pytest.mark.parametrize(
241 "parser_cls,sample,tool_end_text",
242 _DEFERRAL_SAMPLES,
243 ids=lambda v: v.id if hasattr(v, "id") else getattr(v, "__name__", ""),
244 )
245 class TestDeferralFinish:
246 """Test that parse_delta(finished=True) resolves deferred scanner state.
247
248 Simulates a production failure where delta_text is missing the
249 tool-call-end text but delta_token_ids has the token, causing the
250 scanner to defer it. Without finish(), the deferred state is lost
251 and tool call arguments are empty.
252 """
253
254 def test_misaligned_last_delta_with_finish(self, parser_cls, sample, tool_end_text):
255 tokenizer = make_mock_tokenizer(sample)
256 parser = _make_parser(parser_cls, tokenizer, sample)
257
258 request = _test_request()
259
260 all_ids = [tid for tid, _ in sample.tokens]
261 all_texts = [text for _, text in sample.tokens]
262
263 tool_end_id = sample.vocab.get(tool_end_text)
264 split_idx = None
265 for i in range(len(all_ids) - 1, -1, -1):
266 if all_ids[i] == tool_end_id:
267 split_idx = i
268 break
269
270 if split_idx is None:
271 pytest.skip(f"no {tool_end_text} token found")
272
273 first_ids = all_ids[:split_idx]
274 first_text = "".join(all_texts[:split_idx])
275
276 last_ids = all_ids[split_idx:]
277 last_text_missing = "".join(all_texts[split_idx:]).replace(tool_end_text, "")
278
279 result1 = parser.parse_delta(
280 first_text,
281 first_ids,
282 request,
283 prompt_token_ids=[],
284 finished=False,
285 )
286 result2 = parser.parse_delta(
287 last_text_missing, last_ids, request, finished=True
288 )
289
290 output = collect_output([result1, result2])
291
292 tool_calls_only = dataclasses.replace(
293 sample, expected_reasoning=None, expected_content=None
294 )
295 assert_parse_output(output, tool_calls_only)
296
297
298 @pytest.mark.parametrize(
299 "parser_cls,sample",
300 [(p.parser_cls, p.samples[0]) for p in _PARSERS],
301 ids=[p.name for p in _PARSERS],
302 )
303 class TestParserEngineAdjustRequest:
304 """Verify ParserEngine and its adapters set skip_special_tokens=False."""
305
306 def test_adjust_request_disables_skip_special_tokens(self, parser_cls, sample):
307 tokenizer = make_mock_tokenizer(sample)
308 parser = parser_cls(tokenizer, sample.tools)
309 request = _test_request()
310 assert request.skip_special_tokens is True
311 adjusted = parser.adjust_request(request)
312 assert adjusted.skip_special_tokens is False
313
314
315 _TOOL_CALL_SAMPLES = [
316 (p.parser_cls, s, p.think_end, p.tool_start)
317 for p in _PARSERS
318 for s in p.samples
319 if s.expected_tool_calls
320 and s.expected_reasoning
321 # Inkling's typed-block format (structural role/kind tokens, shared
322 # block-end token) doesn't fit the generic reasoning/tool split
323 # below; its filtering modes are covered in test_inkling.py instead.
324 and p.name != "inkling"
325 ]
326
327
328 def _tool_suppression_expectations(
329 sample, think_end: str, tool_start: str, *, include_tool_block: bool
330 ) -> tuple[str, str]:
331 reasoning, content = _raw_tool_suppression_expectations(
332 sample, think_end, tool_start, include_tool_block=include_tool_block
333 )
334 if sample.content_lstrip:
335 content = content.lstrip(sample.content_lstrip)
336 return reasoning, content
337
338
339 def _raw_tool_suppression_expectations(
340 sample, think_end: str, tool_start: str, *, include_tool_block: bool
341 ) -> tuple[str, str]:
342 """Expected (reasoning, content) from the raw token text.
343
344 With ``include_tool_block=True`` (skip_tool_parsing / reasoning
345 adapter first pass), tool terminal text is preserved as content so
346 a second-pass parser can see it.
347
348 With ``include_tool_block=False`` (_suppress_tool_calls /
349 tool_choice='none'), the state machine consumes tool blocks and
350 only non-tool content survives.
351 """
352 full_text = "".join(text for _, text in sample.tokens)
353 reasoning = sample.expected_reasoning
354 idx = full_text.find(reasoning)
355 if idx < 0:
356 return (full_text, "")
357 after_reasoning = full_text[idx + len(reasoning) :]
358 if think_end:
359 pos = after_reasoning.find(think_end)
360 if pos >= 0:
361 if include_tool_block:
362 return (reasoning, after_reasoning[pos + len(think_end) :])
363 after_reasoning = after_reasoning[pos + len(think_end) :]
364 if tool_start:
365 pos = after_reasoning.find(tool_start)
366 if pos >= 0:
367 if include_tool_block:
368 return (reasoning, after_reasoning[pos:])
369 return (reasoning, after_reasoning[:pos])
370 if include_tool_block:
371 return (full_text, "")
372 return (reasoning, after_reasoning)
373
374
375 @pytest.mark.parametrize("chunk_size", [1, 5, None], ids=lambda c: f"chunk{c}")
376 @pytest.mark.parametrize(
377 "mode",
378 ["skip_tool_parsing", "suppress_tool_calls"],
379 ids=["skip_tool_parsing", "suppress_tool_calls"],
380 )
381 @pytest.mark.parametrize(
382 "parser_cls,sample,think_end,tool_start",
383 _TOOL_CALL_SAMPLES,
384 ids=lambda v: v.id if hasattr(v, "id") else getattr(v, "__name__", ""),
385 )
386 class TestToolCallFilteringReplay:
387 """Replay with tool calls not extracted, in both filtering modes.
388
389 ``skip_tool_parsing`` (reasoning adapter first pass): tool terminal
390 text is preserved as content for a second-pass tool parser.
391
392 ``suppress_tool_calls`` (tool_choice='none'): tool call blocks are
393 consumed by the state machine and do not leak into content.
394 """
395
396 def test_replay(self, parser_cls, sample, think_end, tool_start, mode, chunk_size):
397 tokenizer = make_mock_tokenizer(sample)
398 kwargs = {}
399 if sample.chat_template_kwargs:
400 kwargs["chat_template_kwargs"] = sample.chat_template_kwargs
401 parser = parser_cls(tokenizer, **kwargs)
402
403 request = _test_request()
404 request.tools = DUMMY_TOOLS
405 if mode == "skip_tool_parsing":
406 parser.skip_tool_parsing = True
407 else:
408 request.tool_choice = "none"
409
410 all_ids = [tid for tid, _ in sample.tokens]
411 all_texts = [text for _, text in sample.tokens]
412 if chunk_size is None:
413 chunk_size = len(all_ids)
414
415 results = []
416 chunks = list(range(0, len(all_ids), chunk_size))
417 for i, start in enumerate(chunks):
418 end = min(start + chunk_size, len(all_ids))
419 is_last = i == len(chunks) - 1
420 result = parser.parse_delta(
421 "".join(all_texts[start:end]),
422 all_ids[start:end],
423 request,
424 prompt_token_ids=(sample.prompt_token_ids or [])
425 if start == 0
426 else None,
427 finished=is_last,
428 )
429 results.append(result)
430
431 output = collect_output(results)
432
433 include_block = mode == "skip_tool_parsing"
434 expected_reasoning, expected_content = _tool_suppression_expectations(
435 sample, think_end, tool_start, include_tool_block=include_block
436 )
437
438 assert output.reasoning == expected_reasoning, (
439 f"Reasoning mismatch (mode={mode}):\n"
440 f" expected: {expected_reasoning!r}\n"
441 f" actual: {output.reasoning!r}"
442 )
443 assert output.tool_calls == [], (
444 f"Expected no tool calls (mode={mode}) but got {output.tool_calls}"
445 )
446 assert output.content == expected_content, (
447 f"Content mismatch (mode={mode}):\n"
448 f" expected: {expected_content!r}\n"
449 f" actual: {output.content!r}"
450 )
451
452
453 @pytest.mark.parametrize(
454 "parser_cls,sample,think_end,tool_start",
455 _TOOL_CALL_SAMPLES,
456 ids=lambda v: v.id if hasattr(v, "id") else getattr(v, "__name__", ""),
457 )
458 class TestToolCallFilteringNonStreaming:
459 """Non-streaming parse() with tool_choice='none' must suppress tool
460 calls and not leak special tokens into content."""
461
462 def test_parse(self, parser_cls, sample, think_end, tool_start):
463 tokenizer = make_mock_tokenizer(sample)
464 kwargs = {}
465 if sample.chat_template_kwargs:
466 kwargs["chat_template_kwargs"] = sample.chat_template_kwargs
467 parser = parser_cls(tokenizer, **kwargs)
468
469 request = _test_request()
470 request.tools = DUMMY_TOOLS
471 request.tool_choice = "none"
472
473 output = parse_non_streaming(parser, sample, request)
474
475 expected_reasoning, expected_content = _tool_suppression_expectations(
476 sample, think_end, tool_start, include_tool_block=False
477 )
478 assert output.reasoning == expected_reasoning, (
479 f"Reasoning mismatch:\n"
480 f" expected: {expected_reasoning!r}\n"
481 f" actual: {output.reasoning!r}"
482 )
483 assert output.tool_calls == [], (
484 f"Expected no tool calls but got {output.tool_calls}"
485 )
486 assert output.content == expected_content, (
487 f"Content mismatch:\n"
488 f" expected: {expected_content!r}\n"
489 f" actual: {output.content!r}"
490 )
491
492
493 _WS_TOOL_SAMPLES = [(t[0], t[1]) for t in _TOOL_CALL_SAMPLES if "whitespace" in t[1].id]
494
495
496 @pytest.mark.parametrize(
497 "parser_cls,sample",
498 _WS_TOOL_SAMPLES,
499 ids=lambda v: v.id if hasattr(v, "id") else getattr(v, "__name__", ""),
500 )
501 class TestToolChoiceNoneStreamingParity:
502 """Streaming and non-streaming must return the same content
503 when tool_choice='none' suppresses tool calls."""
504
505 def test_content_matches(self, parser_cls, sample):
506 tokenizer = make_mock_tokenizer(sample)
507 kwargs = {}
508 if sample.chat_template_kwargs:
509 kwargs["chat_template_kwargs"] = sample.chat_template_kwargs
510 request = _test_request()
511 request.tools = DUMMY_TOOLS
512 request.tool_choice = "none"
513
514 ns_output = parse_non_streaming(
515 parser_cls(tokenizer, **kwargs),
516 sample,
517 request,
518 )
519
520 s_parser = parser_cls(tokenizer, **kwargs)
521 results = []
522 for i, (tid, text) in enumerate(sample.tokens):
523 is_last = i == len(sample.tokens) - 1
524 results.append(
525 s_parser.parse_delta(
526 text,
527 [tid],
528 request,
529 prompt_token_ids=(sample.prompt_token_ids or [])
530 if i == 0
531 else None,
532 finished=is_last,
533 )
534 )
535 s_output = collect_output(results)
536
537 assert ns_output.content == s_output.content, (
538 f"Streaming/non-streaming content mismatch:\n"
539 f" streaming: {s_output.content!r}\n"
540 f" non-streaming: {ns_output.content!r}"
541 )
542
543
544 _DROP_TOKENS = {"<bos>": 99990, "<eos>": 99991}
545
546
547 def _inject_drop_tokens(sample):
548 """Insert <bos> at stream start and <eos> between the first two tokens."""
549 new_vocab = {**sample.vocab, **_DROP_TOKENS}
550 tokens = list(sample.tokens)
551 tokens.insert(0, (99990, "<bos>"))
552 if len(tokens) >= 3:
553 tokens.insert(2, (99991, "<eos>"))
554 else:
555 tokens.append((99991, "<eos>"))
556 return dataclasses.replace(sample, vocab=new_vocab, tokens=tokens)
557
558
559 class TestDropTokenReplay:
560 """Verify unconfigured special tokens are silently dropped across
561 all parsers and chunk sizes."""
562
563 @pytest.mark.parametrize(
564 "parser_info",
565 _PARSERS,
566 ids=[p.name for p in _PARSERS],
567 )
568 @pytest.mark.parametrize("chunk_size", [1, 3, None])
569 def test_drop_tokens_removed_from_output(self, parser_info, chunk_size):
570 for sample in parser_info.samples:
571 injected = _inject_drop_tokens(sample)
572 tokenizer = make_mock_tokenizer(injected)
573 parser = _make_parser(parser_info.parser_cls, tokenizer, sample)
574
575 results = replay_streaming(
576 parser,
577 injected.tokens,
578 chunk_size=chunk_size,
579 tools=sample.tools,
580 prompt_token_ids=sample.prompt_token_ids,
581 )
582 output = collect_output(results)
583
584 assert_no_terminal_leakage(
585 output,
586 list(_DROP_TOKENS.keys()),
587 context=f"parser={parser_info.name}, chunk={chunk_size}",
588 )
589 assert_parse_output(output, sample)
590
591
592 class TestDropTokenNonStreaming:
593 """Non-streaming parse() must also strip unconfigured special tokens."""
594
595 @pytest.mark.parametrize(
596 "parser_info",
597 _PARSERS,
598 ids=[p.name for p in _PARSERS],
599 )
600 def test_drop_tokens_removed_from_output(self, parser_info):
601 for sample in parser_info.samples:
602 injected = _inject_drop_tokens(sample)
603 tokenizer = make_mock_tokenizer(injected)
604 parser = _make_parser(parser_info.parser_cls, tokenizer, sample)
605
606 request = _test_request(tools=sample.tools)
607 output = parse_non_streaming(parser, injected, request)
608
609 assert_no_terminal_leakage(
610 output,
611 list(_DROP_TOKENS.keys()),
612 context=f"parser={parser_info.name}",
613 )
614
615
616 class TestAdapterReferences:
617 """Verify make_adapters sets reasoning/tool parser class refs on parser engine
618 parser classes so the serving layer finds them and calls adjust_request."""
619
620 @pytest.mark.parametrize(
621 "parser_name",
622 list(_ENGINE_PARSERS.keys()),
623 )
624 def test_adapter_cls_refs_set(self, parser_name):
625 parser_cls = _ENGINE_PARSERS[parser_name]
626 assert parser_cls.reasoning_parser_cls is not None, (
627 f"{parser_name}: reasoning_parser_cls is None"
628 )
629 assert parser_cls.tool_parser_cls is not None, (
630 f"{parser_name}: tool_parser_cls is None"
631 )