main
py 901 lines 31.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import asyncio
5 import contextlib
6 import json
7 import sys
8 import tempfile
9 from argparse import Namespace
10 from collections.abc import Awaitable, Callable
11 from http import HTTPStatus
12 from io import BytesIO, StringIO
13 from typing import Any, NoReturn, TypeAlias
14 from urllib.parse import urlparse
15
16 import aiohttp
17 import pybase64 as base64
18 import pydantic
19 import torch
20 from fastapi import UploadFile
21 from prometheus_client import start_http_server
22 from pydantic import Field, TypeAdapter, field_validator, model_validator
23 from pydantic_core.core_schema import ValidationInfo
24 from starlette.datastructures import State
25 from starlette.responses import JSONResponse
26 from tqdm import tqdm
27 from urllib3.util import parse_url
28
29 import vllm.envs as envs
30 from vllm.config import config
31 from vllm.connections import HTTPResponseSizeExceededError, global_http_connection
32 from vllm.engine.arg_utils import AsyncEngineArgs
33 from vllm.engine.protocol import EngineClient
34 from vllm.entrypoints.openai.chat_completion.protocol import (
35 ChatCompletionRequest,
36 ChatCompletionResponse,
37 )
38 from vllm.entrypoints.pooling.embed.protocol import (
39 EmbeddingRequest,
40 EmbeddingResponse,
41 )
42 from vllm.entrypoints.pooling.scoring.protocol import (
43 RerankRequest,
44 RerankResponse,
45 ScoreRequest,
46 ScoreResponse,
47 )
48 from vllm.entrypoints.serve import create_error_response
49 from vllm.entrypoints.serve.engine.protocol import (
50 ErrorInfo,
51 ErrorResponse,
52 OpenAIBaseModel,
53 )
54 from vllm.entrypoints.speech_to_text.transcription.protocol import (
55 TranscriptionRequest,
56 TranscriptionResponseVariant,
57 )
58 from vllm.entrypoints.speech_to_text.translation.protocol import (
59 TranslationRequest,
60 TranslationResponse,
61 TranslationResponseVerbose,
62 )
63 from vllm.exceptions import VLLMValidationError
64 from vllm.logger import configure_logging_from_args, init_logger
65 from vllm.reasoning import ReasoningParserManager
66 from vllm.utils import random_uuid
67 from vllm.utils.argparse_utils import FlexibleArgumentParser
68 from vllm.utils.mem_constants import MiB_bytes
69 from vllm.version import __version__ as VLLM_VERSION
70
71 from .api_server.entry import init_app_state
72 from .cli_args import BaseFrontendArgs
73
74 logger = init_logger(__name__)
75
76
77 def _get_audio_download_limit_bytes() -> int:
78 return int(envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB * MiB_bytes)
79
80
81 def _raise_audio_filesize_limit(size_bytes: int) -> NoReturn:
82 raise VLLMValidationError(
83 "Maximum file size exceeded",
84 parameter="audio_filesize_mb",
85 value=size_bytes / MiB_bytes,
86 )
87
88
89 class BatchTranscriptionRequest(TranscriptionRequest):
90 """Batch transcription request that uses file_url instead of file.
91
92 This class extends TranscriptionRequest but replaces the file field
93 with file_url to support batch processing from audio files written in JSON format.
94 """
95
96 file_url: str = Field(
97 ...,
98 description=(
99 "Either a URL of the audio or a data URL with base64 encoded audio data. "
100 ),
101 )
102
103 # Override file to be optional and unused for batch processing
104 file: UploadFile | None = Field(default=None, exclude=True) # type: ignore[assignment]
105
106 @model_validator(mode="before")
107 @classmethod
108 def validate_no_file(cls, data: Any):
109 """Ensure file field is not provided in batch requests."""
110 if isinstance(data, dict) and "file" in data:
111 raise VLLMValidationError(
112 "The 'file' field is not supported in batch requests. "
113 "Use 'file_url' instead.",
114 parameter="file",
115 )
116 return data
117
118
119 class BatchTranslationRequest(TranslationRequest):
120 """Batch translation request that uses file_url instead of file.
121
122 This class extends TranslationRequest but replaces the file field
123 with file_url to support batch processing from audio files written in JSON format.
124 """
125
126 file_url: str = Field(
127 ...,
128 description=(
129 "Either a URL of the audio or a data URL with base64 encoded audio data. "
130 ),
131 )
132
133 # Override file to be optional and unused for batch processing
134 file: UploadFile | None = Field(default=None, exclude=True) # type: ignore[assignment]
135
136 @model_validator(mode="before")
137 @classmethod
138 def validate_no_file(cls, data: Any):
139 """Ensure file field is not provided in batch requests."""
140 if isinstance(data, dict) and "file" in data:
141 raise VLLMValidationError(
142 "The 'file' field is not supported in batch requests. "
143 "Use 'file_url' instead.",
144 parameter="file",
145 )
146 return data
147
148
149 BatchRequestInputBody: TypeAlias = (
150 ChatCompletionRequest
151 | EmbeddingRequest
152 | ScoreRequest
153 | RerankRequest
154 | BatchTranscriptionRequest
155 | BatchTranslationRequest
156 )
157
158
159 class BatchRequestInput(OpenAIBaseModel):
160 """The per-line object of the batch input file.
161
162 NOTE: Currently only the `/v1/chat/completions` endpoint is supported.
163 """
164
165 # A developer-provided per-request id that will be used to match outputs to
166 # inputs. Must be unique for each request in a batch.
167 custom_id: str
168
169 # The HTTP method to be used for the request. Currently only POST is
170 # supported.
171 method: str
172
173 # The OpenAI API relative URL to be used for the request. Currently
174 # /v1/chat/completions is supported.
175 url: str
176
177 # The parameters of the request.
178 body: BatchRequestInputBody
179
180 @field_validator("body", mode="plain")
181 @classmethod
182 def check_type_for_url(cls, value: Any, info: ValidationInfo):
183 # Use url to disambiguate models
184 url: str = info.data["url"]
185 if url == "/v1/chat/completions":
186 return ChatCompletionRequest.model_validate(value)
187 if url == "/v1/embeddings":
188 return TypeAdapter(EmbeddingRequest).validate_python(value)
189 if url.endswith("/score"):
190 return TypeAdapter(ScoreRequest).validate_python(value)
191 if url.endswith("/rerank"):
192 return RerankRequest.model_validate(value)
193 if url == "/v1/audio/transcriptions":
194 return BatchTranscriptionRequest.model_validate(value)
195 if url == "/v1/audio/translations":
196 return BatchTranslationRequest.model_validate(value)
197 return TypeAdapter(BatchRequestInputBody).validate_python(value)
198
199
200 AllResponse: TypeAlias = (
201 ChatCompletionResponse
202 | EmbeddingResponse
203 | ScoreResponse
204 | RerankResponse
205 | TranscriptionResponseVariant
206 | TranslationResponse
207 | TranslationResponseVerbose
208 )
209
210
211 class BatchResponseData(OpenAIBaseModel):
212 # HTTP status code of the response.
213 status_code: int = 200
214
215 # An unique identifier for the API request.
216 request_id: str
217
218 # The body of the response.
219 body: AllResponse | None = None
220
221
222 class BatchRequestOutput(OpenAIBaseModel):
223 """The per-line object of the batch output and error files"""
224
225 id: str
226
227 # A developer-provided per-request id that will be used to match outputs to
228 # inputs.
229 custom_id: str
230
231 response: BatchResponseData | None
232
233 # For requests that failed with a non-HTTP error, this will contain more
234 # information on the cause of the failure.
235 error: Any | None
236
237
238 @config
239 class BatchFrontendArgs(BaseFrontendArgs):
240 """Arguments for the batch runner frontend."""
241
242 input_file: str | None = None
243 """The path or url to a single input file. Currently supports local file
244 paths, or the http protocol (http or https). If a URL is specified,
245 the file should be available via HTTP GET."""
246 output_file: str | None = None
247 """The path or url to a single output file. Currently supports
248 local file paths, or web (http or https) urls. If a URL is specified,
249 the file should be available via HTTP PUT."""
250 output_tmp_dir: str | None = None
251 """The directory to store the output file before uploading it
252 to the output URL."""
253 enable_metrics: bool = False
254 """Enable Prometheus metrics"""
255 host: str | None = None
256 """Host name for the Prometheus metrics server
257 (only needed if enable-metrics is set)."""
258 port: int = 8000
259 """Port number for the Prometheus metrics server
260 (only needed if enable-metrics is set)."""
261 url: str = "0.0.0.0"
262 """[DEPRECATED] Host name for the Prometheus metrics server
263 (only needed if enable-metrics is set). Use --host instead."""
264
265 @classmethod
266 def _customize_cli_kwargs(
267 cls,
268 frontend_kwargs: dict[str, Any],
269 ) -> dict[str, Any]:
270 frontend_kwargs = super()._customize_cli_kwargs(frontend_kwargs)
271
272 frontend_kwargs["input_file"]["flags"] = ["-i"]
273 frontend_kwargs["input_file"]["required"] = True
274 frontend_kwargs["output_file"]["flags"] = ["-o"]
275 frontend_kwargs["output_file"]["required"] = True
276
277 frontend_kwargs["enable_metrics"]["action"] = "store_true"
278
279 frontend_kwargs["url"]["deprecated"] = True
280 return frontend_kwargs
281
282
283 def make_arg_parser(parser: FlexibleArgumentParser):
284 parser = BatchFrontendArgs.add_cli_args(parser)
285 parser = AsyncEngineArgs.add_cli_args(parser)
286 return parser
287
288
289 def parse_args():
290 parser = FlexibleArgumentParser(description="vLLM OpenAI-Compatible batch runner.")
291 args = make_arg_parser(parser).parse_args()
292 configure_logging_from_args(args)
293
294 # Backward compatibility: If --url is set, use it for host
295 url_explicit = any(arg == "--url" or arg.startswith("--url=") for arg in sys.argv)
296 host_explicit = any(
297 arg == "--host" or arg.startswith("--host=") for arg in sys.argv
298 )
299 if url_explicit and hasattr(args, "url") and not host_explicit:
300 args.host = args.url
301 logger.warning_once(
302 "Using --url for metrics is deprecated. Please use --host instead."
303 )
304
305 return args
306
307
308 # explicitly use pure text format, with a newline at the end
309 # this makes it impossible to see the animation in the progress bar
310 # but will avoid messing up with ray or multiprocessing, which wraps
311 # each line of output with some prefix.
312 _BAR_FORMAT = "{desc}: {percentage:3.0f}% Completed | {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]\n" # noqa: E501
313
314
315 class BatchProgressTracker:
316 def __init__(self):
317 self._total = 0
318 self._pbar: tqdm | None = None
319
320 def submitted(self):
321 self._total += 1
322
323 def completed(self):
324 if self._pbar:
325 self._pbar.update()
326
327 def pbar(self) -> tqdm:
328 enable_tqdm = (
329 not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0
330 )
331 self._pbar = tqdm(
332 total=self._total,
333 unit="req",
334 desc="Running batch",
335 mininterval=5,
336 disable=not enable_tqdm,
337 bar_format=_BAR_FORMAT,
338 )
339 return self._pbar
340
341
342 async def read_file(path_or_url: str) -> str:
343 if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
344 async with aiohttp.ClientSession() as session, session.get(path_or_url) as resp:
345 resp.raise_for_status()
346 return await resp.text()
347 else:
348 with open(path_or_url, encoding="utf-8") as f:
349 return f.read()
350
351
352 async def write_local_file(
353 output_path: str, batch_outputs: list[BatchRequestOutput]
354 ) -> None:
355 """Write the responses to a local file.
356 output_path: The path to write the responses to.
357 batch_outputs: The list of batch outputs to write.
358 """
359 # We should make this async, but as long as run_batch runs as a
360 # standalone program, blocking the event loop won't affect performance.
361 with open(output_path, "w", encoding="utf-8") as f:
362 for o in batch_outputs:
363 print(o.model_dump_json(), file=f)
364
365
366 async def upload_data(output_url: str, data_or_file: str, from_file: bool) -> None:
367 """Upload a local file to a URL.
368 output_url: The URL to upload the file to.
369 data_or_file: Either the data to upload or the path to the file to upload.
370 from_file: If True, data_or_file is the path to the file to upload.
371 """
372 # Timeout is a common issue when uploading large files.
373 # We retry max_retries times before giving up.
374 max_retries = 5
375 # Number of seconds to wait before retrying.
376 delay = 5
377
378 for attempt in range(1, max_retries + 1):
379 try:
380 # We increase the timeout to 1000 seconds to allow
381 # for large files (default is 300).
382 async with aiohttp.ClientSession(
383 timeout=aiohttp.ClientTimeout(total=1000)
384 ) as session:
385 if from_file:
386 with open(data_or_file, "rb") as file:
387 async with session.put(output_url, data=file) as response:
388 if response.status != 200:
389 error_text = await response.text()
390 raise Exception(
391 f"Failed to upload file.\n"
392 f"Status: {response.status}\n"
393 f"Response: {error_text}"
394 )
395 else:
396 async with session.put(output_url, data=data_or_file) as response:
397 if response.status != 200:
398 error_text = await response.text()
399 raise Exception(
400 f"Failed to upload data.\n"
401 f"Status: {response.status}\n"
402 f"Response: {error_text}"
403 )
404 # Upload succeeded; do not retry.
405 return
406 except Exception as e:
407 if attempt < max_retries:
408 logger.error(
409 "Failed to upload data (attempt %d). Error message: %s.\nRetrying in %d seconds...", # noqa: E501
410 attempt,
411 e,
412 delay,
413 )
414 await asyncio.sleep(delay)
415 else:
416 raise Exception(
417 f"Failed to upload data (attempt {attempt}). Error message: {str(e)}." # noqa: E501
418 ) from e
419
420
421 async def write_file(
422 path_or_url: str, batch_outputs: list[BatchRequestOutput], output_tmp_dir: str
423 ) -> None:
424 """Write batch_outputs to a file or upload to a URL.
425 path_or_url: The path or URL to write batch_outputs to.
426 batch_outputs: The list of batch outputs to write.
427 output_tmp_dir: The directory to store the output file before uploading it
428 to the output URL.
429 """
430 if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
431 if output_tmp_dir is None:
432 logger.info("Writing outputs to memory buffer")
433 output_buffer = StringIO()
434 for o in batch_outputs:
435 print(o.model_dump_json(), file=output_buffer)
436 output_buffer.seek(0)
437 logger.info("Uploading outputs to %s", path_or_url)
438 await upload_data(
439 path_or_url,
440 output_buffer.read().strip().encode("utf-8"),
441 from_file=False,
442 )
443 else:
444 # Write responses to a temporary file and then upload it to the URL.
445 with tempfile.NamedTemporaryFile(
446 mode="w",
447 encoding="utf-8",
448 dir=output_tmp_dir,
449 prefix="tmp_batch_output_",
450 suffix=".jsonl",
451 ) as f:
452 logger.info("Writing outputs to temporary local file %s", f.name)
453 await write_local_file(f.name, batch_outputs)
454 logger.info("Uploading outputs to %s", path_or_url)
455 await upload_data(path_or_url, f.name, from_file=True)
456 else:
457 logger.info("Writing outputs to local file %s", path_or_url)
458 await write_local_file(path_or_url, batch_outputs)
459
460
461 async def download_bytes_from_url(
462 url: str,
463 allowed_media_domains: list[str] | None = None,
464 ) -> bytes:
465 """Download data from a URL or decode from a data URL.
466
467 Args:
468 url: Either an HTTP/HTTPS URL or a data URL (data:...;base64,...)
469 allowed_media_domains: If set, only HTTP/HTTPS URLs whose hostname
470 is in this list are permitted. data: URLs are not subject to
471 this restriction.
472
473 Returns:
474 Data as bytes
475
476 """
477 parsed = urlparse(url)
478
479 # Handle data URLs (base64 encoded) - not subject to domain restrictions
480 if parsed.scheme == "data":
481 # Format: data:...;base64,<base64_data>
482 if "," in url:
483 header, data = url.split(",", 1)
484 if "base64" in header:
485 decoded_size_bytes = len(data.rstrip("=")) * 3 // 4
486 if decoded_size_bytes > _get_audio_download_limit_bytes():
487 _raise_audio_filesize_limit(decoded_size_bytes)
488 return base64.b64decode(data)
489 else:
490 raise VLLMValidationError(
491 f"Unsupported data URL encoding: {header}",
492 parameter="url",
493 )
494 else:
495 raise VLLMValidationError(
496 f"Invalid data URL format: {url}",
497 parameter="url",
498 )
499
500 # Handle HTTP/HTTPS URLs
501 elif parsed.scheme in ("http", "https"):
502 if allowed_media_domains is not None:
503 url_spec = parse_url(url)
504 if url_spec.hostname not in allowed_media_domains:
505 raise VLLMValidationError(
506 f"The URL must be from one of the allowed domains: "
507 f"{allowed_media_domains}. Input URL domain: "
508 f"{url_spec.hostname}",
509 parameter="url",
510 value=url_spec.hostname,
511 )
512 # Use the normalized URL to prevent parsing discrepancies
513 # between urllib3 and aiohttp (e.g. backslash-@ attacks).
514 url = url_spec.url
515
516 try:
517 return await global_http_connection.async_get_bytes(
518 url,
519 allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
520 max_bytes=_get_audio_download_limit_bytes(),
521 )
522 except HTTPResponseSizeExceededError as exc:
523 _raise_audio_filesize_limit(exc.received_bytes)
524
525 else:
526 raise VLLMValidationError(
527 f"Unsupported URL scheme: {parsed.scheme}. "
528 "Supported schemes: http, https, data",
529 parameter="url",
530 value=parsed.scheme,
531 )
532
533
534 def make_error_request_output(
535 request: BatchRequestInput, error_msg: str
536 ) -> BatchRequestOutput:
537 batch_output = BatchRequestOutput(
538 id=f"vllm-{random_uuid()}",
539 custom_id=request.custom_id,
540 response=BatchResponseData(
541 status_code=HTTPStatus.BAD_REQUEST,
542 request_id=f"vllm-batch-{random_uuid()}",
543 ),
544 error=error_msg,
545 )
546 return batch_output
547
548
549 async def make_async_error_request_output(
550 request: BatchRequestInput, error_msg: str
551 ) -> BatchRequestOutput:
552 return make_error_request_output(request, error_msg)
553
554
555 async def run_request(
556 serving_engine_func: Callable,
557 request: BatchRequestInput,
558 tracker: BatchProgressTracker,
559 ) -> BatchRequestOutput:
560 try:
561 response = await serving_engine_func(request.body)
562 except Exception as e:
563 response = create_error_response(e)
564
565 if isinstance(response, JSONResponse):
566 with contextlib.suppress(pydantic.ValidationError):
567 response = TypeAdapter(AllResponse | ErrorResponse).validate_python(
568 json.loads(response.body)
569 )
570
571 if isinstance(response, AllResponse):
572 batch_output = BatchRequestOutput(
573 id=f"vllm-{random_uuid()}",
574 custom_id=request.custom_id,
575 response=BatchResponseData(
576 body=response, request_id=f"vllm-batch-{random_uuid()}"
577 ),
578 error=None,
579 )
580 elif isinstance(response, ErrorResponse):
581 batch_output = BatchRequestOutput(
582 id=f"vllm-{random_uuid()}",
583 custom_id=request.custom_id,
584 response=BatchResponseData(
585 status_code=response.error.code,
586 request_id=f"vllm-batch-{random_uuid()}",
587 ),
588 error=response,
589 )
590 else:
591 batch_output = make_error_request_output(
592 request, error_msg="Request must not be sent in stream mode"
593 )
594
595 tracker.completed()
596 return batch_output
597
598
599 WrapperFn: TypeAlias = Callable[[Callable], Callable]
600
601
602 def handle_endpoint_request(
603 request: BatchRequestInput,
604 tracker: BatchProgressTracker,
605 url_matcher: Callable[[str], bool],
606 handler_getter: Callable[[], Callable | None],
607 wrapper_fn: WrapperFn | None = None,
608 ) -> Awaitable[BatchRequestOutput] | None:
609 """Generic handler for endpoint requests.
610
611 Args:
612 request: The batch request input
613 tracker: Progress tracker for the batch
614 url_matcher: Function that takes a URL and returns True if it matches
615 handler_getter: Function that returns the handler function or None
616 wrapper_fn: Optional function to wrap the handler (e.g., for transcriptions)
617
618 Returns:
619 Awaitable[BatchRequestOutput] if the request was handled,
620 None if URL didn't match
621
622 """
623 if not url_matcher(request.url):
624 return None
625
626 handler_fn = handler_getter()
627 if handler_fn is None:
628 error_msg = f"Model does not support endpoint: {request.url}"
629 return make_async_error_request_output(request, error_msg=error_msg)
630
631 # Apply wrapper if provided (e.g., for transcriptions/translations)
632 if wrapper_fn is not None:
633 handler_fn = wrapper_fn(handler_fn)
634
635 tracker.submitted()
636 return run_request(handler_fn, request, tracker)
637
638
639 def make_transcription_wrapper(
640 is_translation: bool,
641 allowed_media_domains: list[str] | None = None,
642 ) -> WrapperFn:
643 """Factory function to create a wrapper for transcription/translation handlers.
644 The wrapper converts BatchTranscriptionRequest or BatchTranslationRequest
645 to TranscriptionRequest or TranslationRequest and calls the appropriate handler.
646
647 Args:
648 is_translation: If True, process as translation; otherwise process
649 as transcription
650 allowed_media_domains: If set, only URLs from these domains are
651 permitted for HTTP/HTTPS fetches.
652
653 Returns:
654 A function that takes a handler and returns a wrapped handler
655
656 """
657
658 def wrapper(handler_fn: Callable):
659 async def transcription_wrapper(
660 batch_request_body: (BatchTranscriptionRequest | BatchTranslationRequest),
661 ) -> (
662 TranscriptionResponseVariant
663 | TranslationResponse
664 | TranslationResponseVerbose
665 | ErrorResponse
666 ):
667 try:
668 # Download data from URL
669 audio_data = await download_bytes_from_url(
670 batch_request_body.file_url,
671 allowed_media_domains=allowed_media_domains,
672 )
673
674 # Create a mock file from the downloaded audio data
675 mock_file = UploadFile(
676 file=BytesIO(audio_data),
677 filename="audio.bin",
678 )
679
680 # Convert batch request to regular request
681 # by copying all fields except file_url and setting file to mock_file
682 request_dict = batch_request_body.model_dump(exclude={"file_url"})
683 request_dict["file"] = mock_file
684
685 if is_translation:
686 # Create TranslationRequest from BatchTranslationRequest
687 translation_request = TranslationRequest.model_validate(
688 request_dict
689 )
690 return await handler_fn(audio_data, translation_request)
691 else:
692 # Create TranscriptionRequest from BatchTranscriptionRequest
693 transcription_request = TranscriptionRequest.model_validate(
694 request_dict
695 )
696 return await handler_fn(audio_data, transcription_request)
697 except Exception as e:
698 operation = "translation" if is_translation else "transcription"
699 return ErrorResponse(
700 error=ErrorInfo(
701 message=f"Failed to process {operation}: {str(e)}",
702 type="BadRequestError",
703 code=HTTPStatus.BAD_REQUEST.value,
704 )
705 )
706
707 return transcription_wrapper
708
709 return wrapper
710
711
712 async def build_endpoint_registry(
713 engine_client: EngineClient,
714 args: Namespace,
715 ) -> dict[str, dict[str, Any]]:
716 """Build the endpoint registry with all serving objects and handler configurations.
717
718 Args:
719 engine_client: The engine client
720 args: Command line arguments
721
722 Returns:
723 Dictionary mapping endpoint keys to their configurations
724
725 """
726 supported_tasks = await engine_client.get_supported_tasks()
727 logger.info("Supported tasks: %s", supported_tasks)
728
729 # Create a state object to hold serving objects
730 state = State()
731
732 # Initialize all serving objects using init_app_state
733 # This provides full functionality including chat template processing,
734 # LoRA support, tool servers, etc.
735 await init_app_state(engine_client, state, args, supported_tasks)
736
737 # Get serving objects from state (defaulting to None if not set)
738 openai_serving_chat = getattr(state, "openai_serving_chat", None)
739 openai_serving_transcription = getattr(state, "openai_serving_transcription", None)
740 openai_serving_translation = getattr(state, "openai_serving_translation", None)
741 serving_embedding = getattr(state, "serving_embedding", None)
742 serving_scores = getattr(state, "serving_scores", None)
743
744 allowed_media_domains = getattr(args, "allowed_media_domains", None)
745
746 # Registry of endpoint configurations
747 endpoint_registry: dict[str, dict[str, Any]] = {
748 "completions": {
749 "url_matcher": lambda url: url == "/v1/chat/completions",
750 "handler_getter": lambda: (
751 openai_serving_chat.create_chat_completion
752 if openai_serving_chat is not None
753 else None
754 ),
755 "wrapper_fn": None,
756 },
757 "embeddings": {
758 "url_matcher": lambda url: url == "/v1/embeddings",
759 "handler_getter": lambda: (
760 serving_embedding if serving_embedding is not None else None
761 ),
762 "wrapper_fn": None,
763 },
764 "score": {
765 "url_matcher": lambda url: url.endswith("/score"),
766 "handler_getter": lambda: (
767 serving_scores if serving_scores is not None else None
768 ),
769 "wrapper_fn": None,
770 },
771 "rerank": {
772 "url_matcher": lambda url: url.endswith("/rerank"),
773 "handler_getter": lambda: (
774 serving_scores if serving_scores is not None else None
775 ),
776 "wrapper_fn": None,
777 },
778 "transcriptions": {
779 "url_matcher": lambda url: url == "/v1/audio/transcriptions",
780 "handler_getter": lambda: (
781 openai_serving_transcription.create_transcription
782 if openai_serving_transcription is not None
783 else None
784 ),
785 "wrapper_fn": make_transcription_wrapper(
786 is_translation=False,
787 allowed_media_domains=allowed_media_domains,
788 ),
789 },
790 "translations": {
791 "url_matcher": lambda url: url == "/v1/audio/translations",
792 "handler_getter": lambda: (
793 openai_serving_translation.create_translation
794 if openai_serving_translation is not None
795 else None
796 ),
797 "wrapper_fn": make_transcription_wrapper(
798 is_translation=True,
799 allowed_media_domains=allowed_media_domains,
800 ),
801 },
802 }
803
804 return endpoint_registry
805
806
807 def validate_run_batch_args(args):
808 valid_reasoning_parsers = ReasoningParserManager.list_registered()
809 if (
810 reasoning_parser := args.structured_outputs_config.reasoning_parser
811 ) and reasoning_parser not in valid_reasoning_parsers:
812 raise KeyError(
813 f"invalid reasoning parser: {reasoning_parser} "
814 f"(chose from {{ {','.join(valid_reasoning_parsers)} }})"
815 )
816
817
818 async def run_batch(
819 engine_client: EngineClient,
820 args: Namespace,
821 ) -> None:
822 endpoint_registry = await build_endpoint_registry(
823 engine_client=engine_client,
824 args=args,
825 )
826
827 tracker = BatchProgressTracker()
828 logger.info("Reading batch from %s...", args.input_file)
829
830 # Submit all requests in the file to the engine "concurrently".
831 response_futures: list[Awaitable[BatchRequestOutput]] = []
832 for request_json in (await read_file(args.input_file)).strip().split("\n"):
833 # Skip empty lines.
834 request_json = request_json.strip()
835 if not request_json:
836 continue
837
838 request = BatchRequestInput.model_validate_json(request_json)
839
840 # Use the last segment of the URL as the endpoint key.
841 # More advanced URL matching is done in url_matcher of endpoint_registry.
842 endpoint_key = request.url.split("/")[-1]
843
844 result = None
845 if endpoint_key in endpoint_registry:
846 endpoint_config = endpoint_registry[endpoint_key]
847 result = handle_endpoint_request(
848 request,
849 tracker,
850 url_matcher=endpoint_config["url_matcher"],
851 handler_getter=endpoint_config["handler_getter"],
852 wrapper_fn=endpoint_config["wrapper_fn"],
853 )
854
855 if result is not None:
856 response_futures.append(result)
857 else:
858 response_futures.append(
859 make_async_error_request_output(
860 request,
861 error_msg=f"URL {request.url} was used. "
862 "Supported endpoints: /v1/chat/completions, /v1/embeddings,"
863 " /v1/audio/transcriptions, /v1/audio/translations, /score, "
864 " /rerank.",
865 )
866 )
867
868 with tracker.pbar():
869 responses = await asyncio.gather(*response_futures)
870
871 await write_file(args.output_file, responses, args.output_tmp_dir)
872
873
874 async def main(args: Namespace):
875 from vllm.entrypoints.launchers.api_server.entry import build_async_engine_client
876 from vllm.usage.usage_lib import UsageContext
877
878 validate_run_batch_args(args)
879
880 async with build_async_engine_client(
881 args,
882 usage_context=UsageContext.OPENAI_BATCH_RUNNER,
883 ) as engine_client:
884 await run_batch(engine_client, args)
885
886
887 if __name__ == "__main__":
888 args = parse_args()
889
890 logger.info("vLLM batch processing API version %s", VLLM_VERSION)
891 logger.info("args: %s", args)
892
893 # Start the Prometheus metrics server. LLMEngine uses the Prometheus client
894 # to publish metrics at the /metrics endpoint.
895 if args.enable_metrics:
896 logger.info("Prometheus metrics enabled")
897 start_http_server(port=args.port, addr=args.host)
898 else:
899 logger.info("Prometheus metrics disabled")
900
901 asyncio.run(main(args))