main
py 341 lines 13.6 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import asyncio
5 import time
6 from collections.abc import AsyncGenerator
7 from http import HTTPStatus
8
9 from fastapi import Request
10
11 from vllm.entrypoints.chat_utils import ConversationMessage
12 from vllm.entrypoints.generate.base.protocol import RequestResponseMetadata
13 from vllm.entrypoints.openai.chat_completion.protocol import (
14 BatchChatCompletionRequest,
15 ChatCompletionRequest,
16 ChatCompletionResponse,
17 ChatCompletionResponseChoice,
18 ChatMessage,
19 )
20 from vllm.entrypoints.openai.chat_completion.serving import OpenAIServingChat
21 from vllm.entrypoints.serve.engine.protocol import ErrorResponse, UsageInfo
22 from vllm.entrypoints.serve.utils.api_utils import get_max_tokens
23 from vllm.inputs import EngineInput
24 from vllm.logger import init_logger
25 from vllm.outputs import RequestOutput
26 from vllm.parser.abstract_parser import Parser
27 from vllm.tokenizers import TokenizerLike
28 from vllm.utils.async_utils import merge_async_iterators
29 from vllm.utils.collection_utils import as_list
30
31 logger = init_logger(__name__)
32
33
34 class OpenAIServingChatBatch(OpenAIServingChat):
35 """Extends OpenAIServingChat with the /v1/chat/completions/batch endpoint.
36
37 Processes N conversations from a single request concurrently and returns
38 one choice per conversation indexed 0, 1, ..., N-1.
39 """
40
41 async def render_batch_chat_request(
42 self,
43 request: BatchChatCompletionRequest,
44 ) -> (
45 tuple[
46 list[ChatCompletionRequest],
47 list[list[ConversationMessage]],
48 list[EngineInput],
49 ]
50 | ErrorResponse
51 ):
52 """Validate the model and preprocess a batched chat completion request.
53
54 Performs engine-aware checks then delegates per-conversation
55 preprocessing to OnlineRenderer, validating the chat template
56 once for the whole batch.
57
58 Returns:
59 A tuple of (single_requests, all_conversations, engine_prompts) on
60 success — one entry per conversation — or an ErrorResponse on
61 failure. The renderer adjusts ``single_requests`` in place, so
62 sampling must read them rather than rebuilding from ``request``.
63
64 """
65 error_check_ret = await self._check_model(request)
66 if error_check_ret is not None:
67 logger.error("Error with model %s", error_check_ret)
68 return error_check_ret
69
70 self._preflight()
71
72 renderer = self.online_renderer
73
74 if not renderer.use_harmony:
75 # Common case: validate the chat template once for the whole batch.
76 error_check_ret = renderer.validate_chat_template(
77 request_chat_template=request.chat_template,
78 chat_template_kwargs=request.chat_template_kwargs,
79 trust_request_chat_template=renderer.trust_request_chat_template,
80 )
81 if error_check_ret is not None:
82 return error_check_ret
83
84 parser = renderer.parser
85 tool_dicts: list[dict] | None = None
86
87 single_requests: list[ChatCompletionRequest] = []
88 all_conversations: list[list[ConversationMessage]] = []
89 all_engine_prompts: list[EngineInput] = []
90
91 for messages in request.messages:
92 single_request = request.to_chat_completion_request(messages)
93 if renderer.use_harmony:
94 renderer.adjust_harmony_request(single_request)
95 conversation, engine_prompts = renderer._make_request_with_harmony(
96 single_request, should_include_tools=tool_dicts is not None
97 )
98 else:
99 conversation, engine_prompts = await renderer.preprocess_chat(
100 single_request,
101 messages,
102 default_template=renderer.chat_template,
103 default_template_content_format=renderer.chat_template_content_format,
104 default_template_kwargs=renderer.default_chat_template_kwargs,
105 tool_dicts=tool_dicts,
106 parser=parser,
107 )
108 single_requests.append(single_request)
109 all_conversations.append(conversation)
110 all_engine_prompts.append(engine_prompts[0])
111
112 return single_requests, all_conversations, all_engine_prompts
113
114 async def create_batch_chat_completion(
115 self,
116 request: BatchChatCompletionRequest,
117 raw_request: Request | None = None,
118 ) -> ChatCompletionResponse | ErrorResponse:
119 """Batch Chat Completion endpoint (/v1/chat/completions/batch).
120
121 Processes N conversations from a single request concurrently and
122 returns one choice per conversation indexed 0, 1, ..., N-1.
123 Streaming, tool use, and beam search are not supported.
124 """
125 tokenizer = self.renderer.tokenizer
126 assert tokenizer is not None
127 render_result = await self.render_batch_chat_request(request)
128 if isinstance(render_result, ErrorResponse):
129 return render_result
130 single_requests, all_conversations, engine_prompts = render_result
131
132 request_id = (
133 f"chatcmpl-{self._base_request_id(raw_request, request.request_id)}"
134 )
135 request_metadata = RequestResponseMetadata(request_id=request_id)
136 if raw_request:
137 raw_request.state.request_metadata = request_metadata
138
139 lora_request = self._maybe_get_adapters(request, supports_default_mm_loras=True)
140 model_name = self.models.model_name(lora_request)
141 data_parallel_rank = self._get_data_parallel_rank(raw_request)
142 max_model_len = self.model_config.max_model_len
143
144 generators: list[AsyncGenerator[RequestOutput, None]] = []
145 for i, engine_prompt in enumerate(engine_prompts):
146 sub_request_id = f"{request_id}_{i}"
147 max_tokens = get_max_tokens(
148 max_model_len,
149 request.max_completion_tokens
150 if request.max_completion_tokens is not None
151 else request.max_tokens,
152 self._extract_prompt_len(engine_prompt),
153 self.default_sampling_params,
154 self.override_max_tokens,
155 )
156 single_request = single_requests[i]
157 sampling_params = single_request.to_sampling_params(
158 max_tokens, self.default_sampling_params
159 )
160 self._log_inputs(
161 sub_request_id,
162 engine_prompt,
163 params=sampling_params,
164 lora_request=lora_request,
165 )
166 trace_headers = (
167 None
168 if raw_request is None
169 else await self._get_trace_headers(raw_request.headers)
170 )
171 session_id = self._get_session_id(single_request, raw_request)
172 generators.append(
173 self.engine_client.generate(
174 engine_prompt,
175 sampling_params,
176 sub_request_id,
177 lora_request=lora_request,
178 trace_headers=trace_headers,
179 priority=request.priority,
180 data_parallel_rank=data_parallel_rank,
181 session_id=session_id,
182 reasoning_ended=None,
183 )
184 )
185
186 return await self.chat_completion_full_generator_batch(
187 request, # type: ignore[arg-type]
188 generators,
189 request_id,
190 model_name,
191 all_conversations,
192 tokenizer,
193 request_metadata,
194 self.parser_cls,
195 )
196
197 async def chat_completion_full_generator_batch(
198 self,
199 request: BatchChatCompletionRequest, # type: ignore[override]
200 generators: list[AsyncGenerator[RequestOutput, None]],
201 request_id: str,
202 model_name: str,
203 all_conversations: list[list[ConversationMessage]],
204 tokenizer: TokenizerLike,
205 request_metadata: RequestResponseMetadata,
206 parser_cls: type[Parser] | None = None,
207 ) -> ErrorResponse | ChatCompletionResponse:
208 """Handle batched (non-streaming) chat completions.
209
210 Fans out N generators (one per conversation in the batch), collects
211 the final output for each, and assembles a single
212 ``ChatCompletionResponse`` whose ``choices`` are indexed 0,...,N-1.
213
214 Tool-use and streaming are rejected upstream by the
215 ``check_batch_mode`` validator, so neither needs to be handled here.
216 """
217 created_time = int(time.time())
218 final_results: dict[int, RequestOutput] = {}
219 try:
220 async for prompt_idx, res in merge_async_iterators(*generators):
221 final_results[prompt_idx] = res
222 except asyncio.CancelledError:
223 return self.create_error_response("Client disconnected")
224
225 choices: list[ChatCompletionResponseChoice] = []
226 total_prompt_tokens = 0
227 total_completion_tokens = 0
228
229 for prompt_idx in range(len(generators)):
230 final_res = final_results.get(prompt_idx)
231 if final_res is None:
232 return self.create_error_response(
233 f"No output received from the engine for prompt {prompt_idx}.",
234 err_type="InternalServerError",
235 status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
236 )
237
238 assert final_res.prompt_token_ids is not None
239 num_prompt_tokens = len(final_res.prompt_token_ids)
240 if final_res.encoder_prompt_token_ids is not None:
241 num_prompt_tokens += len(final_res.encoder_prompt_token_ids)
242 total_prompt_tokens += num_prompt_tokens
243 total_completion_tokens += sum(
244 len(output.token_ids) for output in final_res.outputs
245 )
246
247 for output in final_res.outputs:
248 self._raise_if_error(output.finish_reason, request_id)
249
250 if request.logprobs and (
251 request.top_logprobs is not None or request.logprob_token_ids
252 ):
253 assert output.logprobs is not None, "Did not output logprobs"
254 logprobs = self._create_chat_logprobs(
255 token_ids=output.token_ids,
256 top_logprobs=output.logprobs,
257 num_output_top_logprobs=request.top_logprobs,
258 logprob_token_ids=request.logprob_token_ids,
259 tokenizer=tokenizer,
260 return_as_token_id=request.return_tokens_as_token_ids,
261 )
262 else:
263 logprobs = None
264
265 if parser_cls is not None:
266 single_request = request.to_chat_completion_request(
267 request.messages[prompt_idx]
268 )
269 parser = parser_cls(
270 tokenizer,
271 None,
272 chat_template_kwargs=self._effective_chat_template_kwargs(
273 single_request
274 ),
275 )
276 parser.set_prompt_token_ids(final_res.prompt_token_ids)
277 reasoning, content, _ = parser.parse(
278 output.text,
279 request=single_request,
280 model_output_token_ids=output.token_ids,
281 )
282 if not request.include_reasoning:
283 reasoning = None
284 else:
285 reasoning = None
286 content = output.text
287
288 role = (
289 self.response_role
290 if request.add_generation_prompt
291 else request.messages[prompt_idx][-1]["role"]
292 )
293
294 message = ChatMessage(role=role, reasoning=reasoning, content=content)
295
296 if request.echo:
297 conversation = all_conversations[prompt_idx]
298 last_msg_content: str | list[dict[str, str]] = ""
299 if (
300 conversation
301 and "content" in conversation[-1]
302 and conversation[-1].get("role") == role
303 ):
304 last_msg_content = conversation[-1]["content"] or ""
305 if isinstance(last_msg_content, list):
306 last_msg_content = "\n".join(
307 msg["text"] for msg in last_msg_content
308 )
309 message.content = last_msg_content + (message.content or "")
310
311 choice_data = ChatCompletionResponseChoice(
312 index=prompt_idx,
313 message=message,
314 logprobs=logprobs,
315 finish_reason=output.finish_reason
316 if output.finish_reason
317 else "stop",
318 stop_reason=output.stop_reason,
319 token_ids=(
320 as_list(output.token_ids) if request.return_token_ids else None
321 ),
322 )
323 choices.append(choice_data)
324
325 usage = UsageInfo(
326 prompt_tokens=total_prompt_tokens,
327 completion_tokens=total_completion_tokens,
328 total_tokens=total_prompt_tokens + total_completion_tokens,
329 )
330 request_metadata.final_usage_info = usage
331
332 choices.sort(key=lambda c: c.index)
333
334 return ChatCompletionResponse(
335 id=request_id,
336 created=created_time,
337 model=model_name,
338 choices=choices,
339 usage=usage,
340 system_fingerprint=self.system_fingerprint,
341 )