main
py 663 lines 23.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """End-to-end tests for the streaming input feature in AsyncLLM.
4
5 These tests verify that:
6 1. Streaming inputs work correctly with bunched inputs (queued)
7 2. Streaming inputs work correctly with spaced out inputs
8 3. Outputs are equivalent whether inputs are bunched or spaced
9 4. Cancelling the output stream correctly aborts the session
10 5. Closing the input stream correctly signals completion
11 6. Queued inputs are cancelled when the session is aborted
12 """
13
14 import asyncio
15 from collections.abc import AsyncGenerator
16
17 import pytest
18 import pytest_asyncio
19
20 from vllm import SamplingParams
21 from vllm.engine.protocol import StreamingInput
22 from vllm.exceptions import VLLMValidationError
23 from vllm.outputs import RequestOutput
24 from vllm.platforms import current_platform
25 from vllm.sampling_params import RequestOutputKind
26 from vllm.utils.torch_utils import set_default_torch_num_threads
27 from vllm.v1.engine.async_llm import AsyncLLM
28
29 if not current_platform.is_cuda():
30 pytest.skip(reason="V1 currently only supported on CUDA.", allow_module_level=True)
31
32 # Use a small model that doesn't require authentication for fast tests
33 MODEL = "facebook/opt-125m"
34
35
36 @pytest_asyncio.fixture(scope="module", loop_scope="module")
37 async def engine():
38 """Create an AsyncLLM engine for the test.
39
40 Note: Using function scope because pytest_asyncio creates a new event loop
41 for each test, and the output_handler task gets cancelled between tests
42 with module scope.
43 """
44 from vllm.engine.arg_utils import AsyncEngineArgs
45
46 engine_args = AsyncEngineArgs(
47 model=MODEL, enforce_eager=True, gpu_memory_utilization=0.7
48 )
49 with set_default_torch_num_threads(1):
50 engine = AsyncLLM.from_engine_args(engine_args)
51 try:
52 yield engine
53 finally:
54 engine.shutdown()
55 await asyncio.sleep(0.1)
56
57
58 def get_sampling_params(max_tokens: int = 20) -> SamplingParams:
59 """Create sampling params for streaming input tests."""
60 return SamplingParams(
61 max_tokens=max_tokens,
62 ignore_eos=True,
63 output_kind=RequestOutputKind.DELTA,
64 temperature=0.0, # Deterministic for reproducibility
65 )
66
67
68 async def collect_outputs(
69 output_gen: AsyncGenerator[RequestOutput, None],
70 ) -> tuple[list[RequestOutput], str]:
71 """Collect all outputs from a generate call, return outputs and full text."""
72 outputs: list[RequestOutput] = []
73 full_text = ""
74 async for output in output_gen:
75 outputs.append(output)
76 if output.outputs and output.outputs[0].text:
77 full_text += output.outputs[0].text
78 return outputs, full_text
79
80
81 @pytest.mark.asyncio(loop_scope="module")
82 async def test_streaming_input_bunched(engine: AsyncLLM):
83 """Test streaming input where all inputs are sent at once (bunched/queued).
84
85 This tests the case where multiple inputs arrive before any completes.
86 The inputs should be queued and processed in sequence.
87 """
88 request_id = "test_bunched"
89 sampling_params = get_sampling_params(max_tokens=10)
90
91 # Create an input generator that yields all inputs quickly
92 async def bunched_input_generator() -> AsyncGenerator[StreamingInput, None]:
93 # Send multiple inputs rapidly - they should be queued
94 yield StreamingInput(prompt="Hello, my name is")
95 yield StreamingInput(prompt=" Alice and I like")
96 yield StreamingInput(prompt=" to code in Python")
97
98 outputs, full_text = await collect_outputs(
99 engine.generate(
100 bunched_input_generator(),
101 sampling_params,
102 request_id,
103 )
104 )
105
106 # Verify we got outputs
107 assert len(outputs) > 0, "Should have received outputs"
108
109 # Verify the final output is marked as finished
110 assert outputs[-1].finished, "Last output should be marked as finished"
111
112 # Verify intermediate outputs are not marked as finished
113 for output in outputs[:-1]:
114 assert not output.finished, "Intermediate outputs should not be finished"
115
116 # Verify we generated some text
117 assert len(full_text) > 0, "Should have generated text"
118 print(f"Bunched test generated: {full_text}")
119
120
121 @pytest.mark.asyncio(loop_scope="module")
122 async def test_streaming_input_spaced(engine: AsyncLLM):
123 """Test streaming input where inputs are spaced out.
124
125 This tests the case where each input completes processing before the
126 next one is sent. Each chunk should be prefilled, generate tokens,
127 then the next chunk should be processed.
128 """
129 request_id = "test_spaced"
130 sampling_params = get_sampling_params(max_tokens=10)
131
132 # Track when each input is sent
133 input_times: list[float] = []
134 outputs_per_chunk: list[int] = [0, 0, 0]
135 current_chunk = 0
136
137 async def spaced_input_generator() -> AsyncGenerator[StreamingInput, None]:
138 nonlocal current_chunk
139 import time
140
141 # First input
142 input_times.append(time.time())
143 yield StreamingInput(prompt="Hello, my name is")
144 current_chunk = 0
145
146 # Wait for some outputs to be generated
147 await asyncio.sleep(0.5)
148
149 # Second input
150 input_times.append(time.time())
151 current_chunk = 1
152 yield StreamingInput(prompt=" Alice and I like")
153
154 # Wait for some outputs
155 await asyncio.sleep(0.5)
156
157 # Third input
158 input_times.append(time.time())
159 current_chunk = 2
160 yield StreamingInput(prompt=" to code in Python")
161
162 outputs: list[RequestOutput] = []
163 full_text = ""
164
165 async for output in engine.generate(
166 spaced_input_generator(),
167 sampling_params,
168 request_id,
169 ):
170 outputs.append(output)
171 if output.outputs and output.outputs[0].text:
172 full_text += output.outputs[0].text
173 outputs_per_chunk[current_chunk] += 1
174
175 # Verify we got outputs
176 assert len(outputs) > 0, "Should have received outputs"
177
178 # Verify the final output is marked as finished
179 assert outputs[-1].finished, "Last output should be marked as finished"
180
181 # Verify we received outputs from multiple chunks
182 # (with spaced inputs, we should see outputs distributed across chunks)
183 chunks_with_outputs = sum(1 for c in outputs_per_chunk if c > 0)
184 assert chunks_with_outputs >= 1, "Should have outputs from at least one chunk"
185
186 print(f"Spaced test generated: {full_text}")
187 print(f"Outputs per chunk: {outputs_per_chunk}")
188
189
190 @pytest.mark.asyncio(loop_scope="module")
191 async def test_streaming_input_output_equivalence(engine: AsyncLLM):
192 """Test that bunched and spaced inputs produce equivalent outputs.
193
194 When the same prompts are provided either bunched or spaced,
195 the final concatenated output should be the same (with deterministic
196 sampling).
197 """
198 prompts = ["Hello, my name is", " Bob and I work", " at Anthropic"]
199 sampling_params = get_sampling_params(max_tokens=15)
200
201 # Test bunched inputs
202 async def bunched_gen() -> AsyncGenerator[StreamingInput, None]:
203 for prompt in prompts:
204 yield StreamingInput(prompt=prompt)
205
206 _, bunched_text = await collect_outputs(
207 engine.generate(bunched_gen(), sampling_params, "equiv_bunched")
208 )
209
210 # Test spaced inputs (same prompts, but with delays)
211 async def spaced_gen() -> AsyncGenerator[StreamingInput, None]:
212 for prompt in prompts:
213 yield StreamingInput(prompt=prompt)
214 await asyncio.sleep(0.3)
215
216 _, spaced_text = await collect_outputs(
217 engine.generate(spaced_gen(), sampling_params, "equiv_spaced")
218 )
219
220 # Both should produce the same output since we use temperature=0
221 assert bunched_text == spaced_text, (
222 f"Bunched and spaced should produce same output.\n"
223 f"Bunched: {bunched_text!r}\n"
224 f"Spaced: {spaced_text!r}"
225 )
226
227 print(f"Equivalence test passed. Generated: {bunched_text}")
228
229
230 @pytest.mark.asyncio(loop_scope="module")
231 async def test_streaming_input_cancel_output_stream(engine: AsyncLLM):
232 """Test that cancelling the output stream aborts the entire session.
233
234 When the consumer cancels iteration over the output generator,
235 the session should be aborted including any queued inputs.
236 """
237 request_id = "test_cancel_output"
238 sampling_params = get_sampling_params(max_tokens=1000)
239
240 input_completed = asyncio.Event()
241 input_task_cancelled = False
242
243 async def slow_input_generator() -> AsyncGenerator[StreamingInput, None]:
244 nonlocal input_task_cancelled
245 try:
246 yield StreamingInput(prompt="Tell me a very long story about")
247 yield StreamingInput(prompt=" a dragon and a knight")
248
249 # This should be cancelled before we get here
250 await asyncio.sleep(10)
251 yield StreamingInput(prompt=" who become friends")
252 input_completed.set()
253 except asyncio.CancelledError:
254 input_task_cancelled = True
255 raise
256
257 outputs_received = 0
258 output_gen = engine.generate(slow_input_generator(), sampling_params, request_id)
259
260 # Collect a few outputs then cancel
261 try:
262 async for output in output_gen:
263 outputs_received += 1
264 if outputs_received >= 5:
265 # Cancel by breaking out of the loop (generator will be GC'd)
266 break
267 finally:
268 # Explicitly close the generator to ensure cleanup
269 await output_gen.aclose()
270
271 # Give time for cleanup
272 await asyncio.sleep(0.5)
273
274 # Verify we got some outputs before cancelling
275 assert outputs_received >= 5, "Should have received outputs before cancel"
276
277 # Verify the input task was cancelled
278 assert input_task_cancelled, "Input task should have been cancelled"
279
280 # Verify the session is properly cleaned up
281 assert not engine.output_processor.has_unfinished_requests(), (
282 "Should have no unfinished requests after cancel"
283 )
284
285 print(f"Cancel test passed. Received {outputs_received} outputs before cancel")
286
287
288 @pytest.mark.asyncio(loop_scope="module")
289 async def test_streaming_input_close_signals_completion(engine: AsyncLLM):
290 """Test that closing the input stream signals completion.
291
292 When the input generator finishes (naturally or via return),
293 the session should complete with finished=True on the last output.
294 """
295 request_id = "test_close_completion"
296 sampling_params = get_sampling_params(max_tokens=15)
297
298 input_generator_finished = False
299
300 async def limited_input_generator() -> AsyncGenerator[StreamingInput, None]:
301 nonlocal input_generator_finished
302 yield StreamingInput(prompt="What is 2 + 2? The answer is")
303 # Generator finishes naturally here
304 input_generator_finished = True
305
306 outputs, _ = await collect_outputs(
307 engine.generate(limited_input_generator(), sampling_params, request_id)
308 )
309
310 # Verify the input generator completed
311 assert input_generator_finished, "Input generator should have finished"
312
313 # Verify we got a finished output
314 assert len(outputs) > 0, "Should have received outputs"
315 assert outputs[-1].finished, "Last output should be marked as finished"
316
317 # Verify the session is cleaned up
318 assert not engine.output_processor.has_unfinished_requests(), (
319 "Should have no unfinished requests"
320 )
321
322 print("Close completion test passed")
323
324
325 @pytest.mark.asyncio(loop_scope="module")
326 async def test_streaming_input_abort_queued_inputs(engine: AsyncLLM):
327 """Test that aborting the session cancels queued inputs.
328
329 When multiple inputs are queued and the session is aborted,
330 all pending inputs should be cancelled.
331 """
332 request_id = "test_abort_queued"
333 # Use large max_tokens to ensure we have time to queue inputs
334 sampling_params = get_sampling_params(max_tokens=2000)
335
336 inputs_sent = 0
337 input_cancelled = False
338
339 async def many_inputs_generator() -> AsyncGenerator[StreamingInput, None]:
340 nonlocal inputs_sent, input_cancelled
341 try:
342 # Send several inputs to fill the queue
343 for i in range(10):
344 yield StreamingInput(prompt=f" Part {i}: Tell me about the number {i}.")
345 inputs_sent += 1
346 # Small delay to interleave with output processing
347 await asyncio.sleep(0.05)
348 except asyncio.CancelledError:
349 input_cancelled = True
350 raise
351
352 outputs_received = 0
353 output_gen = engine.generate(many_inputs_generator(), sampling_params, request_id)
354
355 try:
356 async for output in output_gen:
357 outputs_received += 1
358 # Cancel after receiving some outputs
359 if outputs_received >= 10:
360 break
361 finally:
362 await output_gen.aclose()
363
364 # Give time for cleanup
365 await asyncio.sleep(0.5)
366
367 # Verify we received some outputs
368 assert outputs_received >= 10, "Should have received outputs before abort"
369
370 # Verify the input generator was cancelled OR finished naturally
371 # (it might finish naturally if all inputs were sent before cancel)
372 assert input_cancelled or inputs_sent == 10, (
373 f"Input generator should have been cancelled or completed. "
374 f"cancelled={input_cancelled}, inputs_sent={inputs_sent}"
375 )
376
377 # Verify the session is cleaned up
378 assert not engine.output_processor.has_unfinished_requests(), (
379 "Should have no unfinished requests after abort"
380 )
381
382 print(
383 f"Abort queued test passed. Sent {inputs_sent} inputs, "
384 f"received {outputs_received} outputs"
385 )
386
387
388 @pytest.mark.asyncio(loop_scope="module")
389 async def test_streaming_input_error_propagation(engine: AsyncLLM):
390 """Test that errors in the input generator are propagated to the caller."""
391 request_id = "test_error_propagation"
392 sampling_params = get_sampling_params(max_tokens=20)
393
394 class InputError(Exception):
395 pass
396
397 async def error_input_generator() -> AsyncGenerator[StreamingInput, None]:
398 yield StreamingInput(prompt="Start with this")
399 await asyncio.sleep(0.1)
400 raise InputError("Simulated input error")
401
402 # Note: The current implementation catches exceptions and puts them
403 # in the queue, so we should get the error when iterating outputs
404 with pytest.raises(InputError, match="Simulated input error"):
405 async for _ in engine.generate(
406 error_input_generator(), sampling_params, request_id
407 ):
408 pass
409
410 # Give time for cleanup
411 await asyncio.sleep(0.3)
412
413 # Verify the session is cleaned up
414 assert not engine.output_processor.has_unfinished_requests(), (
415 "Should have no unfinished requests after error"
416 )
417
418
419 @pytest.mark.asyncio(loop_scope="module")
420 async def test_streaming_input_multiple_concurrent_sessions(engine: AsyncLLM):
421 """Test multiple concurrent streaming input sessions.
422
423 Multiple streaming sessions should be able to run concurrently
424 without interfering with each other.
425 """
426 num_sessions = 3
427 results: list[tuple[str, str]] = []
428
429 async def run_session(session_id: int) -> tuple[str, str]:
430 request_id = f"test_concurrent_{session_id}"
431 sampling_params = get_sampling_params(max_tokens=10)
432
433 prompts = [f"Session {session_id}: Hello", f" world from session {session_id}"]
434
435 async def input_gen() -> AsyncGenerator[StreamingInput, None]:
436 for prompt in prompts:
437 yield StreamingInput(prompt=prompt)
438 await asyncio.sleep(0.1)
439
440 _, text = await collect_outputs(
441 engine.generate(input_gen(), sampling_params, request_id)
442 )
443 return request_id, text
444
445 # Run sessions concurrently
446 tasks = [asyncio.create_task(run_session(i)) for i in range(num_sessions)]
447 results = await asyncio.gather(*tasks)
448
449 # Verify all sessions completed
450 assert len(results) == num_sessions
451
452 for request_id, text in results:
453 assert len(text) > 0, f"Session {request_id} should have generated text"
454 print(f"{request_id}: {text}")
455
456 # Verify cleanup
457 assert not engine.output_processor.has_unfinished_requests()
458
459
460 @pytest.mark.asyncio(loop_scope="module")
461 async def test_streaming_input_per_chunk_sampling_params(engine: AsyncLLM):
462 """Test that per-chunk sampling params are respected.
463
464 Each StreamingInput can have its own sampling_params.
465 """
466 request_id = "test_per_chunk_params"
467 base_params = get_sampling_params(max_tokens=10)
468
469 async def variable_params_generator() -> AsyncGenerator[StreamingInput, None]:
470 # First chunk with base params
471 yield StreamingInput(prompt="Count to five:", sampling_params=base_params)
472
473 # Second chunk with different max_tokens
474 chunk_params = get_sampling_params(max_tokens=5)
475 yield StreamingInput(
476 prompt=" Now count backwards:", sampling_params=chunk_params
477 )
478
479 outputs, full_text = await collect_outputs(
480 engine.generate(variable_params_generator(), base_params, request_id)
481 )
482
483 assert len(outputs) > 0, "Should have received outputs"
484 assert outputs[-1].finished, "Last output should be finished"
485 assert len(full_text) > 0, "Should have generated text"
486
487 print(f"Per-chunk params test generated: {full_text}")
488
489
490 @pytest.mark.asyncio(loop_scope="module")
491 async def test_streaming_input_empty_generator(engine: AsyncLLM):
492 """Test behavior when the input generator yields nothing.
493
494 An empty generator should still produce a finished output.
495 """
496 request_id = "test_empty_generator"
497 sampling_params = get_sampling_params(max_tokens=10)
498
499 async def empty_generator() -> AsyncGenerator[StreamingInput, None]:
500 # Don't yield anything
501 return
502 yield # Make it a generator
503
504 outputs: list[RequestOutput] = []
505 async for output in engine.generate(empty_generator(), sampling_params, request_id):
506 outputs.append(output)
507
508 # Should still get a finished marker
509 assert len(outputs) >= 1, "Should receive at least one output"
510 assert outputs[-1].finished, "Should have a finished output"
511
512
513 @pytest.mark.asyncio(loop_scope="module")
514 async def test_streaming_input_single_chunk(engine: AsyncLLM):
515 """Test streaming input with a single chunk.
516
517 This is effectively the same as a regular non-streaming request,
518 but using the streaming input API.
519 """
520 request_id = "test_single_chunk"
521 sampling_params = get_sampling_params(max_tokens=15)
522
523 async def single_chunk_generator() -> AsyncGenerator[StreamingInput, None]:
524 yield StreamingInput(prompt="What color is the sky? The sky is")
525
526 outputs, full_text = await collect_outputs(
527 engine.generate(single_chunk_generator(), sampling_params, request_id)
528 )
529
530 assert len(outputs) > 0
531 assert outputs[-1].finished
532 assert "blue" in full_text.lower() or len(full_text) > 0
533
534 print(f"Single chunk test generated: {full_text}")
535
536
537 @pytest.mark.asyncio(loop_scope="module")
538 async def test_streaming_input_reuse_request_id(engine: AsyncLLM):
539 """Test that request IDs can be reused after a session completes."""
540 request_id = "test_reuse_id"
541 sampling_params = get_sampling_params(max_tokens=5)
542
543 # First session
544 async def gen1() -> AsyncGenerator[StreamingInput, None]:
545 yield StreamingInput(prompt="First session")
546
547 _, text1 = await collect_outputs(
548 engine.generate(gen1(), sampling_params, request_id)
549 )
550
551 # Second session with same ID
552 async def gen2() -> AsyncGenerator[StreamingInput, None]:
553 yield StreamingInput(prompt="Second session")
554
555 _, text2 = await collect_outputs(
556 engine.generate(gen2(), sampling_params, request_id)
557 )
558
559 assert len(text1) > 0
560 assert len(text2) > 0
561 assert not engine.output_processor.has_unfinished_requests()
562
563 print(f"Reuse ID test: session 1: {text1}, session 2: {text2}")
564
565
566 @pytest.mark.asyncio(loop_scope="module")
567 async def test_streaming_input_validation_errors(engine: AsyncLLM):
568 """Test that invalid configurations raise appropriate errors."""
569
570 async def dummy_generator() -> AsyncGenerator[StreamingInput, None]:
571 yield StreamingInput(prompt="test")
572
573 # Test n > 1 is rejected
574 with pytest.raises(
575 VLLMValidationError, match="Input streaming not currently supported"
576 ):
577 params_n2 = SamplingParams(max_tokens=10, n=2)
578 async for _ in engine.generate(dummy_generator(), params_n2, "test_n2"):
579 pass
580
581 # Test FINAL_ONLY is rejected
582 with pytest.raises(
583 VLLMValidationError, match="Input streaming not currently supported"
584 ):
585 params_final = SamplingParams(
586 max_tokens=10, output_kind=RequestOutputKind.FINAL_ONLY
587 )
588 async for _ in engine.generate(dummy_generator(), params_final, "test_final"):
589 pass
590
591 # Test stop strings are rejected
592 with pytest.raises(
593 VLLMValidationError, match="Input streaming not currently supported"
594 ):
595 params_stop = SamplingParams(max_tokens=10, stop=["stop"])
596 async for _ in engine.generate(dummy_generator(), params_stop, "test_stop"):
597 pass
598
599
600 @pytest.mark.asyncio(loop_scope="module")
601 async def test_streaming_input_delayed_generator_exit(engine: AsyncLLM):
602 """Test that output generator exits when input generator closes after outputs.
603
604 This tests the case where:
605 1. Multiple inputs are sent and fully processed
606 2. The engine has finished
607 3. The input generator doesn't exit until after the engine finishes
608 4. The output generator should exit properly once the input generator exits
609 """
610 request_id = "test_delayed_exit"
611 sampling_params = get_sampling_params(max_tokens=10)
612
613 engine_finished_event = asyncio.Event()
614 input_generator_exited = False
615 finish_count = 0
616
617 async def delayed_exit_input_generator() -> AsyncGenerator[StreamingInput, None]:
618 nonlocal input_generator_exited
619 # Send all inputs immediately
620 yield StreamingInput(prompt="Hello, my name is")
621 yield StreamingInput(prompt=" Alice")
622
623 # Wait until the engine has finished generating before exiting
624 await engine_finished_event.wait()
625
626 # Add a small delay to ensure we're testing the "delayed exit" case
627 await asyncio.sleep(0.1)
628 input_generator_exited = True
629
630 outputs: list[RequestOutput] = []
631 full_text = ""
632
633 async for output in engine.generate(
634 delayed_exit_input_generator(), sampling_params, request_id
635 ):
636 outputs.append(output)
637 if output.outputs and output.outputs[0].text:
638 full_text += output.outputs[0].text
639
640 # Signal when the engine finishes both input chunks (each gets a finish_reason)
641 # Note: output.finished will be False while input stream is open
642 if output.outputs and output.outputs[0].finish_reason is not None:
643 finish_count += 1
644 if finish_count == 2:
645 engine_finished_event.set()
646
647 # Verify the input generator exited properly
648 assert input_generator_exited, (
649 "Input generator should have exited after engine finished"
650 )
651
652 # Verify we got outputs
653 assert len(outputs) > 0, "Should have received outputs"
654
655 # Verify we generated some text
656 assert len(full_text) > 0, "Should have generated text"
657
658 # Verify the session is cleaned up
659 assert not engine.output_processor.has_unfinished_requests(), (
660 "Should have no unfinished requests"
661 )
662
663 print(f"Delayed exit test passed. Generated: {full_text}")