chore: import upstream snapshot with attribution
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:55:37 +08:00
commit 7ce4c8e27e
5900 changed files with 1668062 additions and 0 deletions
View File
@@ -0,0 +1,132 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
E2E tests for GGUF plugin functionality.
"""
import os
from typing import NamedTuple
import pytest
from transformers import AutoTokenizer
from ...conftest import VllmRunner
from ...models.utils import check_logprobs_close
from ...utils import multi_gpu_test
os.environ["TOKENIZERS_PARALLELISM"] = "true"
MAX_MODEL_LEN = 1024
class GGUFTestConfig(NamedTuple):
original_model: str
gguf_model_path: str # Full path to .gguf file
QWEN3_CONFIG = GGUFTestConfig(
original_model="Qwen/Qwen3-0.6B",
gguf_model_path="unsloth/Qwen3-0.6B-GGUF:Q8_0",
)
OLMOE_CONFIG = GGUFTestConfig(
original_model="allenai/OLMoE-1B-7B-0125",
gguf_model_path="allenai/OLMoE-1B-7B-0125-GGUF:Q6_K",
)
MODELS = [
QWEN3_CONFIG,
OLMOE_CONFIG,
]
def check_model_outputs(
vllm_runner: type[VllmRunner],
prompts: list[str],
model: GGUFTestConfig,
dtype: str,
max_tokens: int,
num_logprobs: int,
tp_size: int,
):
tokenizer = AutoTokenizer.from_pretrained(model.original_model)
if tokenizer.chat_template is not None:
messages = [[{"role": "user", "content": prompt}] for prompt in prompts]
prompts = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# Run gguf model.
with vllm_runner(
model_name=model.gguf_model_path,
enforce_eager=True,
tokenizer_name=model.original_model,
dtype=dtype,
max_model_len=MAX_MODEL_LEN,
tensor_parallel_size=tp_size,
) as gguf_model:
gguf_outputs = gguf_model.generate_greedy_logprobs(
prompts[:-1], max_tokens, num_logprobs
)
# Run unquantized model.
# Should run with tp=1, otherwise the test will stuck at
# nccl initialization.
with vllm_runner(
model_name=model.original_model,
enforce_eager=True, # faster tests
dtype=dtype,
max_model_len=MAX_MODEL_LEN,
tensor_parallel_size=1,
) as original_model:
original_outputs = original_model.generate_greedy_logprobs(
prompts[:-1], max_tokens, num_logprobs
)
check_logprobs_close(
outputs_0_lst=original_outputs,
outputs_1_lst=gguf_outputs,
name_0="original",
name_1="gguf",
)
@pytest.mark.parametrize("model", MODELS)
@pytest.mark.parametrize("dtype", ["bfloat16"])
@pytest.mark.parametrize("max_tokens", [32])
@pytest.mark.parametrize("num_logprobs", [8])
@pytest.mark.parametrize("tp_size", [1])
def test_models(
vllm_runner: type[VllmRunner],
example_prompts: list[str],
model: GGUFTestConfig,
dtype: str,
max_tokens: int,
num_logprobs: int,
tp_size: int,
) -> None:
check_model_outputs(
vllm_runner, example_prompts, model, dtype, max_tokens, num_logprobs, tp_size
)
@pytest.mark.parametrize("model", MODELS)
@pytest.mark.parametrize("dtype", ["half"])
@pytest.mark.parametrize("max_tokens", [8])
@pytest.mark.parametrize("num_logprobs", [5])
@pytest.mark.parametrize("tp_size", [2])
@multi_gpu_test(num_gpus=2)
def test_distributed(
vllm_runner: type[VllmRunner],
example_prompts: list[str],
model: GGUFTestConfig,
dtype: str,
max_tokens: int,
num_logprobs: int,
tp_size: int,
) -> None:
check_model_outputs(
vllm_runner, example_prompts, model, dtype, max_tokens, num_logprobs, tp_size
)
@@ -0,0 +1,167 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import os
os.environ["TOKENIZERS_PARALLELISM"] = "true"
from typing import Any, NamedTuple
import pytest
from huggingface_hub import hf_hub_download
from pytest import MarkDecorator
from transformers import AutoModelForImageTextToText
from vllm.assets.image import ImageAsset
from vllm.multimodal.image import rescale_image_size
from vllm.utils.torch_utils import set_default_torch_num_threads
from ...conftest import IMAGE_ASSETS, HfRunner, VllmRunner
from ...models.utils import check_logprobs_close
class GGUFMMTestConfig(NamedTuple):
original_model: str
gguf_repo: str
gguf_backbone: str
gguf_mmproj: str
prompt: list[str]
image_names: list[str] # Store names, load PIL images at runtime
max_model_len: int = 4096
marks: list[MarkDecorator] = []
mm_processor_kwargs: dict[str, Any] = {}
@property
def gguf_model(self):
hf_hub_download(self.gguf_repo, filename=self.gguf_mmproj)
return hf_hub_download(self.gguf_repo, filename=self.gguf_backbone)
# Common prompts aligned with test_common.py "gemma3" entry format
_GEMMA3_PROMPTS = IMAGE_ASSETS.prompts(
{
"stop_sign": (
"<bos><start_of_turn>user\n"
"<start_of_image>What's the content in the center of the image?"
"<end_of_turn>\n<start_of_turn>model\n"
),
"cherry_blossom": (
"<bos><start_of_turn>user\n"
"<start_of_image>What is the season?"
"<end_of_turn>\n<start_of_turn>model\n"
),
}
)
# Image asset names - load at runtime to avoid pickle issues with subprocess
_GEMMA3_IMAGE_NAMES = ["stop_sign", "cherry_blossom"]
# Regular multimodal (no pan-and-scan) - uses QAT Q4_0 GGUF
GEMMA3_CONFIG = GGUFMMTestConfig(
original_model="google/gemma-3-4b-it",
gguf_repo="google/gemma-3-4b-it-qat-q4_0-gguf",
gguf_backbone="gemma-3-4b-it-q4_0.gguf",
gguf_mmproj="mmproj-model-f16-4B.gguf",
prompt=_GEMMA3_PROMPTS,
image_names=_GEMMA3_IMAGE_NAMES,
max_model_len=4096,
mm_processor_kwargs={},
)
# Pan-and-scan multimodal - uses unquantized BF16 GGUF
GEMMA3_CONFIG_PAN_AND_SCAN = GGUFMMTestConfig(
original_model="google/gemma-3-4b-it",
gguf_repo="google/gemma-3-4b-it-qat-q4_0-gguf",
gguf_backbone="gemma-3-4b-it-q4_0.gguf",
gguf_mmproj="mmproj-model-f16-4B.gguf",
prompt=_GEMMA3_PROMPTS,
image_names=_GEMMA3_IMAGE_NAMES,
max_model_len=4096,
mm_processor_kwargs={"do_pan_and_scan": True},
)
MODELS_TO_TEST = [GEMMA3_CONFIG, GEMMA3_CONFIG_PAN_AND_SCAN]
def run_multimodal_gguf_test(
hf_runner: type[HfRunner],
vllm_runner: type[VllmRunner],
model: GGUFMMTestConfig,
dtype: str,
max_tokens: int,
num_logprobs: int,
):
# Load images at runtime (inside subprocess) to avoid pickle issues
images = [ImageAsset(name).pil_image for name in model.image_names]
size_factors = [0.25, 0.5, 1.0]
inputs_per_image = [
(
[prompt for _ in size_factors],
[rescale_image_size(image, factor) for factor in size_factors],
)
for image, prompt in zip(images, model.prompt)
]
# NOTE: Run vLLM first to avoid CUDA init issues with multiprocessing fork.
# Run GGUF model via vLLM.
with (
set_default_torch_num_threads(1),
vllm_runner(
model_name=model.gguf_model,
enforce_eager=True,
tokenizer_name=model.original_model,
dtype=dtype,
max_model_len=model.max_model_len,
mm_processor_kwargs=model.mm_processor_kwargs,
) as gguf_model,
):
gguf_outputs_per_case = [
gguf_model.generate_greedy_logprobs(
prompts,
max_tokens,
num_logprobs=num_logprobs,
images=images,
)
for prompts, images in inputs_per_image
]
# Then run HfRunner for HuggingFace baseline comparison.
with hf_runner(
model.original_model,
dtype=dtype,
auto_cls=AutoModelForImageTextToText,
) as hf_model:
hf_outputs_per_case = [
hf_model.generate_greedy_logprobs_limit(
prompts,
max_tokens,
num_logprobs=num_logprobs,
images=images,
)
for prompts, images in inputs_per_image
]
for hf_outputs, gguf_outputs in zip(hf_outputs_per_case, gguf_outputs_per_case):
check_logprobs_close(
outputs_0_lst=hf_outputs,
outputs_1_lst=gguf_outputs,
name_0="hf",
name_1="gguf",
)
@pytest.mark.parametrize("model", MODELS_TO_TEST)
@pytest.mark.parametrize("dtype", ["bfloat16"])
@pytest.mark.parametrize("max_tokens", [32])
@pytest.mark.parametrize("num_logprobs", [10])
def test_gemma3_mm_gguf(
hf_runner: type[HfRunner],
vllm_runner: type[VllmRunner],
model: GGUFMMTestConfig,
dtype: str,
max_tokens: int,
num_logprobs: int,
) -> None:
run_multimodal_gguf_test(
hf_runner, vllm_runner, model, dtype, max_tokens, num_logprobs
)
@@ -0,0 +1,65 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import os
import shutil
import pytest
from huggingface_hub import snapshot_download
from vllm.plugins.lora_resolvers.filesystem_resolver import FilesystemResolver
MODEL_NAME = "Qwen/Qwen3-0.6B"
LORA_NAME = "charent/self_cognition_Alice"
PA_NAME = "swapnilbp/llama_tweet_ptune"
@pytest.fixture(scope="module")
def adapter_cache(request, tmpdir_factory):
# Create dir that mimics the structure of the adapter cache
adapter_cache = tmpdir_factory.mktemp(request.module.__name__) / "adapter_cache"
return adapter_cache
@pytest.fixture(scope="module")
def qwen3_lora_files():
return snapshot_download(repo_id=LORA_NAME)
@pytest.fixture(scope="module")
def pa_files():
return snapshot_download(repo_id=PA_NAME)
@pytest.mark.asyncio
async def test_filesystem_resolver(adapter_cache, qwen3_lora_files):
model_files = adapter_cache / LORA_NAME
shutil.copytree(qwen3_lora_files, model_files)
fs_resolver = FilesystemResolver(adapter_cache)
assert fs_resolver is not None
lora_request = await fs_resolver.resolve_lora(MODEL_NAME, LORA_NAME)
assert lora_request is not None
assert lora_request.lora_name == LORA_NAME
assert lora_request.lora_path == os.path.join(adapter_cache, LORA_NAME)
@pytest.mark.asyncio
async def test_missing_adapter(adapter_cache):
fs_resolver = FilesystemResolver(adapter_cache)
assert fs_resolver is not None
missing_lora_request = await fs_resolver.resolve_lora(MODEL_NAME, "foobar")
assert missing_lora_request is None
@pytest.mark.asyncio
async def test_nonlora_adapter(adapter_cache, pa_files):
model_files = adapter_cache / PA_NAME
shutil.copytree(pa_files, model_files)
fs_resolver = FilesystemResolver(adapter_cache)
assert fs_resolver is not None
pa_request = await fs_resolver.resolve_lora(MODEL_NAME, PA_NAME)
assert pa_request is None
@@ -0,0 +1,107 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import os
import pytest
from huggingface_hub.constants import HF_HUB_CACHE
from vllm.plugins.lora_resolvers.hf_hub_resolver import HfHubResolver
LORA_LIB_MODEL_NAME = "ibm-granite/granite-3.3-8b-instruct"
# Repo with multiple LoRAs contained in it
LORA_LIB = "ibm-granite/granite-3.3-8b-rag-agent-lib"
LORA_NAME = "ibm-granite/granite-3.3-8b-rag-agent-lib/answerability_prediction_lora" # noqa: E501
NON_LORA_SUBPATH = "ibm-granite/granite-3.3-8b-rag-agent-lib/README.md"
LIB_DOWNLOAD_DIR = os.path.join(
HF_HUB_CACHE, "models--ibm-granite--granite-3.3-8b-rag-agent-lib"
)
INVALID_REPO_NAME = "thisrepodoesnotexist"
# Repo with only one LoRA in the root dir
LORA_REPO_MODEL_NAME = "meta-llama/Llama-2-7b-hf"
LORA_REPO = "yard1/llama-2-7b-sql-lora-test"
REPO_DOWNLOAD_DIR = os.path.join(
HF_HUB_CACHE, "models--yard1--llama-2-7b-sql-lora-test"
)
@pytest.mark.asyncio
async def test_hf_resolver_with_direct_path():
hf_resolver = HfHubResolver([LORA_REPO])
assert hf_resolver is not None
lora_request = await hf_resolver.resolve_lora(LORA_REPO_MODEL_NAME, LORA_REPO)
assert lora_request.lora_name == LORA_REPO
assert REPO_DOWNLOAD_DIR in lora_request.lora_path
assert "adapter_config.json" in os.listdir(lora_request.lora_path)
@pytest.mark.asyncio
async def test_hf_resolver_with_nested_paths():
hf_resolver = HfHubResolver([LORA_LIB])
assert hf_resolver is not None
lora_request = await hf_resolver.resolve_lora(LORA_LIB_MODEL_NAME, LORA_NAME)
assert lora_request is not None
assert lora_request.lora_name == LORA_NAME
assert LIB_DOWNLOAD_DIR in lora_request.lora_path
assert "adapter_config.json" in os.listdir(lora_request.lora_path)
@pytest.mark.asyncio
async def test_hf_resolver_with_multiple_repos():
hf_resolver = HfHubResolver([LORA_LIB, LORA_REPO])
assert hf_resolver is not None
lora_request = await hf_resolver.resolve_lora(LORA_LIB_MODEL_NAME, LORA_NAME)
assert lora_request is not None
assert lora_request.lora_name == LORA_NAME
assert LIB_DOWNLOAD_DIR in lora_request.lora_path
assert "adapter_config.json" in os.listdir(lora_request.lora_path)
@pytest.mark.asyncio
async def test_missing_adapter():
hf_resolver = HfHubResolver([LORA_LIB])
assert hf_resolver is not None
missing_lora_request = await hf_resolver.resolve_lora(LORA_LIB_MODEL_NAME, "foobar")
assert missing_lora_request is None
@pytest.mark.asyncio
async def test_nonlora_adapter():
hf_resolver = HfHubResolver([LORA_LIB])
assert hf_resolver is not None
readme_request = await hf_resolver.resolve_lora(
LORA_LIB_MODEL_NAME, NON_LORA_SUBPATH
)
assert readme_request is None
@pytest.mark.asyncio
async def test_invalid_repo():
hf_resolver = HfHubResolver([LORA_LIB])
assert hf_resolver is not None
invalid_repo_req = await hf_resolver.resolve_lora(
INVALID_REPO_NAME,
f"{INVALID_REPO_NAME}/foo",
)
assert invalid_repo_req is None
@pytest.mark.asyncio
async def test_trailing_slash():
hf_resolver = HfHubResolver([LORA_LIB])
assert hf_resolver is not None
lora_request = await hf_resolver.resolve_lora(
LORA_LIB_MODEL_NAME,
f"{LORA_NAME}/",
)
assert lora_request is not None
assert lora_request.lora_name == f"{LORA_NAME}/"
assert LIB_DOWNLOAD_DIR in lora_request.lora_path
assert "adapter_config.json" in os.listdir(lora_request.lora_path)
@@ -0,0 +1,235 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
import pytest
import requests
# Test configuration for BGE-M3 sparse plugin
from tests.utils import RemoteOpenAIServer
from vllm.entrypoints.pooling.pooling.protocol import IOProcessorResponse
model_config = {
"model_name": "BAAI/bge-m3",
"plugin": "bge_m3_sparse_plugin",
"test_input": "What is the capital of France?",
"hf_overrides": json.dumps(
{"architectures": ["BgeM3EmbeddingModel"], "head_dtype": "float16"}
),
}
dense_embedding_sum = [
-0.7214539647102356, # "What is the capital of France?"
-0.6926871538162231, # "What is the capital of Germany?"
-0.7129564881324768, # "What is the capital of Spain?"
]
def _float_close(expected: object, result: object):
assert isinstance(expected, float) and isinstance(result, float), (
f"{expected=} or {result=} is not float"
)
return (expected - result) < 1e-3 or abs(expected / result - 1) < 1e-3
def _get_attr_or_val(obj: object | dict, key: str):
if isinstance(obj, dict) and key in obj:
return obj[key]
return getattr(obj, key, None)
def _check_dense_embedding(data, index=0):
assert _float_close(sum(data), dense_embedding_sum[index]), (
"dense-embedding result not match"
)
def _check_sparse_embedding(data, check_tokens=False):
expected_weights = [
{"token_id": 32, "weight": 0.0552978515625, "token": "?"},
{"token_id": 70, "weight": 0.09808349609375, "token": "the"},
{"token_id": 83, "weight": 0.08154296875, "token": "is"},
{"token_id": 111, "weight": 0.11810302734375, "token": "of"},
{"token_id": 4865, "weight": 0.1171875, "token": "What"},
{"token_id": 9942, "weight": 0.292236328125, "token": "France"},
{"token_id": 10323, "weight": 0.2802734375, "token": "capital"},
]
expected_embed = {x["token_id"]: x for x in expected_weights}
assert len(data) == len(expected_embed)
for entry in data:
expected_val = expected_embed[_get_attr_or_val(entry, "token_id")]
assert _float_close(
expected_val["weight"], _get_attr_or_val(entry, "weight")
), f"actual embed {entry} not equal to {expected_val}"
if check_tokens:
assert expected_val["token"] == _get_attr_or_val(entry, "token"), (
f"actual embed {entry} not equal to {expected_val}"
)
else:
assert _get_attr_or_val(entry, "token") is None, (
f"{entry} should not return token"
)
@pytest.fixture(scope="function")
def server():
args = [
"--runner",
"pooling",
"--enforce-eager",
"--max-num-seqs",
"32",
"--hf_overrides",
model_config["hf_overrides"],
"--io-processor-plugin",
model_config["plugin"],
]
with RemoteOpenAIServer(model_config["model_name"], args) as remote_server:
yield remote_server
@pytest.mark.asyncio
@pytest.mark.parametrize(
"return_tokens",
[True, False],
)
async def test_bge_m3_sparse_plugin_online(
server: RemoteOpenAIServer, return_tokens: bool
):
"""Test BGE-M3 sparse plugin in online mode via API."""
request_payload = {
"model": model_config["model_name"],
"task": "plugin",
"data": {"input": model_config["test_input"], "return_tokens": return_tokens},
}
ret = requests.post(
server.url_for("pooling"),
json=request_payload,
)
response = ret.json()
# Verify the request response is in the correct format
assert (parsed_response := IOProcessorResponse(**response).data)
# Verify the output is formatted as expected for this plugin
assert _get_attr_or_val(parsed_response, "data")
assert len(_get_attr_or_val(parsed_response, "data")) > 0
data_entry = _get_attr_or_val(parsed_response, "data")[0]
assert _get_attr_or_val(data_entry, "object") == "dense&sparse"
assert _get_attr_or_val(data_entry, "sparse_embedding")
# Verify sparse embedding format
sparse_embedding = _get_attr_or_val(data_entry, "sparse_embedding")
assert isinstance(sparse_embedding, list)
_check_sparse_embedding(sparse_embedding, return_tokens)
# Verify dense embedding format
dense_embedding = _get_attr_or_val(data_entry, "dense_embedding")
assert isinstance(dense_embedding, list)
_check_dense_embedding(dense_embedding)
# Verify usage information
usage = _get_attr_or_val(parsed_response, "usage")
assert usage, f"usage not found for {parsed_response}"
assert _get_attr_or_val(usage, "prompt_tokens") > 0
assert _get_attr_or_val(usage, "total_tokens") == _get_attr_or_val(
usage, "prompt_tokens"
)
@pytest.mark.parametrize(
"return_tokens",
[True, False],
)
def test_bge_m3_sparse_plugin_offline(vllm_runner, return_tokens: bool):
"""Test BGE-M3 sparse plugin in offline mode."""
prompt = {
"data": {
"input": model_config["test_input"],
"return_tokens": return_tokens,
}
}
with vllm_runner(
model_config["model_name"],
runner="pooling",
enforce_eager=True,
max_num_seqs=32,
io_processor_plugin=model_config["plugin"],
hf_overrides=json.loads(model_config["hf_overrides"]),
default_torch_num_threads=1,
) as llm_runner:
llm = llm_runner.get_llm()
pooler_output = llm.encode(prompt, pooling_task="plugin")
outputs = pooler_output[0]
# Verify output structure
assert hasattr(outputs, "outputs")
response = outputs.outputs
assert hasattr(response, "data")
assert len(response.data) == 1
# Verify response data
for i, output in enumerate(response.data):
# Each output should have sparse embeddings
sparse_embedding = output.sparse_embedding
assert isinstance(sparse_embedding, list)
_check_sparse_embedding(sparse_embedding, return_tokens)
dense_embedding = output.dense_embedding
assert isinstance(dense_embedding, list)
_check_dense_embedding(dense_embedding)
# Verify usage
assert response.usage.prompt_tokens > 0
assert response.usage.total_tokens == response.usage.prompt_tokens
def test_bge_m3_sparse_plugin_offline_multiple_inputs(vllm_runner):
"""Test BGE-M3 sparse plugin with multiple inputs in offline mode."""
prompts = {
"data": {
"input": [
"What is the capital of France?",
"What is the capital of Germany?",
"What is the capital of Spain?",
],
"return_tokens": True,
}
}
with vllm_runner(
model_config["model_name"],
runner="pooling",
enforce_eager=True,
max_num_seqs=32,
io_processor_plugin=model_config["plugin"],
hf_overrides=json.loads(model_config["hf_overrides"]),
default_torch_num_threads=1,
) as llm_runner:
llm = llm_runner.get_llm()
pooler_output = llm.encode(prompts, pooling_task="plugin")
outputs = pooler_output[0]
# Verify output structure
assert hasattr(outputs, "outputs")
response = outputs.outputs
assert hasattr(response, "data")
assert len(response.data) == 3
for i, output in enumerate(response.data):
# Each output should have sparse embeddings
sparse_embedding = output.sparse_embedding
assert isinstance(sparse_embedding, list)
dense_embedding = output.dense_embedding
assert isinstance(dense_embedding, list)
_check_dense_embedding(dense_embedding, i)
# Verify usage
assert response.usage.prompt_tokens > 0
assert response.usage.total_tokens == response.usage.prompt_tokens
@@ -0,0 +1,222 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
from typing import TypedDict
import pytest
import requests
from tests.utils import RemoteOpenAIServer
from vllm.entrypoints.pooling.pooling.protocol import IOProcessorResponse
# Test configuration for ColBERT query plugin
class ModelConfig(TypedDict):
model_name: str
plugin: str
query_input: str
document_input: str
hf_overrides: str
embedding_dim: int
query_maxlen: int
model_config: ModelConfig = {
"model_name": "jinaai/jina-colbert-v2",
"plugin": "colbert_query_plugin",
"query_input": "What is machine learning?",
"document_input": "Machine learning is a subset of artificial intelligence.",
"hf_overrides": json.dumps({"architectures": ["ColBERTJinaRobertaModel"]}),
"embedding_dim": 128,
"query_maxlen": 32,
}
def _get_attr_or_val(obj: object | dict, key: str):
if isinstance(obj, dict) and key in obj:
return obj[key]
return getattr(obj, key, None)
def _check_token_embeddings(entry, expected_input_type: str):
assert _get_attr_or_val(entry, "object") == "embedding"
assert _get_attr_or_val(entry, "input_type") == expected_input_type
embedding = _get_attr_or_val(entry, "embedding")
assert isinstance(embedding, list) and len(embedding) > 0
for token_embedding in embedding:
assert isinstance(token_embedding, list)
assert len(token_embedding) == model_config["embedding_dim"]
return embedding
@pytest.fixture(scope="module")
def server():
args = [
"--runner",
"pooling",
"--enforce-eager",
"--max-num-seqs",
"32",
"--trust-remote-code",
"--hf_overrides",
model_config["hf_overrides"],
"--io-processor-plugin",
model_config["plugin"],
]
with RemoteOpenAIServer(model_config["model_name"], args) as remote_server:
yield remote_server
def _post_pooling(server: RemoteOpenAIServer, data: dict):
request_payload = {
"model": model_config["model_name"],
"task": "plugin",
"data": data,
}
ret = requests.post(server.url_for("pooling"), json=request_payload)
ret.raise_for_status()
response = ret.json()
parsed_response = IOProcessorResponse(**response).data
assert parsed_response
return parsed_response
def test_colbert_query_plugin_query_online(server: RemoteOpenAIServer):
"""Queries are expanded to exactly query_maxlen token vectors."""
parsed_response = _post_pooling(
server, {"input": model_config["query_input"], "input_type": "query"}
)
data = _get_attr_or_val(parsed_response, "data")
assert len(data) == 1
embedding = _check_token_embeddings(data[0], "query")
assert len(embedding) == model_config["query_maxlen"]
usage = _get_attr_or_val(parsed_response, "usage")
assert _get_attr_or_val(usage, "prompt_tokens") == model_config["query_maxlen"]
def test_colbert_query_plugin_document_online(server: RemoteOpenAIServer):
"""Documents return one vector per token, with no mask expansion."""
parsed_response = _post_pooling(
server, {"input": model_config["document_input"], "input_type": "document"}
)
data = _get_attr_or_val(parsed_response, "data")
assert len(data) == 1
embedding = _check_token_embeddings(data[0], "document")
# No query expansion: number of vectors tracks the input length.
assert len(embedding) != model_config["query_maxlen"]
usage = _get_attr_or_val(parsed_response, "usage")
assert _get_attr_or_val(usage, "prompt_tokens") == len(embedding)
def test_colbert_query_plugin_missing_input_type_online(server: RemoteOpenAIServer):
"""input_type is required; omitting it is rejected."""
request_payload = {
"model": model_config["model_name"],
"task": "plugin",
"data": {"input": model_config["document_input"]},
}
ret = requests.post(server.url_for("pooling"), json=request_payload)
assert ret.status_code == 400
def test_colbert_query_plugin_batch_online(server: RemoteOpenAIServer):
"""A list input returns one entry per prompt."""
queries = ["What is machine learning?", "What is deep learning?"]
parsed_response = _post_pooling(server, {"input": queries, "input_type": "query"})
data = _get_attr_or_val(parsed_response, "data")
assert len(data) == len(queries)
for i, entry in enumerate(data):
assert _get_attr_or_val(entry, "index") == i
embedding = _check_token_embeddings(entry, "query")
assert len(embedding) == model_config["query_maxlen"]
@pytest.mark.parametrize("input_type", ["query", "document"])
def test_colbert_query_plugin_offline(vllm_runner, input_type: str):
"""Test the ColBERT query plugin in offline mode."""
input_text = (
model_config["query_input"]
if input_type == "query"
else model_config["document_input"]
)
prompt = {
"data": {
"input": input_text,
"input_type": input_type,
}
}
with vllm_runner(
model_config["model_name"],
runner="pooling",
enforce_eager=True,
max_num_seqs=32,
trust_remote_code=True,
io_processor_plugin=model_config["plugin"],
hf_overrides=json.loads(model_config["hf_overrides"]),
default_torch_num_threads=1,
) as llm_runner:
llm = llm_runner.get_llm()
pooler_output = llm.encode(prompt, pooling_task="plugin")
response = pooler_output[0].outputs
assert len(response.data) == 1
embedding = _check_token_embeddings(response.data[0], input_type)
if input_type == "query":
assert len(embedding) == model_config["query_maxlen"]
else:
assert len(embedding) != model_config["query_maxlen"]
assert response.usage.prompt_tokens == len(embedding)
assert response.usage.total_tokens == response.usage.prompt_tokens
def test_colbert_query_plugin_offline_multiple_inputs(vllm_runner):
"""Test the ColBERT query plugin with multiple inputs in offline mode."""
queries = [
"What is machine learning?",
"What is deep learning?",
"Why?",
]
prompts = {
"data": {
"input": queries,
"input_type": "query",
}
}
with vllm_runner(
model_config["model_name"],
runner="pooling",
enforce_eager=True,
max_num_seqs=32,
trust_remote_code=True,
io_processor_plugin=model_config["plugin"],
hf_overrides=json.loads(model_config["hf_overrides"]),
default_torch_num_threads=1,
) as llm_runner:
llm = llm_runner.get_llm()
pooler_output = llm.encode(prompts, pooling_task="plugin")
response = pooler_output[0].outputs
assert len(response.data) == len(queries)
for i, entry in enumerate(response.data):
assert entry.index == i
embedding = _check_token_embeddings(entry, "query")
assert len(embedding) == model_config["query_maxlen"]
expected_tokens = model_config["query_maxlen"] * len(queries)
assert response.usage.prompt_tokens == expected_tokens
assert response.usage.total_tokens == response.usage.prompt_tokens
@@ -0,0 +1,236 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for the `vllm.endpoint_plugins` framework (RFC #46565).
Uses the worked in repo example plugin (`vllm_add_dummy_endpoint_plugin`,
installed via `tests/plugins/vllm_add_dummy_endpoint_plugin`) exercising both
`EndpointPlugin` hooks against a fake `EngineClient`, unit tests for the
`load_endpoint_plugins` gating matrix and an e2e test that drives a real HTTP
request through the plugin's route.
"""
from argparse import Namespace
from typing import Any
import httpx
import pytest
from fastapi import FastAPI
from vllm_add_dummy_endpoint_plugin import DummyAdminEndpointPlugin
from vllm.entrypoints.openai.api_server import (
_attach_endpoint_plugins,
_init_endpoint_plugins_state,
build_app,
)
from vllm.entrypoints.openai.cli_args import make_arg_parser
from vllm.plugins import load_endpoint_plugins
from vllm.plugins.endpoint_plugins.interface import EndpointPlugin
from vllm.utils.argparse_utils import FlexibleArgumentParser
class _RaisingEndpointPlugin:
"""Factory that raises to exercise the "instantiation fails" path."""
name = "raising_endpoint_plugin"
required_tasks = None
def __init__(self):
raise RuntimeError("boom")
class _FakeEngineClient:
"""Minimal stand in exercising `collective_rpc`. Not a real engine."""
def __init__(self, rpc_result: Any = None):
self.rpc_result = rpc_result
self.rpc_calls: list[tuple[str, tuple, dict]] = []
async def collective_rpc(self, method, timeout=None, args=(), kwargs=None):
self.rpc_calls.append((method, args, kwargs or {}))
return self.rpc_result
def _build_args() -> Namespace:
parser = FlexibleArgumentParser()
subparsers = parser.add_subparsers()
serve_parser = subparsers.add_parser("serve")
make_arg_parser(serve_parser)
return serve_parser.parse_args([])
def _fake_loader(factories: dict[str, Any]):
def _load_plugins_by_group(group: str) -> dict[str, Any]:
assert group == "vllm.endpoint_plugins"
return factories
return _load_plugins_by_group
def test_dummy_plugin_satisfies_protocol():
assert isinstance(DummyAdminEndpointPlugin(), EndpointPlugin)
def test_no_plugins_loaded_when_allowlist_unset(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("VLLM_PLUGINS", raising=False)
assert load_endpoint_plugins(("generate",)) == []
def test_no_plugins_loaded_when_allowlist_is_empty_string(
monkeypatch: pytest.MonkeyPatch,
):
"""`VLLM_PLUGINS=""` parses to `[""]`, not `None` (see `vllm.envs`), so it
must be treated as a (non strict) allowlist matching no plugin name, not
as "unset"."""
monkeypatch.setenv("VLLM_PLUGINS", "")
assert load_endpoint_plugins(("generate",)) == []
def test_plugin_loaded_when_allowlisted_and_task_matches(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setenv("VLLM_PLUGINS", "dummy_admin_endpoint_plugin")
plugins = load_endpoint_plugins(("generate",))
assert len(plugins) == 1
assert isinstance(plugins[0], DummyAdminEndpointPlugin)
def test_plugin_skipped_when_required_tasks_miss(monkeypatch: pytest.MonkeyPatch):
class _GenerateOnlyPlugin(DummyAdminEndpointPlugin):
required_tasks = ("generate",)
monkeypatch.setenv("VLLM_PLUGINS", "dummy_admin_endpoint_plugin")
monkeypatch.setattr(
"vllm.plugins.load_plugins_by_group",
_fake_loader({"dummy_admin_endpoint_plugin": _GenerateOnlyPlugin}),
)
assert load_endpoint_plugins(("embed",)) == []
assert len(load_endpoint_plugins(("generate",))) == 1
def test_plugin_loaded_when_required_tasks_is_none(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("VLLM_PLUGINS", "dummy_admin_endpoint_plugin")
assert len(load_endpoint_plugins(supported_tasks=None)) == 1
def test_plugin_skipped_when_required_tasks_set_but_supported_tasks_none(
monkeypatch: pytest.MonkeyPatch,
):
class _GenerateOnlyPlugin(DummyAdminEndpointPlugin):
required_tasks = ("generate",)
monkeypatch.setenv("VLLM_PLUGINS", "dummy_admin_endpoint_plugin")
monkeypatch.setattr(
"vllm.plugins.load_plugins_by_group",
_fake_loader({"dummy_admin_endpoint_plugin": _GenerateOnlyPlugin}),
)
assert load_endpoint_plugins(supported_tasks=None) == []
def test_factory_raising_is_logged_and_skipped(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv(
"VLLM_PLUGINS", "raising_endpoint_plugin,dummy_admin_endpoint_plugin"
)
monkeypatch.setattr(
"vllm.plugins.load_plugins_by_group",
_fake_loader(
{
"raising_endpoint_plugin": _RaisingEndpointPlugin,
"dummy_admin_endpoint_plugin": DummyAdminEndpointPlugin,
}
),
)
plugins = load_endpoint_plugins(("generate",))
assert len(plugins) == 1
assert isinstance(plugins[0], DummyAdminEndpointPlugin)
def test_attach_is_noop_when_nothing_discovered(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("VLLM_PLUGINS", raising=False)
app = FastAPI()
_attach_endpoint_plugins(app, ("generate",))
assert app.state.endpoint_plugins == []
@pytest.mark.asyncio
async def test_init_state_is_noop_without_phase_a(monkeypatch: pytest.MonkeyPatch):
"""`init_app_state` callers that never ran `build_app` (e.g.
`run_batch.py`, which builds a bare `State()`) must not crash just
because `state.endpoint_plugins` was never set."""
from starlette.datastructures import State
monkeypatch.setenv("VLLM_PLUGINS", "dummy_admin_endpoint_plugin")
state = State()
await _init_endpoint_plugins_state(_FakeEngineClient(), state, _build_args())
assert not hasattr(state, "dummy_engine_client")
@pytest.mark.asyncio
async def test_render_server_attaches_endpoint_plugins_with_no_engine_client(
monkeypatch: pytest.MonkeyPatch,
):
"""The CPU only render server has no `EngineClient` but a plugin eligible
for the `render` task (`required_tasks` is `None` or includes `"render"`)
still gets its routes attached at Phase A. Phase B passes `None` for
`engine_client` and it's up to the plugin to handle that."""
monkeypatch.setenv("VLLM_PLUGINS", "dummy_admin_endpoint_plugin")
args = _build_args()
app = build_app(args, ("render",))
assert len(app.state.endpoint_plugins) == 1
assert any(
getattr(route, "path", None) == "/v1/admin/scheduler_config"
for route in app.routes
)
await _init_endpoint_plugins_state(None, app.state, args)
assert app.state.dummy_engine_client is None
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/v1/admin/scheduler_config")
assert response.status_code == 503
@pytest.mark.asyncio
async def test_endpoint_plugin_end_to_end(monkeypatch: pytest.MonkeyPatch):
"""Phase A (attach) + Phase B (init) wired through `build_app` then
exercised with a real HTTP request against the worked example plugin."""
monkeypatch.setenv("VLLM_PLUGINS", "dummy_admin_endpoint_plugin")
args = _build_args()
app = build_app(args, supported_tasks=())
assert len(app.state.endpoint_plugins) == 1
assert any(
getattr(route, "path", None) == "/v1/admin/scheduler_config"
for route in app.routes
)
fake_engine_client = _FakeEngineClient(rpc_result=["cfg-a", "cfg-b"])
await _init_endpoint_plugins_state(fake_engine_client, app.state, args)
assert app.state.dummy_engine_client is fake_engine_client
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/v1/admin/scheduler_config")
assert response.status_code == 200
assert response.json() == {"scheduler_config": ["cfg-a", "cfg-b"]}
assert fake_engine_client.rpc_calls == [("get_scheduler_config", (), {})]
@@ -0,0 +1,98 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Sequence
from unittest.mock import MagicMock, patch
import pytest
from vllm.config import VllmConfig
from vllm.inputs import PromptType
from vllm.outputs import PoolingRequestOutput
from vllm.plugins.io_processors import get_io_processor
from vllm.plugins.io_processors.interface import IOProcessor
from vllm.renderers import BaseRenderer
class DummyIOProcessor(IOProcessor):
"""Minimal IOProcessor used as the target of the mocked plugin entry point."""
def pre_process(
self,
prompt: object,
request_id: str | None = None,
**kwargs,
) -> PromptType | Sequence[PromptType]:
raise NotImplementedError
def post_process(
self,
model_output: Sequence[PoolingRequestOutput],
request_id: str | None = None,
**kwargs,
) -> object:
raise NotImplementedError
@pytest.fixture
def my_plugin_entry_points():
"""Patch importlib.metadata.entry_points to expose a single 'my_plugin'
entry point backed by DummyIOProcessor, exercising the full plugin-loading
code path: entry_points → plugin.load() → func() →
resolve_obj_by_qualname → IOProcessor.__init__."""
qualname = f"{DummyIOProcessor.__module__}.{DummyIOProcessor.__qualname__}"
ep = MagicMock()
ep.name = "my_plugin"
ep.value = qualname
ep.load.return_value = lambda: qualname
with patch("importlib.metadata.entry_points", return_value=[ep]):
yield
def test_loading_missing_plugin():
vllm_config = VllmConfig()
renderer = MagicMock(spec=BaseRenderer)
with pytest.raises(ValueError):
get_io_processor(
vllm_config, renderer=renderer, plugin_from_init="wrong_plugin"
)
def test_loading_plugin(my_plugin_entry_points):
# Plugin name supplied via plugin_from_init.
vllm_config = MagicMock(spec=VllmConfig)
renderer = MagicMock(spec=BaseRenderer)
result = get_io_processor(
vllm_config, renderer=renderer, plugin_from_init="my_plugin"
)
assert isinstance(result, DummyIOProcessor)
def test_loading_missing_plugin_from_model_config():
# Build a mock VllmConfig whose hf_config advertises a plugin name,
# exercising the model-config code path without loading a real model.
mock_hf_config = MagicMock()
mock_hf_config.to_dict.return_value = {"io_processor_plugin": "wrong_plugin"}
vllm_config = MagicMock(spec=VllmConfig)
vllm_config.model_config.hf_config = mock_hf_config
renderer = MagicMock(spec=BaseRenderer)
with pytest.raises(ValueError):
get_io_processor(vllm_config, renderer=renderer)
def test_loading_plugin_from_model_config(my_plugin_entry_points):
# Plugin name supplied via the model's hf_config.
mock_hf_config = MagicMock()
mock_hf_config.to_dict.return_value = {"io_processor_plugin": "my_plugin"}
vllm_config = MagicMock(spec=VllmConfig)
vllm_config.model_config.hf_config = mock_hf_config
renderer = MagicMock(spec=BaseRenderer)
result = get_io_processor(vllm_config, renderer=renderer)
assert isinstance(result, DummyIOProcessor)
@@ -0,0 +1,101 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from tests.utils import create_new_process_for_each_test
from vllm import LLM, SamplingParams
from vllm.assets.image import ImageAsset
from vllm.multimodal.image import convert_image_mode
@create_new_process_for_each_test()
def test_plugin(
monkeypatch: pytest.MonkeyPatch,
dummy_opt_path: str,
):
with monkeypatch.context() as m:
m.setenv("VLLM_PLUGINS", "")
with pytest.raises(ValueError, match="are not supported for now"):
LLM(model=dummy_opt_path, load_format="dummy")
@create_new_process_for_each_test()
def test_oot_registration_text_generation(
monkeypatch: pytest.MonkeyPatch,
dummy_opt_path: str,
):
with monkeypatch.context() as m:
m.setenv("VLLM_PLUGINS", "register_dummy_model")
prompts = ["Hello, my name is", "The text does not matter"]
sampling_params = SamplingParams(temperature=0)
llm = LLM(model=dummy_opt_path, load_format="dummy")
first_token = llm.get_tokenizer().decode(0)
outputs = llm.generate(prompts, sampling_params)
for output in outputs:
generated_text = output.outputs[0].text
# make sure only the first token is generated
rest = generated_text.replace(first_token, "")
assert rest == ""
@create_new_process_for_each_test()
def test_oot_registration_embedding(
monkeypatch: pytest.MonkeyPatch,
dummy_gemma2_embedding_path: str,
):
with monkeypatch.context() as m:
m.setenv("VLLM_PLUGINS", "register_dummy_model")
prompts = ["Hello, my name is", "The text does not matter"]
llm = LLM(
model=dummy_gemma2_embedding_path, load_format="dummy", max_model_len=2048
)
outputs = llm.embed(prompts)
for output in outputs:
assert all(v == 0 for v in output.outputs.embedding)
image = convert_image_mode(ImageAsset("cherry_blossom").pil_image, "RGB")
@create_new_process_for_each_test()
def test_oot_registration_multimodal(
monkeypatch: pytest.MonkeyPatch,
dummy_llava_path: str,
):
with monkeypatch.context() as m:
m.setenv("VLLM_PLUGINS", "register_dummy_model")
prompts = [
{
"prompt": "What's in the image?<image>",
"multi_modal_data": {"image": image},
},
{
"prompt": "Describe the image<image>",
"multi_modal_data": {"image": image},
},
]
sampling_params = SamplingParams(temperature=0)
llm = LLM(
model=dummy_llava_path,
load_format="dummy",
max_num_seqs=1,
trust_remote_code=True,
gpu_memory_utilization=0.98,
max_model_len=4096,
enforce_eager=True,
limit_mm_per_prompt={"image": 1},
)
first_token = llm.get_tokenizer().decode(0)
outputs = llm.generate(prompts, sampling_params)
for output in outputs:
generated_text = output.outputs[0].text
# make sure only the first token is generated
rest = generated_text.replace(first_token, "")
assert rest == ""
@@ -0,0 +1,42 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from tests.utils import VLLM_PATH, RemoteOpenAIServer
chatml_jinja_path = VLLM_PATH / "examples/template_chatml.jinja"
assert chatml_jinja_path.exists()
def run_and_test_dummy_opt_api_server(model, tp=1):
# the model is registered through the plugin
server_args = [
"--gpu-memory-utilization",
"0.10",
"--dtype",
"float32",
"--chat-template",
str(chatml_jinja_path),
"--load-format",
"dummy",
"-tp",
f"{tp}",
]
with RemoteOpenAIServer(model, server_args) as server:
client = server.get_client()
completion = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"},
],
temperature=0,
)
generated_text = completion.choices[0].message.content
assert generated_text is not None
# make sure only the first token is generated
rest = generated_text.replace("<s>", "")
assert rest == ""
def test_oot_registration_for_api_server(dummy_opt_path: str):
run_and_test_dummy_opt_api_server(dummy_opt_path)
@@ -0,0 +1,47 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
from vllm.plugins import load_general_plugins
def test_platform_plugins():
# simulate workload by running an example
import runpy
current_file = __file__
import os
example_file = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(current_file))),
"examples",
"basic/offline_inference/basic.py",
)
runpy.run_path(example_file)
# check if the plugin is loaded correctly
from vllm.platforms import _init_trace, current_platform
assert current_platform.device_name == "DummyDevice", (
f"Expected DummyDevice, got {current_platform.device_name}, "
"possibly because current_platform is imported before the plugin"
f" is loaded. The first import:\n{_init_trace}"
)
def test_oot_custom_op(default_vllm_config, monkeypatch: pytest.MonkeyPatch):
# simulate workload by running an example
load_general_plugins()
from vllm.model_executor.layers.rotary_embedding import RotaryEmbedding
layer = RotaryEmbedding(16, 16, 16, 16, True, torch.float16)
assert layer.__class__.__name__ == "DummyRotaryEmbedding", (
f"Expected DummyRotaryEmbedding, got {layer.__class__.__name__}, "
"possibly because the custom op is not registered correctly."
)
assert hasattr(layer, "addition_config"), (
"Expected DummyRotaryEmbedding to have an 'addition_config' attribute, "
"which is set by the custom op."
)
@@ -0,0 +1,36 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm.engine.arg_utils import EngineArgs
from vllm.sampling_params import SamplingParams
from vllm.v1.core.sched.scheduler import Scheduler
from vllm.v1.engine.llm_engine import LLMEngine
class DummyV1Scheduler(Scheduler):
def schedule(self, throttle_prefills: bool = False):
raise Exception("Exception raised by DummyV1Scheduler")
def test_scheduler_plugins_v1(monkeypatch: pytest.MonkeyPatch):
with monkeypatch.context() as m:
# Explicitly turn off engine multiprocessing so
# that the scheduler runs in this process
m.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
with pytest.raises(Exception) as exception_info:
engine_args = EngineArgs(
model="facebook/opt-125m",
enforce_eager=True, # reduce test time
scheduler_cls=DummyV1Scheduler,
)
engine = LLMEngine.from_engine_args(engine_args=engine_args)
sampling_params = SamplingParams(max_tokens=1)
engine.add_request("0", "foo", sampling_params)
engine.step()
assert str(exception_info.value) == "Exception raised by DummyV1Scheduler"
@@ -0,0 +1,76 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from dummy_stat_logger.dummy_stat_logger import DummyStatLogger
from vllm.config import VllmConfig
from vllm.engine.arg_utils import AsyncEngineArgs
from vllm.v1.engine.async_llm import AsyncLLM
from vllm.v1.metrics.loggers import load_stat_logger_plugin_factories
def test_stat_logger_plugin_is_discovered(monkeypatch: pytest.MonkeyPatch):
with monkeypatch.context() as m:
m.setenv("VLLM_PLUGINS", "dummy_stat_logger")
factories = load_stat_logger_plugin_factories()
assert len(factories) == 1, f"Expected 1 factory, got {len(factories)}"
assert factories[0] is DummyStatLogger, (
f"Expected DummyStatLogger class, got {factories[0]}"
)
# instantiate and confirm the right type
vllm_config = VllmConfig()
instance = factories[0](vllm_config)
assert isinstance(instance, DummyStatLogger)
def test_no_plugins_loaded_if_env_empty(monkeypatch: pytest.MonkeyPatch):
with monkeypatch.context() as m:
m.setenv("VLLM_PLUGINS", "")
factories = load_stat_logger_plugin_factories()
assert factories == []
def test_invalid_stat_logger_plugin_raises(monkeypatch: pytest.MonkeyPatch):
def fake_plugin_loader(group: str):
assert group == "vllm.stat_logger_plugins"
return {"bad": object()}
with monkeypatch.context() as m:
m.setattr(
"vllm.v1.metrics.loggers.load_plugins_by_group",
fake_plugin_loader,
)
with pytest.raises(
TypeError,
match="Stat logger plugin 'bad' must be a subclass of StatLoggerBase",
):
load_stat_logger_plugin_factories()
@pytest.mark.asyncio
async def test_stat_logger_plugin_integration_with_engine(
monkeypatch: pytest.MonkeyPatch,
):
with monkeypatch.context() as m:
m.setenv("VLLM_PLUGINS", "dummy_stat_logger")
engine_args = AsyncEngineArgs(
model="facebook/opt-125m",
enforce_eager=True, # reduce test time
disable_log_stats=True, # disable default loggers
)
engine = AsyncLLM.from_engine_args(engine_args=engine_args)
assert len(engine.logger_manager.stat_loggers) == 2
assert len(engine.logger_manager.stat_loggers[0].per_engine_stat_loggers) == 1
assert isinstance(
engine.logger_manager.stat_loggers[0].per_engine_stat_loggers[0],
DummyStatLogger,
)
engine.shutdown()
@@ -0,0 +1,153 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import importlib.util
import io
import imagehash
import pybase64 as base64
import pytest
import requests
from PIL import Image
from tests.utils import RemoteOpenAIServer
from vllm.entrypoints.pooling.pooling.protocol import IOProcessorResponse
pytestmark = pytest.mark.skipif(
importlib.util.find_spec("terratorch") is None,
reason="terratorch unavailable while PyPI has `lightning` quarantined; see #41376",
)
models_config = {
"ibm-nasa-geospatial/Prithvi-EO-2.0-300M-TL-Sen1Floods11": {
"image_url": "https://huggingface.co/christian-pinto/Prithvi-EO-2.0-300M-TL-VLLM/resolve/main/valencia_example_2024-10-26.tiff", # noqa: E501
"out_hash": "aa6d92ad25926a5e",
"plugin": "prithvi_to_tiff",
},
"ibm-nasa-geospatial/Prithvi-EO-2.0-300M-BurnScars": {
"image_url": "https://huggingface.co/ibm-nasa-geospatial/Prithvi-EO-2.0-300M-BurnScars/resolve/main/examples/subsetted_512x512_HLS.S30.T10SEH.2018190.v1.4_merged.tif", # noqa: E501
"out_hash": "c07f4f602da73552",
"plugin": "prithvi_to_tiff",
},
}
def _compute_image_hash(base64_data: str) -> str:
# Decode the base64 output and create image from byte stream
decoded_image = base64.b64decode(base64_data)
image = Image.open(io.BytesIO(decoded_image))
# Compute perceptual hash of the output image
return str(imagehash.phash(image))
@pytest.fixture(scope="function")
def server(model_name, plugin):
args = [
"--runner",
"pooling",
"--enforce-eager",
"--skip-tokenizer-init",
# Limit the maximum number of parallel requests
# to avoid the model going OOM in CI.
"--max-num-seqs",
"32",
"--io-processor-plugin",
plugin,
"--enable-mm-embeds",
]
with RemoteOpenAIServer(model_name, args) as remote_server:
yield remote_server
@pytest.mark.asyncio
@pytest.mark.parametrize(
"model_name, image_url, plugin, expected_hash",
[
(model_name, config["image_url"], config["plugin"], config["out_hash"])
for model_name, config in models_config.items()
],
)
async def test_prithvi_mae_plugin_online(
server: RemoteOpenAIServer,
model_name: str,
image_url: str | dict,
plugin: str,
expected_hash: str,
):
request_payload_url = {
"data": {
"data": image_url,
"data_format": "url",
"image_format": "tiff",
"out_data_format": "b64_json",
},
"priority": 0,
"model": model_name,
"softmax": False,
}
ret = requests.post(
server.url_for("pooling"),
json=request_payload_url,
)
response = ret.json()
# verify the request response is in the correct format
assert (parsed_response := IOProcessorResponse(**response))
# verify the output is formatted as expected for this plugin
plugin_data = parsed_response.data
assert all(plugin_data.get(attr) for attr in ["type", "format", "data"])
# Compute the output image hash and compare it against the expected hash
image_hash = _compute_image_hash(plugin_data["data"])
assert image_hash == expected_hash, (
f"Image hash mismatch: expected {expected_hash}, got {image_hash}"
)
@pytest.mark.parametrize(
"model_name, image_url, plugin, expected_hash",
[
(model_name, config["image_url"], config["plugin"], config["out_hash"])
for model_name, config in models_config.items()
],
)
def test_prithvi_mae_plugin_offline(
vllm_runner, model_name: str, image_url: str | dict, plugin: str, expected_hash: str
):
img_data = dict(
data=image_url,
data_format="url",
image_format="tiff",
out_data_format="b64_json",
)
prompt = dict(data=img_data)
with vllm_runner(
model_name,
runner="pooling",
skip_tokenizer_init=True,
enable_mm_embeds=True,
enforce_eager=True,
# Limit the maximum number of parallel requests
# to avoid the model going OOM in CI.
max_num_seqs=32,
io_processor_plugin=plugin,
default_torch_num_threads=1,
) as llm_runner:
pooler_output = llm_runner.get_llm().encode(prompt, pooling_task="plugin")
output = pooler_output[0].outputs
# verify the output is formatted as expected for this plugin
assert all(hasattr(output, attr) for attr in ["type", "format", "data"])
# Compute the output image hash and compare it against the expected hash
image_hash = _compute_image_hash(output.data)
assert image_hash == expected_hash, (
f"Image hash mismatch: expected {expected_hash}, got {image_hash}"
)