| 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 | ) |