main
py 456 lines 14.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from collections.abc import AsyncGenerator
6 from unittest.mock import MagicMock
7
8 import httpx
9 import pytest
10
11 from tests.entrypoints.openai.chat_completion.test_serving_chat import (
12 BASE_MODEL_PATHS,
13 MockHFConfig,
14 MockModelConfig,
15 _build_renderer,
16 )
17 from tests.entrypoints.openai.chat_completion.test_serving_chat import (
18 MODEL_NAME as MOCK_MODEL_NAME,
19 )
20 from tests.utils import RemoteOpenAIServer
21 from vllm.entrypoints.generate.base.protocol import RequestResponseMetadata
22 from vllm.entrypoints.openai.chat_completion.batch_serving import (
23 OpenAIServingChatBatch,
24 )
25 from vllm.entrypoints.openai.chat_completion.protocol import (
26 BatchChatCompletionRequest,
27 )
28 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
29 from vllm.exceptions import VLLMValidationError
30 from vllm.outputs import CompletionOutput, RequestOutput
31 from vllm.renderers.online_renderer import OnlineRenderer
32 from vllm.v1.engine.async_llm import AsyncLLM
33
34 # any model with a chat template defined in tokenizer_config should work here
35 MODEL_NAME = "Qwen/Qwen2.5-1.5B-Instruct"
36
37
38 @pytest.fixture(scope="module")
39 def default_server_args():
40 return [
41 # use half precision for speed and memory savings in CI environment
42 "--max-model-len",
43 "2048",
44 "--max-num-seqs",
45 "128",
46 "--enforce-eager",
47 ]
48
49
50 @pytest.fixture(scope="module")
51 def server(default_server_args):
52 with RemoteOpenAIServer(MODEL_NAME, default_server_args) as remote_server:
53 yield remote_server
54
55
56 @pytest.mark.asyncio
57 @pytest.mark.parametrize(
58 "model_name",
59 [MODEL_NAME],
60 )
61 async def test_batched_chat_completions(
62 server: RemoteOpenAIServer, model_name: str
63 ) -> None:
64 conversations = [
65 [{"role": "user", "content": "Reply with exactly the word: alpha"}],
66 [{"role": "user", "content": "Reply with exactly the word: beta"}],
67 ]
68
69 async with httpx.AsyncClient() as http_client:
70 response = await http_client.post(
71 f"{server.url_for('v1/chat/completions/batch')}",
72 json={
73 "model": model_name,
74 "messages": conversations,
75 },
76 timeout=60,
77 )
78
79 assert response.status_code == 200, response.text
80 data = response.json()
81
82 choices = data["choices"]
83 assert len(choices) == 2
84
85 indices = {choice["index"] for choice in choices}
86 assert indices == {0, 1}
87
88 # Each conversation should produce a non-empty text response.
89 for choice in choices:
90 assert choice["message"]["content"]
91
92
93 @pytest.mark.asyncio
94 @pytest.mark.parametrize(
95 "model_name",
96 [MODEL_NAME],
97 )
98 async def test_batched_chat_completions_with_json_schema(
99 server: RemoteOpenAIServer, model_name: str
100 ) -> None:
101 schema = {
102 "type": "object",
103 "properties": {
104 "answer": {"type": "string", "enum": ["yes", "no"]},
105 },
106 "required": ["answer"],
107 }
108 conversations = [
109 [{"role": "user", "content": "Is the sky blue? Answer in JSON."}],
110 [{"role": "user", "content": "Is fire cold? Answer in JSON."}],
111 ]
112
113 async with httpx.AsyncClient() as http_client:
114 response = await http_client.post(
115 f"{server.url_for('v1/chat/completions/batch')}",
116 json={
117 "model": model_name,
118 "messages": conversations,
119 "response_format": {
120 "type": "json_schema",
121 "json_schema": {"name": "answer", "schema": schema, "strict": True},
122 },
123 },
124 timeout=60,
125 )
126
127 assert response.status_code == 200, response.text
128 data = response.json()
129
130 choices = data["choices"]
131 assert len(choices) == 2
132
133 for choice in choices:
134 parsed = json.loads(choice["message"]["content"])
135 assert "answer" in parsed
136 assert parsed["answer"] in ("yes", "no")
137
138
139 @pytest.mark.asyncio
140 @pytest.mark.parametrize(
141 "model_name",
142 [MODEL_NAME],
143 )
144 async def test_batched_chat_completions_logprobs_not_token_id_placeholders(
145 server: RemoteOpenAIServer, model_name: str
146 ) -> None:
147 # Regression test: requesting `return_token_ids` alongside logprobs must not
148 # corrupt the logprob `token` fields into "token_id:{id}" placeholders. That
149 # placeholder rendering is controlled by `return_tokens_as_token_ids`, which
150 # this request leaves unset.
151 conversations = [
152 [{"role": "user", "content": "Reply with exactly the word: alpha"}],
153 ]
154
155 async with httpx.AsyncClient() as http_client:
156 response = await http_client.post(
157 f"{server.url_for('v1/chat/completions/batch')}",
158 json={
159 "model": model_name,
160 "messages": conversations,
161 "logprobs": True,
162 "top_logprobs": 1,
163 "return_token_ids": True,
164 },
165 timeout=60,
166 )
167
168 assert response.status_code == 200, response.text
169 data = response.json()
170
171 content = data["choices"][0]["logprobs"]["content"]
172 assert content
173 for entry in content:
174 assert not entry["token"].startswith("token_id:")
175 for top in entry["top_logprobs"]:
176 assert not top["token"].startswith("token_id:")
177
178
179 @pytest.mark.asyncio
180 @pytest.mark.parametrize(
181 "model_name",
182 [MODEL_NAME],
183 )
184 async def test_batched_chat_completions_return_tokens_as_token_ids(
185 server: RemoteOpenAIServer, model_name: str
186 ) -> None:
187 # Complementary check: when `return_tokens_as_token_ids` is explicitly set,
188 # the logprob tokens *should* be rendered as "token_id:{id}" placeholders,
189 # proving the new field is actually wired through.
190 conversations = [
191 [{"role": "user", "content": "Reply with exactly the word: alpha"}],
192 ]
193
194 async with httpx.AsyncClient() as http_client:
195 response = await http_client.post(
196 f"{server.url_for('v1/chat/completions/batch')}",
197 json={
198 "model": model_name,
199 "messages": conversations,
200 "logprobs": True,
201 "top_logprobs": 1,
202 "return_tokens_as_token_ids": True,
203 },
204 timeout=60,
205 )
206
207 assert response.status_code == 200, response.text
208 data = response.json()
209
210 content = data["choices"][0]["logprobs"]["content"]
211 assert content
212 assert all(entry["token"].startswith("token_id:") for entry in content)
213
214
215 @pytest.mark.asyncio
216 async def test_batched_chat_completions_logprob_token_ids(
217 server: RemoteOpenAIServer,
218 ) -> None:
219 conversations = [[{"role": "user", "content": "Hello"}]]
220
221 async with httpx.AsyncClient() as http_client:
222 response = await http_client.post(
223 f"{server.url_for('v1/chat/completions/batch')}",
224 json={
225 "model": MODEL_NAME,
226 "messages": conversations,
227 "max_tokens": 1,
228 "temperature": 0,
229 "logprobs": True,
230 "top_logprobs": 5,
231 "logprob_token_ids": [100, 1000, 5000],
232 "return_tokens_as_token_ids": True,
233 },
234 timeout=60,
235 )
236
237 assert response.status_code == 200, response.text
238 content = response.json()["choices"][0]["logprobs"]["content"]
239 assert content
240 sampled_token = content[0]["token"]
241 assert {entry["token"] for entry in content[0]["top_logprobs"]} == {
242 "token_id:100",
243 "token_id:1000",
244 "token_id:5000",
245 sampled_token,
246 }
247
248
249 def _make_request_output(
250 prompt_idx: int,
251 text: str,
252 prompt_token_ids: list[int] | None = None,
253 ) -> RequestOutput:
254 return RequestOutput(
255 request_id=f"req-{prompt_idx}",
256 prompt=None,
257 prompt_token_ids=prompt_token_ids or [1, 2, 3],
258 prompt_logprobs=None,
259 outputs=[
260 CompletionOutput(
261 index=0,
262 text=text,
263 token_ids=[4, 5],
264 cumulative_logprob=None,
265 logprobs=None,
266 finish_reason="stop",
267 )
268 ],
269 finished=True,
270 )
271
272
273 async def _generator(
274 prompt_idx: int,
275 text: str,
276 prompt_token_ids: list[int] | None = None,
277 ) -> AsyncGenerator[RequestOutput, None]:
278 yield _make_request_output(prompt_idx, text, prompt_token_ids)
279
280
281 @pytest.mark.asyncio
282 @pytest.mark.skip_global_cleanup
283 async def test_batched_echo_does_not_prepend_user_prompt() -> None:
284 """Echo must only echo the assistant turn, never the user's prompt.
285
286 With `add_generation_prompt` (the default) the response role is `assistant`
287 while the last conversation message is the user's, so there is nothing to
288 echo; without the role check the user prompt leaks into the answer.
289 """
290 serving = OpenAIServingChatBatch.__new__(OpenAIServingChatBatch)
291 serving.response_role = "assistant"
292 serving.system_fingerprint = None
293
294 conversations = [[{"role": "user", "content": "USER PROMPT"}]]
295 request = BatchChatCompletionRequest(
296 model="test-model",
297 messages=conversations,
298 echo=True,
299 )
300
301 response = await serving.chat_completion_full_generator_batch(
302 request=request,
303 generators=[_generator(0, "ASSISTANT ANSWER")],
304 request_id="req-echo",
305 model_name="test-model",
306 all_conversations=conversations,
307 tokenizer=None,
308 request_metadata=RequestResponseMetadata(request_id="req-echo"),
309 )
310
311 assert response.choices[0].message.content == "ASSISTANT ANSWER"
312
313
314 @pytest.mark.asyncio
315 @pytest.mark.skip_global_cleanup
316 async def test_batched_echo_prepends_matching_assistant_prefix() -> None:
317 """A trailing assistant turn is a real prefix and must still be echoed."""
318 serving = OpenAIServingChatBatch.__new__(OpenAIServingChatBatch)
319 serving.response_role = "assistant"
320 serving.system_fingerprint = None
321
322 conversations = [
323 [
324 {"role": "user", "content": "USER PROMPT"},
325 {"role": "assistant", "content": "PREFIX "},
326 ]
327 ]
328 request = BatchChatCompletionRequest(
329 model="test-model",
330 messages=conversations,
331 echo=True,
332 add_generation_prompt=False,
333 continue_final_message=True,
334 )
335
336 response = await serving.chat_completion_full_generator_batch(
337 request=request,
338 generators=[_generator(0, "ASSISTANT ANSWER")],
339 request_id="req-echo-2",
340 model_name="test-model",
341 all_conversations=conversations,
342 tokenizer=None,
343 request_metadata=RequestResponseMetadata(request_id="req-echo-2"),
344 )
345
346 assert response.choices[0].message.content == "PREFIX ASSISTANT ANSWER"
347
348
349 @pytest.mark.asyncio
350 @pytest.mark.skip_global_cleanup
351 async def test_batched_harmony_response_format_uses_structural_tag() -> None:
352 """Harmony must constrain only the final channel of every conversation."""
353 mock_engine = MagicMock(spec=AsyncLLM)
354 mock_engine.model_config = MockModelConfig()
355 mock_engine.errored = False
356 mock_engine.input_processor = MagicMock()
357 mock_engine.model_config.hf_config = MockHFConfig(model_type="gpt_oss")
358 mock_engine.renderer = _build_renderer(mock_engine.model_config)
359 mock_engine.generate.side_effect = lambda *args, **kwargs: _generator(0, "ok")
360
361 models = OpenAIServingModels(mock_engine, BASE_MODEL_PATHS)
362 online_renderer = OnlineRenderer(
363 model_config=mock_engine.model_config,
364 renderer=mock_engine.renderer,
365 request_logger=None,
366 chat_template=None,
367 chat_template_content_format="auto",
368 reasoning_parser="openai_gptoss",
369 )
370 serving = OpenAIServingChatBatch(
371 mock_engine,
372 models,
373 response_role="assistant",
374 online_renderer=online_renderer,
375 chat_template=None,
376 chat_template_content_format="auto",
377 request_logger=None,
378 )
379
380 request = BatchChatCompletionRequest(
381 model=MOCK_MODEL_NAME,
382 messages=[
383 [{"role": "user", "content": "France?"}],
384 [{"role": "user", "content": "Japan?"}],
385 ],
386 response_format={
387 "type": "json_schema",
388 "json_schema": {"name": "city", "schema": {"type": "object"}},
389 },
390 )
391 await serving.create_batch_chat_completion(request)
392
393 calls = mock_engine.generate.call_args_list
394 assert len(calls) == 2
395 for call in calls:
396 assert call.args[1].structured_outputs.structural_tag is not None
397
398
399 @pytest.mark.skip_global_cleanup
400 def test_batch_rejects_kv_transfer_prompt_token_ids():
401 """One pre-tokenized prompt cannot stand in for every conversation."""
402 with pytest.raises(
403 VLLMValidationError, match=r"parameter=kv_transfer_params\.prompt_token_ids"
404 ):
405 BatchChatCompletionRequest(
406 model="test-model",
407 messages=[
408 [{"role": "user", "content": "first"}],
409 [{"role": "user", "content": "second"}],
410 ],
411 kv_transfer_params={"prompt_token_ids": [10, 20, 30]},
412 )
413
414
415 @pytest.mark.asyncio
416 @pytest.mark.skip_global_cleanup
417 async def test_batched_parser_receives_each_prompt_prefix() -> None:
418 prefixes = []
419
420 class RecordingParser:
421 def __init__(self, *args, **kwargs):
422 self.prompt_token_ids = None
423
424 def set_prompt_token_ids(self, prompt_token_ids):
425 self.prompt_token_ids = prompt_token_ids
426
427 def parse(self, model_output, **kwargs):
428 prefixes.append(self.prompt_token_ids)
429 return None, model_output, None
430
431 serving = OpenAIServingChatBatch.__new__(OpenAIServingChatBatch)
432 serving.response_role = "assistant"
433 serving.system_fingerprint = None
434 serving.chat_template = None
435 serving.chat_template_content_format = "auto"
436 serving.default_chat_template_kwargs = {}
437
438 request = BatchChatCompletionRequest(
439 model="test-model",
440 messages=[
441 [{"role": "user", "content": "first"}],
442 [{"role": "user", "content": "second"}],
443 ],
444 )
445 await serving.chat_completion_full_generator_batch(
446 request=request,
447 generators=[_generator(0, "one", [1]), _generator(1, "two", [2])],
448 request_id="req-prefix",
449 model_name="test-model",
450 all_conversations=request.messages,
451 tokenizer=object(),
452 request_metadata=RequestResponseMetadata(request_id="req-prefix"),
453 parser_cls=RecordingParser,
454 )
455
456 assert prefixes == [[1], [2]]