main
py 1,107 lines 34.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 import subprocess
6 import tempfile
7 import threading
8 from collections.abc import Generator
9 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10 from unittest.mock import AsyncMock, MagicMock, patch
11
12 import pytest
13
14 import vllm.envs as envs
15 from vllm.assets.audio import AudioAsset
16 from vllm.connections import HTTPConnection
17 from vllm.entrypoints.launchers.run_batch import (
18 BatchRequestOutput,
19 BatchTranscriptionRequest,
20 download_bytes_from_url,
21 make_transcription_wrapper,
22 upload_data,
23 )
24 from vllm.entrypoints.serve.engine.protocol import ErrorResponse
25 from vllm.exceptions import VLLMValidationError
26 from vllm.utils.mem_constants import MiB_bytes
27
28 CHAT_MODEL_NAME = "hmellor/tiny-random-LlamaForCausalLM"
29 EMBEDDING_MODEL_NAME = "intfloat/multilingual-e5-small"
30 RERANKER_MODEL_NAME = "BAAI/bge-reranker-v2-m3"
31 REASONING_MODEL_NAME = "Qwen/Qwen3-0.6B"
32 SPEECH_LARGE_MODEL_NAME = "openai/whisper-large-v3"
33 SPEECH_SMALL_MODEL_NAME = "openai/whisper-small"
34
35 INPUT_BATCH = "\n".join(
36 json.dumps(req)
37 for req in [
38 {
39 "custom_id": "request-1",
40 "method": "POST",
41 "url": "/v1/chat/completions",
42 "body": {
43 "model": CHAT_MODEL_NAME,
44 "messages": [
45 {
46 "role": "system",
47 "content": "You are a helpful assistant.",
48 },
49 {"role": "user", "content": "Hello world!"},
50 ],
51 "max_tokens": 1000,
52 },
53 },
54 {
55 "custom_id": "request-2",
56 "method": "POST",
57 "url": "/v1/chat/completions",
58 "body": {
59 "model": CHAT_MODEL_NAME,
60 "messages": [
61 {
62 "role": "system",
63 "content": "You are an unhelpful assistant.",
64 },
65 {"role": "user", "content": "Hello world!"},
66 ],
67 "max_tokens": 1000,
68 },
69 },
70 {
71 "custom_id": "request-3",
72 "method": "POST",
73 "url": "/v1/chat/completions",
74 "body": {
75 "model": "NonExistModel",
76 "messages": [
77 {
78 "role": "system",
79 "content": "You are an unhelpful assistant.",
80 },
81 {"role": "user", "content": "Hello world!"},
82 ],
83 "max_tokens": 1000,
84 },
85 },
86 {
87 "custom_id": "request-4",
88 "method": "POST",
89 "url": "/bad_url",
90 "body": {
91 "model": CHAT_MODEL_NAME,
92 "messages": [
93 {
94 "role": "system",
95 "content": "You are an unhelpful assistant.",
96 },
97 {"role": "user", "content": "Hello world!"},
98 ],
99 "max_tokens": 1000,
100 },
101 },
102 {
103 "custom_id": "request-5",
104 "method": "POST",
105 "url": "/v1/chat/completions",
106 "body": {
107 "stream": "True",
108 "model": CHAT_MODEL_NAME,
109 "messages": [
110 {
111 "role": "system",
112 "content": "You are an unhelpful assistant.",
113 },
114 {"role": "user", "content": "Hello world!"},
115 ],
116 "max_tokens": 1000,
117 },
118 },
119 ]
120 )
121
122 INVALID_INPUT_BATCH = "\n".join(
123 json.dumps(req)
124 for req in [
125 {
126 "invalid_field": "request-1",
127 "method": "POST",
128 "url": "/v1/chat/completions",
129 "body": {
130 "model": CHAT_MODEL_NAME,
131 "messages": [
132 {"role": "system", "content": "You are a helpful assistant."},
133 {"role": "user", "content": "Hello world!"},
134 ],
135 "max_tokens": 1000,
136 },
137 },
138 {
139 "custom_id": "request-2",
140 "method": "POST",
141 "url": "/v1/chat/completions",
142 "body": {
143 "model": CHAT_MODEL_NAME,
144 "messages": [
145 {"role": "system", "content": "You are an unhelpful assistant."},
146 {"role": "user", "content": "Hello world!"},
147 ],
148 "max_tokens": 1000,
149 },
150 },
151 ]
152 )
153
154 INPUT_EMBEDDING_BATCH = "\n".join(
155 json.dumps(req)
156 for req in [
157 {
158 "custom_id": "request-1",
159 "method": "POST",
160 "url": "/v1/embeddings",
161 "body": {
162 "model": EMBEDDING_MODEL_NAME,
163 "input": "You are a helpful assistant.",
164 },
165 },
166 {
167 "custom_id": "request-2",
168 "method": "POST",
169 "url": "/v1/embeddings",
170 "body": {
171 "model": EMBEDDING_MODEL_NAME,
172 "input": "You are an unhelpful assistant.",
173 },
174 },
175 {
176 "custom_id": "request-3",
177 "method": "POST",
178 "url": "/v1/embeddings",
179 "body": {
180 "model": EMBEDDING_MODEL_NAME,
181 "input": "Hello world!",
182 },
183 },
184 {
185 "custom_id": "request-4",
186 "method": "POST",
187 "url": "/v1/embeddings",
188 "body": {
189 "model": "NonExistModel",
190 "input": "Hello world!",
191 },
192 },
193 ]
194 )
195
196 _SCORE_RERANK_DOCUMENTS = [
197 "The capital of Brazil is Brasilia.",
198 "The capital of France is Paris.",
199 ]
200
201 INPUT_SCORE_BATCH = "\n".join(
202 json.dumps(req)
203 for req in [
204 {
205 "custom_id": "request-1",
206 "method": "POST",
207 "url": "/score",
208 "body": {
209 "model": RERANKER_MODEL_NAME,
210 "queries": "What is the capital of France?",
211 "documents": _SCORE_RERANK_DOCUMENTS,
212 },
213 },
214 {
215 "custom_id": "request-2",
216 "method": "POST",
217 "url": "/v1/score",
218 "body": {
219 "model": RERANKER_MODEL_NAME,
220 "queries": "What is the capital of France?",
221 "documents": _SCORE_RERANK_DOCUMENTS,
222 },
223 },
224 ]
225 )
226
227 INPUT_RERANK_BATCH = "\n".join(
228 json.dumps(req)
229 for req in [
230 {
231 "custom_id": "request-1",
232 "method": "POST",
233 "url": "/rerank",
234 "body": {
235 "model": RERANKER_MODEL_NAME,
236 "query": "What is the capital of France?",
237 "documents": _SCORE_RERANK_DOCUMENTS,
238 },
239 },
240 {
241 "custom_id": "request-2",
242 "method": "POST",
243 "url": "/v1/rerank",
244 "body": {
245 "model": RERANKER_MODEL_NAME,
246 "query": "What is the capital of France?",
247 "documents": _SCORE_RERANK_DOCUMENTS,
248 },
249 },
250 {
251 "custom_id": "request-2",
252 "method": "POST",
253 "url": "/v2/rerank",
254 "body": {
255 "model": RERANKER_MODEL_NAME,
256 "query": "What is the capital of France?",
257 "documents": _SCORE_RERANK_DOCUMENTS,
258 },
259 },
260 ]
261 )
262
263 INPUT_REASONING_BATCH = "\n".join(
264 json.dumps(req)
265 for req in [
266 {
267 "custom_id": "request-1",
268 "method": "POST",
269 "url": "/v1/chat/completions",
270 "body": {
271 "model": REASONING_MODEL_NAME,
272 "messages": [
273 {"role": "system", "content": "You are a helpful assistant."},
274 {"role": "user", "content": "Solve this math problem: 2+2=?"},
275 ],
276 },
277 },
278 {
279 "custom_id": "request-2",
280 "method": "POST",
281 "url": "/v1/chat/completions",
282 "body": {
283 "model": REASONING_MODEL_NAME,
284 "messages": [
285 {"role": "system", "content": "You are a helpful assistant."},
286 {"role": "user", "content": "What is the capital of France?"},
287 ],
288 },
289 },
290 ]
291 )
292
293 MINIMAL_WAV_BASE64 = "UklGRigAAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAAZGF0YQQAAAAAAP9/"
294 _EXACT_LIMIT_AUDIO = b"a" * MiB_bytes
295 _OVERSIZED_BASE64_AUDIO = "A" * (4 * ((MiB_bytes + 2) // 3))
296 _OVERSIZED_HTTP_AUDIO = b"b" * (MiB_bytes + 1)
297
298
299 class _BatchHTTPServer(ThreadingHTTPServer):
300 request_paths: list[str]
301
302
303 class _BatchHTTPHandler(BaseHTTPRequestHandler):
304 def do_GET(self) -> None:
305 server = self.server
306 assert isinstance(server, _BatchHTTPServer)
307 server.request_paths.append(self.path)
308
309 if self.path == "/exact-limit":
310 self._send_body(_EXACT_LIMIT_AUDIO)
311 return
312
313 if self.path == "/oversized-chunked":
314 self.send_response(200)
315 self.end_headers()
316 self._write_body(_OVERSIZED_HTTP_AUDIO)
317 return
318
319 self.send_error(404, "Unknown batch HTTP test path")
320
321 def _send_body(self, body: bytes) -> None:
322 self.send_response(200)
323 self.send_header("Content-Type", "application/octet-stream")
324 self.send_header("Content-Length", str(len(body)))
325 self.end_headers()
326 self._write_body(body)
327
328 def _write_body(self, body: bytes) -> None:
329 try:
330 for offset in range(0, len(body), 64 * 1024):
331 self.wfile.write(body[offset : offset + 64 * 1024])
332 except (BrokenPipeError, ConnectionResetError):
333 self.close_connection = True
334
335 def log_message(self, fmt: str, *args: object) -> None:
336 return
337
338
339 @pytest.fixture
340 def batch_http_server() -> Generator[_BatchHTTPServer, None, None]:
341 server = _BatchHTTPServer(("127.0.0.1", 0), _BatchHTTPHandler)
342 server.request_paths = []
343 thread = threading.Thread(target=server.serve_forever, daemon=True)
344 thread.start()
345 try:
346 yield server
347 finally:
348 server.shutdown()
349 thread.join()
350 server.server_close()
351
352
353 def _batch_http_url(server: _BatchHTTPServer, path: str) -> str:
354 return f"http://localhost:{server.server_port}{path}"
355
356
357 async def _close_async_connection(connection: HTTPConnection) -> None:
358 if connection._async_client is not None:
359 await connection._async_client.close()
360
361
362 INPUT_TRANSCRIPTION_BATCH = (
363 json.dumps(
364 {
365 "custom_id": "request-1",
366 "method": "POST",
367 "url": "/v1/audio/transcriptions",
368 "body": {
369 "model": SPEECH_LARGE_MODEL_NAME,
370 "file_url": f"data:audio/wav;base64,{MINIMAL_WAV_BASE64}",
371 "response_format": "json",
372 },
373 }
374 )
375 + "\n"
376 )
377
378 INPUT_TRANSCRIPTION_HTTP_BATCH = (
379 json.dumps(
380 {
381 "custom_id": "request-1",
382 "method": "POST",
383 "url": "/v1/audio/transcriptions",
384 "body": {
385 "model": SPEECH_LARGE_MODEL_NAME,
386 "file_url": AudioAsset("mary_had_lamb").url,
387 "language": "en",
388 "response_format": "json",
389 },
390 }
391 )
392 + "\n"
393 )
394
395 INPUT_TRANSLATION_BATCH = (
396 json.dumps(
397 {
398 "custom_id": "request-1",
399 "method": "POST",
400 "url": "/v1/audio/translations",
401 "body": {
402 "model": SPEECH_SMALL_MODEL_NAME,
403 "file_url": AudioAsset("mary_had_lamb").url,
404 "response_format": "text",
405 "language": "it",
406 "to_language": "en",
407 "temperature": 0.0,
408 },
409 }
410 )
411 + "\n"
412 )
413
414 WEATHER_TOOL = {
415 "type": "function",
416 "function": {
417 "name": "get_current_weather",
418 "description": "Get the current weather in a given location",
419 "parameters": {
420 "type": "object",
421 "properties": {
422 "location": {
423 "type": "string",
424 "description": "The city and state, e.g. San Francisco, CA",
425 },
426 "unit": {
427 "type": "string",
428 "enum": ["celsius", "fahrenheit"],
429 },
430 },
431 "required": ["location"],
432 },
433 },
434 }
435
436 INPUT_TOOL_CALLING_BATCH = json.dumps(
437 {
438 "custom_id": "request-1",
439 "method": "POST",
440 "url": "/v1/chat/completions",
441 "body": {
442 "model": REASONING_MODEL_NAME,
443 "messages": [
444 {"role": "user", "content": "What is the weather in San Francisco?"},
445 ],
446 "tools": [WEATHER_TOOL],
447 "tool_choice": "required",
448 "max_tokens": 1000,
449 },
450 }
451 )
452
453
454 def test_empty_file():
455 with (
456 tempfile.NamedTemporaryFile("w") as input_file,
457 tempfile.NamedTemporaryFile("r") as output_file,
458 ):
459 input_file.write("")
460 input_file.flush()
461 proc = subprocess.Popen(
462 [
463 "vllm",
464 "run-batch",
465 "-i",
466 input_file.name,
467 "-o",
468 output_file.name,
469 "--model",
470 EMBEDDING_MODEL_NAME,
471 ],
472 )
473 proc.communicate()
474 proc.wait()
475 assert proc.returncode == 0, f"{proc=}"
476
477 contents = output_file.read()
478 assert contents.strip() == ""
479
480
481 def test_completions():
482 with (
483 tempfile.NamedTemporaryFile("w") as input_file,
484 tempfile.NamedTemporaryFile("r") as output_file,
485 ):
486 input_file.write(INPUT_BATCH)
487 input_file.flush()
488 proc = subprocess.Popen(
489 [
490 "vllm",
491 "run-batch",
492 "-i",
493 input_file.name,
494 "-o",
495 output_file.name,
496 "--model",
497 CHAT_MODEL_NAME,
498 ],
499 )
500 proc.communicate()
501 proc.wait()
502 assert proc.returncode == 0, f"{proc=}"
503
504 contents = output_file.read()
505 for line in contents.strip().split("\n"):
506 # Ensure that the output format conforms to the openai api.
507 # Validation should throw if the schema is wrong.
508 BatchRequestOutput.model_validate_json(line)
509
510
511 def test_completions_invalid_input():
512 """Ensure that we fail when the input doesn't conform to the openai api."""
513 with (
514 tempfile.NamedTemporaryFile("w") as input_file,
515 tempfile.NamedTemporaryFile("r") as output_file,
516 ):
517 input_file.write(INVALID_INPUT_BATCH)
518 input_file.flush()
519 proc = subprocess.Popen(
520 [
521 "vllm",
522 "run-batch",
523 "-i",
524 input_file.name,
525 "-o",
526 output_file.name,
527 "--model",
528 CHAT_MODEL_NAME,
529 ],
530 )
531 proc.communicate()
532 proc.wait()
533 assert proc.returncode != 0, f"{proc=}"
534
535
536 def test_embeddings():
537 with (
538 tempfile.NamedTemporaryFile("w") as input_file,
539 tempfile.NamedTemporaryFile("r") as output_file,
540 ):
541 input_file.write(INPUT_EMBEDDING_BATCH)
542 input_file.flush()
543 proc = subprocess.Popen(
544 [
545 "vllm",
546 "run-batch",
547 "-i",
548 input_file.name,
549 "-o",
550 output_file.name,
551 "--model",
552 EMBEDDING_MODEL_NAME,
553 ],
554 )
555 proc.communicate()
556 proc.wait()
557 assert proc.returncode == 0, f"{proc=}"
558
559 contents = output_file.read()
560 for line in contents.strip().split("\n"):
561 # Ensure that the output format conforms to the openai api.
562 # Validation should throw if the schema is wrong.
563 BatchRequestOutput.model_validate_json(line)
564
565
566 @pytest.mark.parametrize("input_batch", [INPUT_SCORE_BATCH, INPUT_RERANK_BATCH])
567 def test_score(input_batch):
568 with (
569 tempfile.NamedTemporaryFile("w") as input_file,
570 tempfile.NamedTemporaryFile("r") as output_file,
571 ):
572 input_file.write(input_batch)
573 input_file.flush()
574 proc = subprocess.Popen(
575 [
576 "vllm",
577 "run-batch",
578 "-i",
579 input_file.name,
580 "-o",
581 output_file.name,
582 "--model",
583 RERANKER_MODEL_NAME,
584 ],
585 )
586 proc.communicate()
587 proc.wait()
588 assert proc.returncode == 0, f"{proc=}"
589
590 contents = output_file.read()
591 for line in contents.strip().split("\n"):
592 # Ensure that the output format conforms to the openai api.
593 # Validation should throw if the schema is wrong.
594 BatchRequestOutput.model_validate_json(line)
595
596 # Ensure that there is no error in the response.
597 line_dict = json.loads(line)
598 assert isinstance(line_dict, dict)
599 assert line_dict["error"] is None
600
601
602 def test_reasoning_parser():
603 """Test that reasoning_parser parameter works correctly in run_batch."""
604 with (
605 tempfile.NamedTemporaryFile("w") as input_file,
606 tempfile.NamedTemporaryFile("r") as output_file,
607 ):
608 input_file.write(INPUT_REASONING_BATCH)
609 input_file.flush()
610 proc = subprocess.Popen(
611 [
612 "vllm",
613 "run-batch",
614 "-i",
615 input_file.name,
616 "-o",
617 output_file.name,
618 "--model",
619 REASONING_MODEL_NAME,
620 "--reasoning-parser",
621 "qwen3",
622 ],
623 )
624 proc.communicate()
625 proc.wait()
626 assert proc.returncode == 0, f"{proc=}"
627
628 contents = output_file.read()
629 for line in contents.strip().split("\n"):
630 # Ensure that the output format conforms to the openai api.
631 # Validation should throw if the schema is wrong.
632 BatchRequestOutput.model_validate_json(line)
633
634 # Ensure that there is no error in the response.
635 line_dict = json.loads(line)
636 assert isinstance(line_dict, dict)
637 assert line_dict["error"] is None
638
639 # Check that reasoning is present and not empty
640 reasoning = line_dict["response"]["body"]["choices"][0]["message"][
641 "reasoning"
642 ]
643 assert reasoning is not None
644 assert len(reasoning) > 0
645
646
647 def test_transcription():
648 with (
649 tempfile.NamedTemporaryFile("w") as input_file,
650 tempfile.NamedTemporaryFile("r") as output_file,
651 ):
652 input_file.write(INPUT_TRANSCRIPTION_BATCH)
653 input_file.flush()
654 proc = subprocess.Popen(
655 [
656 "vllm",
657 "run-batch",
658 "-i",
659 input_file.name,
660 "-o",
661 output_file.name,
662 "--model",
663 SPEECH_LARGE_MODEL_NAME,
664 ],
665 )
666 proc.communicate()
667 proc.wait()
668 assert proc.returncode == 0, f"{proc=}"
669
670 contents = output_file.read()
671 print(f"\n\ncontents: {contents}\n\n")
672 for line in contents.strip().split("\n"):
673 BatchRequestOutput.model_validate_json(line)
674
675 line_dict = json.loads(line)
676 assert isinstance(line_dict, dict)
677 assert line_dict["error"] is None
678
679 response_body = line_dict["response"]["body"]
680 assert response_body is not None
681 assert "text" in response_body
682 assert "usage" in response_body
683
684
685 def test_transcription_http_url():
686 with (
687 tempfile.NamedTemporaryFile("w") as input_file,
688 tempfile.NamedTemporaryFile("r") as output_file,
689 ):
690 input_file.write(INPUT_TRANSCRIPTION_HTTP_BATCH)
691 input_file.flush()
692 proc = subprocess.Popen(
693 [
694 "vllm",
695 "run-batch",
696 "-i",
697 input_file.name,
698 "-o",
699 output_file.name,
700 "--model",
701 SPEECH_LARGE_MODEL_NAME,
702 ],
703 )
704 proc.communicate()
705 proc.wait()
706 assert proc.returncode == 0, f"{proc=}"
707
708 contents = output_file.read()
709 for line in contents.strip().split("\n"):
710 BatchRequestOutput.model_validate_json(line)
711
712 line_dict = json.loads(line)
713 assert isinstance(line_dict, dict)
714 assert line_dict["error"] is None
715
716 response_body = line_dict["response"]["body"]
717 assert response_body is not None
718 assert "text" in response_body
719 assert "usage" in response_body
720
721 transcription_text = response_body["text"]
722 assert "Mary had a little lamb" in transcription_text
723
724
725 def test_translation():
726 with (
727 tempfile.NamedTemporaryFile("w") as input_file,
728 tempfile.NamedTemporaryFile("r") as output_file,
729 ):
730 input_file.write(INPUT_TRANSLATION_BATCH)
731 input_file.flush()
732 proc = subprocess.Popen(
733 [
734 "vllm",
735 "run-batch",
736 "-i",
737 input_file.name,
738 "-o",
739 output_file.name,
740 "--model",
741 SPEECH_SMALL_MODEL_NAME,
742 ],
743 )
744 proc.communicate()
745 proc.wait()
746 assert proc.returncode == 0, f"{proc=}"
747
748 contents = output_file.read()
749 for line in contents.strip().split("\n"):
750 BatchRequestOutput.model_validate_json(line)
751
752 line_dict = json.loads(line)
753 assert isinstance(line_dict, dict)
754 assert line_dict["error"] is None
755
756 response_body = line_dict["response"]["body"]
757 assert response_body is not None
758 assert "text" in response_body
759
760 translation_text = response_body["text"]
761 translation_text_lower = str(translation_text).strip().lower()
762 assert "mary" in translation_text_lower or "lamb" in translation_text_lower
763
764
765 def test_tool_calling():
766 """Test that tool calling works correctly in run_batch.
767 Verifies that requests with tools return tool_calls in the response.
768 """
769 with (
770 tempfile.NamedTemporaryFile("w") as input_file,
771 tempfile.NamedTemporaryFile("r") as output_file,
772 ):
773 input_file.write(INPUT_TOOL_CALLING_BATCH)
774 input_file.flush()
775 proc = subprocess.Popen(
776 [
777 "vllm",
778 "run-batch",
779 "-i",
780 input_file.name,
781 "-o",
782 output_file.name,
783 "--model",
784 REASONING_MODEL_NAME,
785 "--enable-auto-tool-choice",
786 "--tool-call-parser",
787 "hermes",
788 ],
789 )
790 proc.communicate()
791 proc.wait()
792 assert proc.returncode == 0, f"{proc=}"
793
794 contents = output_file.read()
795 for line in contents.strip().split("\n"):
796 if not line.strip(): # Skip empty lines
797 continue
798 # Ensure that the output format conforms to the openai api.
799 # Validation should throw if the schema is wrong.
800 BatchRequestOutput.model_validate_json(line)
801
802 # Ensure that there is no error in the response.
803 line_dict = json.loads(line)
804 assert isinstance(line_dict, dict)
805 assert line_dict["error"] is None
806
807 # Check that tool_calls are present in the response
808 # With tool_choice="required", the model must call a tool
809 response_body = line_dict["response"]["body"]
810 assert response_body is not None
811 message = response_body["choices"][0]["message"]
812 assert "tool_calls" in message
813 tool_calls = message.get("tool_calls")
814 # With tool_choice="required", tool_calls must be present and non-empty
815 assert tool_calls is not None
816 assert isinstance(tool_calls, list)
817 assert len(tool_calls) > 0
818 # Verify tool_calls have the expected structure
819 for tool_call in tool_calls:
820 assert "id" in tool_call
821 assert "type" in tool_call
822 assert tool_call["type"] == "function"
823 assert "function" in tool_call
824 assert "name" in tool_call["function"]
825 assert "arguments" in tool_call["function"]
826 # Verify the tool name matches our tool definition
827 assert tool_call["function"]["name"] == "get_current_weather"
828
829
830 # ---------------------------------------------------------------------------
831 # Unit tests for download_bytes_from_url SSRF protection
832 # ---------------------------------------------------------------------------
833
834
835 def _make_aiohttp_mocks(response_data: bytes = b"fake-data", status: int = 200):
836 """Create mock objects that simulate aiohttp.ClientSession context managers."""
837
838 async def iter_chunked(chunk_size: int):
839 del chunk_size
840 yield response_data
841
842 mock_resp = MagicMock()
843 mock_resp.status = status
844 mock_resp.content_length = len(response_data)
845 mock_resp.content.iter_chunked = iter_chunked
846 mock_resp.read = AsyncMock(return_value=response_data)
847 mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
848 mock_resp.__aexit__ = AsyncMock(return_value=False)
849
850 mock_session = MagicMock()
851 mock_session.get = MagicMock(return_value=mock_resp)
852 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
853 mock_session.__aexit__ = AsyncMock(return_value=False)
854 return mock_session
855
856
857 @pytest.mark.asyncio
858 async def test_download_bytes_data_url_bypasses_domain_check():
859 """data: URLs must work regardless of the domain allowlist."""
860 data_url = f"data:audio/wav;base64,{MINIMAL_WAV_BASE64}"
861 result = await download_bytes_from_url(
862 data_url, allowed_media_domains=["example.com"]
863 )
864 assert isinstance(result, bytes)
865 assert len(result) > 0
866
867
868 @pytest.mark.asyncio
869 async def test_download_bytes_rejects_disallowed_domain():
870 """HTTP URLs whose hostname is not in the allowlist must be rejected."""
871 url = "https://evil.internal/secret"
872 with pytest.raises(VLLMValidationError, match="allowed domains") as exc_info:
873 await download_bytes_from_url(url, allowed_media_domains=["example.com"])
874 # URL validation failures carry structured metadata for the frontend.
875 assert exc_info.value.parameter == "url"
876 assert exc_info.value.value == "evil.internal"
877
878
879 @pytest.mark.asyncio
880 async def test_download_bytes_rejects_unsupported_scheme():
881 """Unsupported URL schemes are rejected with structured metadata."""
882 with pytest.raises(VLLMValidationError, match="Unsupported URL scheme") as exc_info:
883 await download_bytes_from_url("ftp://example.com/file")
884 assert exc_info.value.parameter == "url"
885 assert exc_info.value.value == "ftp"
886
887
888 @pytest.mark.asyncio
889 async def test_download_bytes_rejects_cloud_metadata_ip():
890 """Cloud metadata endpoints must be blocked when an allowlist is set."""
891 url = "http://169.254.169.254/latest/meta-data/"
892 with pytest.raises(VLLMValidationError, match="allowed domains"):
893 await download_bytes_from_url(url, allowed_media_domains=["example.com"])
894
895
896 @pytest.mark.asyncio
897 async def test_download_bytes_rejects_internal_ip():
898 """Private-range IPs must be blocked when an allowlist is set."""
899 for internal_url in [
900 "http://10.0.0.1/secret",
901 "http://192.168.1.1/admin",
902 "http://127.0.0.1:8080/internal",
903 ]:
904 with pytest.raises(VLLMValidationError, match="allowed domains"):
905 await download_bytes_from_url(
906 internal_url, allowed_media_domains=["example.com"]
907 )
908
909
910 @pytest.mark.asyncio
911 async def test_download_bytes_allows_permitted_domain():
912 """HTTP URLs whose hostname IS in the allowlist must be fetched."""
913 url = "https://example.com/audio.wav"
914 expected = b"audio-bytes"
915 mock_session = _make_aiohttp_mocks(expected)
916
917 with patch(
918 "vllm.entrypoints.launchers.run_batch.aiohttp.ClientSession",
919 return_value=mock_session,
920 ):
921 result = await download_bytes_from_url(
922 url, allowed_media_domains=["example.com"]
923 )
924 assert result == expected
925
926
927 @pytest.mark.asyncio
928 async def test_download_bytes_no_allowlist_permits_any_domain():
929 """Without an allowlist all HTTP URLs must be attempted (backward compat)."""
930 url = "https://any-domain.example.org/file.wav"
931 expected = b"some-data"
932 mock_session = _make_aiohttp_mocks(expected)
933
934 with patch(
935 "vllm.entrypoints.launchers.run_batch.aiohttp.ClientSession",
936 return_value=mock_session,
937 ):
938 result = await download_bytes_from_url(url, allowed_media_domains=None)
939 assert result == expected
940
941
942 @pytest.mark.asyncio
943 async def test_download_bytes_empty_allowlist_denies_all():
944 """An empty allowlist must deny all HTTP URLs (least privilege)."""
945 url = "https://any-domain.example.org/file.wav"
946 with pytest.raises(VLLMValidationError, match="allowed domains"):
947 await download_bytes_from_url(url, allowed_media_domains=[])
948
949
950 @pytest.mark.asyncio
951 async def test_download_bytes_unsupported_scheme():
952 """Unsupported URL schemes must be rejected regardless of allowlist."""
953 with pytest.raises(VLLMValidationError, match="Unsupported URL scheme"):
954 await download_bytes_from_url("ftp://example.com/file.wav")
955
956 with pytest.raises(VLLMValidationError, match="Unsupported URL scheme"):
957 await download_bytes_from_url(
958 "ftp://example.com/file.wav",
959 allowed_media_domains=["example.com"],
960 )
961
962
963 @pytest.mark.asyncio
964 async def test_download_bytes_backslash_bypass():
965 """Backslash-@ URL confusion must not bypass the allowlist.
966
967 urllib3.parse_url() and aiohttp/yarl disagree on backslash-before-@.
968 The fix normalizes through urllib3 before handing to aiohttp.
969 """
970 bypass_url = "http://allowed.example.com\\@evil.internal/secret"
971 with pytest.raises(VLLMValidationError, match="allowed domains"):
972 await download_bytes_from_url(
973 bypass_url, allowed_media_domains=["evil.internal"]
974 )
975
976
977 @pytest.mark.asyncio
978 async def test_transcription_wrapper_rejects_oversized_data_url_before_decode(
979 monkeypatch: pytest.MonkeyPatch,
980 ):
981 monkeypatch.setattr(envs, "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", 1)
982 handler = AsyncMock()
983 wrapped_handler = make_transcription_wrapper(is_translation=False)(handler)
984 request = BatchTranscriptionRequest.model_validate(
985 {
986 "model": SPEECH_LARGE_MODEL_NAME,
987 "file_url": f"data:audio/wav;base64,{_OVERSIZED_BASE64_AUDIO}",
988 "response_format": "json",
989 }
990 )
991
992 with patch("vllm.entrypoints.launchers.run_batch.base64.b64decode") as decode:
993 response = await wrapped_handler(request)
994
995 assert isinstance(response, ErrorResponse)
996 assert "Maximum file size exceeded" in response.error.message
997 decode.assert_not_called()
998 handler.assert_not_awaited()
999
1000
1001 @pytest.mark.asyncio
1002 async def test_download_bytes_allows_http_body_at_audio_limit(
1003 monkeypatch: pytest.MonkeyPatch,
1004 batch_http_server: _BatchHTTPServer,
1005 ):
1006 monkeypatch.setattr(envs, "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", 1)
1007 connection = HTTPConnection()
1008 monkeypatch.setattr(
1009 "vllm.entrypoints.launchers.run_batch.global_http_connection",
1010 connection,
1011 )
1012
1013 try:
1014 result = await download_bytes_from_url(
1015 _batch_http_url(batch_http_server, "/exact-limit")
1016 )
1017 assert result == _EXACT_LIMIT_AUDIO
1018 finally:
1019 await _close_async_connection(connection)
1020
1021
1022 @pytest.mark.asyncio
1023 async def test_transcription_wrapper_rejects_oversized_http_before_handler(
1024 monkeypatch: pytest.MonkeyPatch,
1025 batch_http_server: _BatchHTTPServer,
1026 ):
1027 monkeypatch.setattr(envs, "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", 1)
1028 connection = HTTPConnection()
1029 monkeypatch.setattr(
1030 "vllm.entrypoints.launchers.run_batch.global_http_connection",
1031 connection,
1032 )
1033 handler = AsyncMock()
1034 wrapped_handler = make_transcription_wrapper(is_translation=False)(handler)
1035 request = BatchTranscriptionRequest.model_validate(
1036 {
1037 "model": SPEECH_LARGE_MODEL_NAME,
1038 "file_url": _batch_http_url(batch_http_server, "/oversized-chunked"),
1039 "response_format": "json",
1040 }
1041 )
1042
1043 try:
1044 response = await wrapped_handler(request)
1045 finally:
1046 await _close_async_connection(connection)
1047
1048 assert isinstance(response, ErrorResponse)
1049 assert "Maximum file size exceeded" in response.error.message
1050 handler.assert_not_awaited()
1051
1052
1053 # ---------------------------------------------------------------------------
1054 # Unit tests for upload_data retry behavior
1055 # ---------------------------------------------------------------------------
1056
1057
1058 def _make_aiohttp_put_session(status: int = 200, body_text: str = ""):
1059 """Mock an aiohttp.ClientSession whose PUT returns the given status."""
1060 mock_resp = MagicMock()
1061 mock_resp.status = status
1062 mock_resp.text = AsyncMock(return_value=body_text)
1063 mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
1064 mock_resp.__aexit__ = AsyncMock(return_value=False)
1065
1066 mock_session = MagicMock()
1067 mock_session.put = MagicMock(return_value=mock_resp)
1068 mock_session.__aenter__ = AsyncMock(return_value=mock_session)
1069 mock_session.__aexit__ = AsyncMock(return_value=False)
1070 return mock_session
1071
1072
1073 @pytest.mark.asyncio
1074 async def test_upload_data_uploads_once_on_success():
1075 """A successful upload must not be retried (regression guard)."""
1076 session = _make_aiohttp_put_session(status=200)
1077 with patch(
1078 "vllm.entrypoints.launchers.run_batch.aiohttp.ClientSession",
1079 return_value=session,
1080 ):
1081 await upload_data(
1082 "https://example.com/output.jsonl", "payload", from_file=False
1083 )
1084 assert session.put.call_count == 1
1085
1086
1087 @pytest.mark.asyncio
1088 async def test_upload_data_error_includes_awaited_response_body():
1089 """A failed upload must surface the awaited response body, not a coroutine."""
1090 session = _make_aiohttp_put_session(status=500, body_text="server-error-detail")
1091 with (
1092 patch(
1093 "vllm.entrypoints.launchers.run_batch.aiohttp.ClientSession",
1094 return_value=session,
1095 ),
1096 patch(
1097 "vllm.entrypoints.launchers.run_batch.asyncio.sleep",
1098 AsyncMock(),
1099 ),
1100 pytest.raises(Exception) as exc_info,
1101 ):
1102 await upload_data(
1103 "https://example.com/output.jsonl", "payload", from_file=False
1104 )
1105 message = str(exc_info.value)
1106 assert "server-error-detail" in message
1107 assert "coroutine" not in message