Files
2026-07-13 13:17:40 +08:00

448 lines
14 KiB
Python

import os
import pytest
import requests
import sys
from ray import serve
from ray.serve.llm import LLMConfig, build_openai_app
from vllm import AsyncEngineArgs
from vllm.v1.engine.async_llm import AsyncLLM
from vllm.v1.metrics.ray_wrappers import RayPrometheusStatLogger
from vllm.sampling_params import SamplingParams
from ray._common.test_utils import wait_for_condition
from ray.serve._private.constants import SERVE_DEFAULT_APP_NAME
from ray.serve.schema import ApplicationStatus
import time
# Pooling models (classify/reward) are only served through vLLM's native ASGI
# app, which is used when direct streaming is enabled. The default OpenAiIngress
# path does not expose /classify or /pooling, so these tests only run when
# RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING=1.
direct_streaming_only = pytest.mark.skipif(
os.environ.get("RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING", "0") != "1",
reason="Pooling/classify endpoints are only served in direct-streaming mode "
"(RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING=1).",
)
@pytest.mark.asyncio(scope="function")
async def test_engine_metrics():
"""
Test that the stat logger can be created successfully.
Keeping this test small to focus on instantiating the
derived class correctly.
"""
engine_args = AsyncEngineArgs(
model="Qwen/Qwen2.5-0.5B-Instruct",
dtype="auto",
disable_log_stats=False,
enforce_eager=True,
)
engine = AsyncLLM.from_engine_args(
engine_args, stat_loggers=[RayPrometheusStatLogger]
)
for i, prompt in enumerate(["What is the capital of France?", "What is 2+2?"]):
results = engine.generate(
request_id=f"request-id-{i}",
prompt=prompt,
sampling_params=SamplingParams(max_tokens=10),
)
async for _ in results:
pass
@pytest.mark.asyncio(scope="function")
async def test_engine_metrics_with_lora():
"""
Test that the stat logger can be created successfully with LoRA configuration.
This test validates LoRA-enabled engine initialization and basic functionality.
"""
engine_args = AsyncEngineArgs(
model="Qwen/Qwen2.5-0.5B-Instruct", # Using smaller model for testing
disable_log_stats=False,
enforce_eager=True,
enable_prefix_caching=True,
max_model_len=512,
max_lora_rank=64,
enable_lora=True,
max_loras=3,
max_cpu_loras=5,
)
engine = AsyncLLM.from_engine_args(
engine_args, stat_loggers=[RayPrometheusStatLogger]
)
for i, prompt in enumerate(["What is the capital of France?", "What is 2+2?"]):
results = engine.generate(
request_id=f"lora-request-id-{i}",
prompt=prompt,
sampling_params=SamplingParams(max_tokens=10),
)
async for _ in results:
pass
@pytest.mark.asyncio(scope="function")
async def test_engine_metrics_with_spec_decode():
"""
Test that the stat logger can be created successfully with speculative decoding configuration.
This test validates speculative decoding engine initialization and basic functionality.
"""
engine_args = AsyncEngineArgs(
model="Qwen/Qwen2.5-0.5B-Instruct",
dtype="auto",
disable_log_stats=False,
enforce_eager=True,
trust_remote_code=True,
enable_prefix_caching=True,
max_model_len=256,
speculative_config={
"method": "ngram",
"num_speculative_tokens": 5,
"prompt_lookup_max": 4,
},
)
engine = AsyncLLM.from_engine_args(
engine_args, stat_loggers=[RayPrometheusStatLogger]
)
for i, prompt in enumerate(["What is the capital of France?", "What is 2+2?"]):
results = engine.generate(
request_id=f"spec-request-id-{i}",
prompt=prompt,
sampling_params=SamplingParams(max_tokens=10),
)
async for _ in results:
pass
def is_default_app_running():
"""Check if the default application is running successfully."""
try:
default_app = serve.status().applications[SERVE_DEFAULT_APP_NAME]
return default_app.status == ApplicationStatus.RUNNING
except (KeyError, AttributeError):
return False
@pytest.mark.parametrize("model_name", ["deepseek-ai/DeepSeek-V2-Lite"])
def test_deepseek_model(model_name):
"""
Test that the deepseek model can be loaded successfully.
"""
llm_config = LLMConfig(
model_loading_config=dict(
model_id=model_name,
),
deployment_config=dict(
autoscaling_config=dict(min_replicas=1, max_replicas=1),
),
engine_kwargs=dict(
tensor_parallel_size=2,
pipeline_parallel_size=2,
gpu_memory_utilization=0.92,
dtype="auto",
max_num_seqs=40,
max_model_len=8192,
enable_chunked_prefill=True,
enable_prefix_caching=True,
enforce_eager=True,
trust_remote_code=True,
),
)
app = build_openai_app({"llm_configs": [llm_config]})
serve.run(app, blocking=False)
wait_for_condition(is_default_app_running, timeout=300)
serve.shutdown()
time.sleep(1)
@pytest.mark.parametrize("model_name", ["openai/whisper-small"])
def test_transcription_model(model_name):
"""
Test that the transcription models can be loaded successfully.
"""
llm_config = LLMConfig(
model_loading_config=dict(
model_id=model_name,
model_source=model_name,
),
deployment_config=dict(
autoscaling_config=dict(min_replicas=1, max_replicas=4),
),
engine_kwargs=dict(
trust_remote_code=True,
gpu_memory_utilization=0.9,
enable_prefix_caching=True,
),
)
app = build_openai_app({"llm_configs": [llm_config]})
serve.run(app, blocking=False)
wait_for_condition(is_default_app_running, timeout=180)
serve.shutdown()
time.sleep(1)
@pytest.mark.parametrize("model_name", ["BAAI/bge-small-en-v1.5"])
def test_embedding_model(model_name):
"""
Test that embedding models can be loaded and serve embedding requests.
"""
llm_config = LLMConfig(
model_loading_config=dict(
model_id=model_name,
),
deployment_config=dict(
num_replicas=1,
),
engine_kwargs=dict(
enforce_eager=True,
),
)
app = build_openai_app({"llm_configs": [llm_config]})
serve.run(app, blocking=False)
wait_for_condition(is_default_app_running, timeout=180)
response = requests.post(
"http://localhost:8000/v1/embeddings",
json={
"model": model_name,
"input": "Hello, world!",
},
)
assert response.status_code == 200, response.text
data = response.json()
assert "data" in data
assert len(data["data"]) > 0
embedding = data["data"][0]["embedding"]
assert isinstance(embedding, list)
assert len(embedding) > 0
assert all(isinstance(x, float) for x in embedding)
serve.shutdown()
time.sleep(1)
@pytest.mark.parametrize("model_name", ["BAAI/bge-small-en-v1.5"])
def test_score_model(model_name):
"""
Test that embedding models can serve score requests.
"""
llm_config = LLMConfig(
model_loading_config=dict(
model_id=model_name,
),
deployment_config=dict(
num_replicas=1,
),
engine_kwargs=dict(
enforce_eager=True,
),
)
app = build_openai_app({"llm_configs": [llm_config]})
serve.run(app, blocking=False)
wait_for_condition(is_default_app_running, timeout=180)
response = requests.post(
"http://localhost:8000/v1/score",
json={
"model": model_name,
"text_1": "What is the capital of France?",
"text_2": ["Paris is the capital of France.", "Berlin is in Germany."],
},
)
assert response.status_code == 200, response.text
data = response.json()
assert "data" in data
assert len(data["data"]) == 2
for item in data["data"]:
assert "score" in item
assert isinstance(item["score"], float)
serve.shutdown()
time.sleep(1)
def _validate_classify(item):
assert isinstance(item["probs"], list)
assert len(item["probs"]) > 0
assert item["num_classes"] == len(item["probs"])
def _validate_pooling(item):
# Reward models emit a per-token pooling vector; ensure it is non-empty.
assert len(item["data"]) > 0
@direct_streaming_only
@pytest.mark.parametrize(
"model_name,engine_kwargs,endpoint,validate_item",
[
pytest.param(
"Qwen/Qwen3-Reranker-0.6B",
dict(
hf_overrides={
"architectures": ["Qwen3ForSequenceClassification"],
"classifier_from_token": ["no", "yes"],
"is_original_qwen3_reranker": True,
},
),
"/classify",
_validate_classify,
id="classify",
),
pytest.param(
"internlm/internlm2-1_8b-reward",
dict(trust_remote_code=True),
"/pooling",
_validate_pooling,
id="pooling",
),
],
)
def test_pooling_model(model_name, engine_kwargs, endpoint, validate_item):
"""Pooling models (classify/reward) are served via vLLM's native /classify
and /pooling endpoints, which are only mounted in direct-streaming mode."""
llm_config = LLMConfig(
model_loading_config=dict(model_id=model_name),
deployment_config=dict(num_replicas=1),
engine_kwargs=dict(enforce_eager=True, max_model_len=512, **engine_kwargs),
)
app = build_openai_app({"llm_configs": [llm_config]})
serve.run(app, blocking=False)
wait_for_condition(is_default_app_running, timeout=300)
response = requests.post(
f"http://localhost:8000{endpoint}",
json={"model": model_name, "input": "The chef prepared a delicious meal."},
)
assert response.status_code == 200, response.text
data = response.json()
assert data["object"] == "list"
assert len(data["data"]) == 1
validate_item(data["data"][0])
serve.shutdown()
time.sleep(1)
@pytest.fixture
def remote_model_app(request):
"""
Fixture that creates an app with a remote code model for testing.
The remote_code parameter controls whether trust_remote_code is enabled.
This helps avoid regressions for pickling issues for custom huggingface configs,
since this custom code needs to be registered and imported across processes and workers.
"""
remote_code = request.param
base_config = {
"model_loading_config": dict(
model_id="hmellor/Ilama-3.2-1B",
),
"deployment_config": dict(
autoscaling_config=dict(min_replicas=1, max_replicas=1),
),
"engine_kwargs": dict(
trust_remote_code=remote_code,
),
}
llm_config = LLMConfig(**base_config)
app = build_openai_app({"llm_configs": [llm_config]})
yield app
# Cleanup
serve.shutdown()
time.sleep(1)
class TestRemoteCode:
"""Tests for remote code model loading behavior."""
@pytest.mark.parametrize("remote_model_app", [False], indirect=True)
def test_remote_code_failure(self, remote_model_app):
"""
Tests that a remote code model fails to load when trust_remote_code=False.
If it loads successfully without remote code, the fixture should be changed to one that does require remote code.
"""
app = remote_model_app
with pytest.raises(RuntimeError, match="Deploying application default failed"):
serve.run(app, blocking=False)
def check_for_failed_deployment():
"""Check if the application deployment has failed."""
try:
default_app = serve.status().applications[SERVE_DEFAULT_APP_NAME]
return default_app.status == ApplicationStatus.DEPLOY_FAILED
except (KeyError, AttributeError):
return False
# Wait for either failure or success (timeout after 2 minutes)
try:
wait_for_condition(check_for_failed_deployment, timeout=120)
except TimeoutError:
# If deployment didn't fail, check if it succeeded
if is_default_app_running():
pytest.fail(
"App deployed successfully without trust_remote_code=True. "
"This model may not actually require remote code. "
"Consider using a different model that requires remote code."
)
else:
pytest.fail("Deployment did not fail or succeed within timeout period.")
@pytest.mark.parametrize("remote_model_app", [True], indirect=True)
def test_remote_code_success(self, remote_model_app):
"""
Tests that a remote code model succeeds to load when trust_remote_code=True.
"""
app = remote_model_app
serve.run(app, blocking=False)
# Wait for the application to be running (timeout after 5 minutes)
wait_for_condition(is_default_app_running, timeout=300)
def test_nested_engine_kwargs_structured_outputs():
"""Regression test for https://github.com/ray-project/ray/pull/60380"""
llm_config = LLMConfig(
model_loading_config=dict(
model_id="Qwen/Qwen2.5-0.5B-Instruct",
),
deployment_config=dict(
autoscaling_config=dict(min_replicas=1, max_replicas=1),
),
engine_kwargs=dict(
enforce_eager=True,
max_model_len=512,
structured_outputs_config={
"backend": "xgrammar",
},
),
)
app = build_openai_app({"llm_configs": [llm_config]})
serve.run(app, blocking=False)
wait_for_condition(is_default_app_running, timeout=180)
serve.shutdown()
time.sleep(1)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))