main
py 1,129 lines 39.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """The request function for API endpoints."""
4
5 import codecs
6 import io
7 import json
8 import os
9 import sys
10 import time
11 import traceback
12 from collections.abc import Awaitable
13 from dataclasses import dataclass, field
14 from typing import Any, Literal, Protocol
15
16 import aiohttp
17 import regex as re
18 from tqdm.asyncio import tqdm
19
20 AIOHTTP_TIMEOUT = aiohttp.ClientTimeout(total=6 * 60 * 60)
21
22
23 class StreamedResponseHandler:
24 """Handles streaming HTTP responses by accumulating chunks until complete
25 messages are available."""
26
27 def __init__(self):
28 self.buffer = ""
29 self._decoder = codecs.getincrementaldecoder("utf-8")()
30
31 def add_chunk(self, chunk_bytes: bytes) -> list[str]:
32 """Add a chunk of bytes to the buffer and return any complete
33 messages."""
34 chunk_str = self._decoder.decode(chunk_bytes)
35 self.buffer += chunk_str
36
37 messages = []
38
39 # Split by double newlines (SSE message separator)
40 while "\n\n" in self.buffer:
41 message, self.buffer = self.buffer.split("\n\n", 1)
42 message = message.strip()
43 if message:
44 messages.append(message)
45
46 # if self.buffer is not empty, check if it is a complete message
47 # by removing data: prefix and check if it is a valid JSON
48 if self.buffer.startswith("data: "):
49 message_content = self.buffer.removeprefix("data: ").strip()
50 if message_content == "[DONE]":
51 messages.append(self.buffer.strip())
52 self.buffer = ""
53 elif message_content:
54 try:
55 json.loads(message_content)
56 messages.append(self.buffer.strip())
57 self.buffer = ""
58 except json.JSONDecodeError:
59 # Incomplete JSON, wait for more chunks.
60 pass
61
62 return messages
63
64
65 @dataclass
66 class RequestFuncInput:
67 """The input for the request function."""
68
69 prompt: str | list[str] | list[int] | list[list[int]] | list[dict[str, Any]]
70 api_url: str
71 prompt_len: int
72 output_len: int
73 model: str
74 model_name: str | None = None
75 logprobs: int | None = None
76 extra_headers: dict | None = None
77 extra_body: dict | None = None
78 multi_modal_content: dict[str, Any] | list[dict[str, Any]] | None = None
79 ignore_eos: bool = False
80 language: str | None = None
81 request_id: str | None = None
82 # Pre-built chat messages. When set, `async_request_openai_chat_completions`
83 # uses this list directly and skips building messages from `prompt` and
84 # `multi_modal_content`.
85 chat_messages: list[dict[str, Any]] | None = None
86
87
88 @dataclass
89 class RequestFuncOutput:
90 """The output of the request function including metrics."""
91
92 generated_text: str = ""
93 success: bool = False
94 latency: float = 0.0
95 output_tokens: int = 0
96 ttft: float = 0.0 # Time to first token
97 itl: list[float] = field(default_factory=list) # list of inter-token latencies
98 tpot: float = 0.0 # avg next-token latencies
99 prompt_len: int = 0
100 error: str = ""
101 start_time: float = 0.0
102 # Time spent awaiting the benchmark client concurrency semaphore.
103 client_queue_time: float = 0.0
104 input_audio_duration: float = 0.0 # in seconds
105 num_input_sequences: int = 1
106
107
108 class RequestFunc(Protocol):
109 def __call__(
110 self,
111 request_func_input: RequestFuncInput,
112 session: aiohttp.ClientSession,
113 pbar: tqdm | None = None,
114 ) -> Awaitable[RequestFuncOutput]: ...
115
116
117 def _validate_api_url(
118 api_url: str,
119 api_name: str,
120 expected_suffixes: str | set[str],
121 ) -> None:
122 if isinstance(expected_suffixes, str):
123 expected_suffixes = {expected_suffixes}
124
125 expected_suffixes = {*expected_suffixes, "profile"}
126
127 if not api_url.endswith(tuple(expected_suffixes)):
128 raise ValueError(f"{api_name} URL must end with one of: {expected_suffixes}.")
129
130
131 def _update_payload_common(
132 payload: dict[str, Any],
133 request_func_input: RequestFuncInput,
134 ) -> None:
135 if request_func_input.ignore_eos:
136 payload["ignore_eos"] = request_func_input.ignore_eos
137 if request_func_input.extra_body:
138 payload.update(request_func_input.extra_body)
139
140
141 def _update_headers_common(
142 headers: dict[str, Any],
143 request_func_input: RequestFuncInput,
144 ) -> None:
145 if request_func_input.extra_headers:
146 headers |= request_func_input.extra_headers
147 if request_func_input.request_id:
148 headers["x-request-id"] = request_func_input.request_id
149
150
151 def _get_headers(content_type: str | None = None) -> dict[str, str]:
152 headers = {}
153 if content_type:
154 headers["Content-Type"] = content_type
155 api_key = os.environ.get("OPENAI_API_KEY")
156 if api_key:
157 headers["Authorization"] = f"Bearer {api_key}"
158 return headers
159
160
161 async def async_request_openai_completions(
162 request_func_input: RequestFuncInput,
163 session: aiohttp.ClientSession,
164 pbar: tqdm | None = None,
165 ) -> RequestFuncOutput:
166 """The async request function for the OpenAI Completions API.
167
168 Args:
169 request_func_input: The input for the request function.
170 session: The aiohttp session used to issue the request.
171 pbar: The progress bar to display the progress.
172
173 Returns:
174 The output of the request function.
175
176 """
177 api_url = request_func_input.api_url
178 _validate_api_url(api_url, "OpenAI Completions API", "completions")
179
180 payload = {
181 "model": request_func_input.model_name
182 if request_func_input.model_name
183 else request_func_input.model,
184 "prompt": request_func_input.prompt,
185 "repetition_penalty": 1.0,
186 "max_tokens": request_func_input.output_len,
187 "logprobs": request_func_input.logprobs,
188 "stream": True,
189 "stream_options": {
190 "include_usage": True,
191 },
192 }
193 _update_payload_common(payload, request_func_input)
194
195 headers = _get_headers()
196 _update_headers_common(headers, request_func_input)
197
198 output = RequestFuncOutput()
199 output.prompt_len = request_func_input.prompt_len
200
201 generated_text = ""
202 st = time.perf_counter()
203 output.start_time = st
204 most_recent_timestamp = st
205 try:
206 async with session.post(url=api_url, json=payload, headers=headers) as response:
207 if response.status == 200:
208 first_chunk_received = False
209 handler = StreamedResponseHandler()
210
211 async for chunk_bytes in response.content.iter_any():
212 chunk_bytes = chunk_bytes.strip()
213 if not chunk_bytes:
214 continue
215
216 messages = handler.add_chunk(chunk_bytes)
217 for message in messages:
218 # NOTE: SSE comments (often used as pings) start with
219 # a colon. These are not JSON data payload and should
220 # be skipped.
221 if message.startswith(":"):
222 continue
223
224 chunk = message.removeprefix("data: ")
225
226 if chunk != "[DONE]":
227 data = json.loads(chunk)
228
229 # NOTE: Some completion API might have a last
230 # usage summary response without a token so we
231 # want to check a token was generated
232 if choices := data.get("choices"):
233 # Note that text could be empty here
234 # e.g. for special tokens
235 text = choices[0].get("text")
236 timestamp = time.perf_counter()
237 # First token
238 if not first_chunk_received:
239 first_chunk_received = True
240 output.ttft = timestamp - st
241
242 # Decoding phase
243 else:
244 output.itl.append(timestamp - most_recent_timestamp)
245
246 most_recent_timestamp = timestamp
247 generated_text += text or ""
248 elif usage := data.get("usage"):
249 output.output_tokens = usage.get("completion_tokens")
250 if (pt := usage.get("prompt_tokens")) is not None:
251 output.prompt_len = pt
252 if first_chunk_received:
253 output.success = True
254 else:
255 output.success = False
256 output.error = (
257 "Never received a valid chunk to calculate TTFT."
258 "This response will be marked as failed!"
259 )
260 output.generated_text = generated_text
261 output.latency = most_recent_timestamp - st
262 else:
263 output.error = response.reason or ""
264 output.success = False
265 except Exception:
266 output.success = False
267 exc_info = sys.exc_info()
268 output.error = "".join(traceback.format_exception(*exc_info))
269
270 if pbar:
271 pbar.update(1)
272 return output
273
274
275 def _get_chat_content(
276 request_func_input: RequestFuncInput,
277 mm_position: Literal["first", "last"] = "last",
278 ) -> list[dict[str, Any]]:
279 mm_contents: list[dict[str, Any]] = []
280 if request_func_input.multi_modal_content:
281 mm_content = request_func_input.multi_modal_content
282 if isinstance(mm_content, list):
283 mm_contents.extend(mm_content)
284 elif isinstance(mm_content, dict):
285 mm_contents.append(mm_content)
286 else:
287 raise TypeError(
288 "multi_modal_content must be a dict or list[dict] for openai-chat"
289 )
290
291 prompt = request_func_input.prompt
292 if (
293 isinstance(prompt, list)
294 and prompt
295 and all(
296 isinstance(item, dict) and isinstance(item.get("type"), str)
297 for item in prompt
298 )
299 ):
300 prompt_dicts: list[dict[str, Any]] = prompt # type: ignore[assignment]
301 if mm_position == "first":
302 return mm_contents + prompt_dicts
303
304 return prompt_dicts + mm_contents
305
306 text_contents = [{"type": "text", "text": prompt}]
307
308 if mm_position == "first":
309 return mm_contents + text_contents
310
311 return text_contents + mm_contents
312
313
314 def _is_chat_messages(prompt: Any) -> bool:
315 if not isinstance(prompt, list):
316 return False
317 if not prompt:
318 return False
319 return all(
320 isinstance(item, dict)
321 and isinstance(item.get("role"), str)
322 and isinstance(item.get("content"), (str, list))
323 for item in prompt
324 )
325
326
327 def _get_chat_messages(
328 request_func_input: RequestFuncInput,
329 mm_position: Literal["first", "last"] = "last",
330 ) -> list[dict[str, Any]]:
331 prompt = request_func_input.prompt
332 if _is_chat_messages(prompt):
333 return prompt # type: ignore[return-value]
334
335 return [
336 {
337 "role": "user",
338 "content": _get_chat_content(
339 request_func_input,
340 mm_position=mm_position,
341 ),
342 }
343 ]
344
345
346 async def async_request_openai_chat_completions(
347 request_func_input: RequestFuncInput,
348 session: aiohttp.ClientSession,
349 pbar: tqdm | None = None,
350 mm_position: Literal["first", "last"] = "last",
351 ) -> RequestFuncOutput:
352 api_url = request_func_input.api_url
353 _validate_api_url(api_url, "OpenAI Chat Completions API", "chat/completions")
354
355 if request_func_input.chat_messages is not None:
356 messages = request_func_input.chat_messages
357 else:
358 messages = _get_chat_messages(request_func_input, mm_position=mm_position)
359
360 payload = {
361 "model": request_func_input.model_name
362 if request_func_input.model_name
363 else request_func_input.model,
364 "messages": messages,
365 "max_completion_tokens": request_func_input.output_len,
366 "stream": True,
367 "stream_options": {
368 "include_usage": True,
369 },
370 }
371 _update_payload_common(payload, request_func_input)
372
373 headers = _get_headers("application/json")
374 _update_headers_common(headers, request_func_input)
375
376 output = RequestFuncOutput()
377 output.prompt_len = request_func_input.prompt_len
378
379 generated_text = ""
380 st = time.perf_counter()
381 output.start_time = st
382 most_recent_timestamp = st
383 try:
384 async with session.post(url=api_url, json=payload, headers=headers) as response:
385 if response.status == 200:
386 first_chunk_received = False
387 handler = StreamedResponseHandler()
388 async for chunk_bytes in response.content.iter_any():
389 chunk_bytes = chunk_bytes.strip()
390 if not chunk_bytes:
391 continue
392
393 message_strings = handler.add_chunk(chunk_bytes)
394 for message in message_strings:
395 # NOTE: SSE comments (often used as pings) start with
396 # a colon. These are not JSON data payload and should
397 # be skipped.
398 if message.startswith(":"):
399 continue
400
401 chunk = message.removeprefix("data: ")
402
403 if chunk != "[DONE]":
404 timestamp = time.perf_counter()
405 data = json.loads(chunk)
406
407 if choices := data.get("choices"):
408 content = choices[0]["delta"].get("content")
409 # First token
410 if not first_chunk_received:
411 first_chunk_received = True
412 output.ttft = timestamp - st
413
414 # Decoding phase
415 else:
416 output.itl.append(timestamp - most_recent_timestamp)
417
418 generated_text += content or ""
419 # Only token chunks advance the request end;
420 # the trailing usage chunk carries no token.
421 most_recent_timestamp = timestamp
422 elif usage := data.get("usage"):
423 output.output_tokens = usage.get("completion_tokens")
424 if (pt := usage.get("prompt_tokens")) is not None:
425 output.prompt_len = pt
426
427 output.generated_text = generated_text
428 if first_chunk_received:
429 output.success = True
430 else:
431 output.success = False
432 output.error = (
433 "Never received a valid chunk to calculate TTFT."
434 "This response will be marked as failed!"
435 )
436 output.latency = most_recent_timestamp - st
437 else:
438 output.error = response.reason or ""
439 output.success = False
440 except Exception:
441 output.success = False
442 exc_info = sys.exc_info()
443 output.error = "".join(traceback.format_exception(*exc_info))
444
445 if pbar:
446 pbar.update(1)
447 return output
448
449
450 def _extract_sse_data(message: str) -> str | None:
451 """Extract the ``data`` payload of a single SSE message.
452
453 The Responses API stream labels every message with an ``event:`` line
454 before its ``data:`` line, so the payload cannot be recovered by
455 stripping a leading ``data: `` the way the Completions and Chat
456 Completions streams allow.
457
458 Args:
459 message: One complete SSE message, without its trailing blank line.
460
461 Returns:
462 The payload, with multi-line payloads joined by newlines as required
463 by the SSE spec, or None if the message carries no ``data:`` line.
464
465 """
466 # NOTE: split on "\n" rather than using splitlines(), which also breaks on
467 # U+2028, U+2029 and U+0085. Those are legal inside a JSON string and the
468 # server emits them unescaped, so splitlines() would truncate the payload.
469 data_lines = [
470 line.removeprefix("data:").removeprefix(" ")
471 for line in message.split("\n")
472 if line.startswith("data:")
473 ]
474 if not data_lines:
475 return None
476
477 return "\n".join(data_lines)
478
479
480 # Responses events that carry generated tokens. Reasoning deltas are included
481 # because the server is already decoding when it emits them, so excluding them
482 # would report the whole reasoning phase as time to first token.
483 _RESPONSES_TOKEN_DELTA_EVENTS = frozenset(
484 {
485 "response.output_text.delta",
486 "response.reasoning_text.delta",
487 }
488 )
489
490 # Responses events that end a request. Truncation at the output length limit
491 # is a normal benchmark outcome: vLLM reports it as `response.completed` with
492 # `status: "incomplete"`, while the spec also defines a dedicated
493 # `response.incomplete` terminal event.
494 _RESPONSES_TERMINAL_EVENTS = frozenset(
495 {
496 "response.completed",
497 "response.incomplete",
498 }
499 )
500
501 # The spec's failure terminals. vLLM does not emit them today, but
502 # `vllm bench serve` can be pointed at other Responses servers.
503 _RESPONSES_ERROR_EVENTS = frozenset({"error", "response.failed"})
504
505
506 async def async_request_openai_responses(
507 request_func_input: RequestFuncInput,
508 session: aiohttp.ClientSession,
509 pbar: tqdm | None = None,
510 ) -> RequestFuncOutput:
511 api_url = request_func_input.api_url
512 _validate_api_url(api_url, "OpenAI Responses API", "responses")
513
514 payload = {
515 "model": request_func_input.model_name
516 if request_func_input.model_name
517 else request_func_input.model,
518 "input": request_func_input.prompt,
519 "max_output_tokens": request_func_input.output_len,
520 "stream": True,
521 # `store` defaults to true. A server started with
522 # VLLM_ENABLE_RESPONSES_API_STORE=1 keeps every stored response for
523 # the lifetime of the process, which would grow its heap over a long
524 # benchmark run.
525 "store": False,
526 }
527 _update_payload_common(payload, request_func_input)
528
529 headers = _get_headers("application/json")
530 _update_headers_common(headers, request_func_input)
531
532 output = RequestFuncOutput()
533 output.prompt_len = request_func_input.prompt_len
534
535 # Only output text is accumulated. Reasoning text is timed but not
536 # returned, matching how `openai-chat` handles `DeltaMessage.reasoning`.
537 generated_text = ""
538 stream_error: str | None = None
539 saw_terminal_event = False
540 first_chunk_received = False
541 st = time.perf_counter()
542 output.start_time = st
543 most_recent_timestamp = st
544 try:
545 async with session.post(url=api_url, json=payload, headers=headers) as response:
546 if response.status == 200:
547 handler = StreamedResponseHandler()
548
549 async for chunk_bytes in response.content.iter_any():
550 for message in handler.add_chunk(chunk_bytes):
551 # NOTE: `_extract_sse_data` returns None for SSE
552 # comments, which servers may send as keep-alives.
553 chunk = _extract_sse_data(message)
554 if not chunk or chunk == "[DONE]":
555 continue
556
557 timestamp = time.perf_counter()
558 data = json.loads(chunk)
559 event_type = data.get("type")
560
561 if event_type in _RESPONSES_TOKEN_DELTA_EVENTS:
562 # First token
563 if not first_chunk_received:
564 first_chunk_received = True
565 output.ttft = timestamp - st
566
567 # Decoding phase
568 else:
569 output.itl.append(timestamp - most_recent_timestamp)
570
571 if event_type == "response.output_text.delta":
572 generated_text += data.get("delta") or ""
573
574 # Only token events advance the end of the
575 # request, so `latency - ttft` stays equal to
576 # `sum(itl)` and TPOT means what serve.py says.
577 most_recent_timestamp = timestamp
578 elif event_type in _RESPONSES_TERMINAL_EVENTS:
579 saw_terminal_event = True
580 usage = (data.get("response") or {}).get("usage") or {}
581 if (n_output := usage.get("output_tokens")) is not None:
582 output.output_tokens = n_output
583 if (n_input := usage.get("input_tokens")) is not None:
584 output.prompt_len = n_input
585 elif event_type in _RESPONSES_ERROR_EVENTS or data.get("error"):
586 stream_error = json.dumps(data.get("error") or data)
587
588 output.generated_text = generated_text
589 output.latency = most_recent_timestamp - st
590 if stream_error is not None:
591 output.success = False
592 output.error = stream_error
593 elif not first_chunk_received:
594 output.success = False
595 output.error = (
596 "Never received a token delta to calculate TTFT."
597 "This response will be marked as failed!"
598 )
599 elif not saw_terminal_event:
600 # Without the terminal event the response is truncated and
601 # its usage block is missing.
602 output.success = False
603 output.error = (
604 "Stream ended without a terminal event."
605 "This response will be marked as failed!"
606 )
607 else:
608 output.success = True
609 else:
610 output.error = response.reason or ""
611 output.success = False
612 except Exception:
613 output.success = False
614 exc_info = sys.exc_info()
615 output.error = "".join(traceback.format_exception(*exc_info))
616
617 if pbar:
618 pbar.update(1)
619 return output
620
621
622 async def async_request_openai_audio(
623 request_func_input: RequestFuncInput,
624 session: aiohttp.ClientSession,
625 pbar: tqdm | None = None,
626 ) -> RequestFuncOutput:
627 # Lazy import without PlaceholderModule to avoid vllm dep.
628 import soundfile
629
630 api_url = request_func_input.api_url
631 _validate_api_url(api_url, "OpenAI Audio API", {"transcriptions", "translations"})
632
633 payload = {
634 "model": request_func_input.model_name
635 if request_func_input.model_name
636 else request_func_input.model,
637 "max_completion_tokens": request_func_input.output_len,
638 "stream": True,
639 "language": "en",
640 # Flattened due to multipart/form-data
641 "stream_include_usage": True,
642 "stream_continuous_usage_stats": True,
643 }
644 _update_payload_common(payload, request_func_input)
645
646 headers = _get_headers()
647 _update_headers_common(headers, request_func_input)
648
649 # Send audio file
650 def to_bytes(y, sr):
651 buffer = io.BytesIO()
652 soundfile.write(buffer, y, sr, format="WAV")
653 buffer.seek(0)
654 return buffer
655
656 async def send_audio_file(
657 audio_file: io.BytesIO | Any,
658 *,
659 input_audio_duration: float,
660 filename: str | None = None,
661 content_type: str | None = None,
662 ) -> RequestFuncOutput:
663 form = aiohttp.FormData()
664 add_field_kwargs: dict[str, str] = {}
665 if filename is not None:
666 add_field_kwargs["filename"] = filename
667 if content_type is not None:
668 add_field_kwargs["content_type"] = content_type
669 form.add_field("file", audio_file, **add_field_kwargs)
670 for key, value in payload.items():
671 form.add_field(key, str(value))
672
673 output = RequestFuncOutput()
674 output.prompt_len = request_func_input.prompt_len
675 output.input_audio_duration = input_audio_duration
676
677 generated_text = ""
678 st = time.perf_counter()
679 output.start_time = st
680 most_recent_timestamp = st
681 try:
682 async with session.post(
683 url=api_url, data=form, headers=headers
684 ) as response:
685 if response.status == 200:
686 first_chunk_received = False
687 handler = StreamedResponseHandler()
688
689 async for chunk_bytes in response.content.iter_any():
690 chunk_bytes = chunk_bytes.strip()
691 if not chunk_bytes:
692 continue
693
694 messages = handler.add_chunk(chunk_bytes)
695 for message in messages:
696 if type(message) is bytes:
697 message = message.decode("utf-8")
698 chunk = message.removeprefix("data: ")
699 if chunk != "[DONE]":
700 timestamp = time.perf_counter()
701 data = json.loads(chunk)
702
703 if choices := data.get("choices"):
704 content = choices[0]["delta"].get("content")
705 # First token
706 if not first_chunk_received:
707 first_chunk_received = True
708 output.ttft = timestamp - st
709
710 # Decoding phase
711 else:
712 output.itl.append(
713 timestamp - most_recent_timestamp
714 )
715
716 generated_text += content or ""
717 # Only token chunks advance the request
718 # end; the trailing usage chunk carries no
719 # token.
720 most_recent_timestamp = timestamp
721 elif usage := data.get("usage"):
722 output.output_tokens = usage.get(
723 "completion_tokens"
724 )
725
726 output.generated_text = generated_text
727 if first_chunk_received:
728 output.success = True
729 else:
730 output.success = False
731 output.error = (
732 "Never received a valid chunk to calculate TTFT."
733 "This response will be marked as failed!"
734 )
735 output.latency = most_recent_timestamp - st
736 else:
737 output.error = response.reason or ""
738 output.success = False
739 except Exception:
740 output.success = False
741 exc_info = sys.exc_info()
742 output.error = "".join(traceback.format_exception(*exc_info))
743
744 if pbar:
745 pbar.update(1)
746 return output
747
748 mm_audio = request_func_input.multi_modal_content
749 if not isinstance(mm_audio, dict):
750 raise TypeError(
751 "multi_modal_content must be a dict containing 'audio' or 'audio_path'"
752 )
753 if "audio" in mm_audio:
754 with to_bytes(*mm_audio["audio"]) as f:
755 input_audio_duration = soundfile.info(f).duration
756 f.seek(0)
757 return await send_audio_file(
758 f,
759 input_audio_duration=input_audio_duration,
760 filename="audio.wav",
761 content_type="audio/wav",
762 )
763 if "audio_path" in mm_audio:
764 audio_path = mm_audio["audio_path"]
765 with open(audio_path, "rb") as f:
766 return await send_audio_file(
767 f,
768 input_audio_duration=soundfile.info(audio_path).duration,
769 filename=os.path.basename(audio_path),
770 )
771 raise TypeError(
772 "multi_modal_content must be a dict containing 'audio' or 'audio_path'"
773 )
774
775
776 async def _run_pooling_request(
777 session: aiohttp.ClientSession,
778 api_url: str,
779 payload: dict[str, Any],
780 headers: dict[str, Any],
781 pbar: tqdm | None = None,
782 num_input_sequences: int = 1,
783 prompt_len: int = 0,
784 ) -> RequestFuncOutput:
785 output = RequestFuncOutput(num_input_sequences=num_input_sequences)
786 st = time.perf_counter()
787 output.start_time = st
788 try:
789 async with session.post(url=api_url, headers=headers, json=payload) as response:
790 if response.status == 200:
791 encoding_format = payload.get("encoding_format", "float")
792 if encoding_format in ("bytes", "bytes_only"):
793 async for _ in response.content.iter_any():
794 pass
795 else:
796 await response.read()
797 output.ttft = output.latency = time.perf_counter() - st
798
799 if encoding_format == "bytes":
800 metadata = json.loads(response.headers["metadata"])
801 usage = metadata.get("usage", {})
802 elif encoding_format == "bytes_only":
803 usage = {"prompt_tokens": prompt_len}
804 else:
805 data = await response.json()
806 usage = data.get("usage", {})
807
808 output.success = True
809 output.generated_text = ""
810 output.prompt_len = usage.get("prompt_tokens", 0)
811 else:
812 output.success = False
813 output.error = response.reason or ""
814 except Exception as e:
815 output.success = False
816 output.error = str(e)
817
818 if pbar:
819 pbar.update(1)
820 return output
821
822
823 def _get_num_input_sequences(prompt: Any) -> int:
824 if prompt and isinstance(prompt, list) and isinstance(prompt[0], (str, list)):
825 return len(prompt)
826 return 1
827
828
829 async def async_request_openai_embeddings(
830 request_func_input: RequestFuncInput,
831 session: aiohttp.ClientSession,
832 pbar: tqdm | None = None,
833 ) -> RequestFuncOutput:
834 api_url = request_func_input.api_url
835 _validate_api_url(api_url, "OpenAI Embeddings API", "embeddings")
836
837 payload = {
838 "model": request_func_input.model_name
839 if request_func_input.model_name
840 else request_func_input.model,
841 "input": request_func_input.prompt,
842 # Many embedding models have short context length,
843 # this is to avoid dropping some of the requests.
844 "truncate_prompt_tokens": -1,
845 }
846 _update_payload_common(payload, request_func_input)
847
848 headers = _get_headers("application/json")
849 _update_headers_common(headers, request_func_input)
850
851 return await _run_pooling_request(
852 session,
853 api_url,
854 payload=payload,
855 headers=headers,
856 pbar=pbar,
857 num_input_sequences=_get_num_input_sequences(request_func_input.prompt),
858 prompt_len=request_func_input.prompt_len,
859 )
860
861
862 async def async_request_vllm_rerank(
863 request_func_input: RequestFuncInput,
864 session: aiohttp.ClientSession,
865 pbar: tqdm | None = None,
866 ) -> RequestFuncOutput:
867 api_url = request_func_input.api_url
868 _validate_api_url(api_url, "vLLM score API", "rerank")
869
870 assert (
871 isinstance(request_func_input.prompt, list)
872 and len(request_func_input.prompt) > 1
873 )
874
875 payload = {
876 "model": request_func_input.model_name
877 if request_func_input.model_name
878 else request_func_input.model,
879 "query": request_func_input.prompt[0],
880 "documents": request_func_input.prompt[1:],
881 # Many reranker models have short context length,
882 # this is to avoid dropping some of the requests.
883 "truncate_prompt_tokens": -1,
884 }
885
886 headers = _get_headers("application/json")
887 _update_headers_common(headers, request_func_input)
888
889 return await _run_pooling_request(
890 session,
891 api_url,
892 payload=payload,
893 headers=headers,
894 pbar=pbar,
895 num_input_sequences=len(request_func_input.prompt) - 1,
896 prompt_len=request_func_input.prompt_len,
897 )
898
899
900 async def async_request_openai_embeddings_chat(
901 request_func_input: RequestFuncInput,
902 session: aiohttp.ClientSession,
903 pbar: tqdm | None = None,
904 mm_position: Literal["first", "last"] = "last",
905 ) -> RequestFuncOutput:
906 api_url = request_func_input.api_url
907 _validate_api_url(api_url, "OpenAI Embeddings API", "embeddings")
908
909 messages = _get_chat_messages(request_func_input, mm_position=mm_position)
910
911 payload = {
912 "model": request_func_input.model_name
913 if request_func_input.model_name
914 else request_func_input.model,
915 "messages": messages,
916 # Many embedding models have short context length,
917 # this is to avoid dropping some of the requests.
918 "truncate_prompt_tokens": -1,
919 }
920 _update_payload_common(payload, request_func_input)
921
922 headers = _get_headers("application/json")
923 _update_headers_common(headers, request_func_input)
924
925 return await _run_pooling_request(
926 session,
927 api_url,
928 payload=payload,
929 headers=headers,
930 pbar=pbar,
931 prompt_len=request_func_input.prompt_len,
932 )
933
934
935 def _try_extract_request_idx(request_func_input: RequestFuncInput):
936 if request_func_input.request_id:
937 match = re.search(r"(\d+)$", request_func_input.request_id)
938 if match:
939 try:
940 return int(match.group(1))
941 except ValueError:
942 pass
943
944 return None
945
946
947 def _preprocess_clip(request_func_input: RequestFuncInput):
948 if request_func_input.multi_modal_content:
949 # Image input
950 request_func_input.prompt = ""
951
952
953 def _preprocess_vlm2vec(request_func_input: RequestFuncInput):
954 if request_func_input.multi_modal_content:
955 request_idx = _try_extract_request_idx(request_func_input)
956
957 # Adjust the ratio manually if needed.
958 use_image_only_prompt = request_idx is None or request_idx % 2 == 0
959
960 if use_image_only_prompt:
961 # Image input
962 request_func_input.prompt = "Represent the given image."
963 else:
964 # Text+Image input
965 request_func_input.prompt = (
966 f"Represent the given image with the following question: "
967 f"{request_func_input.prompt}"
968 )
969
970
971 async def async_request_openai_embeddings_clip(
972 request_func_input: RequestFuncInput,
973 session: aiohttp.ClientSession,
974 pbar: tqdm | None = None,
975 ) -> RequestFuncOutput:
976 _preprocess_clip(request_func_input)
977
978 return await async_request_openai_embeddings_chat(
979 request_func_input,
980 session,
981 pbar=pbar,
982 )
983
984
985 async def async_request_openai_embeddings_vlm2vec(
986 request_func_input: RequestFuncInput,
987 session: aiohttp.ClientSession,
988 pbar: tqdm | None = None,
989 ) -> RequestFuncOutput:
990 _preprocess_vlm2vec(request_func_input)
991
992 return await async_request_openai_embeddings_chat(
993 request_func_input,
994 session,
995 pbar=pbar,
996 mm_position="first",
997 )
998
999
1000 async def async_request_infinity_embeddings(
1001 request_func_input: RequestFuncInput,
1002 session: aiohttp.ClientSession,
1003 pbar: tqdm | None = None,
1004 ) -> RequestFuncOutput:
1005 api_url = request_func_input.api_url
1006 _validate_api_url(api_url, "Infinity Embeddings API", "embeddings")
1007
1008 payload: dict[str, Any] = {
1009 "model": request_func_input.model_name
1010 if request_func_input.model_name
1011 else request_func_input.model,
1012 }
1013
1014 if request_func_input.prompt:
1015 payload["input"] = request_func_input.prompt
1016 else:
1017 mm_content = request_func_input.multi_modal_content
1018 assert isinstance(mm_content, dict)
1019
1020 mm_type = mm_content["type"]
1021 payload["input"] = mm_content[mm_type]["url"]
1022 payload["modality"] = mm_type.split("_", 1)[0]
1023
1024 _update_payload_common(payload, request_func_input)
1025
1026 headers = _get_headers("application/json")
1027 _update_headers_common(headers, request_func_input)
1028
1029 return await _run_pooling_request(
1030 session,
1031 api_url,
1032 payload=payload,
1033 headers=headers,
1034 pbar=pbar,
1035 num_input_sequences=_get_num_input_sequences(request_func_input.prompt),
1036 prompt_len=request_func_input.prompt_len,
1037 )
1038
1039
1040 async def async_request_infinity_embeddings_clip(
1041 request_func_input: RequestFuncInput,
1042 session: aiohttp.ClientSession,
1043 pbar: tqdm | None = None,
1044 ) -> RequestFuncOutput:
1045 _preprocess_clip(request_func_input)
1046
1047 return await async_request_infinity_embeddings(
1048 request_func_input,
1049 session,
1050 pbar=pbar,
1051 )
1052
1053
1054 async def async_request_vllm_pooling(
1055 request_func_input: RequestFuncInput,
1056 session: aiohttp.ClientSession,
1057 pbar: tqdm | None = None,
1058 ) -> RequestFuncOutput:
1059 api_url = request_func_input.api_url
1060 _validate_api_url(api_url, "vLLM Pooling API", "pooling")
1061
1062 payload = {
1063 "model": request_func_input.model_name
1064 if request_func_input.model_name
1065 else request_func_input.model,
1066 "truncate_prompt_tokens": -1,
1067 }
1068
1069 if isinstance(request_func_input.prompt, dict):
1070 payload = payload | request_func_input.prompt
1071 else:
1072 payload["input"] = request_func_input.prompt
1073
1074 _update_payload_common(payload, request_func_input)
1075
1076 headers = _get_headers("application/json")
1077 _update_headers_common(headers, request_func_input)
1078
1079 return await _run_pooling_request(
1080 session,
1081 api_url,
1082 payload=payload,
1083 headers=headers,
1084 pbar=pbar,
1085 num_input_sequences=_get_num_input_sequences(request_func_input.prompt),
1086 prompt_len=request_func_input.prompt_len,
1087 )
1088
1089
1090 # TODO: Add more request functions for different API protocols.
1091 ASYNC_REQUEST_FUNCS: dict[str, RequestFunc] = {
1092 "vllm": async_request_openai_completions,
1093 "openai": async_request_openai_completions,
1094 "openai-chat": async_request_openai_chat_completions,
1095 "openai-responses": async_request_openai_responses,
1096 "openai-audio": async_request_openai_audio,
1097 "openai-embeddings": async_request_openai_embeddings,
1098 "openai-embeddings-chat": async_request_openai_embeddings_chat,
1099 "openai-embeddings-clip": async_request_openai_embeddings_clip,
1100 "openai-embeddings-vlm2vec": async_request_openai_embeddings_vlm2vec,
1101 # Infinity embedding server: https://github.com/michaelfeil/infinity
1102 "infinity-embeddings": async_request_infinity_embeddings,
1103 "infinity-embeddings-clip": async_request_infinity_embeddings_clip,
1104 # (Infinity embedding server does not support vlm2vec)
1105 "vllm-pooling": async_request_vllm_pooling,
1106 "vllm-rerank": async_request_vllm_rerank,
1107 }
1108
1109 POOLING_BACKENDS = {
1110 "openai-embeddings",
1111 "openai-embeddings-chat",
1112 "openai-embeddings-clip",
1113 "openai-embeddings-vlm2vec",
1114 "infinity-embeddings",
1115 "infinity-embeddings-clip",
1116 "vllm-pooling",
1117 "vllm-rerank",
1118 }
1119
1120 OPENAI_COMPATIBLE_BACKENDS = [
1121 k
1122 for k, v in ASYNC_REQUEST_FUNCS.items()
1123 if v
1124 in (
1125 async_request_openai_completions,
1126 async_request_openai_chat_completions,
1127 async_request_openai_responses,
1128 )
1129 ]