main
py 507 lines 17.6 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for EmbedIOProcessor."""
4
5 import pytest
6 import torch
7 from pydantic import TypeAdapter, ValidationError
8
9 from vllm import PoolingParams
10 from vllm.entrypoints.pooling.embed.io_processor import EmbedIOProcessor
11 from vllm.entrypoints.pooling.embed.protocol import (
12 CohereEmbedRequest,
13 EmbeddingBatchChatInputRequest,
14 EmbeddingBatchChatRequest,
15 EmbeddingChatInputRequest,
16 EmbeddingChatRequest,
17 EmbeddingCompletionRequest,
18 EmbeddingRequest,
19 )
20 from vllm.entrypoints.pooling.typing import (
21 PoolingEngineInput,
22 PoolingServeContext,
23 )
24 from vllm.inputs import tokens_input
25 from vllm.outputs import PoolingOutput, PoolingRequestOutput
26
27
28 class TestEmbeddingRequestParsing:
29 """Unit tests for OpenAI embedding request parsing."""
30
31 def test_input_messages_parses_as_chat_request(self):
32 request: EmbeddingRequest = TypeAdapter(EmbeddingRequest).validate_python(
33 {
34 "model": "test",
35 "input": [{"role": "user", "content": "hello"}],
36 "chat_template_kwargs": {"instruction": "Represent the query: "},
37 }
38 )
39
40 assert isinstance(request, EmbeddingChatInputRequest)
41 assert request.input == [{"role": "user", "content": "hello"}]
42 assert request.messages == [{"role": "user", "content": "hello"}]
43 assert request.chat_template_kwargs == {"instruction": "Represent the query: "}
44
45 def test_batched_input_messages_parses_as_batch_chat_input_request(self):
46 request: EmbeddingRequest = TypeAdapter(EmbeddingRequest).validate_python(
47 {
48 "model": "test",
49 "input": [
50 [{"role": "user", "content": "hello"}],
51 [{"role": "user", "content": "goodbye"}],
52 ],
53 "chat_template_kwargs": {"instruction": "Represent the query: "},
54 }
55 )
56
57 assert isinstance(request, EmbeddingBatchChatInputRequest)
58 assert request.input == [
59 [{"role": "user", "content": "hello"}],
60 [{"role": "user", "content": "goodbye"}],
61 ]
62 assert request.messages == [
63 [{"role": "user", "content": "hello"}],
64 [{"role": "user", "content": "goodbye"}],
65 ]
66 assert request.chat_template_kwargs == {"instruction": "Represent the query: "}
67
68 def test_token_ids_still_parse_as_completion_request(self):
69 request: EmbeddingRequest = TypeAdapter(EmbeddingRequest).validate_python(
70 {
71 "model": "test",
72 "input": [[1, 2, 3], [4, 5]],
73 }
74 )
75
76 assert isinstance(request, EmbeddingCompletionRequest)
77 assert request.input == [[1, 2, 3], [4, 5]]
78
79 def test_messages_still_parses_as_chat_request(self):
80 request: EmbeddingRequest = TypeAdapter(EmbeddingRequest).validate_python(
81 {
82 "model": "test",
83 "messages": [{"role": "user", "content": "hello"}],
84 "chat_template_kwargs": {"instruction": "Represent the query: "},
85 }
86 )
87
88 assert isinstance(request, EmbeddingChatRequest)
89 assert request.messages == [{"role": "user", "content": "hello"}]
90 assert request.chat_template_kwargs == {"instruction": "Represent the query: "}
91
92 def test_batched_messages_parses_as_batch_chat_request(self):
93 request: EmbeddingRequest = TypeAdapter(EmbeddingRequest).validate_python(
94 {
95 "model": "test",
96 "messages": [
97 [{"role": "user", "content": "hello"}],
98 [{"role": "user", "content": "goodbye"}],
99 ],
100 "chat_template_kwargs": {"instruction": "Represent the query: "},
101 }
102 )
103
104 assert isinstance(request, EmbeddingBatchChatRequest)
105 assert request.messages == [
106 [{"role": "user", "content": "hello"}],
107 [{"role": "user", "content": "goodbye"}],
108 ]
109 assert request.chat_template_kwargs == {"instruction": "Represent the query: "}
110
111
112 class TestCohereEmbedRequestParsing:
113 """Unit tests for Cohere embed request parsing."""
114
115 @pytest.mark.parametrize(
116 "request_body",
117 [
118 {"model": "test"},
119 {"model": "test", "texts": ["hello"], "images": ["image-uri"]},
120 {
121 "model": "test",
122 "texts": ["hello"],
123 "inputs": [
124 {"content": [{"type": "text", "text": "hello"}]},
125 ],
126 },
127 {
128 "model": "test",
129 "images": ["image-uri"],
130 "inputs": [
131 {"content": [{"type": "text", "text": "hello"}]},
132 ],
133 },
134 {"model": "test", "texts": []},
135 {"model": "test", "images": []},
136 {"model": "test", "inputs": []},
137 ],
138 )
139 def test_rejects_invalid_input_field_combinations(self, request_body):
140 with pytest.raises(
141 ValidationError,
142 match="Exactly one of texts, images, or inputs must be provided",
143 ):
144 CohereEmbedRequest(**request_body)
145
146 @pytest.mark.parametrize(
147 "request_body",
148 [
149 {"model": "test", "texts": ["hello"]},
150 {"model": "test", "images": ["image-uri"]},
151 {
152 "model": "test",
153 "inputs": [
154 {"content": [{"type": "text", "text": "hello"}]},
155 ],
156 },
157 {
158 "model": "test",
159 "inputs": [
160 {
161 "content": [
162 {"type": "image_url", "image_url": {"url": "image-uri"}}
163 ]
164 },
165 ],
166 },
167 ],
168 )
169 def test_accepts_exactly_one_non_empty_input_field(self, request_body):
170 request = CohereEmbedRequest(**request_body)
171
172 assert request.model == "test"
173
174 @pytest.mark.parametrize(
175 ("content", "error"),
176 [
177 (
178 {"type": "text"},
179 "CohereEmbedContent with type='text' requires text",
180 ),
181 (
182 {"type": "image_url"},
183 "CohereEmbedContent with type='image_url' requires image_url.url",
184 ),
185 (
186 {"type": "image_url", "image_url": {}},
187 "CohereEmbedContent with type='image_url' requires image_url.url",
188 ),
189 (
190 {"type": "image_url", "image_url": {"url": ""}},
191 "CohereEmbedContent with type='image_url' requires image_url.url",
192 ),
193 ],
194 )
195 def test_rejects_invalid_mixed_content_payloads(self, content, error):
196 with pytest.raises(ValidationError, match=error):
197 CohereEmbedRequest(
198 model="test",
199 inputs=[
200 {
201 "content": [content],
202 },
203 ],
204 )
205
206
207 class TestResolveTruncation:
208 """Unit tests for EmbedIOProcessor._resolve_cohere_truncation."""
209
210 @staticmethod
211 def _make_request(**kwargs) -> CohereEmbedRequest:
212 defaults = {
213 "model": "test",
214 "input_type": "search_document",
215 "texts": ["hello"],
216 }
217 return CohereEmbedRequest(**(defaults | kwargs))
218
219 def test_truncate_end_default(self):
220 req = self._make_request()
221 tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req)
222 assert tokens == -1
223 assert side is None
224
225 def test_truncate_end_explicit(self):
226 req = self._make_request(truncate="END")
227 tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req)
228 assert tokens == -1
229 assert side is None
230
231 def test_truncate_end_with_max_tokens(self):
232 req = self._make_request(truncate="END", max_tokens=128)
233 tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req)
234 assert tokens == 128
235 assert side is None
236
237 def test_truncate_none(self):
238 req = self._make_request(truncate="NONE")
239 tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req)
240 assert tokens is None
241 assert side is None
242
243 def test_truncate_none_with_max_tokens(self):
244 """truncate=NONE should NOT set truncate_prompt_tokens; the
245 max_tokens limit is enforced separately via _check_max_tokens."""
246 req = self._make_request(truncate="NONE", max_tokens=10)
247 tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req)
248 assert tokens is None
249 assert side is None
250
251 def test_truncate_start(self):
252 req = self._make_request(truncate="START")
253 tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req)
254 assert tokens == -1
255 assert side == "left"
256
257 def test_truncate_start_with_max_tokens(self):
258 req = self._make_request(truncate="START", max_tokens=64)
259 tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req)
260 assert tokens == 64
261 assert side == "left"
262
263
264 class TestApplyStPrompt:
265 """Unit tests for EmbedIOProcessor._apply_task_instruction."""
266
267 @staticmethod
268 def _make_handler(task_instructions: dict[str, str] | None):
269 handler = object.__new__(EmbedIOProcessor)
270 handler.task_instructions = task_instructions
271 return handler
272
273 def test_no_prompts_configured(self):
274 handler = self._make_handler(None)
275 texts = ["hello", "world"]
276 assert handler._apply_task_instruction(texts, "query") is texts
277
278 def test_matching_input_type(self):
279 handler = self._make_handler({"query": "search_query: "})
280 result = handler._apply_task_instruction(["hello"], "query")
281 assert result == ["search_query: hello"]
282
283 def test_non_matching_input_type(self):
284 handler = self._make_handler({"query": "search_query: "})
285 texts = ["hello"]
286 assert handler._apply_task_instruction(texts, "document") is texts
287
288 def test_multiple_texts(self):
289 handler = self._make_handler(
290 {"query": "Represent this sentence for searching: "}
291 )
292 result = handler._apply_task_instruction(["a", "b", "c"], "query")
293 assert result == [
294 "Represent this sentence for searching: a",
295 "Represent this sentence for searching: b",
296 "Represent this sentence for searching: c",
297 ]
298
299 def test_empty_prefix_returns_unchanged(self):
300 handler = self._make_handler({"passage": ""})
301 texts = ["hello"]
302 assert handler._apply_task_instruction(texts, "passage") is texts
303
304
305 class TestLoadTaskInstructions:
306 """Unit tests for EmbedIOProcessor._load_task_instructions."""
307
308 def test_no_attribute(self):
309 class FakeConfig:
310 pass
311
312 assert EmbedIOProcessor._load_task_instructions(FakeConfig()) is None
313
314 def test_with_task_instructions(self):
315 class FakeConfig:
316 task_instructions = {
317 "retrieval.query": "Represent the query: ",
318 "retrieval.passage": "",
319 }
320
321 result = EmbedIOProcessor._load_task_instructions(FakeConfig())
322 assert result == {
323 "retrieval.query": "Represent the query: ",
324 "retrieval.passage": "",
325 }
326
327 def test_empty_dict(self):
328 class FakeConfig:
329 task_instructions = {}
330
331 assert EmbedIOProcessor._load_task_instructions(FakeConfig()) is None
332
333 def test_non_dict(self):
334 class FakeConfig:
335 task_instructions = "not a dict"
336
337 assert EmbedIOProcessor._load_task_instructions(FakeConfig()) is None
338
339
340 class TestCheckMaxTokens:
341 """Unit tests for EmbedIOProcessor._check_cohere_max_tokens."""
342
343 @staticmethod
344 def _fake_output(n_tokens: int):
345 class _Out:
346 def __init__(self, n: int):
347 self.prompt_token_ids = list(range(n))
348
349 return _Out(n_tokens)
350
351 def test_none_check_is_noop(self):
352 outs = [self._fake_output(100)]
353 EmbedIOProcessor._check_cohere_max_tokens(outs, None)
354
355 def test_within_limit(self):
356 outs = [self._fake_output(5), self._fake_output(3)]
357 EmbedIOProcessor._check_cohere_max_tokens(outs, 5)
358
359 def test_exceeds_limit(self):
360 outs = [self._fake_output(3), self._fake_output(10)]
361 with pytest.raises(ValueError, match="exceeds max_tokens=5"):
362 EmbedIOProcessor._check_cohere_max_tokens(outs, 5)
363
364 def test_exact_limit(self):
365 outs = [self._fake_output(5)]
366 EmbedIOProcessor._check_cohere_max_tokens(outs, 5)
367
368
369 class TestValidateInputType:
370 """Unit tests for EmbedIOProcessor._validate_input_type."""
371
372 @staticmethod
373 def _make_handler(task_instructions: dict[str, str] | None):
374 handler = object.__new__(EmbedIOProcessor)
375 handler.task_instructions = task_instructions
376 return handler
377
378 def test_none_input_type_always_accepted(self):
379 handler = self._make_handler(None)
380 handler._validate_input_type(None)
381 handler_with = self._make_handler({"query": "q: "})
382 handler_with._validate_input_type(None)
383
384 def test_no_prompts_rejects(self):
385 handler = self._make_handler(None)
386 with pytest.raises(ValueError, match="does not define any input_type"):
387 handler._validate_input_type("anything")
388
389 def test_known_type_accepted(self):
390 handler = self._make_handler({"query": "q: ", "document": "d: "})
391 handler._validate_input_type("query")
392 handler._validate_input_type("document")
393
394 def test_unknown_type_rejected(self):
395 handler = self._make_handler({"query": "q: ", "document": "d: "})
396 with pytest.raises(ValueError, match="Unsupported input_type 'other'"):
397 handler._validate_input_type("other")
398
399 def test_error_lists_supported(self):
400 handler = self._make_handler({"a": "", "b": ""})
401 with pytest.raises(ValueError, match="Supported values: a, b"):
402 handler._validate_input_type("z")
403
404
405 class TestChunkedEmbeddingProcessing:
406 """Unit tests for chunked embedding aggregation."""
407
408 class _FakeModelConfig:
409 max_model_len = 3
410
411 @classmethod
412 def _make_handler(cls):
413 handler = object.__new__(EmbedIOProcessor)
414 handler.model_config = cls._FakeModelConfig()
415 handler.enable_chunked_processing = True
416 return handler
417
418 @staticmethod
419 def _make_context() -> PoolingServeContext[EmbeddingCompletionRequest]:
420 request: EmbeddingRequest = TypeAdapter(EmbeddingRequest).validate_python(
421 {
422 "model": "test",
423 "input": [[0, 1, 2, 3, 4], [10, 11]],
424 }
425 )
426 assert isinstance(request, EmbeddingCompletionRequest)
427 pooling_params = PoolingParams()
428 return PoolingServeContext(
429 request=request,
430 pooling_params=pooling_params,
431 model_name="test",
432 request_id="embd-client-prompt-999-chunk-888",
433 engine_inputs=[
434 PoolingEngineInput(
435 prompts=tokens_input(prompt_token_ids=[0, 1, 2, 3, 4]),
436 params=pooling_params,
437 lora_requests=None,
438 priorities=0,
439 ),
440 PoolingEngineInput(
441 prompts=tokens_input(prompt_token_ids=[10, 11]),
442 params=pooling_params,
443 lora_requests=None,
444 priorities=0,
445 ),
446 ],
447 lora_request=None,
448 priorities=0,
449 prompt_extras=None,
450 )
451
452 @staticmethod
453 def _make_output(
454 request_id: str,
455 prompt_token_ids: list[int],
456 embedding: list[float],
457 ) -> PoolingRequestOutput:
458 return PoolingRequestOutput(
459 request_id=request_id,
460 outputs=PoolingOutput(data=torch.tensor(embedding)),
461 prompt_token_ids=prompt_token_ids,
462 num_cached_tokens=0,
463 finished=True,
464 )
465
466 def test_aggregation_uses_metadata_not_request_id_parsing(self):
467 handler = self._make_handler()
468 ctx = self._make_context()
469
470 handler.maybe_pre_process_chunked(ctx)
471
472 assert ctx.prompt_request_ids == [
473 "embd-client-prompt-999-chunk-888-prompt-0-chunk-0",
474 "embd-client-prompt-999-chunk-888-prompt-0-chunk-1",
475 "embd-client-prompt-999-chunk-888-prompt-1-chunk-0",
476 ]
477 assert ctx.chunked_embedding_metadata is not None
478 assert [
479 (item.prompt_index, item.chunk_index)
480 for item in ctx.chunked_embedding_metadata
481 ] == [(0, 0), (0, 1), (1, 0)]
482
483 ctx.final_res_batch = [
484 self._make_output(ctx.prompt_request_ids[0], [0, 1, 2], [1.0, 1.0]),
485 self._make_output(ctx.prompt_request_ids[1], [3, 4], [4.0, 7.0]),
486 self._make_output(ctx.prompt_request_ids[2], [10, 11], [9.0, 9.0]),
487 ]
488
489 handler._post_process_chunked(ctx)
490
491 assert len(ctx.final_res_batch) == 2
492 assert ctx.final_res_batch[0].request_id == (
493 "embd-client-prompt-999-chunk-888-prompt-0"
494 )
495 assert ctx.final_res_batch[0].prompt_token_ids == [0, 1, 2, 3, 4]
496 assert torch.allclose(
497 ctx.final_res_batch[0].outputs.data,
498 torch.tensor([2.2, 3.4]),
499 )
500 assert ctx.final_res_batch[1].request_id == (
501 "embd-client-prompt-999-chunk-888-prompt-1"
502 )
503 assert ctx.final_res_batch[1].prompt_token_ids == [10, 11]
504 assert torch.allclose(
505 ctx.final_res_batch[1].outputs.data,
506 torch.tensor([9.0, 9.0]),
507 )