main
py 2,171 lines 77.3 KB
Raw
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