448 lines
14 KiB
Python
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__]))
|