Skip to content

Commit 7df4131

Browse files
committed
test(vllm): add Cases 4+5 — marker split across chunks + false-positive prefix (TDD, Option B state machine, #582)
1 parent baeb452 commit 7df4131

1 file changed

Lines changed: 156 additions & 0 deletions

File tree

backend/python/vllm/test.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,4 +436,160 @@ async def run():
436436
self.assertEqual(
437437
assembled, "Paris is the capital of France.",
438438
f"Content wrong or duplicated: {assembled!r}",
439+
)
440+
@unittest.expectedFailure
441+
def test_streaming_tool_parser_marker_split_across_chunks(self):
442+
"""
443+
Case 4 (TDD — Option B state machine, marker spans chunk boundary):
444+
445+
vLLM may yield "Some text <tool_" in one iteration and
446+
"call>\\n{...}\\n</tool_call>" in the next. The state machine must hold
447+
the incomplete prefix ("<tool_") in an uncertainty buffer rather than
448+
streaming it, since it could be the start of a marker. Once the full
449+
marker is confirmed in the next chunk, buffering activates.
450+
451+
Expected:
452+
- "Some text " streams through as intermediate content.
453+
- "<tool_" does NOT appear in any streamed intermediate content.
454+
- Final chunk carries the parsed tool_call (name "calc").
455+
"""
456+
import sys, os, asyncio
457+
from types import SimpleNamespace
458+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
459+
from backend import BackendServicer
460+
461+
def make_generate(chunks):
462+
async def gen(*a, **k):
463+
for t in chunks:
464+
yield SimpleNamespace(
465+
outputs=[SimpleNamespace(text=t, token_ids=[1], logprobs=None)],
466+
prompt_token_ids=[0],
467+
)
468+
return lambda *a, **k: gen()
469+
470+
call = SimpleNamespace(
471+
id="call_1",
472+
function=SimpleNamespace(name="calc", arguments='{"x": 1}'),
473+
)
474+
475+
class StateMachineParser:
476+
def __init__(self, tokenizer, tools=None):
477+
pass
478+
479+
def extract_tool_calls(self, c, request=None):
480+
return SimpleNamespace(tools_called=True, content="", tool_calls=[call])
481+
482+
def collect(servicer, req):
483+
async def run():
484+
return [r async for r in servicer._predict(req, None, streaming=True)]
485+
return asyncio.run(run())
486+
487+
# Marker "<tool_call>" is split: first chunk ends with "<tool_",
488+
# second chunk starts with "call>..."
489+
full_tool = '<tool_call>\n{"name": "calc", "arguments": {"x": 1}}\n</tool_call>'
490+
s = BackendServicer()
491+
s.reasoning_parser_cls = None
492+
s.tokenizer = None
493+
s.llm = SimpleNamespace(generate=make_generate([
494+
"Some text <tool_",
495+
"Some text " + full_tool,
496+
]))
497+
s.tool_parser_cls = StateMachineParser
498+
499+
tools_json = '[{"type":"function","function":{"name":"calc"}}]'
500+
req = backend_pb2.PredictOptions(Prompt="x", Tools=tools_json)
501+
replies = collect(s, req)
502+
503+
intermediate_content = [
504+
cd.content
505+
for r in replies[:-1]
506+
for cd in r.chat_deltas
507+
if cd.content
508+
]
509+
510+
# "Some text " must have streamed through before the marker was seen
511+
self.assertTrue(
512+
any("Some text" in c for c in intermediate_content),
513+
"Content before the marker was not streamed progressively.",
514+
)
515+
516+
# The marker prefix "<tool_" must never appear as streamed content
517+
self.assertFalse(
518+
any("<tool_" in c for c in intermediate_content),
519+
"Incomplete marker prefix was streamed before it was confirmed.",
520+
)
521+
522+
# Final chunk must carry the parsed tool_call
523+
names = [tc.name for r in replies for cd in r.chat_deltas for tc in cd.tool_calls]
524+
self.assertIn("calc", names, "Structured tool_call not present in final chunk.")
525+
526+
@unittest.expectedFailure
527+
def test_streaming_tool_parser_false_positive_marker(self):
528+
"""
529+
Case 5 (TDD — Option B state machine, false-positive marker prefix):
530+
531+
The model writes text that starts like a tool-call marker but is not one,
532+
e.g. "Let me use a <tool" followed by "box> to help." The state machine
533+
must flush the uncertainty buffer as content once the prefix is disconfirmed.
534+
535+
Expected:
536+
- All text streams through as content (no tool call).
537+
- Assembled content equals the full sentence exactly once (no duplication).
538+
- No tool_calls in the final reply.
539+
"""
540+
import sys, os, asyncio
541+
from types import SimpleNamespace
542+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
543+
from backend import BackendServicer
544+
545+
def make_generate(chunks):
546+
async def gen(*a, **k):
547+
for t in chunks:
548+
yield SimpleNamespace(
549+
outputs=[SimpleNamespace(text=t, token_ids=[1], logprobs=None)],
550+
prompt_token_ids=[0],
551+
)
552+
return lambda *a, **k: gen()
553+
554+
class StateMachineParser:
555+
def __init__(self, tokenizer, tools=None):
556+
pass
557+
558+
def extract_tool_calls(self, c, request=None):
559+
return SimpleNamespace(tools_called=False, content=c, tool_calls=[])
560+
561+
def collect(servicer, req):
562+
async def run():
563+
return [r async for r in servicer._predict(req, None, streaming=True)]
564+
return asyncio.run(run())
565+
566+
# "<tool" looks like the start of "<tool_call>" but completes as "<toolbox>"
567+
full_text = "Let me use a <toolbox> to help."
568+
s = BackendServicer()
569+
s.reasoning_parser_cls = None
570+
s.tokenizer = None
571+
s.llm = SimpleNamespace(generate=make_generate([
572+
"Let me use a <tool",
573+
full_text,
574+
]))
575+
s.tool_parser_cls = StateMachineParser
576+
577+
tools_json = '[{"type":"function","function":{"name":"calc"}}]'
578+
req = backend_pb2.PredictOptions(Prompt="x", Tools=tools_json)
579+
replies = collect(s, req)
580+
581+
# Full text must be assembled exactly once (uncertainty buffer flushed correctly)
582+
assembled = "".join(
583+
cd.content for r in replies for cd in r.chat_deltas if cd.content
584+
)
585+
self.assertEqual(
586+
assembled, full_text,
587+
f"False-positive marker mangled or duplicated the content: {assembled!r}",
588+
)
589+
590+
# No tool_calls must be present
591+
tool_calls = [tc for r in replies for cd in r.chat_deltas for tc in cd.tool_calls]
592+
self.assertEqual(
593+
len(tool_calls), 0,
594+
"Tool call emitted for plain-text response with false-positive marker.",
439595
)

0 commit comments

Comments
 (0)