[Core / Bugfix] Add Mechanism for Endpoint Rejection (#4762)
Signed-off-by: Alex Brooks <albrooks@redhat.com>
This commit is contained in:
1 parent
7d4decc1d8
commit
b7d08fe5f6
14 files changed
+508
-158
No files matched your search
@@ -0,0 +1,87 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests for endpoint restrictions logic."""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from vllm_omni.config.endpoint_policy import (
|
||||
EndpointRestriction,
|
||||
OmniServingCapability,
|
||||
shutdown_unsupported_routes,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
SERVER_ROUTE = "/v1/completions"
|
||||
REJECTION_REASON = "This route is banned!"
|
||||
|
||||
|
||||
def _make_app_with_server_route():
|
||||
"""Create a FastAPI app with a route to reject."""
|
||||
app = FastAPI()
|
||||
|
||||
@app.post(SERVER_ROUTE)
|
||||
async def existing_handler():
|
||||
"""Returns a response; we want to disable this."""
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_restricted_completions_returns_400():
|
||||
"""Ensure that simple route rejection works."""
|
||||
app = _make_app_with_server_route()
|
||||
restrictions = (EndpointRestriction(OmniServingCapability.COMPLETIONS, REJECTION_REASON),)
|
||||
shutdown_unsupported_routes(app, restrictions)
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post(SERVER_ROUTE, json={"prompt": "hello", "model": "m"})
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["error"]["message"] == REJECTION_REASON
|
||||
assert body["error"]["type"] == "BadRequestError"
|
||||
|
||||
|
||||
def test_unrestricted_completions_not_blocked():
|
||||
"""Ensure that if we don't shutdown any routes, the route stays."""
|
||||
app = _make_app_with_server_route()
|
||||
shutdown_unsupported_routes(app, ())
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post(SERVER_ROUTE)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_route_rejection_is_idempotent():
|
||||
"""Ensure that calling rejection twice doesn't break things."""
|
||||
app = _make_app_with_server_route()
|
||||
restrictions = (EndpointRestriction(OmniServingCapability.COMPLETIONS, REJECTION_REASON),)
|
||||
shutdown_unsupported_routes(app, restrictions)
|
||||
shutdown_unsupported_routes(app, restrictions)
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post(SERVER_ROUTE, json={"prompt": "hello", "model": "m"})
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["error"]["message"] == REJECTION_REASON
|
||||
assert body["error"]["type"] == "BadRequestError"
|
||||
|
||||
|
||||
def test_invalid_route_rejection():
|
||||
"""Ensure that a restriction won't crash if a route doesn't exist."""
|
||||
# NOTE: This test is for safety since the route organization is still messy.
|
||||
# Since the restriction needs a valid OmniServingCapability, its implied the route
|
||||
# is a valid vLLM route, but was already removed from the app by earlier processing.
|
||||
app = FastAPI()
|
||||
restrictions = (EndpointRestriction(OmniServingCapability.COMPLETIONS, REJECTION_REASON),)
|
||||
shutdown_unsupported_routes(app, restrictions)
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post(SERVER_ROUTE, json={"prompt": "hello", "model": "m"})
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["error"]["message"] == REJECTION_REASON
|
||||
assert body["error"]["type"] == "BadRequestError"
|
||||
@@ -226,3 +226,27 @@ def test_thinker_prefix_caching_audio_output(omni_server, openai_client) -> None
|
||||
}
|
||||
|
||||
_run_prefix_cache_check(openai_client, request_config)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||
def test_completions_rejected_for_thinker_talker(omni_server, openai_client) -> None:
|
||||
"""Ensure Thinker-talker models reject /v1/completions; we do this because the
|
||||
thinker-talker handoff implementations currently use ChatML <|im_start|> and
|
||||
<|im_end|> markers to segment the input sequence; when we don't have them,
|
||||
the talker does not get any embeddings, which breaks the server.
|
||||
"""
|
||||
responses = openai_client.send_completions_http_request(
|
||||
{
|
||||
"json": {
|
||||
"model": omni_server.model,
|
||||
"prompt": "Hello, how are you?",
|
||||
"max_tokens": 10,
|
||||
},
|
||||
},
|
||||
err_code=400,
|
||||
)
|
||||
assert not responses[0].success
|
||||
@@ -107,6 +107,7 @@ class FakeAsyncOmniEngine:
|
||||
self.stage_vllm_configs = [None for _ in range(self.num_stages)]
|
||||
self.output_processors = [SimpleNamespace(tokenizer=None) for _ in range(self.num_stages)]
|
||||
self.input_processor = None
|
||||
self.endpoint_restrictions = ()
|
||||
|
||||
self.output_q: queue.Queue[Any] = queue.Queue()
|
||||
self.submitted: list[dict[str, Any]] = []
|
||||
|
||||
@@ -24,8 +24,8 @@ def test_dreamzero_vla_resolves_to_dreamzero_config(monkeypatch):
|
||||
|
||||
def test_dreamzero_config_sets_model_class_and_policy_config(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.config.config_factory.StageConfigFactory._auto_detect_model_type",
|
||||
classmethod(lambda _cls, _model, trust_remote_code=True: ("vla", None)),
|
||||
"vllm_omni.config.config_factory.StageConfigFactory._try_infer_model_type",
|
||||
classmethod(lambda _cls, model, trust_remote_code=True: "vla"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.utils.hf_utils._looks_like_dreamzero",
|
||||
|
||||
@@ -1064,6 +1064,29 @@ class OpenAIClientHandler:
|
||||
)
|
||||
return [resp]
|
||||
|
||||
def send_completions_http_request(
|
||||
self,
|
||||
request_config: dict[str, Any],
|
||||
*,
|
||||
err_code: int | tuple[int, ...] | list[int] | None = None,
|
||||
err_message: str | tuple[str, ...] | list[str] | None = None,
|
||||
) -> list[HttpResponse]:
|
||||
"""POST ``/v1/completions`` with ``json`` or ``raw_body``."""
|
||||
# TODO (Alex): A lot of these helpers should be consolidated as they differ only by endpoint
|
||||
cfg = _merge_http_expectation_kwargs(
|
||||
request_config,
|
||||
err_code=err_code,
|
||||
err_message=err_message,
|
||||
)
|
||||
r = self._post_json_endpoint("/v1/completions", cfg, default_timeout=120.0)
|
||||
resp = self._http_response_from_requests(r)
|
||||
assert_http_error(
|
||||
resp,
|
||||
err_code=cfg.get("err_code"),
|
||||
err_message=cfg.get("err_message"),
|
||||
)
|
||||
return [resp]
|
||||
|
||||
def send_omni_sleep_http_request(
|
||||
self,
|
||||
request_config: dict[str, Any],
|
||||
|
||||
@@ -16,6 +16,7 @@ from transformers import PretrainedConfig, Qwen3OmniMoeConfig
|
||||
|
||||
from tests.helpers.stage_config import get_deploy_config_path, get_deploy_config_stage
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
from vllm_omni.config.endpoint_policy import EndpointRestriction, OmniServingCapability
|
||||
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES, register_pipeline
|
||||
from vllm_omni.config.stage_config import (
|
||||
DeployConfig,
|
||||
@@ -39,6 +40,15 @@ from vllm_omni.engine.arg_utils import SHARED_FIELDS, EngineArgs, internal_black
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_config_factory_caches():
|
||||
"""Clear cached classmethods from the StageConfigFactory to prevent test pollution."""
|
||||
yield
|
||||
StageConfigFactory.get_hf_config.cache_clear()
|
||||
StageConfigFactory.try_infer_model_type.cache_clear()
|
||||
StageConfigFactory.get_pipeline_config.cache_clear()
|
||||
|
||||
|
||||
Q3_OMNI_ALL_STAGES_HF_CONFIG = Qwen3OmniMoeConfig(enable_audio_output=True)
|
||||
Q3_OMNI_THINKER_HF_CONFIG = Qwen3OmniMoeConfig(enable_audio_output=False)
|
||||
|
||||
@@ -666,6 +676,43 @@ class TestPipelineRegistration:
|
||||
assert resolved_config is not None
|
||||
assert len(resolved_config) > 0
|
||||
|
||||
def test_deploy_override_uses_correct_endpoint_restrictions(self, clean_pipeline_registry, tmp_path):
|
||||
"""Ensure endpoint restrictions must come from the final pipeline
|
||||
after deploy config overrides, not the auto-detected pipeline.
|
||||
"""
|
||||
# Register two pipeline configs, where one has an endpoint restriction, and one doesn't
|
||||
restriction = EndpointRestriction(
|
||||
OmniServingCapability.COMPLETIONS,
|
||||
"pipeline_a blocks completions",
|
||||
)
|
||||
pipe_a = PipelineConfig(model_type="detect_type", endpoint_restrictions=(restriction,))
|
||||
pipe_b = PipelineConfig(model_type="override_type", endpoint_restrictions=())
|
||||
register_pipeline(pipe_a)
|
||||
register_pipeline(pipe_b)
|
||||
|
||||
# Create a config with the autodetected type, and write the
|
||||
# deploy config specifying the override type to a temp path
|
||||
class FakeConfig(PretrainedConfig):
|
||||
model_type = "detect_type"
|
||||
|
||||
deploy_yaml = tmp_path / "override.yaml"
|
||||
deploy_yaml.write_text("pipeline: override_type\n")
|
||||
|
||||
# Get the endpoint restrictions, passing the deploy config with the override
|
||||
# type + patching the config for the detected type. Ensure that the endpoint
|
||||
# restrictions correspond to the type in the deploy config.
|
||||
with patch(
|
||||
"vllm_omni.config.config_factory.get_config",
|
||||
return_value=FakeConfig(),
|
||||
):
|
||||
restrictions = StageConfigFactory.get_pipeline_endpoint_restrictions(
|
||||
model="fake/model",
|
||||
trust_remote_code=False,
|
||||
deploy_config_path=str(deploy_yaml),
|
||||
)
|
||||
|
||||
assert restrictions == ()
|
||||
|
||||
|
||||
class TestResolveScheduler:
|
||||
def test_all_execution_types_handled(self):
|
||||
|
||||
Reference in new issue
Block a user