[Frontend] Allow explicit turn-based serving (#7675)
Signed-off-by: Linze-Shi <linzeshi0@gmail.com>
This commit is contained in:
1 parent
c32aaeb2da
commit
c8b3a17bd3
10 files changed
+205
-96
No files matched your search
@@ -1,28 +1,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Guards for the duplex-only API server (unified full-duplex framework).
|
||||
|
||||
A model whose pipeline declares ``duplex_plugin`` is served through
|
||||
``DuplexOmni`` and nothing else: sessions run over ``/v1/realtime?duplex=1``
|
||||
(alias ``/v1/duplex``) and every turn-based route reports "not available".
|
||||
These tests lock that shape, which ``test_api_server_guards.py`` covers for the
|
||||
diffusion and multi-stage servers:
|
||||
|
||||
* the pipeline probe that decides duplex vs turn-based at startup,
|
||||
* the ``app.state`` snapshot of a duplex server (what is wired, what stays
|
||||
``None``),
|
||||
* both duplex websocket routes reaching the session handler, and
|
||||
``/v1/realtime`` without the flag still reporting the turn-based Realtime API
|
||||
as unavailable,
|
||||
* the startup-warmup gate that holds real clients while the warmup session runs.
|
||||
|
||||
CPU-only: no engine is started; ``DuplexOmni`` is subclassed with just the
|
||||
accessors the app-state wiring reads.
|
||||
"""
|
||||
"""CPU tests for serving-mode selection, duplex API wiring and warmup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from argparse import Namespace
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -31,12 +15,14 @@ from fastapi.testclient import TestClient
|
||||
from starlette.datastructures import State
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from vllm_omni.config import stage_config
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
|
||||
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig, PipelineConfig, StagePipelineConfig
|
||||
from vllm_omni.engine.duplex.config import DuplexCapabilities
|
||||
from vllm_omni.entrypoints.duplex.serving import OmniDuplexSessionHandler
|
||||
from vllm_omni.entrypoints.duplex_omni import DuplexOmni
|
||||
from vllm_omni.entrypoints.openai import api_server
|
||||
from vllm_omni.utils.tracking_parser import TrackingNamespace
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -202,9 +188,10 @@ def _minimal_args(**overrides) -> SimpleNamespace:
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_duplex_model_probe_follows_the_pipeline_plugin(monkeypatch, pipeline_config, expected: bool) -> None:
|
||||
"""``duplex_plugin`` alone decides whether ``serve`` builds ``DuplexOmni``."""
|
||||
def test_duplex_serving_requires_a_pipeline_plugin(monkeypatch, tmp_path, pipeline_config, expected: bool) -> None:
|
||||
seen: dict[str, object] = {}
|
||||
deploy_path = tmp_path / "deploy.yaml"
|
||||
deploy_path.write_text("session_mode: duplex\n")
|
||||
|
||||
def fake_get_pipeline_config(*, model, trust_remote_code, deploy_config_path=None, **_kwargs):
|
||||
seen.update(model=model, trust_remote_code=trust_remote_code, deploy_config_path=deploy_config_path)
|
||||
@@ -212,12 +199,12 @@ def test_duplex_model_probe_follows_the_pipeline_plugin(monkeypatch, pipeline_co
|
||||
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", fake_get_pipeline_config)
|
||||
|
||||
kwargs = {"trust_remote_code": True, "deploy_config": "deploy.yaml"}
|
||||
assert api_server._is_duplex_model("demo-duplex-model", kwargs) is expected
|
||||
kwargs = {"trust_remote_code": True, "deploy_config": str(deploy_path)}
|
||||
assert api_server._should_serve_duplex("demo-duplex-model", kwargs) is expected
|
||||
assert seen == {
|
||||
"model": "demo-duplex-model",
|
||||
"trust_remote_code": True,
|
||||
"deploy_config_path": "deploy.yaml",
|
||||
"deploy_config_path": str(deploy_path),
|
||||
}
|
||||
|
||||
|
||||
@@ -230,7 +217,98 @@ def test_duplex_model_probe_propagates_resolution_errors(monkeypatch) -> None:
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", fake_get_pipeline_config)
|
||||
|
||||
with pytest.raises(ValueError, match="trust_remote_code"):
|
||||
api_server._is_duplex_model("demo-duplex-model", {})
|
||||
api_server._should_serve_duplex("demo-duplex-model", {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def duplex_pipeline():
|
||||
return PipelineConfig(
|
||||
model_type="demo",
|
||||
stages=(StagePipelineConfig(stage_id=0, model_stage="demo", final_output=True),),
|
||||
duplex_plugin="pkg.mod.Plugin",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("session_mode", ["turn", "duplex"])
|
||||
@pytest.mark.parametrize("config_source", ["explicit", "default", "basename", "inherited"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_serving_mode_selects_engine_and_preserves_stage_config(
|
||||
monkeypatch, tmp_path, duplex_pipeline, session_mode, config_source
|
||||
):
|
||||
base_path = tmp_path / "base.yaml"
|
||||
base_path.write_text("session_mode: duplex\nstages:\n - stage_id: 0\n max_num_seqs: 4\n")
|
||||
deploy_path = tmp_path / "deploy.yaml"
|
||||
deploy_path.write_text(f"base_config: base.yaml\nsession_mode: {session_mode}\n")
|
||||
if config_source == "inherited":
|
||||
deploy_path = tmp_path / "overlay.yaml"
|
||||
deploy_path.write_text("base_config: deploy.yaml\n")
|
||||
pipeline = replace(duplex_pipeline, default_deploy_config_name=deploy_path.name)
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", lambda **_: pipeline)
|
||||
monkeypatch.setattr(stage_config, "_DEPLOY_DIR", tmp_path)
|
||||
kwargs = {}
|
||||
if config_source != "default":
|
||||
kwargs["deploy_config"] = deploy_path.name if config_source == "basename" else str(deploy_path)
|
||||
|
||||
created = []
|
||||
|
||||
class TurnEngine:
|
||||
def __init__(self, model, **engine_kwargs):
|
||||
self.model = model
|
||||
self.kwargs = engine_kwargs
|
||||
self.closed = False
|
||||
created.append(self)
|
||||
|
||||
def shutdown(self):
|
||||
self.closed = True
|
||||
|
||||
class DuplexEngine(TurnEngine):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(api_server, "AsyncOmni", TurnEngine)
|
||||
monkeypatch.setattr(api_server, "DuplexOmni", DuplexEngine)
|
||||
args = TrackingNamespace(
|
||||
Namespace(model="demo-model", disable_log_stats=True, **kwargs), frozenset({"model", *kwargs})
|
||||
)
|
||||
async with api_server.build_async_omni_from_stage_config(args) as engine:
|
||||
assert type(engine) is (DuplexEngine if session_mode == "duplex" else TurnEngine)
|
||||
assert engine.model == "demo-model"
|
||||
assert engine.kwargs == {**kwargs, "log_stats": False}
|
||||
assert not engine.closed
|
||||
assert len(created) == 1
|
||||
assert engine.closed
|
||||
deploy = stage_config.load_deploy_config(deploy_path)
|
||||
assert deploy.session_mode == session_mode
|
||||
assert deploy.stages[0].max_num_seqs == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("contents", ["{}\n", "session_mode: null\n", "session_mode: typo\n"])
|
||||
def test_duplex_serving_rejects_missing_or_invalid_mode(monkeypatch, tmp_path, duplex_pipeline, contents):
|
||||
deploy_path = tmp_path / "deploy.yaml"
|
||||
deploy_path.write_text(contents)
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", lambda **_: duplex_pipeline)
|
||||
with pytest.raises(ValueError, match="session_mode: turn or duplex"):
|
||||
api_server._should_serve_duplex("demo-model", {"deploy_config": str(deploy_path)})
|
||||
|
||||
|
||||
def test_duplex_serving_requires_a_deploy_config(monkeypatch, duplex_pipeline):
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", lambda **_: duplex_pipeline)
|
||||
with pytest.raises(ValueError, match="requires a deploy config"):
|
||||
api_server._should_serve_duplex("demo-model", {})
|
||||
|
||||
|
||||
def test_duplex_serving_propagates_deploy_load_errors(monkeypatch, tmp_path, duplex_pipeline):
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", lambda **_: duplex_pipeline)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
api_server._should_serve_duplex("demo-model", {"deploy_config": str(tmp_path / "missing.yaml")})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deploy_name,expected", [("minicpmo_4_5.yaml", True), ("minicpmo_4_5_turn.yaml", False)])
|
||||
def test_minicpmo_serving_profiles(monkeypatch, deploy_name, expected):
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO_4_5_PIPELINE
|
||||
|
||||
monkeypatch.setattr(StageConfigFactory, "get_pipeline_config", lambda **_: MINICPMO_4_5_PIPELINE)
|
||||
assert api_server._should_serve_duplex("openbmb/MiniCPM-o-4_5", {"deploy_config": deploy_name}) is expected
|
||||
assert api_server._should_serve_duplex("openbmb/MiniCPM-o-4_5", {}) is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in new issue
Block a user