| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Tests for :class:`ParserEngine` — the glue layer between |
| 4 | :class:`StreamingParserEngine` events and the serving layer's |
| 5 | DeltaMessage / ExtractedToolCallInformation protocol. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import dataclasses |
| 11 | import json |
| 12 | from types import SimpleNamespace |
| 13 | from unittest.mock import MagicMock |
| 14 | |
| 15 | import pytest |
| 16 | import regex as re |
| 17 | |
| 18 | from tests.parser.engine.conftest import make_mock_tokenizer |
| 19 | from vllm.entrypoints.generate.base.protocol import ( |
| 20 | DeltaFunctionCall, |
| 21 | DeltaToolCall, |
| 22 | FunctionDefinition, |
| 23 | ) |
| 24 | from vllm.entrypoints.openai.chat_completion.protocol import ( |
| 25 | ChatCompletionRequest, |
| 26 | ChatCompletionToolsParam, |
| 27 | ) |
| 28 | from vllm.parser.abstract_parser import DelegatingParser |
| 29 | from vllm.parser.engine.adapters import make_adapters |
| 30 | from vllm.parser.engine.events import EventType, SemanticEvent |
| 31 | from vllm.parser.engine.parser_engine import ParserEngine |
| 32 | from vllm.parser.engine.parser_engine_config import ( |
| 33 | ParserEngineConfig, |
| 34 | ParserState, |
| 35 | Transition, |
| 36 | ) |
| 37 | from vllm.parser.parser_manager import ParserManager |
| 38 | |
| 39 | # ── Shared test configs ────────────────────────────────────────────── |
| 40 | |
| 41 | _VOCAB: dict[str, int] = { |
| 42 | "<think>": 200, |
| 43 | "</think>": 201, |
| 44 | "<tool_call>": 202, |
| 45 | "</tool_call>": 203, |
| 46 | } |
| 47 | |
| 48 | |
| 49 | def _combined_config() -> ParserEngineConfig: |
| 50 | """Config with reasoning tags and tool-call tags.""" |
| 51 | return ParserEngineConfig( |
| 52 | name="combined_test", |
| 53 | terminals={ |
| 54 | "THINK_START": "<think>", |
| 55 | "THINK_END": "</think>", |
| 56 | "TOOL_START": "<tool_call>", |
| 57 | "TOOL_END": "</tool_call>", |
| 58 | }, |
| 59 | token_id_terminals={ |
| 60 | "THINK_START": "<think>", |
| 61 | "THINK_END": "</think>", |
| 62 | "TOOL_START": "<tool_call>", |
| 63 | "TOOL_END": "</tool_call>", |
| 64 | }, |
| 65 | transitions={ |
| 66 | (ParserState.REASONING, "THINK_END"): Transition( |
| 67 | ParserState.CONTENT, |
| 68 | (EventType.REASONING_END,), |
| 69 | ), |
| 70 | (ParserState.CONTENT, "THINK_START"): Transition( |
| 71 | ParserState.REASONING, |
| 72 | (EventType.REASONING_START,), |
| 73 | ), |
| 74 | (ParserState.CONTENT, "TOOL_START"): Transition( |
| 75 | ParserState.TOOL_ARGS, |
| 76 | (EventType.TOOL_CALL_START,), |
| 77 | ), |
| 78 | (ParserState.TOOL_ARGS, "TOOL_END"): Transition( |
| 79 | ParserState.CONTENT, |
| 80 | (EventType.TOOL_CALL_END,), |
| 81 | ), |
| 82 | }, |
| 83 | initial_state=ParserState.REASONING, |
| 84 | content_events={ |
| 85 | ParserState.CONTENT: EventType.TEXT_CHUNK, |
| 86 | ParserState.REASONING: EventType.REASONING_CHUNK, |
| 87 | ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK, |
| 88 | }, |
| 89 | ) |
| 90 | |
| 91 | |
| 92 | def _hermes_config() -> ParserEngineConfig: |
| 93 | """Tool-call-only config (no reasoning).""" |
| 94 | return ParserEngineConfig( |
| 95 | name="hermes_test", |
| 96 | terminals={ |
| 97 | "TOOL_START": "<tool_call>", |
| 98 | "TOOL_END": "</tool_call>", |
| 99 | }, |
| 100 | token_id_terminals={ |
| 101 | "TOOL_START": "<tool_call>", |
| 102 | "TOOL_END": "</tool_call>", |
| 103 | }, |
| 104 | transitions={ |
| 105 | (ParserState.CONTENT, "TOOL_START"): Transition( |
| 106 | ParserState.TOOL_ARGS, |
| 107 | (EventType.TOOL_CALL_START,), |
| 108 | ), |
| 109 | (ParserState.TOOL_ARGS, "TOOL_END"): Transition( |
| 110 | ParserState.CONTENT, |
| 111 | (EventType.TOOL_CALL_END,), |
| 112 | ), |
| 113 | }, |
| 114 | content_events={ |
| 115 | ParserState.CONTENT: EventType.TEXT_CHUNK, |
| 116 | ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK, |
| 117 | }, |
| 118 | ) |
| 119 | |
| 120 | |
| 121 | def _make_engine( |
| 122 | config: ParserEngineConfig | None = None, |
| 123 | tools: list | None = None, |
| 124 | vocab: dict[str, int] | None = None, |
| 125 | special_tokens: list[str] | None = None, |
| 126 | ) -> ParserEngine: |
| 127 | tokenizer = make_mock_tokenizer(vocab or _VOCAB, special_tokens=special_tokens) |
| 128 | cfg = config or _combined_config() |
| 129 | return ParserEngine( |
| 130 | tokenizer, |
| 131 | tools=tools, |
| 132 | parser_engine_config=cfg, |
| 133 | ) |
| 134 | |
| 135 | |
| 136 | # ── TestReasoningEndTokenIds ───────────────────────────────────────── |
| 137 | |
| 138 | |
| 139 | def _with_reasoning_exits( |
| 140 | *exits: tuple[str, ParserState, tuple[EventType, ...]], |
| 141 | ) -> ParserEngineConfig: |
| 142 | """Combined config plus extra transitions out of REASONING.""" |
| 143 | base = _combined_config() |
| 144 | transitions = dict(base.transitions) |
| 145 | for terminal, next_state, events in exits: |
| 146 | transitions[(ParserState.REASONING, terminal)] = Transition(next_state, events) |
| 147 | return dataclasses.replace(base, transitions=transitions) |
| 148 | |
| 149 | |
| 150 | class TestReasoningEndTokenIds: |
| 151 | """ParserEngine derives the reasoning-end token set from its config.""" |
| 152 | |
| 153 | def test_every_reasoning_end_exit_contributes(self): |
| 154 | cfg = _with_reasoning_exits( |
| 155 | ( |
| 156 | "TOOL_START", |
| 157 | ParserState.TOOL_ARGS, |
| 158 | (EventType.REASONING_END, EventType.TOOL_CALL_START), |
| 159 | ), |
| 160 | ) |
| 161 | assert _make_engine(cfg).reasoning_end_token_ids == {201, 202} |
| 162 | |
| 163 | def test_transition_staying_in_reasoning_is_ignored(self): |
| 164 | cfg = _with_reasoning_exits(("THINK_START", ParserState.REASONING, ())) |
| 165 | assert _make_engine(cfg).reasoning_end_token_ids == {201} |
| 166 | |
| 167 | def test_unreported_exit_fails_closed(self): |
| 168 | cfg = _with_reasoning_exits( |
| 169 | ("TOOL_START", ParserState.TOOL_ARGS, (EventType.TOOL_CALL_START,)), |
| 170 | ) |
| 171 | assert _make_engine(cfg).reasoning_end_token_ids == frozenset() |
| 172 | |
| 173 | def test_unresolved_think_end_fails_closed(self): |
| 174 | vocab = {k: v for k, v in _VOCAB.items() if k != "</think>"} |
| 175 | assert _make_engine(vocab=vocab).reasoning_end_token_ids == frozenset() |
| 176 | |
| 177 | text_only = dataclasses.replace( |
| 178 | _combined_config(), token_id_terminals={"THINK_START": "<think>"} |
| 179 | ) |
| 180 | assert _make_engine(text_only).reasoning_end_token_ids == frozenset() |
| 181 | |
| 182 | def test_config_without_reasoning_has_empty_set(self): |
| 183 | assert _make_engine(_hermes_config()).reasoning_end_token_ids == frozenset() |
| 184 | |
| 185 | def test_find_reasoning_end_offset_returns_first_match(self): |
| 186 | engine = _make_engine() |
| 187 | assert engine.find_reasoning_end_offset([5, 201, 6, 201]) == 1 |
| 188 | assert engine.find_reasoning_end_offset([5, 6]) == 2 |
| 189 | assert engine.find_reasoning_end_offset([]) == 0 |
| 190 | # Rejected-draft placeholders never match. |
| 191 | assert engine.find_reasoning_end_offset([-1, 201]) == 1 |
| 192 | |
| 193 | def test_find_reasoning_end_offset_with_empty_set_returns_none(self): |
| 194 | engine = _make_engine(_hermes_config()) |
| 195 | assert engine.find_reasoning_end_offset([201]) is None |
| 196 | |
| 197 | |
| 198 | # ── TestEventsToDelta ──────────────────────────────────────────────── |
| 199 | |
| 200 | |
| 201 | class TestEventsToDelta: |
| 202 | """Unit tests for ParserEngine._events_to_delta().""" |
| 203 | |
| 204 | def test_text_chunk_produces_content(self): |
| 205 | engine = _make_engine() |
| 206 | delta = engine._events_to_delta( |
| 207 | [ |
| 208 | SemanticEvent(EventType.TEXT_CHUNK, "Hello world"), |
| 209 | ] |
| 210 | ) |
| 211 | assert delta is not None |
| 212 | assert delta.content == "Hello world" |
| 213 | assert not delta.tool_calls |
| 214 | |
| 215 | def test_reasoning_chunk_produces_reasoning(self): |
| 216 | engine = _make_engine() |
| 217 | delta = engine._events_to_delta( |
| 218 | [ |
| 219 | SemanticEvent(EventType.REASONING_CHUNK, "Let me think"), |
| 220 | ] |
| 221 | ) |
| 222 | assert delta is not None |
| 223 | assert delta.reasoning == "Let me think" |
| 224 | assert delta.content is None |
| 225 | |
| 226 | def test_empty_events_returns_none(self): |
| 227 | engine = _make_engine() |
| 228 | delta = engine._events_to_delta([]) |
| 229 | assert delta is None |
| 230 | |
| 231 | def test_tool_call_produces_tool_call_delta(self): |
| 232 | engine = _make_engine() |
| 233 | events = [ |
| 234 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 235 | SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0), |
| 236 | SemanticEvent( |
| 237 | EventType.ARG_VALUE_CHUNK, |
| 238 | '{"location": "NYC"}', |
| 239 | tool_index=0, |
| 240 | ), |
| 241 | SemanticEvent(EventType.TOOL_CALL_END, tool_index=0), |
| 242 | ] |
| 243 | delta = engine._events_to_delta(events) |
| 244 | assert delta is not None |
| 245 | assert len(delta.tool_calls) > 0 |
| 246 | names = [ |
| 247 | tc.function.name |
| 248 | for tc in delta.tool_calls |
| 249 | if tc.function and tc.function.name |
| 250 | ] |
| 251 | assert "get_weather" in names |
| 252 | |
| 253 | def test_reasoning_end_sets_flag(self): |
| 254 | engine = _make_engine() |
| 255 | assert engine._reasoning_ended is False |
| 256 | engine._events_to_delta([SemanticEvent(EventType.REASONING_END)]) |
| 257 | assert engine._reasoning_ended is True |
| 258 | |
| 259 | def test_mixed_content_and_reasoning(self): |
| 260 | engine = _make_engine() |
| 261 | events = [ |
| 262 | SemanticEvent(EventType.REASONING_CHUNK, "thinking..."), |
| 263 | SemanticEvent(EventType.REASONING_END), |
| 264 | SemanticEvent(EventType.TEXT_CHUNK, "answer"), |
| 265 | ] |
| 266 | delta = engine._events_to_delta(events) |
| 267 | assert delta is not None |
| 268 | assert delta.reasoning == "thinking..." |
| 269 | assert delta.content == "answer" |
| 270 | |
| 271 | @pytest.mark.parametrize( |
| 272 | "events,expected,excluded", |
| 273 | [ |
| 274 | ( |
| 275 | [SemanticEvent(EventType.TEXT_CHUNK, "Hello world")], |
| 276 | "content", |
| 277 | ["tool_calls", "reasoning"], |
| 278 | ), |
| 279 | ( |
| 280 | [SemanticEvent(EventType.REASONING_CHUNK, "Let me think")], |
| 281 | "reasoning", |
| 282 | ["tool_calls", "content"], |
| 283 | ), |
| 284 | ( |
| 285 | [ |
| 286 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 287 | SemanticEvent(EventType.TOOL_NAME, "fn", tool_index=0), |
| 288 | SemanticEvent(EventType.ARG_VALUE_CHUNK, '{"k":1}', tool_index=0), |
| 289 | SemanticEvent(EventType.TOOL_CALL_END, tool_index=0), |
| 290 | ], |
| 291 | "tool_calls", |
| 292 | ["content", "reasoning"], |
| 293 | ), |
| 294 | ], |
| 295 | ids=["content_only", "reasoning_only", "tool_call_only"], |
| 296 | ) |
| 297 | def test_delta_excludes_unset_fields(self, events, expected, excluded): |
| 298 | engine = _make_engine() |
| 299 | delta = engine._events_to_delta(events) |
| 300 | assert delta is not None |
| 301 | dumped = delta.model_dump(exclude_unset=True) |
| 302 | assert expected in dumped |
| 303 | for field in excluded: |
| 304 | assert field not in dumped |
| 305 | |
| 306 | def test_kimi_k2_tool_call_id_includes_func_name(self): |
| 307 | engine = _make_engine() |
| 308 | engine._stream_state.tool_call_id_type = "kimi_k2" |
| 309 | events = [ |
| 310 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 311 | SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0), |
| 312 | SemanticEvent( |
| 313 | EventType.ARG_VALUE_CHUNK, |
| 314 | '{"city": "NYC"}', |
| 315 | tool_index=0, |
| 316 | ), |
| 317 | SemanticEvent(EventType.TOOL_CALL_END, tool_index=0), |
| 318 | ] |
| 319 | delta = engine._events_to_delta(events) |
| 320 | assert delta is not None |
| 321 | assert len(delta.tool_calls) == 1 |
| 322 | assert delta.tool_calls[0].id == "functions.get_weather:0" |
| 323 | |
| 324 | def test_multiple_arg_chunks_same_batch_coalesced(self): |
| 325 | """Multiple events for the same tool in one batch must produce |
| 326 | at most one DeltaToolCall per index.""" |
| 327 | engine = _make_engine() |
| 328 | events = [ |
| 329 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 330 | SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0), |
| 331 | SemanticEvent( |
| 332 | EventType.ARG_VALUE_CHUNK, |
| 333 | '{"city": ', |
| 334 | tool_index=0, |
| 335 | ), |
| 336 | SemanticEvent( |
| 337 | EventType.ARG_VALUE_CHUNK, |
| 338 | '"Tokyo"}', |
| 339 | tool_index=0, |
| 340 | ), |
| 341 | SemanticEvent(EventType.TOOL_CALL_END, tool_index=0), |
| 342 | ] |
| 343 | delta = engine._events_to_delta(events) |
| 344 | assert delta is not None |
| 345 | indices = [tc.index for tc in delta.tool_calls] |
| 346 | assert len(indices) == len(set(indices)), ( |
| 347 | f"Duplicate indices in tool_calls: {delta.tool_calls}" |
| 348 | ) |
| 349 | assert delta.tool_calls[0].function.name == "get_weather" |
| 350 | assert delta.tool_calls[0].id is not None |
| 351 | |
| 352 | |
| 353 | # ── TestCoalesceToolCallDeltas ────────────────────────────────────── |
| 354 | |
| 355 | |
| 356 | class TestCoalesceToolCallDeltas: |
| 357 | """Unit tests for ParserEngine._coalesce_tool_call_deltas().""" |
| 358 | |
| 359 | def test_no_duplicates_unchanged(self): |
| 360 | deltas = [ |
| 361 | DeltaToolCall( |
| 362 | index=0, |
| 363 | id="a", |
| 364 | type="function", |
| 365 | function=DeltaFunctionCall(name="f"), |
| 366 | ), |
| 367 | DeltaToolCall( |
| 368 | index=1, |
| 369 | function=DeltaFunctionCall(arguments="{}"), |
| 370 | ), |
| 371 | ] |
| 372 | result = ParserEngine._coalesce_tool_call_deltas(deltas) |
| 373 | assert len(result) == 2 |
| 374 | assert result[0].index == 0 |
| 375 | assert result[1].index == 1 |
| 376 | |
| 377 | def test_name_and_args_same_index_merged(self): |
| 378 | deltas = [ |
| 379 | DeltaToolCall( |
| 380 | index=0, |
| 381 | id="call_1", |
| 382 | type="function", |
| 383 | function=DeltaFunctionCall(name="get_weather"), |
| 384 | ), |
| 385 | DeltaToolCall( |
| 386 | index=0, |
| 387 | function=DeltaFunctionCall(arguments='{"city":'), |
| 388 | ), |
| 389 | DeltaToolCall( |
| 390 | index=0, |
| 391 | function=DeltaFunctionCall(arguments='"Tokyo"}'), |
| 392 | ), |
| 393 | ] |
| 394 | result = ParserEngine._coalesce_tool_call_deltas(deltas) |
| 395 | assert len(result) == 1 |
| 396 | assert result[0].index == 0 |
| 397 | assert result[0].id == "call_1" |
| 398 | assert result[0].type == "function" |
| 399 | assert result[0].function.name == "get_weather" |
| 400 | assert result[0].function.arguments == '{"city":"Tokyo"}' |
| 401 | |
| 402 | def test_empty_list(self): |
| 403 | assert ParserEngine._coalesce_tool_call_deltas([]) == [] |
| 404 | |
| 405 | def test_single_element(self): |
| 406 | tc = DeltaToolCall( |
| 407 | index=0, |
| 408 | function=DeltaFunctionCall(name="f"), |
| 409 | ) |
| 410 | result = ParserEngine._coalesce_tool_call_deltas([tc]) |
| 411 | assert result == [tc] |
| 412 | |
| 413 | def test_partial_duplicates(self): |
| 414 | deltas = [ |
| 415 | DeltaToolCall( |
| 416 | index=0, |
| 417 | id="a", |
| 418 | type="function", |
| 419 | function=DeltaFunctionCall(name="f1"), |
| 420 | ), |
| 421 | DeltaToolCall( |
| 422 | index=1, |
| 423 | id="b", |
| 424 | type="function", |
| 425 | function=DeltaFunctionCall(name="f2"), |
| 426 | ), |
| 427 | DeltaToolCall( |
| 428 | index=0, |
| 429 | function=DeltaFunctionCall(arguments='{"x":1}'), |
| 430 | ), |
| 431 | ] |
| 432 | result = ParserEngine._coalesce_tool_call_deltas(deltas) |
| 433 | assert len(result) == 2 |
| 434 | assert result[0].index == 0 |
| 435 | assert result[0].function.name == "f1" |
| 436 | assert result[0].function.arguments == '{"x":1}' |
| 437 | assert result[1].index == 1 |
| 438 | |
| 439 | def test_id_type_from_later_entry(self): |
| 440 | deltas = [ |
| 441 | DeltaToolCall( |
| 442 | index=0, |
| 443 | function=DeltaFunctionCall(arguments='{"a":1}'), |
| 444 | ), |
| 445 | DeltaToolCall( |
| 446 | index=0, |
| 447 | id="call_1", |
| 448 | type="function", |
| 449 | function=DeltaFunctionCall(name="f"), |
| 450 | ), |
| 451 | ] |
| 452 | result = ParserEngine._coalesce_tool_call_deltas(deltas) |
| 453 | assert len(result) == 1 |
| 454 | assert result[0].id == "call_1" |
| 455 | assert result[0].type == "function" |
| 456 | assert result[0].function.name == "f" |
| 457 | assert result[0].function.arguments == '{"a":1}' |
| 458 | |
| 459 | |
| 460 | # ── TestContentWhitespaceHandling ──────────────────────────────────── |
| 461 | |
| 462 | |
| 463 | class TestContentWhitespaceHandling: |
| 464 | """Unit tests for whitespace deferral / dropping in _events_to_delta.""" |
| 465 | |
| 466 | def test_whitespace_only_deferred_until_next_tick(self): |
| 467 | engine = _make_engine() |
| 468 | d1 = engine._events_to_delta( |
| 469 | [SemanticEvent(EventType.TEXT_CHUNK, " \n")], |
| 470 | ) |
| 471 | assert d1 is None |
| 472 | d2 = engine._events_to_delta( |
| 473 | [SemanticEvent(EventType.TEXT_CHUNK, "hello")], |
| 474 | ) |
| 475 | assert d2 is not None |
| 476 | assert d2.content == " \nhello" |
| 477 | |
| 478 | def test_whitespace_only_emitted_on_finished(self): |
| 479 | engine = _make_engine() |
| 480 | d = engine._events_to_delta( |
| 481 | [SemanticEvent(EventType.TEXT_CHUNK, " \n")], |
| 482 | finished=True, |
| 483 | ) |
| 484 | assert d is not None |
| 485 | assert d.content == " \n" |
| 486 | |
| 487 | def test_whitespace_dropped_before_tool_call(self): |
| 488 | engine = _make_engine() |
| 489 | engine._events_to_delta( |
| 490 | [ |
| 491 | SemanticEvent(EventType.TEXT_CHUNK, " \n"), |
| 492 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 493 | ] |
| 494 | ) |
| 495 | d = engine._events_to_delta( |
| 496 | [ |
| 497 | SemanticEvent(EventType.TOOL_NAME, "f", tool_index=0), |
| 498 | SemanticEvent( |
| 499 | EventType.ARG_VALUE_CHUNK, |
| 500 | '{"a":1}', |
| 501 | tool_index=0, |
| 502 | ), |
| 503 | SemanticEvent(EventType.TOOL_CALL_END, tool_index=0), |
| 504 | ] |
| 505 | ) |
| 506 | assert d is not None |
| 507 | assert d.content is None |
| 508 | assert d.tool_calls |
| 509 | |
| 510 | def test_real_content_before_tool_preserved(self): |
| 511 | engine = _make_engine() |
| 512 | d = engine._events_to_delta( |
| 513 | [ |
| 514 | SemanticEvent(EventType.TEXT_CHUNK, "prefix"), |
| 515 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 516 | ] |
| 517 | ) |
| 518 | assert d is not None |
| 519 | assert d.content == "prefix" |
| 520 | |
| 521 | def test_whitespace_after_nonws_content_preserved(self): |
| 522 | engine = _make_engine() |
| 523 | engine._events_to_delta( |
| 524 | [SemanticEvent(EventType.TEXT_CHUNK, "hello")], |
| 525 | ) |
| 526 | d = engine._events_to_delta( |
| 527 | [SemanticEvent(EventType.TEXT_CHUNK, " \n")], |
| 528 | ) |
| 529 | assert d is not None |
| 530 | assert d.content == " \n" |
| 531 | |
| 532 | def test_whitespace_after_nonws_not_dropped_with_tools(self): |
| 533 | engine = _make_engine() |
| 534 | engine._events_to_delta( |
| 535 | [SemanticEvent(EventType.TEXT_CHUNK, "hello")], |
| 536 | ) |
| 537 | d = engine._events_to_delta( |
| 538 | [ |
| 539 | SemanticEvent(EventType.TEXT_CHUNK, " \n"), |
| 540 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 541 | ] |
| 542 | ) |
| 543 | assert d is not None |
| 544 | assert d.content == " \n" |
| 545 | |
| 546 | |
| 547 | # ── TestPostToolContentDeferral ────────────────────────────────────── |
| 548 | |
| 549 | |
| 550 | class TestPostToolContentDeferral: |
| 551 | """Regression: content after TOOL_CALL_END in the same batch must not |
| 552 | produce a mixed DeltaMessage(content=..., tool_calls=...) — that causes |
| 553 | split_delta to reorder content before tool_calls, breaking the Responses |
| 554 | API state machine.""" |
| 555 | |
| 556 | def test_text_after_tool_end_deferred(self): |
| 557 | engine = _make_engine() |
| 558 | events = [ |
| 559 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 560 | SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0), |
| 561 | SemanticEvent(EventType.ARG_VALUE_CHUNK, '{"city":"NYC"}', tool_index=0), |
| 562 | SemanticEvent(EventType.TOOL_CALL_END, tool_index=0), |
| 563 | SemanticEvent(EventType.TEXT_CHUNK, "\nHere is the result"), |
| 564 | ] |
| 565 | delta = engine._events_to_delta(events) |
| 566 | assert delta is not None |
| 567 | assert delta.tool_calls |
| 568 | assert delta.content is None |
| 569 | |
| 570 | deferred = engine._events_to_delta([]) |
| 571 | assert deferred is not None |
| 572 | assert deferred.content == "\nHere is the result" |
| 573 | assert not deferred.tool_calls |
| 574 | |
| 575 | def test_text_after_tool_deferred_even_when_finished(self): |
| 576 | engine = _make_engine() |
| 577 | events = [ |
| 578 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 579 | SemanticEvent(EventType.TOOL_NAME, "f", tool_index=0), |
| 580 | SemanticEvent(EventType.ARG_VALUE_CHUNK, "{}", tool_index=0), |
| 581 | SemanticEvent(EventType.TOOL_CALL_END, tool_index=0), |
| 582 | SemanticEvent(EventType.TEXT_CHUNK, "done"), |
| 583 | ] |
| 584 | delta = engine._events_to_delta(events, finished=True) |
| 585 | assert delta is not None |
| 586 | assert delta.tool_calls |
| 587 | assert delta.content is None |
| 588 | |
| 589 | def test_text_before_tool_not_deferred(self): |
| 590 | engine = _make_engine() |
| 591 | engine._content_has_nonws = True |
| 592 | events = [ |
| 593 | SemanticEvent(EventType.TEXT_CHUNK, "hello"), |
| 594 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 595 | SemanticEvent(EventType.TOOL_NAME, "f", tool_index=0), |
| 596 | SemanticEvent(EventType.ARG_VALUE_CHUNK, "{}", tool_index=0), |
| 597 | SemanticEvent(EventType.TOOL_CALL_END, tool_index=0), |
| 598 | ] |
| 599 | delta = engine._events_to_delta(events) |
| 600 | assert delta is not None |
| 601 | assert delta.content == "hello" |
| 602 | assert delta.tool_calls |
| 603 | |
| 604 | def test_deferred_content_not_flushed_during_arg_continuation(self): |
| 605 | """Deferred content from batch N must not mix with arg-continuation |
| 606 | tool events in batch N+1 — that creates a DeltaMessage with both |
| 607 | content and nameless tool_calls, which crashes the Responses API |
| 608 | state machine (name=None → Pydantic ValidationError).""" |
| 609 | engine = _make_engine() |
| 610 | engine._content_has_nonws = True |
| 611 | |
| 612 | batch1 = [ |
| 613 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 614 | SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0), |
| 615 | SemanticEvent(EventType.ARG_VALUE_CHUNK, '{"city":', tool_index=0), |
| 616 | SemanticEvent(EventType.TEXT_CHUNK, "\n"), |
| 617 | ] |
| 618 | delta1 = engine._events_to_delta(batch1) |
| 619 | assert delta1 is not None |
| 620 | assert delta1.tool_calls |
| 621 | assert delta1.content is None |
| 622 | |
| 623 | batch2 = [ |
| 624 | SemanticEvent(EventType.ARG_VALUE_CHUNK, '"NYC"}', tool_index=0), |
| 625 | ] |
| 626 | delta2 = engine._events_to_delta(batch2) |
| 627 | assert delta2 is not None |
| 628 | assert delta2.tool_calls |
| 629 | assert delta2.content is None |
| 630 | |
| 631 | flush = engine._events_to_delta([]) |
| 632 | assert flush is not None |
| 633 | assert flush.content == "\n" |
| 634 | assert not flush.tool_calls |
| 635 | |
| 636 | |
| 637 | # ── TestFixArgTypes ────────────────────────────────────────────────── |
| 638 | |
| 639 | |
| 640 | def _make_tool(name: str, properties: dict) -> ChatCompletionToolsParam: |
| 641 | return ChatCompletionToolsParam( |
| 642 | type="function", |
| 643 | function=FunctionDefinition( |
| 644 | name=name, |
| 645 | parameters={"type": "object", "properties": properties}, |
| 646 | ), |
| 647 | ) |
| 648 | |
| 649 | |
| 650 | class TestFixArgTypes: |
| 651 | """Tests for ParserEngine._fix_arg_types().""" |
| 652 | |
| 653 | def test_string_param_reverted_from_int(self): |
| 654 | tool = _make_tool("f", {"zipcode": {"type": "string"}}) |
| 655 | engine = _make_engine(tools=[tool]) |
| 656 | result = engine._fix_arg_types('{"zipcode": 12345}', "f") |
| 657 | assert '"zipcode": "12345"' in result |
| 658 | |
| 659 | def test_string_param_reverted_from_bool(self): |
| 660 | tool = _make_tool("f", {"flag": {"type": "string"}}) |
| 661 | engine = _make_engine(tools=[tool]) |
| 662 | result = engine._fix_arg_types('{"flag": true}', "f") |
| 663 | assert '"flag": "true"' in result |
| 664 | |
| 665 | def test_string_param_reverted_from_null(self): |
| 666 | tool = _make_tool("f", {"val": {"type": "string"}}) |
| 667 | engine = _make_engine(tools=[tool]) |
| 668 | result = engine._fix_arg_types('{"val": null}', "f") |
| 669 | assert '"val": "null"' in result |
| 670 | |
| 671 | def test_int_param_not_changed(self): |
| 672 | tool = _make_tool("f", {"count": {"type": "integer"}}) |
| 673 | engine = _make_engine(tools=[tool]) |
| 674 | result = engine._fix_arg_types('{"count": 42}', "f") |
| 675 | assert '"count": 42' in result |
| 676 | |
| 677 | def test_no_tools_returns_unchanged(self): |
| 678 | engine = _make_engine(tools=None) |
| 679 | original = '{"a": 1}' |
| 680 | assert engine._fix_arg_types(original, "f") == original |
| 681 | |
| 682 | def test_unknown_function_returns_unchanged(self): |
| 683 | tool = _make_tool("known", {"x": {"type": "string"}}) |
| 684 | engine = _make_engine(tools=[tool]) |
| 685 | original = '{"x": 1}' |
| 686 | assert engine._fix_arg_types(original, "unknown") == original |
| 687 | |
| 688 | def test_invalid_json_returns_unchanged(self): |
| 689 | tool = _make_tool("f", {"x": {"type": "string"}}) |
| 690 | engine = _make_engine(tools=[tool]) |
| 691 | original = "not json" |
| 692 | assert engine._fix_arg_types(original, "f") == original |
| 693 | |
| 694 | def test_string_value_not_touched(self): |
| 695 | tool = _make_tool("f", {"name": {"type": "string"}}) |
| 696 | engine = _make_engine(tools=[tool]) |
| 697 | original = '{"name": "Alice"}' |
| 698 | assert engine._fix_arg_types(original, "f") == original |
| 699 | |
| 700 | @pytest.mark.parametrize( |
| 701 | "properties, input_json, expected_substr", |
| 702 | [ |
| 703 | ({"count": {"type": "integer"}}, '{"count": "42"}', '"count": 42'), |
| 704 | ({"score": {"type": "number"}}, '{"score": "3.14"}', '"score": 3.14'), |
| 705 | ({"flag": {"type": "boolean"}}, '{"flag": "true"}', '"flag": true'), |
| 706 | ({"flag": {"type": "boolean"}}, '{"flag": "false"}', '"flag": false'), |
| 707 | ({"val": {"type": "null"}}, '{"val": "null"}', '"val": null'), |
| 708 | ({"val": {"type": ["string", "null"]}}, '{"val": "null"}', '"val": null'), |
| 709 | ({"score": {"type": "number"}}, '{"score": "108."}', '"score": 108'), |
| 710 | ], |
| 711 | ids=[ |
| 712 | "string_to_int", |
| 713 | "string_to_float", |
| 714 | "string_to_bool_true", |
| 715 | "string_to_bool_false", |
| 716 | "string_to_null", |
| 717 | "string_to_null_union", |
| 718 | "trailing_dot_float", |
| 719 | ], |
| 720 | ) |
| 721 | def test_string_coerced_to_schema_type( |
| 722 | self, |
| 723 | properties, |
| 724 | input_json, |
| 725 | expected_substr, |
| 726 | ): |
| 727 | tool = _make_tool("f", properties) |
| 728 | engine = _make_engine(tools=[tool]) |
| 729 | result = engine._fix_arg_types(input_json, "f") |
| 730 | assert expected_substr in result |
| 731 | |
| 732 | def test_mixed_types_coerced(self): |
| 733 | tool = _make_tool( |
| 734 | "f", |
| 735 | { |
| 736 | "count": {"type": "integer"}, |
| 737 | "active": {"type": "boolean"}, |
| 738 | "score": {"type": "number"}, |
| 739 | }, |
| 740 | ) |
| 741 | engine = _make_engine(tools=[tool]) |
| 742 | result = engine._fix_arg_types( |
| 743 | '{"count": "42", "active": "true", "score": "3.14"}', "f" |
| 744 | ) |
| 745 | parsed = json.loads(result) |
| 746 | assert parsed["count"] == 42 |
| 747 | assert parsed["active"] is True |
| 748 | assert parsed["score"] == 3.14 |
| 749 | |
| 750 | def test_nested_object_coercion(self): |
| 751 | tool = _make_tool( |
| 752 | "f", |
| 753 | { |
| 754 | "inner": { |
| 755 | "type": "object", |
| 756 | "properties": { |
| 757 | "count": {"type": "integer"}, |
| 758 | }, |
| 759 | }, |
| 760 | }, |
| 761 | ) |
| 762 | engine = _make_engine(tools=[tool]) |
| 763 | result = engine._fix_arg_types('{"inner": {"count": "42"}}', "f") |
| 764 | parsed = json.loads(result) |
| 765 | assert parsed["inner"]["count"] == 42 |
| 766 | |
| 767 | def test_array_item_coercion(self): |
| 768 | tool = _make_tool( |
| 769 | "f", |
| 770 | { |
| 771 | "nums": { |
| 772 | "type": "array", |
| 773 | "items": {"type": "integer"}, |
| 774 | }, |
| 775 | }, |
| 776 | ) |
| 777 | engine = _make_engine(tools=[tool]) |
| 778 | result = engine._fix_arg_types('{"nums": ["42", "5"]}', "f") |
| 779 | parsed = json.loads(result) |
| 780 | assert parsed["nums"] == [42, 5] |
| 781 | |
| 782 | def test_array_mixed_item_types(self): |
| 783 | tool = _make_tool( |
| 784 | "f", |
| 785 | { |
| 786 | "vals": { |
| 787 | "type": "array", |
| 788 | "items": {"type": "number"}, |
| 789 | }, |
| 790 | }, |
| 791 | ) |
| 792 | engine = _make_engine(tools=[tool]) |
| 793 | result = engine._fix_arg_types('{"vals": ["42", "3.14"]}', "f") |
| 794 | parsed = json.loads(result) |
| 795 | assert parsed["vals"] == [42, 3.14] |
| 796 | |
| 797 | |
| 798 | # ── TestBuildExtractedResult ───────────────────────────────────────── |
| 799 | |
| 800 | |
| 801 | class TestBuildExtractedResult: |
| 802 | """Tests for ParserEngine._build_extracted_result().""" |
| 803 | |
| 804 | def test_no_tool_calls(self): |
| 805 | engine = _make_engine() |
| 806 | result = engine._build_extracted_result() |
| 807 | assert result.tools_called is False |
| 808 | assert result.tool_calls == [] |
| 809 | |
| 810 | def test_single_tool_call(self): |
| 811 | engine = _make_engine(_hermes_config()) |
| 812 | text = '<tool_call>{"name": "f", "arguments": {"a": 1}}</tool_call>' |
| 813 | events = engine._engine.feed(text, []) |
| 814 | events.extend(engine._engine.finish()) |
| 815 | delta = engine._events_to_delta(events) |
| 816 | result = engine._build_extracted_result(delta) |
| 817 | assert result.tools_called is True |
| 818 | assert len(result.tool_calls) == 1 |
| 819 | assert result.tool_calls[0].function.name == "f" |
| 820 | |
| 821 | def test_content_passthrough(self): |
| 822 | engine = _make_engine(_hermes_config()) |
| 823 | text = "Hello world" |
| 824 | events = engine._engine.feed(text, []) |
| 825 | events.extend(engine._engine.finish()) |
| 826 | delta = engine._events_to_delta(events, finished=True) |
| 827 | result = engine._build_extracted_result(delta) |
| 828 | assert result.tools_called is False |
| 829 | assert result.content == "Hello world" |
| 830 | |
| 831 | |
| 832 | # ── TestEngineBasedPath ────────────────────────────────────────────── |
| 833 | |
| 834 | |
| 835 | class TestEngineBasedPath: |
| 836 | """Tests for the _engine_based accumulation behavior in |
| 837 | DelegatingParser.parse_delta.""" |
| 838 | |
| 839 | def test_engine_based_true_when_both_parsers_engine(self): |
| 840 | r = SimpleNamespace(engine_based_streaming=True) |
| 841 | t = SimpleNamespace(engine_based_streaming=True) |
| 842 | engine_based = r.engine_based_streaming and t.engine_based_streaming |
| 843 | assert engine_based is True |
| 844 | |
| 845 | def test_engine_based_false_when_reasoning_parser_not_engine(self): |
| 846 | r = SimpleNamespace(engine_based_streaming=False) |
| 847 | t = SimpleNamespace(engine_based_streaming=True) |
| 848 | engine_based = r.engine_based_streaming and t.engine_based_streaming |
| 849 | assert engine_based is False |
| 850 | |
| 851 | def test_parse_delta_streaming(self, mock_request): |
| 852 | """Engine's parse_delta returns content from streaming events.""" |
| 853 | engine = _make_engine(_hermes_config()) |
| 854 | engine._streaming_initialized = True |
| 855 | result = engine.parse_delta( |
| 856 | "Hello", |
| 857 | [], |
| 858 | mock_request, |
| 859 | finished=False, |
| 860 | ) |
| 861 | assert result is not None |
| 862 | assert result.content == "Hello" |
| 863 | |
| 864 | def test_parse_delta_tool_call(self, mock_request): |
| 865 | """Engine's parse_delta handles tool calls in streaming.""" |
| 866 | engine = _make_engine(_hermes_config()) |
| 867 | engine._streaming_initialized = True |
| 868 | result = engine.parse_delta( |
| 869 | '<tool_call>{"name": "f", "arguments": {}}</tool_call>', |
| 870 | [], |
| 871 | mock_request, |
| 872 | finished=True, |
| 873 | ) |
| 874 | assert result is not None |
| 875 | assert len(result.tool_calls) > 0 |
| 876 | |
| 877 | |
| 878 | # ── TestParseTokenIdPassthrough ──────────────────────────────────── |
| 879 | |
| 880 | |
| 881 | class TestParseTokenIdPassthrough: |
| 882 | """parse() must forward model_output_token_ids to _single_pass_parse |
| 883 | so that token-ID-based strict terminal matching is active.""" |
| 884 | |
| 885 | def test_literal_tool_tag_in_content_preserved_with_token_ids(self, mock_request): |
| 886 | engine = _make_engine(_hermes_config()) |
| 887 | text = ( |
| 888 | "Use <tool_call> to call tools." |
| 889 | '<tool_call>{"name": "f", "arguments": {"a": 1}}</tool_call>' |
| 890 | ) |
| 891 | token_ids = [ |
| 892 | 65, |
| 893 | 66, |
| 894 | 67, |
| 895 | 68, |
| 896 | 69, |
| 897 | 70, |
| 898 | 71, # "Use <tool_call> to call tools." |
| 899 | 202, # real <tool_call> |
| 900 | 72, |
| 901 | 73, |
| 902 | 74, # '{"name": "f", ...}' |
| 903 | 203, # real </tool_call> |
| 904 | ] |
| 905 | |
| 906 | _, content, tool_calls = engine.parse( |
| 907 | text, mock_request, model_output_token_ids=token_ids |
| 908 | ) |
| 909 | |
| 910 | assert content is not None |
| 911 | assert "<tool_call>" in content |
| 912 | assert content == "Use <tool_call> to call tools." |
| 913 | assert tool_calls is not None |
| 914 | assert len(tool_calls) == 1 |
| 915 | assert tool_calls[0].name == "f" |
| 916 | |
| 917 | def test_parse_with_token_ids_basic(self, mock_request): |
| 918 | engine = _make_engine(_hermes_config()) |
| 919 | text = '<tool_call>{"name": "h", "arguments": {"x": 1}}</tool_call>' |
| 920 | token_ids = [202, 65, 66, 67, 203] |
| 921 | |
| 922 | _, content, tool_calls = engine.parse( |
| 923 | text, mock_request, model_output_token_ids=token_ids |
| 924 | ) |
| 925 | |
| 926 | assert tool_calls is not None |
| 927 | assert len(tool_calls) == 1 |
| 928 | assert tool_calls[0].name == "h" |
| 929 | |
| 930 | def test_parse_without_token_ids_backward_compat(self, mock_request): |
| 931 | engine = _make_engine(_hermes_config()) |
| 932 | text = '<tool_call>{"name": "g", "arguments": {}}</tool_call>' |
| 933 | |
| 934 | _, content, tool_calls = engine.parse(text, mock_request) |
| 935 | |
| 936 | assert tool_calls is not None |
| 937 | assert len(tool_calls) == 1 |
| 938 | assert tool_calls[0].name == "g" |
| 939 | |
| 940 | |
| 941 | # ── TestAdapterFinishOnStreamEnd ──────────────────────────────────── |
| 942 | |
| 943 | |
| 944 | class _CombinedTestEngine(ParserEngine): |
| 945 | def __init__(self, tokenizer, tools=None, **kwargs): |
| 946 | super().__init__( |
| 947 | tokenizer, tools, parser_engine_config=_combined_config(), **kwargs |
| 948 | ) |
| 949 | |
| 950 | |
| 951 | _CombinedReasoningAdapter, _CombinedToolAdapter = make_adapters(_CombinedTestEngine) |
| 952 | |
| 953 | |
| 954 | class _CombinedDelegating(DelegatingParser): |
| 955 | reasoning_parser_cls = _CombinedReasoningAdapter |
| 956 | tool_parser_cls = _CombinedToolAdapter |
| 957 | |
| 958 | |
| 959 | def test_parser_manager_preserves_shared_engine_adapters(monkeypatch): |
| 960 | monkeypatch.setattr( |
| 961 | ParserManager, |
| 962 | "get_reasoning_parser", |
| 963 | classmethod(lambda cls, name: _CombinedReasoningAdapter), |
| 964 | ) |
| 965 | monkeypatch.setattr( |
| 966 | ParserManager, |
| 967 | "get_tool_parser", |
| 968 | classmethod(lambda cls, name, enabled, model: _CombinedToolAdapter), |
| 969 | ) |
| 970 | |
| 971 | parser_cls = ParserManager.get_parser( |
| 972 | tool_parser_name="combined", |
| 973 | reasoning_parser_name="combined", |
| 974 | enable_auto_tools=True, |
| 975 | ) |
| 976 | |
| 977 | assert parser_cls is not None |
| 978 | assert issubclass(parser_cls, DelegatingParser) |
| 979 | parser = parser_cls(make_mock_tokenizer(_VOCAB)) |
| 980 | assert parser.reasoning_parser is not None |
| 981 | assert parser.tool_parser is not None |
| 982 | assert parser.reasoning_parser._parser_engine_cls is _CombinedTestEngine |
| 983 | assert parser.tool_parser._parser_engine_cls is _CombinedTestEngine |
| 984 | request = _make_delegating_request() |
| 985 | token_ids = [ord("a"), ord("b"), 201, ord("c")] |
| 986 | reasoning, content, _ = parser.parse( |
| 987 | "ab</think>c", |
| 988 | request, |
| 989 | model_output_token_ids=token_ids, |
| 990 | ) |
| 991 | assert reasoning == "ab" |
| 992 | assert content == "c" |
| 993 | assert parser.count_reasoning_tokens(token_ids) == 2 |
| 994 | |
| 995 | |
| 996 | def test_parser_manager_preserves_shared_engine_reasoning_wiring(): |
| 997 | parser_cls = ParserManager.get_parser( |
| 998 | tool_parser_name="qwen3_xml", |
| 999 | reasoning_parser_name="qwen3", |
| 1000 | enable_auto_tools=True, |
| 1001 | ) |
| 1002 | |
| 1003 | assert parser_cls is not None |
| 1004 | parser = parser_cls( |
| 1005 | make_mock_tokenizer(_VOCAB), |
| 1006 | chat_template_kwargs={"enable_thinking": False}, |
| 1007 | ) |
| 1008 | assert parser.reasoning_parser is not None |
| 1009 | assert parser.reasoning_parser._parser_engine.thinking_enabled is False |
| 1010 | |
| 1011 | |
| 1012 | def test_parser_manager_preserves_reasoning_only_adapter(monkeypatch): |
| 1013 | monkeypatch.setattr( |
| 1014 | ParserManager, |
| 1015 | "get_reasoning_parser", |
| 1016 | classmethod(lambda cls, name: _CombinedReasoningAdapter), |
| 1017 | ) |
| 1018 | monkeypatch.setattr( |
| 1019 | ParserManager, |
| 1020 | "get_tool_parser", |
| 1021 | classmethod(lambda cls, name, enabled, model: None), |
| 1022 | ) |
| 1023 | |
| 1024 | parser_cls = ParserManager.get_parser(reasoning_parser_name="combined") |
| 1025 | |
| 1026 | assert parser_cls is not None |
| 1027 | parser = parser_cls(make_mock_tokenizer(_VOCAB)) |
| 1028 | assert parser.reasoning_parser is not None |
| 1029 | assert parser.tool_parser is None |
| 1030 | reasoning, content, _ = parser.parse( |
| 1031 | 'ab</think><tool_call>{"name":"h","arguments":{}}</tool_call>', |
| 1032 | _make_delegating_request(), |
| 1033 | model_output_token_ids=[ord("a"), ord("b"), 201], |
| 1034 | ) |
| 1035 | assert reasoning == "ab" |
| 1036 | assert content == '<tool_call>{"name":"h","arguments":{}}</tool_call>' |
| 1037 | assert parser.count_reasoning_tokens([ord("a"), ord("b"), 201]) == 2 |
| 1038 | |
| 1039 | |
| 1040 | def test_parser_manager_preserves_tool_only_adapter(monkeypatch): |
| 1041 | monkeypatch.setattr( |
| 1042 | ParserManager, |
| 1043 | "get_reasoning_parser", |
| 1044 | classmethod(lambda cls, name: None), |
| 1045 | ) |
| 1046 | monkeypatch.setattr( |
| 1047 | ParserManager, |
| 1048 | "get_tool_parser", |
| 1049 | classmethod(lambda cls, name, enabled, model: _CombinedToolAdapter), |
| 1050 | ) |
| 1051 | |
| 1052 | parser_cls = ParserManager.get_parser( |
| 1053 | tool_parser_name="combined", enable_auto_tools=True |
| 1054 | ) |
| 1055 | |
| 1056 | assert parser_cls is not None |
| 1057 | parser = parser_cls(make_mock_tokenizer(_VOCAB)) |
| 1058 | assert parser.reasoning_parser is None |
| 1059 | assert parser.tool_parser is not None |
| 1060 | reasoning, content, tool_calls = parser.parse( |
| 1061 | "<think>ab</think>c", |
| 1062 | _make_delegating_request(), |
| 1063 | model_output_token_ids=[200, ord("a"), ord("b"), 201, ord("c")], |
| 1064 | ) |
| 1065 | assert reasoning is None |
| 1066 | assert content == "<think>ab</think>c" |
| 1067 | assert tool_calls == [] |
| 1068 | |
| 1069 | |
| 1070 | def test_reasoning_adapter_counts_after_final_non_streaming_parse(): |
| 1071 | parser = _CombinedReasoningAdapter(make_mock_tokenizer(_VOCAB)) |
| 1072 | request = _make_delegating_request() |
| 1073 | token_ids = [ord("a"), ord("b"), 201, ord("c")] |
| 1074 | |
| 1075 | parser.extract_reasoning_streaming( |
| 1076 | "", |
| 1077 | "ab</think>c", |
| 1078 | "ab</think>c", |
| 1079 | [], |
| 1080 | token_ids, |
| 1081 | token_ids, |
| 1082 | ) |
| 1083 | parser.extract_reasoning("ab</think>c", request) |
| 1084 | |
| 1085 | assert parser.count_reasoning_tokens(token_ids) == 2 |
| 1086 | |
| 1087 | |
| 1088 | def _make_delegating_request(): |
| 1089 | req = MagicMock(spec=ChatCompletionRequest) |
| 1090 | req.tools = [] |
| 1091 | req.tool_choice = "auto" |
| 1092 | req.include_reasoning = True |
| 1093 | return req |
| 1094 | |
| 1095 | |
| 1096 | class TestAdapterFinishOnStreamEnd: |
| 1097 | """Engine adapters must flush buffered text when streaming ends. |
| 1098 | |
| 1099 | When a DelegatingParser wraps engine adapters, the underlying |
| 1100 | StreamingParserEngine.finish() must be called on the last |
| 1101 | parse_delta(finished=True) so that lexer-buffered text (terminal |
| 1102 | prefixes) and scanner-deferred terminals are not silently lost. |
| 1103 | """ |
| 1104 | |
| 1105 | def test_lexer_buffer_flushed_on_finished(self): |
| 1106 | """Text buffered as a potential terminal prefix must be emitted |
| 1107 | as content when the stream ends.""" |
| 1108 | tokenizer = make_mock_tokenizer(_VOCAB) |
| 1109 | parser = _CombinedDelegating(tokenizer) |
| 1110 | request = _make_delegating_request() |
| 1111 | |
| 1112 | # Feed reasoning then content with a trailing '<' that looks like |
| 1113 | # the start of a terminal ('<think>' or '<tool_call>'). |
| 1114 | parser.parse_delta("</think>", [201], request, finished=False) |
| 1115 | delta = parser.parse_delta("Hello world<", [], request, finished=True) |
| 1116 | # The '<' must NOT be silently dropped. |
| 1117 | assert delta is not None |
| 1118 | assert delta.content is not None |
| 1119 | assert "<" in delta.content, ( |
| 1120 | "Trailing '<' lost: lexer buffer was not flushed on finish" |
| 1121 | ) |
| 1122 | |
| 1123 | def test_args_buffer_flushed_on_finished(self): |
| 1124 | """Pending arg buffer text must be emitted when stream ends |
| 1125 | mid-tool-call (closing brace held back in buffer).""" |
| 1126 | tokenizer = make_mock_tokenizer(_VOCAB) |
| 1127 | parser = _CombinedDelegating(tokenizer) |
| 1128 | request = _make_delegating_request() |
| 1129 | |
| 1130 | parser.parse_delta("</think>", [201], request, finished=False) |
| 1131 | parser.parse_delta("<tool_call>", [202], request, finished=False) |
| 1132 | parser.parse_delta('{"name": "f"}', [], request, finished=False) |
| 1133 | # The closing } is held back in args buffer, waiting for |
| 1134 | # a TOOL_END terminal. Stream ends without one — finish() |
| 1135 | # must flush the buffer. |
| 1136 | delta = parser.parse_delta("", [], request, finished=True) |
| 1137 | assert delta is not None, ( |
| 1138 | "Engine finish should produce a delta with flushed args/end" |
| 1139 | ) |
| 1140 | |
| 1141 | |
| 1142 | # ── TestReasoningOnlyDelegatingParser ───────────────────────────── |
| 1143 | |
| 1144 | |
| 1145 | class _ReasoningOnlyDelegating(DelegatingParser): |
| 1146 | """DelegatingParser with reasoning adapter but NO tool adapter.""" |
| 1147 | |
| 1148 | reasoning_parser_cls = _CombinedReasoningAdapter |
| 1149 | tool_parser_cls = None |
| 1150 | |
| 1151 | |
| 1152 | class TestReasoningOnlyEndTokenLeak: |
| 1153 | """When there is no tool parser, the content passthrough must not |
| 1154 | re-emit the end-of-reasoning marker (e.g. ``</think>``) as content. |
| 1155 | |
| 1156 | Regression test for the scenario where ``</think>`` arrives as a |
| 1157 | single-token delta: the engine correctly consumes it (emitting |
| 1158 | REASONING_END with no content), but the content passthrough |
| 1159 | fired because ``delta_message is None`` and reasoning had just ended. |
| 1160 | """ |
| 1161 | |
| 1162 | def test_think_end_not_leaked_as_content(self): |
| 1163 | tokenizer = make_mock_tokenizer(_VOCAB) |
| 1164 | parser = _ReasoningOnlyDelegating(tokenizer) |
| 1165 | request = _make_delegating_request() |
| 1166 | |
| 1167 | # Feed reasoning text. |
| 1168 | d1 = parser.parse_delta( |
| 1169 | "I am thinking", |
| 1170 | [], |
| 1171 | request, |
| 1172 | finished=False, |
| 1173 | ) |
| 1174 | assert d1 is not None |
| 1175 | assert d1.reasoning is not None |
| 1176 | assert d1.content is None |
| 1177 | |
| 1178 | # Feed </think> as a single-token delta. |
| 1179 | d2 = parser.parse_delta( |
| 1180 | "</think>", |
| 1181 | [201], |
| 1182 | request, |
| 1183 | finished=False, |
| 1184 | ) |
| 1185 | # The end-of-reasoning marker must NOT appear as content. |
| 1186 | if d2 is not None: |
| 1187 | assert d2.content is None, f"</think> leaked as content: {d2.content!r}" |
| 1188 | |
| 1189 | # Feed content after reasoning. |
| 1190 | d3 = parser.parse_delta( |
| 1191 | "\n\nHello!", |
| 1192 | [], |
| 1193 | request, |
| 1194 | finished=False, |
| 1195 | ) |
| 1196 | assert d3 is not None |
| 1197 | assert d3.content is not None |
| 1198 | assert "</think>" not in d3.content |
| 1199 | |
| 1200 | def test_streaming_content_matches_non_streaming(self): |
| 1201 | """Concatenated streaming content must match extract_reasoning.""" |
| 1202 | tokenizer = make_mock_tokenizer(_VOCAB) |
| 1203 | # No <think> in input: the combined config starts in REASONING |
| 1204 | # state, so all text before </think> is reasoning. |
| 1205 | full_text = "reasoning</think>\n\nHello!" |
| 1206 | |
| 1207 | # Non-streaming extraction. |
| 1208 | parser_ns = _ReasoningOnlyDelegating(tokenizer) |
| 1209 | request = _make_delegating_request() |
| 1210 | reasoning, content = parser_ns.extract_reasoning(full_text, request) |
| 1211 | assert reasoning == "reasoning" |
| 1212 | assert content == "\n\nHello!" |
| 1213 | |
| 1214 | # Streaming extraction — simulate per-token deltas. |
| 1215 | parser_s = _ReasoningOnlyDelegating(tokenizer) |
| 1216 | deltas = [ |
| 1217 | ("reasoning", []), |
| 1218 | ("</think>", [201]), |
| 1219 | ("\n\n", []), |
| 1220 | ("Hello!", []), |
| 1221 | ] |
| 1222 | content_parts: list[str] = [] |
| 1223 | for text, ids in deltas: |
| 1224 | dm = parser_s.parse_delta(text, ids, request, finished=False) |
| 1225 | if dm is not None and dm.content: |
| 1226 | content_parts.append(dm.content) |
| 1227 | dm = parser_s.parse_delta("", [], request, finished=True) |
| 1228 | if dm is not None and dm.content: |
| 1229 | content_parts.append(dm.content) |
| 1230 | |
| 1231 | streaming_content = "".join(content_parts) |
| 1232 | assert streaming_content == content, ( |
| 1233 | f"Streaming content {streaming_content!r} " |
| 1234 | f"does not match non-streaming {content!r}" |
| 1235 | ) |
| 1236 | |
| 1237 | def test_multi_token_delta_preserves_content_after_think_end(self): |
| 1238 | """Content after </think> in the same delta must not be lost.""" |
| 1239 | tokenizer = make_mock_tokenizer(_VOCAB) |
| 1240 | parser = _ReasoningOnlyDelegating(tokenizer) |
| 1241 | request = _make_delegating_request() |
| 1242 | |
| 1243 | # Feed reasoning text. |
| 1244 | d1 = parser.parse_delta( |
| 1245 | "thinking", |
| 1246 | [], |
| 1247 | request, |
| 1248 | finished=False, |
| 1249 | ) |
| 1250 | assert d1 is not None |
| 1251 | assert d1.reasoning is not None |
| 1252 | |
| 1253 | # Feed </think> and content in the same delta (e.g. speculative |
| 1254 | # decoding accepting multiple tokens at once). Token IDs must |
| 1255 | # cover all text so the scanner can split correctly. |
| 1256 | # chr(10)='\n', chr(72)='H', chr(105)='i', chr(33)='!' |
| 1257 | d2 = parser.parse_delta( |
| 1258 | "</think>\n\nHi!", |
| 1259 | [201, 10, 10, 72, 105, 33], |
| 1260 | request, |
| 1261 | finished=False, |
| 1262 | ) |
| 1263 | assert d2 is not None, "Content after </think> in multi-token delta was lost" |
| 1264 | assert d2.content is not None, ( |
| 1265 | "Content after </think> in multi-token delta was nullified" |
| 1266 | ) |
| 1267 | assert "</think>" not in d2.content |
| 1268 | assert "Hi!" in d2.content |
| 1269 | |
| 1270 | |
| 1271 | class TestSkipToolSpanForwarding: |
| 1272 | """Reasoning (skip_tool_parsing) pass: tool syntax is forwarded verbatim |
| 1273 | for the tool pass, and the lexical passthrough flag never goes stale. |
| 1274 | |
| 1275 | ``_combined_config`` defines ``</tool_call>`` only from ``TOOL_ARGS``; the |
| 1276 | skip pass stays in ``CONTENT``, so the closer arrives with no current-state |
| 1277 | transition. It must still be forwarded and must clear the span — otherwise |
| 1278 | ``_in_skipped_tool_span`` stays True for the rest of the request. This is the |
| 1279 | shape of every current engine grammar whose wrapper closer has no CONTENT |
| 1280 | transition (qwen3, deepseek, glm47_moe, nemotron_v3). |
| 1281 | """ |
| 1282 | |
| 1283 | _TOOL_VOCAB = {**_VOCAB, '{"a":1}': 300} |
| 1284 | _TOOL_CALL = '<tool_call>{"a":1}</tool_call>' |
| 1285 | _TOOL_IDS = [202, 300, 203] |
| 1286 | |
| 1287 | def _skip_engine(self): |
| 1288 | engine = _make_engine( |
| 1289 | vocab=self._TOOL_VOCAB, |
| 1290 | special_tokens=list(_VOCAB.keys()), |
| 1291 | ) |
| 1292 | engine._engine.skip_tool_parsing = True |
| 1293 | engine._engine.reset(initial_state=ParserState.CONTENT) |
| 1294 | return engine |
| 1295 | |
| 1296 | def test_tool_syntax_forwarded_and_span_cleared(self): |
| 1297 | engine = self._skip_engine() |
| 1298 | events = engine._engine.feed(self._TOOL_CALL, self._TOOL_IDS) |
| 1299 | forwarded = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK) |
| 1300 | assert forwarded == self._TOOL_CALL |
| 1301 | assert engine._engine._in_skipped_tool_span is False |
| 1302 | |
| 1303 | def test_span_not_stale_across_two_tool_calls(self): |
| 1304 | engine = self._skip_engine() |
| 1305 | engine._engine.feed(self._TOOL_CALL, self._TOOL_IDS) |
| 1306 | assert engine._engine._in_skipped_tool_span is False |
| 1307 | events = engine._engine.feed(self._TOOL_CALL, self._TOOL_IDS) |
| 1308 | forwarded = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK) |
| 1309 | assert forwarded == self._TOOL_CALL |
| 1310 | assert engine._engine._in_skipped_tool_span is False |
| 1311 | |
| 1312 | def test_content_after_transitionless_closer_observable(self): |
| 1313 | """Observable lifecycle contract (not just the private flag): after a |
| 1314 | tool closer with no CONTENT transition, a later *shared* block-ender |
| 1315 | that also closes text blocks must be consumed, not leaked as content. |
| 1316 | |
| 1317 | The config combines the two real patterns — a qwen3-style tool-only |
| 1318 | closer with no CONTENT transition (``</a>``) and an inkling-style token |
| 1319 | that closes both tool and text blocks (``</b>``). A stale span would |
| 1320 | route the trailing ``</b>`` down the forward path and leak it. |
| 1321 | """ |
| 1322 | ts, close_a, close_b = "<tc>", "</a>", "</b>" |
| 1323 | cfg = ParserEngineConfig( |
| 1324 | name="shared_closer_test", |
| 1325 | terminals={"TOOL_START": ts, "CLOSE_A": close_a, "CLOSE_B": close_b}, |
| 1326 | transitions={ |
| 1327 | (ParserState.CONTENT, "TOOL_START"): Transition( |
| 1328 | ParserState.TOOL_ARGS, (EventType.TOOL_CALL_START,) |
| 1329 | ), |
| 1330 | (ParserState.TOOL_ARGS, "CLOSE_A"): Transition( |
| 1331 | ParserState.CONTENT, (EventType.TOOL_CALL_END,) |
| 1332 | ), |
| 1333 | (ParserState.TOOL_ARGS, "CLOSE_B"): Transition( |
| 1334 | ParserState.CONTENT, (EventType.TOOL_CALL_END,) |
| 1335 | ), |
| 1336 | (ParserState.CONTENT, "CLOSE_B"): Transition(ParserState.CONTENT, ()), |
| 1337 | }, |
| 1338 | initial_state=ParserState.CONTENT, |
| 1339 | content_events={ |
| 1340 | ParserState.CONTENT: EventType.TEXT_CHUNK, |
| 1341 | ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK, |
| 1342 | }, |
| 1343 | ) |
| 1344 | engine = _make_engine(config=cfg, vocab={ts: 210, close_a: 211, close_b: 212}) |
| 1345 | engine._engine.skip_tool_parsing = True |
| 1346 | engine._engine.reset(initial_state=ParserState.CONTENT) |
| 1347 | |
| 1348 | events = engine._engine.feed(f"{ts}args{close_a}text{close_b}", []) |
| 1349 | content = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK) |
| 1350 | |
| 1351 | assert content == f"{ts}args{close_a}text" |
| 1352 | assert close_b not in content |
| 1353 | assert engine._engine._in_skipped_tool_span is False |
| 1354 | |
| 1355 | |
| 1356 | # ── TestToolAdapterForwardsKwargs ────────────────────────────────── |
| 1357 | |
| 1358 | |
| 1359 | class TestToolAdapterForwardsKwargs: |
| 1360 | """ParserEngineToolAdapter.__init__ must forward **kwargs to the |
| 1361 | parser engine class so chat_template_kwargs reach model parsers.""" |
| 1362 | |
| 1363 | @pytest.mark.parametrize( |
| 1364 | "enable_thinking,expected_state", |
| 1365 | [ |
| 1366 | (False, ParserState.CONTENT), |
| 1367 | (True, ParserState.REASONING), |
| 1368 | ], |
| 1369 | ) |
| 1370 | def test_kwargs_forwarded_to_parser_engine(self, enable_thinking, expected_state): |
| 1371 | from vllm.parser.qwen3 import Qwen3Parser |
| 1372 | |
| 1373 | vocab = {"<think>": 100, "</think>": 101} |
| 1374 | tokenizer = make_mock_tokenizer(vocab) |
| 1375 | |
| 1376 | _, ToolAdapter = make_adapters(Qwen3Parser) |
| 1377 | adapter = ToolAdapter( |
| 1378 | tokenizer, |
| 1379 | tools=None, |
| 1380 | chat_template_kwargs={"enable_thinking": enable_thinking}, |
| 1381 | ) |
| 1382 | engine = adapter._parser_engine |
| 1383 | assert engine.parser_engine_config.initial_state == expected_state |
| 1384 | |
| 1385 | |
| 1386 | # ── TestExtractContentIdsNoEmptyReturn ───────────────────────────── |
| 1387 | |
| 1388 | |
| 1389 | class TestExtractContentIdsNoEmptyReturn: |
| 1390 | """extract_content_ids must return input_ids (not []) when there is |
| 1391 | no THINK_END token ID and _reasoning_ended is True.""" |
| 1392 | |
| 1393 | _NO_THINK_CONFIG = ParserEngineConfig(name="no_think_end", token_id_terminals={}) |
| 1394 | |
| 1395 | @pytest.mark.parametrize("input_ids", [[1, 2, 3], []]) |
| 1396 | def test_returns_input_ids_without_think_end(self, input_ids): |
| 1397 | engine = _make_engine(self._NO_THINK_CONFIG) |
| 1398 | assert engine._reasoning_end_token_id is None |
| 1399 | engine._reasoning_ended = True |
| 1400 | assert engine.extract_content_ids(input_ids) == input_ids |
| 1401 | |
| 1402 | |
| 1403 | # ── TestValuePostprocessorRemoved ────────────────────────────────── |
| 1404 | |
| 1405 | |
| 1406 | class TestValuePostprocessorRemoved: |
| 1407 | """ParserEngineConfig no longer has a value_postprocessor field.""" |
| 1408 | |
| 1409 | def test_no_value_postprocessor_field(self): |
| 1410 | config = ParserEngineConfig(name="test") |
| 1411 | assert not hasattr(config, "value_postprocessor") |
| 1412 | |
| 1413 | def test_constructor_rejects_value_postprocessor(self): |
| 1414 | with pytest.raises(TypeError): |
| 1415 | ParserEngineConfig( |
| 1416 | name="test", |
| 1417 | value_postprocessor=lambda x: x, # type: ignore[call-arg] |
| 1418 | ) |
| 1419 | |
| 1420 | |
| 1421 | # ── TestArgDeltaWithConverter ───────────────────────────────────── |
| 1422 | |
| 1423 | |
| 1424 | _KV_RE = re.compile(r"(\w+)=(\S+)") |
| 1425 | |
| 1426 | |
| 1427 | def _kv_converter(raw_args: str, partial: bool) -> str: |
| 1428 | params: dict[str, str] = {} |
| 1429 | for m in _KV_RE.finditer(raw_args): |
| 1430 | params[m.group(1)] = m.group(2) |
| 1431 | return json.dumps(params, ensure_ascii=False) |
| 1432 | |
| 1433 | |
| 1434 | def _converter_config( |
| 1435 | converter=_kv_converter, |
| 1436 | name: str = "converter_test", |
| 1437 | ) -> ParserEngineConfig: |
| 1438 | return ParserEngineConfig( |
| 1439 | name=name, |
| 1440 | terminals={ |
| 1441 | "TOOL_START": "<tool_call>", |
| 1442 | "TOOL_END": "</tool_call>", |
| 1443 | }, |
| 1444 | token_id_terminals={ |
| 1445 | "TOOL_START": "<tool_call>", |
| 1446 | "TOOL_END": "</tool_call>", |
| 1447 | }, |
| 1448 | transitions={ |
| 1449 | (ParserState.CONTENT, "TOOL_START"): Transition( |
| 1450 | ParserState.TOOL_ARGS, |
| 1451 | (EventType.TOOL_CALL_START,), |
| 1452 | ), |
| 1453 | (ParserState.TOOL_ARGS, "TOOL_END"): Transition( |
| 1454 | ParserState.CONTENT, |
| 1455 | (EventType.TOOL_CALL_END,), |
| 1456 | ), |
| 1457 | }, |
| 1458 | content_events={ |
| 1459 | ParserState.CONTENT: EventType.TEXT_CHUNK, |
| 1460 | ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK, |
| 1461 | }, |
| 1462 | arg_converter=converter, |
| 1463 | stream_arg_deltas=True, |
| 1464 | ) |
| 1465 | |
| 1466 | |
| 1467 | def _collect_arg_deltas(deltas: list) -> str: |
| 1468 | parts: list[str] = [] |
| 1469 | for d in deltas: |
| 1470 | if d is None: |
| 1471 | continue |
| 1472 | for tc in d.tool_calls or []: |
| 1473 | if tc.function and tc.function.arguments: |
| 1474 | parts.append(tc.function.arguments) |
| 1475 | return "".join(parts) |
| 1476 | |
| 1477 | |
| 1478 | def _run_streaming_tool(engine, name: str, chunks: list[str]) -> dict: |
| 1479 | deltas = [] |
| 1480 | deltas.append( |
| 1481 | engine._events_to_delta( |
| 1482 | [SemanticEvent(EventType.TOOL_CALL_START, tool_index=0)] |
| 1483 | ) |
| 1484 | ) |
| 1485 | deltas.append( |
| 1486 | engine._events_to_delta( |
| 1487 | [SemanticEvent(EventType.TOOL_NAME, name, tool_index=0)] |
| 1488 | ) |
| 1489 | ) |
| 1490 | for chunk in chunks: |
| 1491 | deltas.append( |
| 1492 | engine._events_to_delta( |
| 1493 | [SemanticEvent(EventType.ARG_VALUE_CHUNK, chunk, tool_index=0)] |
| 1494 | ) |
| 1495 | ) |
| 1496 | deltas.append( |
| 1497 | engine._events_to_delta([SemanticEvent(EventType.TOOL_CALL_END, tool_index=0)]) |
| 1498 | ) |
| 1499 | return json.loads(_collect_arg_deltas(deltas)) |
| 1500 | |
| 1501 | |
| 1502 | class TestArgDeltaWithConverter: |
| 1503 | """Exercise _compute_arg_delta with arg_converter + stream_arg_deltas. |
| 1504 | |
| 1505 | The startswith guard on line 814 of parser_engine.py validates that |
| 1506 | converted JSON grows prefix-monotonically across streaming ticks. |
| 1507 | These tests exercise that path with a synthetic config. |
| 1508 | """ |
| 1509 | |
| 1510 | def test_streaming_arg_deltas_prefix_monotonic(self): |
| 1511 | engine = _make_engine(_converter_config()) |
| 1512 | deltas = [] |
| 1513 | |
| 1514 | deltas.append( |
| 1515 | engine._events_to_delta( |
| 1516 | [SemanticEvent(EventType.TOOL_CALL_START, tool_index=0)], |
| 1517 | ) |
| 1518 | ) |
| 1519 | deltas.append( |
| 1520 | engine._events_to_delta( |
| 1521 | [SemanticEvent(EventType.TOOL_NAME, "f", tool_index=0)], |
| 1522 | ) |
| 1523 | ) |
| 1524 | deltas.append( |
| 1525 | engine._events_to_delta( |
| 1526 | [ |
| 1527 | SemanticEvent( |
| 1528 | EventType.ARG_VALUE_CHUNK, |
| 1529 | "a=hello ", |
| 1530 | tool_index=0, |
| 1531 | ) |
| 1532 | ], |
| 1533 | ) |
| 1534 | ) |
| 1535 | deltas.append( |
| 1536 | engine._events_to_delta( |
| 1537 | [ |
| 1538 | SemanticEvent( |
| 1539 | EventType.ARG_VALUE_CHUNK, |
| 1540 | "b=world ", |
| 1541 | tool_index=0, |
| 1542 | ) |
| 1543 | ], |
| 1544 | ) |
| 1545 | ) |
| 1546 | deltas.append( |
| 1547 | engine._events_to_delta( |
| 1548 | [ |
| 1549 | SemanticEvent( |
| 1550 | EventType.ARG_VALUE_CHUNK, |
| 1551 | "c=ok", |
| 1552 | tool_index=0, |
| 1553 | ) |
| 1554 | ], |
| 1555 | ) |
| 1556 | ) |
| 1557 | deltas.append( |
| 1558 | engine._events_to_delta( |
| 1559 | [SemanticEvent(EventType.TOOL_CALL_END, tool_index=0)], |
| 1560 | ) |
| 1561 | ) |
| 1562 | |
| 1563 | all_args = _collect_arg_deltas(deltas) |
| 1564 | assert json.loads(all_args) == { |
| 1565 | "a": "hello", |
| 1566 | "b": "world", |
| 1567 | "c": "ok", |
| 1568 | } |
| 1569 | |
| 1570 | def test_streaming_arg_deltas_with_type_coercion(self): |
| 1571 | tool = _make_tool( |
| 1572 | "f", |
| 1573 | { |
| 1574 | "count": {"type": "integer"}, |
| 1575 | "name": {"type": "string"}, |
| 1576 | }, |
| 1577 | ) |
| 1578 | engine = _make_engine(_converter_config(), tools=[tool]) |
| 1579 | deltas = [] |
| 1580 | |
| 1581 | deltas.append( |
| 1582 | engine._events_to_delta( |
| 1583 | [SemanticEvent(EventType.TOOL_CALL_START, tool_index=0)], |
| 1584 | ) |
| 1585 | ) |
| 1586 | deltas.append( |
| 1587 | engine._events_to_delta( |
| 1588 | [SemanticEvent(EventType.TOOL_NAME, "f", tool_index=0)], |
| 1589 | ) |
| 1590 | ) |
| 1591 | deltas.append( |
| 1592 | engine._events_to_delta( |
| 1593 | [ |
| 1594 | SemanticEvent( |
| 1595 | EventType.ARG_VALUE_CHUNK, |
| 1596 | "count=5 ", |
| 1597 | tool_index=0, |
| 1598 | ) |
| 1599 | ], |
| 1600 | ) |
| 1601 | ) |
| 1602 | deltas.append( |
| 1603 | engine._events_to_delta( |
| 1604 | [ |
| 1605 | SemanticEvent( |
| 1606 | EventType.ARG_VALUE_CHUNK, |
| 1607 | "name=test", |
| 1608 | tool_index=0, |
| 1609 | ) |
| 1610 | ], |
| 1611 | ) |
| 1612 | ) |
| 1613 | deltas.append( |
| 1614 | engine._events_to_delta( |
| 1615 | [SemanticEvent(EventType.TOOL_CALL_END, tool_index=0)], |
| 1616 | ) |
| 1617 | ) |
| 1618 | |
| 1619 | all_args = _collect_arg_deltas(deltas) |
| 1620 | parsed = json.loads(all_args) |
| 1621 | assert parsed == {"count": 5, "name": "test"} |
| 1622 | assert isinstance(parsed["count"], int) |
| 1623 | |
| 1624 | def test_streamable_string_keys_cached_after_name_delta(self, monkeypatch): |
| 1625 | tool = _make_tool( |
| 1626 | "f", |
| 1627 | { |
| 1628 | "name": {"type": "string"}, |
| 1629 | "count": {"type": "integer"}, |
| 1630 | }, |
| 1631 | ) |
| 1632 | engine = _make_engine(_converter_config(), tools=[tool]) |
| 1633 | |
| 1634 | original = ParserEngine._streamable_string_keys |
| 1635 | calls: list[dict] = [] |
| 1636 | |
| 1637 | def wrapped(properties: dict) -> set[str] | None: |
| 1638 | calls.append(properties) |
| 1639 | return original(properties) |
| 1640 | |
| 1641 | monkeypatch.setattr( |
| 1642 | ParserEngine, |
| 1643 | "_streamable_string_keys", |
| 1644 | staticmethod(wrapped), |
| 1645 | ) |
| 1646 | |
| 1647 | _run_streaming_tool( |
| 1648 | engine, |
| 1649 | "f", |
| 1650 | ["name=alice ", "count=4", "2"], |
| 1651 | ) |
| 1652 | |
| 1653 | assert calls == [ |
| 1654 | { |
| 1655 | "name": {"type": "string"}, |
| 1656 | "count": {"type": "integer"}, |
| 1657 | } |
| 1658 | ] |
| 1659 | |
| 1660 | |
| 1661 | # ── TestSafeArgPrefix ──────────────────────────────────────────── |
| 1662 | |
| 1663 | |
| 1664 | class TestSafeArgPrefix: |
| 1665 | """Unit tests for ParserEngine._safe_arg_prefix.""" |
| 1666 | |
| 1667 | @pytest.mark.parametrize( |
| 1668 | "json_str, expected", |
| 1669 | [ |
| 1670 | ('{"a": 1}', '{"a": '), |
| 1671 | ('{"a": 1, "b": 2}', '{"a": 1, "b": '), |
| 1672 | ('{"a": "hello", "b": "world"}', '{"a": "hello", "b": "world'), |
| 1673 | ('{"obj": {"x": 1}, "b": 2}', '{"obj": {"x": 1}, "b": '), |
| 1674 | ('{"url": "http://x:80", "b": 1}', '{"url": "http://x:80", "b": '), |
| 1675 | ('{"a": 1', '{"a": '), |
| 1676 | ("{}", ""), |
| 1677 | ("{", ""), |
| 1678 | ("", ""), |
| 1679 | ('{"k":1}', '{"k":'), |
| 1680 | ('{"k": 1, "v":2}', '{"k": 1, "v":'), |
| 1681 | ('{"k":"value"}', '{"k":"value'), |
| 1682 | ('{"k":"unterminated', '{"k":"unterminated'), |
| 1683 | (r'{"k":"escaped \" quote"}', r'{"k":"escaped \" quote'), |
| 1684 | ], |
| 1685 | ) |
| 1686 | def test_safe_arg_prefix(self, json_str, expected): |
| 1687 | assert ParserEngine._safe_arg_prefix(json_str) == expected |
| 1688 | |
| 1689 | |
| 1690 | # ── Coercion instability regression tests ──────────────────────── |
| 1691 | |
| 1692 | |
| 1693 | def _growing_kv_converter(raw_args: str, partial: bool) -> str: |
| 1694 | """Converter that produces growing bare values (no delimiter).""" |
| 1695 | params: dict[str, str] = {} |
| 1696 | for part in raw_args.split(" "): |
| 1697 | if "=" in part: |
| 1698 | k, v = part.split("=", 1) |
| 1699 | params[k] = v |
| 1700 | return json.dumps(params, ensure_ascii=False) |
| 1701 | |
| 1702 | |
| 1703 | class TestCoercionInstabilityRegression: |
| 1704 | """Regression tests for _fix_arg_types coercion instability. |
| 1705 | |
| 1706 | These tests exercise scenarios where a trailing value's coercion |
| 1707 | status changes between ticks (e.g. "4" coerces to int but "4e" |
| 1708 | does not). Before the _safe_arg_prefix fix, these would violate |
| 1709 | the startswith prefix invariant and permanently drop deltas. |
| 1710 | """ |
| 1711 | |
| 1712 | def test_coercion_flip_does_not_corrupt_stream(self): |
| 1713 | tool = _make_tool( |
| 1714 | "f", |
| 1715 | { |
| 1716 | "count": {"type": "integer"}, |
| 1717 | "flag": {"type": "string"}, |
| 1718 | }, |
| 1719 | ) |
| 1720 | engine = _make_engine(_converter_config(), tools=[tool]) |
| 1721 | parsed = _run_streaming_tool( |
| 1722 | engine, |
| 1723 | "f", |
| 1724 | ["count=42 ", "flag=ok"], |
| 1725 | ) |
| 1726 | assert parsed == {"count": 42, "flag": "ok"} |
| 1727 | assert isinstance(parsed["count"], int) |
| 1728 | |
| 1729 | def test_bool_partial_value_coercion_is_safe(self): |
| 1730 | """Boolean value building char by char must not break prefix.""" |
| 1731 | tool = _make_tool( |
| 1732 | "f", |
| 1733 | { |
| 1734 | "name": {"type": "string"}, |
| 1735 | "flag": {"type": "boolean"}, |
| 1736 | }, |
| 1737 | ) |
| 1738 | cfg = _converter_config(_growing_kv_converter) |
| 1739 | engine = _make_engine(cfg, tools=[tool]) |
| 1740 | parsed = _run_streaming_tool( |
| 1741 | engine, |
| 1742 | "f", |
| 1743 | ["name=hello ", "flag=t", "r", "u", "e"], |
| 1744 | ) |
| 1745 | assert parsed == {"name": "hello", "flag": True} |
| 1746 | assert isinstance(parsed["flag"], bool) |
| 1747 | |
| 1748 | def test_int_partial_value_flip_is_safe(self): |
| 1749 | """Integer that becomes non-coercible must not break prefix. |
| 1750 | |
| 1751 | A dummy first arg is needed so the name emission consumes the |
| 1752 | first ARG_VALUE_CHUNK, ensuring _compute_arg_delta runs for the |
| 1753 | chunk where val="4" coerces to int 4. On the next chunk val |
| 1754 | grows to "4e" which is NOT a valid int, flipping the coercion. |
| 1755 | """ |
| 1756 | tool = _make_tool( |
| 1757 | "f", |
| 1758 | { |
| 1759 | "dummy": {"type": "string"}, |
| 1760 | "val": {"type": "integer"}, |
| 1761 | "extra": {"type": "string"}, |
| 1762 | }, |
| 1763 | ) |
| 1764 | cfg = _converter_config(_growing_kv_converter) |
| 1765 | engine = _make_engine(cfg, tools=[tool]) |
| 1766 | parsed = _run_streaming_tool( |
| 1767 | engine, |
| 1768 | "f", |
| 1769 | ["dummy=x ", "val=4", "e ", "extra=ok"], |
| 1770 | ) |
| 1771 | assert parsed["dummy"] == "x" |
| 1772 | assert parsed["val"] == "4e" |
| 1773 | assert isinstance(parsed["val"], str) |
| 1774 | assert parsed["extra"] == "ok" |
| 1775 | |
| 1776 | |
| 1777 | _DROP_VOCAB: dict[str, int] = { |
| 1778 | **_VOCAB, |
| 1779 | "<bos>": 204, |
| 1780 | "<eos>": 205, |
| 1781 | } |
| 1782 | |
| 1783 | |
| 1784 | class TestDropSpecialTokens: |
| 1785 | """Special token dropping via the __DROP__ terminal mechanism.""" |
| 1786 | |
| 1787 | def test_drops_special_token_by_id_from_content(self): |
| 1788 | """A special token (not a configured terminal) is dropped when |
| 1789 | it arrives as its actual token ID.""" |
| 1790 | config = ParserEngineConfig( |
| 1791 | name="drop_content_test", |
| 1792 | terminals={}, |
| 1793 | token_id_terminals={}, |
| 1794 | transitions={}, |
| 1795 | initial_state=ParserState.CONTENT, |
| 1796 | content_events={ParserState.CONTENT: EventType.TEXT_CHUNK}, |
| 1797 | ) |
| 1798 | engine = _make_engine( |
| 1799 | config=config, |
| 1800 | vocab=_DROP_VOCAB, |
| 1801 | special_tokens=list(_DROP_VOCAB.keys()), |
| 1802 | ) |
| 1803 | engine._engine.reset() |
| 1804 | events = engine._engine.feed("hello<bos>world", [72, 204, 73]) |
| 1805 | delta = engine._events_to_delta(events) |
| 1806 | assert delta is not None |
| 1807 | assert "<bos>" not in delta.content |
| 1808 | assert delta.content == "helloworld" |
| 1809 | |
| 1810 | def test_drops_special_token_by_id_from_reasoning(self): |
| 1811 | engine = _make_engine( |
| 1812 | vocab=_DROP_VOCAB, |
| 1813 | special_tokens=list(_DROP_VOCAB.keys()), |
| 1814 | ) |
| 1815 | engine._engine.reset() |
| 1816 | events = engine._engine.feed("thinking<eos>more", [72, 205, 73]) |
| 1817 | delta = engine._events_to_delta(events) |
| 1818 | assert delta is not None |
| 1819 | assert "<eos>" not in delta.reasoning |
| 1820 | assert delta.reasoning == "thinkingmore" |
| 1821 | |
| 1822 | def test_drops_via_text_fallback_when_no_token_ids(self): |
| 1823 | """When no token IDs are provided, text-based lexer catches |
| 1824 | drop tokens as a fallback.""" |
| 1825 | engine = _make_engine( |
| 1826 | vocab=_DROP_VOCAB, |
| 1827 | special_tokens=list(_DROP_VOCAB.keys()), |
| 1828 | ) |
| 1829 | engine._engine.reset() |
| 1830 | events = engine._engine.feed("hello<bos>world", []) |
| 1831 | delta = engine._events_to_delta(events) |
| 1832 | assert delta is not None |
| 1833 | assert "<bos>" not in delta.reasoning |
| 1834 | |
| 1835 | def test_regular_tokens_spelling_special_survive(self): |
| 1836 | """Regular tokens that spell out a drop-token string survive |
| 1837 | when token IDs prove they are not the special token.""" |
| 1838 | vocab = {**_DROP_VOCAB, "h": 72, "<": 73, "bos": 74, ">": 75, "w": 76} |
| 1839 | engine = _make_engine( |
| 1840 | vocab=vocab, |
| 1841 | special_tokens=list(_DROP_VOCAB.keys()), |
| 1842 | ) |
| 1843 | engine._engine.reset() |
| 1844 | # Feed regular token IDs (73, 74, 75) that spell <bos>, |
| 1845 | # not the special token ID 204. |
| 1846 | events = engine._engine.feed("h<bos>w", [72, 73, 74, 75, 76]) |
| 1847 | delta = engine._events_to_delta(events) |
| 1848 | assert delta is not None |
| 1849 | assert "<bos>" in delta.reasoning |
| 1850 | |
| 1851 | def test_configured_terminal_not_treated_as_drop(self): |
| 1852 | """Tokens already in config.terminals (like <think>) are handled |
| 1853 | by the state machine, not the drop mechanism.""" |
| 1854 | engine = _make_engine( |
| 1855 | vocab=_DROP_VOCAB, |
| 1856 | special_tokens=list(_DROP_VOCAB.keys()), |
| 1857 | ) |
| 1858 | # Start from CONTENT so <think> triggers REASONING_START |
| 1859 | engine._engine.reset(initial_state=ParserState.CONTENT) |
| 1860 | events = engine._engine.feed("<think>", [200]) |
| 1861 | has_reasoning_start = any(e.type == EventType.REASONING_START for e in events) |
| 1862 | assert has_reasoning_start |
| 1863 | |
| 1864 | def test_tool_name_not_affected_by_drops(self): |
| 1865 | engine = _make_engine( |
| 1866 | vocab=_DROP_VOCAB, |
| 1867 | special_tokens=list(_DROP_VOCAB.keys()), |
| 1868 | ) |
| 1869 | engine._engine.reset() |
| 1870 | events = [ |
| 1871 | SemanticEvent(EventType.TOOL_CALL_START, tool_index=0), |
| 1872 | SemanticEvent(EventType.TOOL_NAME, "get_weather", tool_index=0), |
| 1873 | SemanticEvent(EventType.ARG_VALUE_CHUNK, '{"city": "NYC"}', tool_index=0), |
| 1874 | SemanticEvent(EventType.TOOL_CALL_END, tool_index=0), |
| 1875 | ] |
| 1876 | delta = engine._events_to_delta(events) |
| 1877 | assert delta is not None |
| 1878 | assert delta.tool_calls is not None |
| 1879 | names = [ |
| 1880 | tc.function.name |
| 1881 | for tc in delta.tool_calls |
| 1882 | if tc.function and tc.function.name |
| 1883 | ] |
| 1884 | assert "get_weather" in names |
| 1885 | |
| 1886 | def test_adjacent_drop_tokens_by_id(self): |
| 1887 | engine = _make_engine( |
| 1888 | vocab=_DROP_VOCAB, |
| 1889 | special_tokens=list(_DROP_VOCAB.keys()), |
| 1890 | ) |
| 1891 | engine._engine.reset() |
| 1892 | events = engine._engine.feed("<bos><eos>", [204, 205]) |
| 1893 | delta = engine._events_to_delta(events) |
| 1894 | assert delta is None |
| 1895 | |
| 1896 | def test_no_special_tokens_means_no_drops(self): |
| 1897 | """Engine with no special tokens passes all text through.""" |
| 1898 | engine = _make_engine(special_tokens=[]) |
| 1899 | engine._engine.reset() |
| 1900 | events = engine._engine.feed("hello<bos>world", []) |
| 1901 | delta = engine._events_to_delta(events) |
| 1902 | assert delta is not None |
| 1903 | assert "<bos>" in delta.reasoning |
| 1904 | |
| 1905 | def test_drops_applied_with_skip_tool_parsing(self): |
| 1906 | """Drop tokens are always dropped, even with skip_tool_parsing. |
| 1907 | DROP_TERMINALs have no transitions by construction, so no parser |
| 1908 | pass can use them.""" |
| 1909 | for initial_state in (ParserState.REASONING, ParserState.CONTENT): |
| 1910 | engine = _make_engine( |
| 1911 | vocab=_DROP_VOCAB, |
| 1912 | special_tokens=list(_DROP_VOCAB.keys()), |
| 1913 | ) |
| 1914 | engine._engine.skip_tool_parsing = True |
| 1915 | engine._engine.reset(initial_state=initial_state) |
| 1916 | events = engine._engine.feed("hello<bos>world", [72, 204, 73]) |
| 1917 | delta = engine._events_to_delta(events) |
| 1918 | assert delta is not None |
| 1919 | output = (delta.reasoning or "") + (delta.content or "") |
| 1920 | assert "<bos>" not in output, f"<bos> leaked in state {initial_state}" |
| 1921 | |
| 1922 | def test_transitions_unaffected_by_drop_in_reasoning_with_skip_tool_parsing(self): |
| 1923 | """With skip_tool_parsing in REASONING state, drop tokens are |
| 1924 | removed but configured terminals still fire their transitions.""" |
| 1925 | engine = _make_engine( |
| 1926 | vocab=_DROP_VOCAB, |
| 1927 | special_tokens=list(_DROP_VOCAB.keys()), |
| 1928 | ) |
| 1929 | engine._engine.skip_tool_parsing = True |
| 1930 | engine._engine.reset() |
| 1931 | events = engine._engine.feed("thought<bos></think>answer", [72, 204, 201, 73]) |
| 1932 | types = [e.type for e in events] |
| 1933 | assert EventType.REASONING_CHUNK in types |
| 1934 | assert EventType.REASONING_END in types |
| 1935 | assert EventType.TEXT_CHUNK in types |
| 1936 | reasoning_text = "".join( |
| 1937 | e.value for e in events if e.type == EventType.REASONING_CHUNK |
| 1938 | ) |
| 1939 | assert "<bos>" not in reasoning_text |
| 1940 | |
| 1941 | def test_drops_in_tool_args_state(self): |
| 1942 | """Drop tokens in TOOL_ARGS state are silently discarded.""" |
| 1943 | vocab = {**_DROP_VOCAB, '{"a":1}': 300} |
| 1944 | engine = _make_engine( |
| 1945 | vocab=vocab, |
| 1946 | special_tokens=list(_DROP_VOCAB.keys()), |
| 1947 | ) |
| 1948 | engine._engine.reset(initial_state=ParserState.CONTENT) |
| 1949 | events = engine._engine.feed( |
| 1950 | '<tool_call>{"a":1}<bos></tool_call>', |
| 1951 | [202, 300, 204, 203], |
| 1952 | ) |
| 1953 | arg_chunks = [e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK] |
| 1954 | combined = "".join(arg_chunks) |
| 1955 | assert "<bos>" not in combined |
| 1956 | assert '{"a":1}' in combined |
| 1957 | |
| 1958 | def test_mixed_configured_and_drop_terminals(self): |
| 1959 | """Configured terminals trigger transitions while drop terminals |
| 1960 | are silently removed in the same stream.""" |
| 1961 | engine = _make_engine( |
| 1962 | vocab=_DROP_VOCAB, |
| 1963 | special_tokens=list(_DROP_VOCAB.keys()), |
| 1964 | ) |
| 1965 | engine._engine.reset() |
| 1966 | events = engine._engine.feed("thought<bos></think>answer", [72, 204, 201, 73]) |
| 1967 | types = [e.type for e in events] |
| 1968 | assert EventType.REASONING_CHUNK in types |
| 1969 | assert EventType.REASONING_END in types |
| 1970 | assert EventType.TEXT_CHUNK in types |
| 1971 | reasoning_text = "".join( |
| 1972 | e.value for e in events if e.type == EventType.REASONING_CHUNK |
| 1973 | ) |
| 1974 | assert "<bos>" not in reasoning_text |
| 1975 | |
| 1976 | |
| 1977 | # ── TestTruncatedToolOpenerStreamParity ────────────────────────────── |
| 1978 | |
| 1979 | |
| 1980 | class TestTruncatedToolOpenerStreamParity: |
| 1981 | """Regression tests for #47137: when generation terminates inside a |
| 1982 | ``<tool_call>`` opener that has not been promoted to a tool call |
| 1983 | (via ``max_tokens`` or a ``stop`` string), the non-streaming path |
| 1984 | must drop the incomplete markup, matching the streaming path.""" |
| 1985 | |
| 1986 | _QWEN3_VOCAB = { |
| 1987 | "<tool_call>": 100, |
| 1988 | "</tool_call>": 101, |
| 1989 | } |
| 1990 | |
| 1991 | def _make_parser(self): |
| 1992 | from vllm.parser.parser_manager import ParserManager |
| 1993 | |
| 1994 | parser_cls = ParserManager.get_parser( |
| 1995 | tool_parser_name="qwen3_coder", |
| 1996 | enable_auto_tools=True, |
| 1997 | ) |
| 1998 | tokenizer = make_mock_tokenizer(self._QWEN3_VOCAB) |
| 1999 | return parser_cls(tokenizer, []) |
| 2000 | |
| 2001 | def _stream_content(self, request, chunks: list[str]) -> str: |
| 2002 | parser = self._make_parser() |
| 2003 | content = "" |
| 2004 | for i, chunk in enumerate(chunks): |
| 2005 | delta_token_ids = [ |
| 2006 | tid for text, tid in self._QWEN3_VOCAB.items() if text in chunk |
| 2007 | ] |
| 2008 | delta = parser.parse_delta( |
| 2009 | chunk, |
| 2010 | delta_token_ids, |
| 2011 | request, |
| 2012 | prompt_token_ids=[1], |
| 2013 | finished=(i == len(chunks) - 1), |
| 2014 | ) |
| 2015 | if delta and delta.content: |
| 2016 | content += delta.content |
| 2017 | return content |
| 2018 | |
| 2019 | @pytest.mark.parametrize( |
| 2020 | "chunks", |
| 2021 | [ |
| 2022 | ["<tool_call>"], |
| 2023 | ["<tool_call>", "\n"], |
| 2024 | ["<tool_call>", "\n", "<"], |
| 2025 | ["<tool_call>", "\n", "<function"], |
| 2026 | ], |
| 2027 | ) |
| 2028 | def test_truncated_opener_dropped_in_both_paths(self, mock_request, chunks): |
| 2029 | """A cutoff inside the opener yields no content and no tool calls |
| 2030 | in both the non-streaming and streaming paths.""" |
| 2031 | text = "".join(chunks) |
| 2032 | |
| 2033 | _, content, tool_calls = self._make_parser().parse( |
| 2034 | text, mock_request, enable_auto_tools=True |
| 2035 | ) |
| 2036 | streamed = self._stream_content(mock_request, chunks) |
| 2037 | |
| 2038 | assert not tool_calls |
| 2039 | assert (content or "") == streamed == "" |
| 2040 | |
| 2041 | def test_content_before_truncated_opener_preserved(self, mock_request): |
| 2042 | """Only the incomplete markup is dropped; content generated before |
| 2043 | the opener is returned identically by both paths.""" |
| 2044 | chunks = ["Checking the weather. ", "<tool_call>", "\n", "<function"] |
| 2045 | text = "".join(chunks) |
| 2046 | |
| 2047 | _, content, tool_calls = self._make_parser().parse( |
| 2048 | text, mock_request, enable_auto_tools=True |
| 2049 | ) |
| 2050 | streamed = self._stream_content(mock_request, chunks) |
| 2051 | |
| 2052 | assert not tool_calls |
| 2053 | assert content == streamed == "Checking the weather. " |
| 2054 | |
| 2055 | def test_content_around_unpromoted_tool_block_stays_ordered(self, mock_request): |
| 2056 | """Text surrounding a complete-but-unparsable tool block keeps its |
| 2057 | original order in the non-streaming path, matching streaming.""" |
| 2058 | chunks = ["A ", "<tool_call>", "garbage", "</tool_call>", " B"] |
| 2059 | text = "".join(chunks) |
| 2060 | |
| 2061 | _, content, tool_calls = self._make_parser().parse( |
| 2062 | text, mock_request, enable_auto_tools=True |
| 2063 | ) |
| 2064 | streamed = self._stream_content(mock_request, chunks) |
| 2065 | |
| 2066 | assert not tool_calls |
| 2067 | assert content == streamed == "A B" |
| 2068 | |
| 2069 | def test_complete_tool_call_still_promoted(self, mock_request): |
| 2070 | """Sanity check: a complete tool call still parses in the |
| 2071 | non-streaming path after the truncation fix.""" |
| 2072 | text = ( |
| 2073 | "<tool_call>\n" |
| 2074 | "<function=get_weather>\n" |
| 2075 | "<parameter=city>Tokyo</parameter>\n" |
| 2076 | "</function>\n" |
| 2077 | "</tool_call>" |
| 2078 | ) |
| 2079 | _, content, tool_calls = self._make_parser().parse( |
| 2080 | text, mock_request, enable_auto_tools=True |
| 2081 | ) |
| 2082 | assert tool_calls is not None and len(tool_calls) == 1 |
| 2083 | assert tool_calls[0].name == "get_weather" |
| 2084 | assert not content |
| 2085 | |
| 2086 | |
| 2087 | class TestThinkMarkupWithoutReasoningParser: |
| 2088 | """With no reasoning parser configured, reasoning markup is plain |
| 2089 | content: engine-backed tool parsers must return it verbatim in both |
| 2090 | the non-streaming and streaming paths, not consume the markers or |
| 2091 | reclassify the block as reasoning and drop it.""" |
| 2092 | |
| 2093 | _VOCAB = { |
| 2094 | "<think>": 90, |
| 2095 | "</think>": 91, |
| 2096 | "<tool_call>": 100, |
| 2097 | "</tool_call>": 101, |
| 2098 | } |
| 2099 | |
| 2100 | # Two distinct failure modes are covered: qwen3_coder consumed a |
| 2101 | # stray </think> marker in CONTENT state (unbalanced markup), |
| 2102 | # deepseek_v4 re-entered REASONING on <think> and dropped the block |
| 2103 | # from non-streaming content entirely. |
| 2104 | _PARSERS = ["qwen3_coder", "deepseek_v4"] |
| 2105 | |
| 2106 | def _make_parser(self, tool_parser_name): |
| 2107 | from vllm.parser.parser_manager import ParserManager |
| 2108 | |
| 2109 | parser_cls = ParserManager.get_parser( |
| 2110 | tool_parser_name=tool_parser_name, |
| 2111 | enable_auto_tools=True, |
| 2112 | ) |
| 2113 | tokenizer = make_mock_tokenizer(self._VOCAB) |
| 2114 | return parser_cls(tokenizer, []) |
| 2115 | |
| 2116 | def _stream(self, tool_parser_name, request, chunks): |
| 2117 | parser = self._make_parser(tool_parser_name) |
| 2118 | content, reasoning = "", "" |
| 2119 | for i, chunk in enumerate(chunks): |
| 2120 | delta_token_ids = [ |
| 2121 | tid for text, tid in self._VOCAB.items() if text in chunk |
| 2122 | ] |
| 2123 | delta = parser.parse_delta( |
| 2124 | chunk, |
| 2125 | delta_token_ids, |
| 2126 | request, |
| 2127 | prompt_token_ids=[1], |
| 2128 | finished=(i == len(chunks) - 1), |
| 2129 | ) |
| 2130 | if delta and delta.content: |
| 2131 | content += delta.content |
| 2132 | if delta and delta.reasoning: |
| 2133 | reasoning += delta.reasoning |
| 2134 | return content, reasoning |
| 2135 | |
| 2136 | @pytest.mark.parametrize("tool_parser_name", _PARSERS) |
| 2137 | def test_think_block_passes_through_both_paths( |
| 2138 | self, mock_request, tool_parser_name |
| 2139 | ): |
| 2140 | chunks = ["<think>", "reasoning here", "</think>", "the answer"] |
| 2141 | text = "".join(chunks) |
| 2142 | |
| 2143 | reasoning, content, tool_calls = self._make_parser(tool_parser_name).parse( |
| 2144 | text, mock_request, enable_auto_tools=True |
| 2145 | ) |
| 2146 | streamed_content, streamed_reasoning = self._stream( |
| 2147 | tool_parser_name, mock_request, chunks |
| 2148 | ) |
| 2149 | |
| 2150 | assert not tool_calls |
| 2151 | assert reasoning is None |
| 2152 | assert streamed_reasoning == "" |
| 2153 | assert content == streamed_content == text |
| 2154 | |
| 2155 | def test_think_block_preserved_alongside_tool_call(self, mock_request): |
| 2156 | """The tools-called branch also returns the tool parser's content; |
| 2157 | a think block preceding a promoted tool call must survive in it.""" |
| 2158 | think = "<think>plan</think>" |
| 2159 | text = think + ( |
| 2160 | "<tool_call>\n" |
| 2161 | "<function=get_weather>\n" |
| 2162 | "<parameter=city>Tokyo</parameter>\n" |
| 2163 | "</function>\n" |
| 2164 | "</tool_call>" |
| 2165 | ) |
| 2166 | _, content, tool_calls = self._make_parser("qwen3_coder").parse( |
| 2167 | text, mock_request, enable_auto_tools=True |
| 2168 | ) |
| 2169 | assert tool_calls is not None and len(tool_calls) == 1 |
| 2170 | assert tool_calls[0].name == "get_weather" |
| 2171 | assert content == think |