# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Unit tests for EmbedIOProcessor.""" import pytest import torch from pydantic import TypeAdapter, ValidationError from vllm import PoolingParams from vllm.entrypoints.pooling.embed.io_processor import EmbedIOProcessor from vllm.entrypoints.pooling.embed.protocol import ( CohereEmbedContent, CohereEmbedInput, CohereEmbedRequest, EmbeddingBatchChatInputRequest, EmbeddingBatchChatRequest, EmbeddingChatInputRequest, EmbeddingChatRequest, EmbeddingCompletionRequest, EmbeddingRequest, ) from vllm.entrypoints.pooling.typing import PoolingServeContext from vllm.outputs import PoolingOutput, PoolingRequestOutput class TestEmbeddingRequestParsing: """Unit tests for OpenAI embedding request parsing.""" def test_input_messages_parses_as_chat_request(self): request = TypeAdapter(EmbeddingRequest).validate_python( { "model": "test", "input": [{"role": "user", "content": "hello"}], "chat_template_kwargs": {"instruction": "Represent the query: "}, } ) assert isinstance(request, EmbeddingChatInputRequest) assert request.input == [{"role": "user", "content": "hello"}] assert request.messages == [{"role": "user", "content": "hello"}] assert request.chat_template_kwargs == {"instruction": "Represent the query: "} def test_batched_input_messages_parses_as_batch_chat_input_request(self): request = TypeAdapter(EmbeddingRequest).validate_python( { "model": "test", "input": [ [{"role": "user", "content": "hello"}], [{"role": "user", "content": "goodbye"}], ], "chat_template_kwargs": {"instruction": "Represent the query: "}, } ) assert isinstance(request, EmbeddingBatchChatInputRequest) assert request.input == [ [{"role": "user", "content": "hello"}], [{"role": "user", "content": "goodbye"}], ] assert request.messages == [ [{"role": "user", "content": "hello"}], [{"role": "user", "content": "goodbye"}], ] assert request.chat_template_kwargs == {"instruction": "Represent the query: "} def test_token_ids_still_parse_as_completion_request(self): request = TypeAdapter(EmbeddingRequest).validate_python( { "model": "test", "input": [[1, 2, 3], [4, 5]], } ) assert isinstance(request, EmbeddingCompletionRequest) assert request.input == [[1, 2, 3], [4, 5]] def test_messages_still_parses_as_chat_request(self): request = TypeAdapter(EmbeddingRequest).validate_python( { "model": "test", "messages": [{"role": "user", "content": "hello"}], "chat_template_kwargs": {"instruction": "Represent the query: "}, } ) assert isinstance(request, EmbeddingChatRequest) assert request.messages == [{"role": "user", "content": "hello"}] assert request.chat_template_kwargs == {"instruction": "Represent the query: "} def test_batched_messages_parses_as_batch_chat_request(self): request = TypeAdapter(EmbeddingRequest).validate_python( { "model": "test", "messages": [ [{"role": "user", "content": "hello"}], [{"role": "user", "content": "goodbye"}], ], "chat_template_kwargs": {"instruction": "Represent the query: "}, } ) assert isinstance(request, EmbeddingBatchChatRequest) assert request.messages == [ [{"role": "user", "content": "hello"}], [{"role": "user", "content": "goodbye"}], ] assert request.chat_template_kwargs == {"instruction": "Represent the query: "} class TestCohereEmbedRequestParsing: """Unit tests for Cohere embed request parsing.""" @pytest.mark.parametrize( "request_body", [ {"model": "test"}, {"model": "test", "texts": ["hello"], "images": ["image-uri"]}, { "model": "test", "texts": ["hello"], "inputs": [ {"content": [{"type": "text", "text": "hello"}]}, ], }, { "model": "test", "images": ["image-uri"], "inputs": [ {"content": [{"type": "text", "text": "hello"}]}, ], }, {"model": "test", "texts": []}, {"model": "test", "images": []}, {"model": "test", "inputs": []}, ], ) def test_rejects_invalid_input_field_combinations(self, request_body): with pytest.raises( ValidationError, match="Exactly one of texts, images, or inputs must be provided", ): CohereEmbedRequest(**request_body) @pytest.mark.parametrize( "request_body", [ {"model": "test", "texts": ["hello"]}, {"model": "test", "images": ["image-uri"]}, { "model": "test", "inputs": [ {"content": [{"type": "text", "text": "hello"}]}, ], }, { "model": "test", "inputs": [ { "content": [ {"type": "image_url", "image_url": {"url": "image-uri"}} ] }, ], }, ], ) def test_accepts_exactly_one_non_empty_input_field(self, request_body): request = CohereEmbedRequest(**request_body) assert request.model == "test" @pytest.mark.parametrize( ("content", "error"), [ ( {"type": "text"}, "CohereEmbedContent with type='text' requires text", ), ( {"type": "image_url"}, "CohereEmbedContent with type='image_url' requires image_url.url", ), ( {"type": "image_url", "image_url": {}}, "CohereEmbedContent with type='image_url' requires image_url.url", ), ( {"type": "image_url", "image_url": {"url": ""}}, "CohereEmbedContent with type='image_url' requires image_url.url", ), ], ) def test_rejects_invalid_mixed_content_payloads(self, content, error): with pytest.raises(ValidationError, match=error): CohereEmbedRequest( model="test", inputs=[ { "content": [content], }, ], ) class TestResolveTruncation: """Unit tests for EmbedIOProcessor._resolve_cohere_truncation.""" @staticmethod def _make_request(**kwargs) -> CohereEmbedRequest: defaults = { "model": "test", "input_type": "search_document", "texts": ["hello"], } return CohereEmbedRequest(**(defaults | kwargs)) def test_truncate_end_default(self): req = self._make_request() tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req) assert tokens == -1 assert side is None def test_truncate_end_explicit(self): req = self._make_request(truncate="END") tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req) assert tokens == -1 assert side is None def test_truncate_end_with_max_tokens(self): req = self._make_request(truncate="END", max_tokens=128) tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req) assert tokens == 128 assert side is None def test_truncate_none(self): req = self._make_request(truncate="NONE") tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req) assert tokens is None assert side is None def test_truncate_none_with_max_tokens(self): """truncate=NONE should NOT set truncate_prompt_tokens; the max_tokens limit is enforced separately via _check_max_tokens.""" req = self._make_request(truncate="NONE", max_tokens=10) tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req) assert tokens is None assert side is None def test_truncate_start(self): req = self._make_request(truncate="START") tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req) assert tokens == -1 assert side == "left" def test_truncate_start_with_max_tokens(self): req = self._make_request(truncate="START", max_tokens=64) tokens, side = EmbedIOProcessor._resolve_cohere_truncation(req) assert tokens == 64 assert side == "left" class TestApplyStPrompt: """Unit tests for EmbedIOProcessor._apply_task_instruction.""" @staticmethod def _make_handler(task_instructions: dict[str, str] | None): handler = object.__new__(EmbedIOProcessor) handler.task_instructions = task_instructions return handler def test_no_prompts_configured(self): handler = self._make_handler(None) texts = ["hello", "world"] assert handler._apply_task_instruction(texts, "query") is texts def test_matching_input_type(self): handler = self._make_handler({"query": "search_query: "}) result = handler._apply_task_instruction(["hello"], "query") assert result == ["search_query: hello"] def test_non_matching_input_type(self): handler = self._make_handler({"query": "search_query: "}) texts = ["hello"] assert handler._apply_task_instruction(texts, "document") is texts def test_multiple_texts(self): handler = self._make_handler( {"query": "Represent this sentence for searching: "} ) result = handler._apply_task_instruction(["a", "b", "c"], "query") assert result == [ "Represent this sentence for searching: a", "Represent this sentence for searching: b", "Represent this sentence for searching: c", ] def test_empty_prefix_returns_unchanged(self): handler = self._make_handler({"passage": ""}) texts = ["hello"] assert handler._apply_task_instruction(texts, "passage") is texts class TestLoadTaskInstructions: """Unit tests for EmbedIOProcessor._load_task_instructions.""" def test_no_attribute(self): class FakeConfig: pass assert EmbedIOProcessor._load_task_instructions(FakeConfig()) is None def test_with_task_instructions(self): class FakeConfig: task_instructions = { "retrieval.query": "Represent the query: ", "retrieval.passage": "", } result = EmbedIOProcessor._load_task_instructions(FakeConfig()) assert result == { "retrieval.query": "Represent the query: ", "retrieval.passage": "", } def test_empty_dict(self): class FakeConfig: task_instructions = {} assert EmbedIOProcessor._load_task_instructions(FakeConfig()) is None def test_non_dict(self): class FakeConfig: task_instructions = "not a dict" assert EmbedIOProcessor._load_task_instructions(FakeConfig()) is None class TestCheckMaxTokens: """Unit tests for EmbedIOProcessor._check_cohere_max_tokens.""" @staticmethod def _fake_output(n_tokens: int): class _Out: def __init__(self, n: int): self.prompt_token_ids = list(range(n)) return _Out(n_tokens) def test_none_check_is_noop(self): outs = [self._fake_output(100)] EmbedIOProcessor._check_cohere_max_tokens(outs, None) def test_within_limit(self): outs = [self._fake_output(5), self._fake_output(3)] EmbedIOProcessor._check_cohere_max_tokens(outs, 5) def test_exceeds_limit(self): outs = [self._fake_output(3), self._fake_output(10)] with pytest.raises(ValueError, match="exceeds max_tokens=5"): EmbedIOProcessor._check_cohere_max_tokens(outs, 5) def test_exact_limit(self): outs = [self._fake_output(5)] EmbedIOProcessor._check_cohere_max_tokens(outs, 5) class TestValidateInputType: """Unit tests for EmbedIOProcessor._validate_input_type.""" @staticmethod def _make_handler(task_instructions: dict[str, str] | None): handler = object.__new__(EmbedIOProcessor) handler.task_instructions = task_instructions return handler def test_none_input_type_always_accepted(self): handler = self._make_handler(None) handler._validate_input_type(None) handler_with = self._make_handler({"query": "q: "}) handler_with._validate_input_type(None) def test_no_prompts_rejects(self): handler = self._make_handler(None) with pytest.raises(ValueError, match="does not define any input_type"): handler._validate_input_type("anything") def test_known_type_accepted(self): handler = self._make_handler({"query": "q: ", "document": "d: "}) handler._validate_input_type("query") handler._validate_input_type("document") def test_unknown_type_rejected(self): handler = self._make_handler({"query": "q: ", "document": "d: "}) with pytest.raises(ValueError, match="Unsupported input_type 'other'"): handler._validate_input_type("other") def test_error_lists_supported(self): handler = self._make_handler({"a": "", "b": ""}) with pytest.raises(ValueError, match="Supported values: a, b"): handler._validate_input_type("z") class TestChunkedEmbeddingProcessing: """Unit tests for chunked embedding aggregation.""" class _FakeModelConfig: max_model_len = 3 @classmethod def _make_handler(cls): handler = object.__new__(EmbedIOProcessor) handler.model_config = cls._FakeModelConfig() return handler @staticmethod def _make_context() -> PoolingServeContext[EmbeddingCompletionRequest]: request = TypeAdapter(EmbeddingRequest).validate_python( { "model": "test", "input": [[0, 1, 2, 3, 4], [10, 11]], } ) assert isinstance(request, EmbeddingCompletionRequest) return PoolingServeContext( request=request, pooling_params=PoolingParams(), model_name="test", request_id="embd-client-prompt-999-chunk-888", engine_inputs=[ {"prompt_token_ids": [0, 1, 2, 3, 4]}, {"prompt_token_ids": [10, 11]}, ], ) @staticmethod def _make_output( request_id: str, prompt_token_ids: list[int], embedding: list[float], ) -> PoolingRequestOutput: return PoolingRequestOutput( request_id=request_id, outputs=PoolingOutput(data=torch.tensor(embedding)), prompt_token_ids=prompt_token_ids, num_cached_tokens=0, finished=True, ) def test_aggregation_uses_metadata_not_request_id_parsing(self): handler = self._make_handler() ctx = self._make_context() handler._pre_process_chunked(ctx) assert ctx.prompt_request_ids == [ "embd-client-prompt-999-chunk-888-prompt-0-chunk-0", "embd-client-prompt-999-chunk-888-prompt-0-chunk-1", "embd-client-prompt-999-chunk-888-prompt-1-chunk-0", ] assert ctx.chunked_embedding_metadata is not None assert [ (item.prompt_index, item.chunk_index) for item in ctx.chunked_embedding_metadata ] == [(0, 0), (0, 1), (1, 0)] ctx.final_res_batch = [ self._make_output(ctx.prompt_request_ids[0], [0, 1, 2], [1.0, 1.0]), self._make_output(ctx.prompt_request_ids[1], [3, 4], [4.0, 7.0]), self._make_output(ctx.prompt_request_ids[2], [10, 11], [9.0, 9.0]), ] handler._post_process_chunked(ctx) assert len(ctx.final_res_batch) == 2 assert ctx.final_res_batch[0].request_id == ( "embd-client-prompt-999-chunk-888-prompt-0" ) assert ctx.final_res_batch[0].prompt_token_ids == [0, 1, 2, 3, 4] assert torch.allclose( ctx.final_res_batch[0].outputs.data, torch.tensor([2.2, 3.4]), ) assert ctx.final_res_batch[1].request_id == ( "embd-client-prompt-999-chunk-888-prompt-1" ) assert ctx.final_res_batch[1].prompt_token_ids == [10, 11] assert torch.allclose( ctx.final_res_batch[1].outputs.data, torch.tensor([9.0, 9.0]), ) class TestPreProcessCohereOnline: """Unit tests for EmbedIOProcessor._pre_process_cohere_online.""" @staticmethod def _make_context(**request_kwargs) -> PoolingServeContext[CohereEmbedRequest]: return PoolingServeContext( request=CohereEmbedRequest(model="test", **request_kwargs), pooling_params=PoolingParams(), model_name="test", request_id="embd-test", ) @staticmethod def _make_handler(): handler = object.__new__(EmbedIOProcessor) handler._validate_input_type = lambda _input_type: None return handler def test_text_only_without_task_prefix_uses_completion_path(self): handler = self._make_handler() ctx = self._make_context(texts=["hello"]) calls: list[tuple[str, object]] = [] def preprocess_cmpl_online(request, prompt_input, prompt_embeds): calls.append(("completion", prompt_input)) return ["completion"] handler._get_task_instruction_prefix = lambda _input_type: None handler._has_chat_template = lambda: False handler._preprocess_cmpl_online = preprocess_cmpl_online handler._batch_render_chat = lambda *_args, **_kwargs: pytest.fail( "text-only request should not require chat rendering" ) handler._pre_process_cohere_online(ctx) assert ctx.engine_inputs == ["completion"] assert calls == [("completion", ["hello"])] def test_text_only_falls_back_to_prefixed_completion_without_template(self): handler = self._make_handler() ctx = self._make_context(texts=["hello"], input_type="query") calls: list[tuple[str, object]] = [] def preprocess_cmpl(request, prompt_input, prompt_embeds): calls.append(("completion", prompt_input)) return ["fallback"] handler._get_task_instruction_prefix = lambda _input_type: "query: " handler._has_chat_template = lambda: False handler._batch_render_chat = lambda *_args, **_kwargs: pytest.fail( "chat rendering should be skipped without a template" ) handler._preprocess_cmpl_online = preprocess_cmpl handler._pre_process_cohere_online(ctx) assert ctx.engine_inputs == ["fallback"] assert calls == [("completion", ["query: hello"])] def test_text_only_with_template_uses_chat_path(self): handler = self._make_handler() ctx = self._make_context(texts=["hello"], input_type="query") calls: list[tuple[str, object]] = [] def batch_render_chat( request, all_messages, truncate_prompt_tokens, truncation_side, ): calls.append( ( "chat", { "request": request, "all_messages": all_messages, "truncate_prompt_tokens": truncate_prompt_tokens, "truncation_side": truncation_side, }, ) ) return ["chat"] handler._get_task_instruction_prefix = lambda _input_type: "query: " handler._has_chat_template = lambda: True handler._batch_render_chat = batch_render_chat handler._preprocess_cmpl_online = lambda *_args, **_kwargs: pytest.fail( "completion path should be skipped when a template exists" ) handler._pre_process_cohere_online(ctx) assert ctx.engine_inputs == ["chat"] assert calls == [ ( "chat", { "request": ctx.request, "all_messages": [ handler._mixed_input_to_messages( CohereEmbedInput( content=[CohereEmbedContent(type="text", text="hello")] ), task_prefix="query: ", ) ], "truncate_prompt_tokens": -1, "truncation_side": None, }, ) ] class TestPreProcessOpenAIEmbeddingChatOnline: """Unit tests for OpenAI embedding chat preprocessing.""" class _FakeModelConfig: max_model_len = 128 encoder_config: dict[str, object] = {} pooler_config = None multimodal_config = None is_encoder_decoder = False class _FakeRenderer: tokenizer = object() def __init__(self): self.calls = [] def render_chat( self, all_messages, chat_params, tok_params, prompt_extras=None, ): self.calls.append( { "all_messages": all_messages, "chat_params": chat_params, "tok_params": tok_params, "prompt_extras": prompt_extras, } ) return all_messages, [ {"prompt_token_ids": [index]} for index, _ in enumerate(all_messages) ] @classmethod def _make_handler(cls, renderer): handler = object.__new__(EmbedIOProcessor) handler.renderer = renderer handler.model_config = cls._FakeModelConfig() handler.chat_template = "template" handler.chat_template_content_format = "auto" handler.trust_request_chat_template = False handler.enable_chunked_processing = False return handler @staticmethod def _make_context( request: ( EmbeddingChatRequest | EmbeddingBatchChatRequest | EmbeddingChatInputRequest | EmbeddingBatchChatInputRequest ), ) -> PoolingServeContext[ EmbeddingChatRequest | EmbeddingBatchChatRequest | EmbeddingChatInputRequest | EmbeddingBatchChatInputRequest ]: return PoolingServeContext( request=request, pooling_params=PoolingParams(), model_name="test", request_id="embd-test", ) def test_chat_template_kwargs_forwarded_for_batched_input_messages(self): request = TypeAdapter(EmbeddingRequest).validate_python( { "model": "test", "input": [ [{"role": "user", "content": "hello"}], [{"role": "user", "content": "goodbye"}], ], "add_generation_prompt": True, "chat_template_kwargs": {"instruction": "Represent the query: "}, "mm_processor_kwargs": {"max_pixels": 1}, "cache_salt": "salt", } ) assert isinstance(request, EmbeddingBatchChatInputRequest) renderer = self._FakeRenderer() handler = self._make_handler(renderer) ctx = self._make_context(request) handler.pre_process_online(ctx) assert ctx.engine_inputs == [ {"prompt_token_ids": [0]}, {"prompt_token_ids": [1]}, ] assert len(renderer.calls) == 1 call = renderer.calls[0] assert call["all_messages"] == request.messages assert call["prompt_extras"] == { "mm_processor_kwargs": {"max_pixels": 1}, "cache_salt": "salt", } chat_template_kwargs = call["chat_params"].chat_template_kwargs assert chat_template_kwargs["instruction"] == "Represent the query: " assert chat_template_kwargs["add_generation_prompt"] is True assert chat_template_kwargs["continue_final_message"] is False assert "tools" not in chat_template_kwargs assert chat_template_kwargs["tokenize"] is False