chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,21 @@
applications:
- name: kv-llm
route_prefix: /
import_path: ray.serve.llm:build_openai_app
runtime_env:
env_vars:
RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING: "1"
RAY_SERVE_ENABLE_HA_PROXY: "1"
args:
llm_configs:
- model_loading_config:
model_id: qwen3-0.6b
model_source: Qwen/Qwen3-0.6B
accelerator_type: null
deployment_config:
autoscaling_config:
min_replicas: 0
initial_replicas: 0
max_replicas: 1
request_router_config:
request_router_class: ray.serve.llm.request_router.KVAwareRouter
@@ -0,0 +1,529 @@
"""KVRouterActor attachment and live replica-membership tracking.
Attachment is covered two ways: ``build_openai_app`` with a Python ``LLMConfig``,
and a declarative YAML config deployed via ``serve deploy`` (the dotted-string
router class only YAML can express). Membership tracking is covered by deploying
a dummy multi-replica deployment and asserting the actor's LongPoll listener
stays in sync with the live replicas across scale up/down.
"""
import os
import subprocess
import sys
from typing import List
from unittest import mock
import pytest
import ray
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.ingress.builder import (
LLMServingArgs,
build_openai_app,
)
from ray.llm._internal.serve.core.ingress.tokenizer import REQUEST_TOKEN_IDS_KWARG
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_actor import (
KV_ROUTER_ACTOR_NAME,
KVRouterActor,
get_worker_id,
)
from ray.serve._private.common import (
REPLICA_ID_FULL_ID_STR_PREFIX,
DeploymentID,
DeploymentTargetInfo,
ReplicaID,
RequestMetadata,
RunningReplicaInfo,
)
from ray.serve._private.constants import SERVE_DEPLOYMENT_ACTOR_PREFIX, SERVE_NAMESPACE
from ray.serve._private.request_router import PendingRequest
from ray.serve.config import DeploymentActorConfig
from ray.serve.llm.request_router import KVAwareRouter
from ray.util.state import list_actors
def get_kv_actor_configs(deployment):
return [
cfg
for cfg in (deployment._deployment_config.deployment_actors or [])
if (cfg["name"] if isinstance(cfg, dict) else cfg.name) == KV_ROUTER_ACTOR_NAME
]
def build_test_llm_config(experimental_configs=None) -> LLMConfig:
return LLMConfig(
model_loading_config={
"model_id": "qwen3-0.6b",
"model_source": "Qwen/Qwen3-0.6B",
},
accelerator_type=None,
deployment_config={
"autoscaling_config": {"min_replicas": 1, "max_replicas": 1},
"request_router_config": {"request_router_class": KVAwareRouter},
},
experimental_configs=experimental_configs or {},
)
def build_non_kv_llm_config(**engine_kwargs) -> LLMConfig:
"""An LLMConfig whose request router is the default (not a KVAwareRouter)."""
return LLMConfig(
model_loading_config={
"model_id": "qwen3-0.6b",
"model_source": "Qwen/Qwen3-0.6B",
},
accelerator_type=None,
deployment_config={
"autoscaling_config": {"min_replicas": 1, "max_replicas": 1}
},
engine_kwargs=engine_kwargs,
)
def get_kv_actor_names(app_name: str) -> list:
prefix = f"{SERVE_DEPLOYMENT_ACTOR_PREFIX}{app_name}::"
suffix = f"::{KV_ROUTER_ACTOR_NAME}"
return [
a["name"]
for a in list_actors(filters=[("state", "=", "ALIVE")])
if a["name"] and a["name"].startswith(prefix) and a["name"].endswith(suffix)
]
def discover_deployment_actor(app_name, deployment_name, actor_name):
"""Handle to a deployment-scoped actor by app/deployment/logical name."""
prefix = f"{SERVE_DEPLOYMENT_ACTOR_PREFIX}{app_name}::{deployment_name}::"
suffix = f"::{actor_name}"
for entry in ray.util.list_named_actors(all_namespaces=True):
name = entry.get("name") or ""
if (
entry.get("namespace") == SERVE_NAMESPACE
and name.startswith(prefix)
and (name.endswith(suffix))
):
return ray.get_actor(name, namespace=SERVE_NAMESPACE)
return None
def get_candidate_ids(app_name):
handle = discover_deployment_actor(
app_name, "ReplicaTrackingDeployment", KV_ROUTER_ACTOR_NAME
)
assert handle is not None
return ray.get(handle.get_candidate_worker_ids.remote())
def get_live_replica_worker_ids(app_name, deployment_name="ReplicaTrackingDeployment"):
"""Worker ids derived directly from the deployment's alive replica actors."""
prefix = f"{REPLICA_ID_FULL_ID_STR_PREFIX}{app_name}#{deployment_name}#"
return {
get_worker_id(a["name"][len(prefix) :])
for a in list_actors(filters=[("state", "=", "ALIVE")])
if a["name"] and a["name"].startswith(prefix)
}
@pytest.fixture(autouse=True)
def enable_direct_streaming(monkeypatch):
monkeypatch.setattr(
"ray.llm._internal.serve.core.ingress.builder."
"RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING",
True,
)
@pytest.fixture(scope="module")
def serve_instance():
if not ray.is_initialized():
ray.init(address="auto")
yield
serve.shutdown()
def test_build_openai_app_attaches_kv_actor():
"""A KVAwareRouter on the LLMConfig attaches the KVRouterActor."""
app = build_openai_app(LLMServingArgs(llm_configs=[build_test_llm_config()]))
configs = get_kv_actor_configs(app._bound_deployment)
assert len(configs) == 1
actor_cfg = configs[0]
assert actor_cfg.get_actor_class().__ray_actor_class__ is KVRouterActor
assert actor_cfg.actor_options["num_cpus"] == 0
assert actor_cfg.init_kwargs == {"indexer_threads": 4}
def test_configurable_indexer_threads():
llm_config = build_test_llm_config(experimental_configs={"KV_INDEXER_THREADS": 8})
app = build_openai_app(LLMServingArgs(llm_configs=[llm_config]))
actor_cfg = get_kv_actor_configs(app._bound_deployment)[0]
assert actor_cfg.init_kwargs["indexer_threads"] == 8
def test_non_kv_router_warns_kv_events_config():
"""Without a KVAwareRouter no KVRouterActor is attached and a user-provided
kv_events_config is left untouched (just unused), with a warning pointing at
how to consume the engine's KV events."""
kv_events_config = {
"enable_kv_cache_events": True,
"publisher": "zmq",
"endpoint": "tcp://*:5557",
}
llm_config = build_non_kv_llm_config(kv_events_config=kv_events_config)
with mock.patch(
"ray.llm._internal.serve.routing_policies.kv_aware.utils.logger"
) as logger:
app = build_openai_app(LLMServingArgs(llm_configs=[llm_config]))
assert get_kv_actor_configs(app._bound_deployment) == []
assert llm_config.engine_kwargs["kv_events_config"] == kv_events_config
logger.warning.assert_called_once()
assert "KVAwareRouter" in logger.warning.call_args.args[0]
def test_yaml_config_attaches_kv_actor(serve_instance):
"""Deploying a YAML config that selects KVAwareRouter creates the KVRouterActor."""
config_file = os.path.join(
os.path.dirname(__file__), "test_config_files", "llm_kv_aware_deployment.yaml"
)
app_name = "kv-llm"
subprocess.check_output(["serve", "deploy", config_file], stderr=subprocess.STDOUT)
try:
wait_for_condition(lambda: len(get_kv_actor_names(app_name)) == 1, timeout=60)
finally:
serve.delete(app_name, _blocking=True)
class _TestKVRouterActor(KVRouterActor):
"""KVRouterActor augmented with test-only introspection."""
async def get_candidate_worker_ids(self) -> List[int]:
"""The workers currently tracked from running replicas.
Async so it runs on the actor's event loop, serialized with
``_on_deployment_targets`` which mutates the same map on that loop.
"""
return sorted(self._replica_id_by_worker)
@serve.deployment(
num_replicas=4,
deployment_actors=[
DeploymentActorConfig(
name=KV_ROUTER_ACTOR_NAME,
actor_class=ray.remote(_TestKVRouterActor),
actor_options={"num_cpus": 0},
init_kwargs={},
),
],
)
class ReplicaTrackingDeployment:
"""Dummy deployment with a KVRouterActor deployment actor.
Advertises a per-replica KV-events endpoint via ``record_routing_stats`` as a
real engine would, so the selection service tracks each replica as a worker.
"""
async def __call__(self) -> str:
return "ok"
async def record_routing_stats(self) -> dict:
rank = serve.get_replica_context().rank.local_rank
return {
"kv_event_metadata": {
"endpoint": f"tcp://{ray.util.get_node_ip_address()}:{25000 + rank}",
"block_size": 16,
"max_num_batched_tokens": 8192,
"dp_rank": 0,
}
}
class TestReplicaTrackingIntegration:
def test_tracks_running_replicas(self, serve_instance):
"""KVRouterActor's LongPollClient receives the running replicas."""
app_name = "kv-replica-tracking"
serve.run(
ReplicaTrackingDeployment.bind(), name=app_name, route_prefix="/kv_track"
)
try:
wait_for_condition(
lambda: len(get_candidate_ids(app_name)) == 4, timeout=30
)
# The tracked workers are exactly those of the live replica actors.
assert set(get_candidate_ids(app_name)) == get_live_replica_worker_ids(
app_name
)
finally:
serve.delete(app_name, _blocking=True)
def test_membership_broadcast_on_scale(self, serve_instance):
"""A scale up then down is broadcast over LongPoll; the actor re-syncs to
exactly the live replica set each time.
"""
app_name = "kv-replica-scale"
def tracks_live_replicas(expected):
# The tracked workers match the live replica actors by their actual
# ids (a stale handle is possible while the deployment is updated).
try:
tracked = set(get_candidate_ids(app_name))
except ray.exceptions.RayActorError:
return False
return len(tracked) == expected and tracked == get_live_replica_worker_ids(
app_name
)
def scale(num_replicas):
serve.run(
ReplicaTrackingDeployment.options(num_replicas=num_replicas).bind(),
name=app_name,
route_prefix="/kv_scale",
)
scale(2)
try:
wait_for_condition(lambda: tracks_live_replicas(2), timeout=30)
scale(4) # upscale: the new replicas are picked up over LongPoll.
wait_for_condition(lambda: tracks_live_replicas(4), timeout=30)
scale(2) # downscale: the departed replicas are dropped.
wait_for_condition(lambda: tracks_live_replicas(2), timeout=30)
finally:
serve.delete(app_name, _blocking=True)
class _LocalKVRouterActor(_TestKVRouterActor):
"""In-process KVRouterActor with the selection service and LongPoll disabled,
to drive ``_on_deployment_targets`` directly with synthetic snapshots.
"""
def _create_selection_service(self) -> None:
self._svc = None # reconcile membership without dynamo
def _start_replica_tracking(self) -> None:
pass
def _schedule(self, coro) -> None:
coro.close() # _svc is None, so the scheduled upsert is a no-op
def make_target_info(unique_ids):
"""A DeploymentTargetInfo whose replicas advertise a KV-events endpoint via
routing_stats, exactly as the controller broadcasts it over LongPoll."""
deployment_id = DeploymentID(name="d", app_name="app")
running_replicas = [
RunningReplicaInfo(
replica_id=ReplicaID(unique_id=uid, deployment_id=deployment_id),
node_id="node",
node_ip="10.0.0.1",
availability_zone="az",
actor_name=f"actor-{uid}",
max_ongoing_requests=1,
routing_stats={
"kv_event_metadata": {
"endpoint": "tcp://10.0.0.1:25000",
"block_size": 16,
"max_num_batched_tokens": 8192,
"dp_rank": 0,
}
},
)
for uid in unique_ids
]
return DeploymentTargetInfo(is_available=True, running_replicas=running_replicas)
class TestOnDeploymentTargets:
async def test_reconciles_added_and_removed_workers(self):
actor = _LocalKVRouterActor()
actor._on_deployment_targets(make_target_info(["a", "b"]))
assert set(await actor.get_candidate_worker_ids()) == {
get_worker_id("a"),
get_worker_id("b"),
}
# "a" departs and "c" joins: the tracked set follows the new snapshot.
actor._on_deployment_targets(make_target_info(["b", "c"]))
assert set(await actor.get_candidate_worker_ids()) == {
get_worker_id("b"),
get_worker_id("c"),
}
class _StubReplica:
"""RunningReplica stand-in exposing only replica_id.unique_id."""
def __init__(self, unique_id: str):
self.replica_id = ReplicaID(
unique_id=unique_id, deployment_id=DeploymentID(name="d", app_name="app")
)
class _SelectWorkerStub:
def __init__(self, worker_id: int):
self._worker_id = worker_id
self.token_ids = None
self.allowed = None
async def remote(self, request_id, token_ids, allowed_worker_ids):
self.token_ids = token_ids
self.allowed = allowed_worker_ids
return {
"worker_id": self._worker_id,
"dp_rank": 0,
"overlap_tokens": 1,
"effective_prefill_tokens": len(token_ids),
}
class _KVRouterActorStub:
def __init__(self, worker_id: int):
self.select_worker = _SelectWorkerStub(worker_id)
class _StubKVAwareRouter(KVAwareRouter):
"""KVAwareRouter with the scorer actor injected, bypassing actor discovery."""
def __init__(self, kv_router_actor):
self._kv_router_actor = kv_router_actor
def _build_kv_aware_router(worker_id: int) -> KVAwareRouter:
return _StubKVAwareRouter(_KVRouterActorStub(worker_id))
@pytest.mark.asyncio
async def test_select_worker_requires_tokens():
actor = KVRouterActor.__new__(KVRouterActor)
actor._svc = object()
with pytest.raises(ValueError, match="non-empty token_ids"):
await actor.select_worker("req-empty", [], [get_worker_id("r1")])
@pytest.mark.asyncio
async def test_select_worker_without_dynamo_raises():
"""Without ai-dynamo the actor cannot score, so it raises a clear error
instead of silently degrading to a non-KV-aware pick."""
actor = KVRouterActor.__new__(KVRouterActor)
actor._svc = None
with pytest.raises(RuntimeError, match="ai-dynamo is not installed"):
await actor.select_worker("req", [1, 2, 3], [get_worker_id("r1")])
@pytest.mark.asyncio
async def test_choose_replicas_routes_to_selected_worker():
"""choose_replicas maps candidates to worker ids, asks the actor to select,
and returns the chosen worker's replica."""
replicas = [_StubReplica("r1"), _StubReplica("r2")]
worker_ids = [get_worker_id("r1"), get_worker_id("r2")]
router = _build_kv_aware_router(worker_ids[1])
pending = PendingRequest(
args=[],
kwargs={REQUEST_TOKEN_IDS_KWARG: [10, 11, 12]},
metadata=RequestMetadata(request_id="req-1", internal_request_id="int-1"),
)
groups = await router.choose_replicas(replicas, pending)
# The actor selected r2's worker, so r2 is returned.
assert groups == [[replicas[1]]]
# choose_replicas forwarded the prompt token ids and the full candidate set.
select = router._kv_router_actor.select_worker
assert select.token_ids == [10, 11, 12]
assert sorted(select.allowed) == sorted(worker_ids)
@pytest.mark.asyncio
async def test_missing_token_ids_picks_random_replica():
"""Token-less requests (batch prompts, truncated bodies) route to a single
random replica so they spread."""
replicas = [_StubReplica("r1"), _StubReplica("r2")]
router = _build_kv_aware_router(get_worker_id("r1"))
picked = set()
for _ in range(50):
pending = PendingRequest(
args=[],
kwargs={},
metadata=RequestMetadata(request_id="req", internal_request_id="int"),
)
groups = await router.choose_replicas(replicas, pending)
assert len(groups) == 1 and len(groups[0]) == 1
assert groups[0][0] in replicas
picked.add(groups[0][0].replica_id.unique_id)
# The picked replica varies across calls, so load spreads (not stuck on one).
assert picked == {"r1", "r2"}
assert router._kv_router_actor.select_worker.token_ids is None
@pytest.mark.asyncio
async def test_tokenize_call_picks_random_replica():
"""The pre-routing /tokenize RPC is routed through choose_replicas before any
token ids exist; it must resolve so KV routing can bootstrap, and picks a random
replica without scoring."""
replicas = [_StubReplica("r1"), _StubReplica("r2")]
router = _build_kv_aware_router(get_worker_id("r2"))
pending = PendingRequest(
args=[],
kwargs={},
metadata=RequestMetadata(
request_id="req-tokenize",
internal_request_id="int-tokenize",
call_method="tokenize",
),
)
groups = await router.choose_replicas(replicas, pending)
assert len(groups) == 1 and len(groups[0]) == 1
assert groups[0][0] in replicas
assert router._kv_router_actor.select_worker.token_ids is None
@pytest.mark.asyncio
async def test_empty_token_ids_picks_random_replica():
"""Empty token ids carry no KV signal, so pick a random replica instead of
handing an empty prompt to the Dynamo selection service (which rejects it)."""
replicas = [_StubReplica("r1"), _StubReplica("r2")]
router = _build_kv_aware_router(get_worker_id("r2"))
pending = PendingRequest(
args=[],
kwargs={REQUEST_TOKEN_IDS_KWARG: []},
metadata=RequestMetadata(
request_id="req-empty", internal_request_id="int-empty"
),
)
groups = await router.choose_replicas(replicas, pending)
assert len(groups) == 1 and len(groups[0]) == 1
assert groups[0][0] in replicas
assert router._kv_router_actor.select_worker.token_ids is None
@pytest.mark.asyncio
async def test_no_pending_request_picks_random_replica():
"""Serve may ask again after route metadata has been consumed; pick a random
replica (nothing to score on)."""
replicas = [_StubReplica("r1"), _StubReplica("r2")]
router = _build_kv_aware_router(get_worker_id("r1"))
groups = await router.choose_replicas(replicas, pending_request=None)
assert len(groups) == 1 and len(groups[0]) == 1
assert groups[0][0] in replicas
assert router._kv_router_actor.select_worker.token_ids is None
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,122 @@
import sys
from types import SimpleNamespace
from unittest import mock
import pytest
import ray
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.routing_policies.kv_aware.vllm.kv_events import (
assign_replica_kv_events_endpoint,
configure_kv_events_for_kv_routing,
get_kv_event_routing_stats,
resolve_kv_event_source_endpoint,
)
from ray.serve.llm.request_router import KVAwareRouter
def make_kv_aware_llm_config(**kwargs) -> LLMConfig:
return LLMConfig(
model_loading_config={
"model_id": "qwen3-0.6b",
"model_source": "Qwen/Qwen3-0.6B",
},
accelerator_type=None,
deployment_config={
"autoscaling_config": {"min_replicas": 1, "max_replicas": 1},
"request_router_config": {"request_router_class": KVAwareRouter},
},
**kwargs,
)
@pytest.fixture(scope="module")
def ray_instance():
started = not ray.is_initialized()
if started:
ray.init()
yield
if started:
ray.shutdown()
class TestConfigureKvEvents:
def test_configure_enables_events_and_pins_seed(self):
"""KV-aware config turns on engine ZMQ KV events and pins the hash seed."""
llm_config = make_kv_aware_llm_config()
configure_kv_events_for_kv_routing(llm_config)
assert llm_config.engine_kwargs["kv_events_config"] == {
"enable_kv_cache_events": True,
"publisher": "zmq",
"endpoint": "tcp://*:5557",
"replay_endpoint": "tcp://*:6557",
}
assert llm_config.runtime_env["env_vars"]["PYTHONHASHSEED"] == "0"
@pytest.mark.parametrize(
"engine_kwargs, local_rank, expected_port, expected_replay_port",
[
# Non-DP: offset the base port by the replica's node-local rank so
# colocated replicas don't bind the same ZMQ PUB port.
({}, 2, 5559, 6559),
# DP: data_parallel_rank set -> offset 0 (the engine offsets the
# bound port by dp_rank itself), so local_rank must be ignored.
({"data_parallel_rank": 2}, 2, 5557, 6557),
],
)
def test_assign_replica_endpoint_offsets_port(
self, engine_kwargs, local_rank, expected_port, expected_replay_port
):
"""Per-replica endpoint offset: by node-local rank without DP, 0 with DP."""
llm_config = make_kv_aware_llm_config(engine_kwargs=dict(engine_kwargs))
configure_kv_events_for_kv_routing(llm_config) # base ports 5557 / 6557
replica_context = SimpleNamespace(rank=SimpleNamespace(local_rank=local_rank))
with mock.patch("ray.serve.get_replica_context", return_value=replica_context):
assign_replica_kv_events_endpoint(llm_config)
kv_events_config = llm_config.engine_kwargs["kv_events_config"]
assert kv_events_config["endpoint"] == f"tcp://*:{expected_port}"
assert kv_events_config["replay_endpoint"] == f"tcp://*:{expected_replay_port}"
def test_resolve_endpoint_is_node_routable(self, ray_instance):
"""The advertised endpoint is the replica's node IP."""
llm_config = make_kv_aware_llm_config()
configure_kv_events_for_kv_routing(llm_config)
endpoint = resolve_kv_event_source_endpoint(llm_config)
node_ip = ray.util.get_node_ip_address()
assert endpoint == f"tcp://{node_ip}:5557"
def test_routing_stats_advertise_endpoint(self, ray_instance):
"""The replica advertises its node-routable endpoint plus the engine
facts the selection service needs to schedule it via record_routing_stats."""
llm_config = make_kv_aware_llm_config()
configure_kv_events_for_kv_routing(llm_config)
stats = get_kv_event_routing_stats(
llm_config, block_size=16, max_num_batched_tokens=4096
)
node_ip = ray.util.get_node_ip_address()
assert stats == {
"kv_event_metadata": {
"endpoint": f"tcp://{node_ip}:5557",
"block_size": 16,
"max_num_batched_tokens": 4096,
"dp_rank": 0,
"replay_endpoint": f"tcp://{node_ip}:6557",
}
}
def test_routing_stats_empty_without_kv_events(self):
"""Nothing to advertise when KV-cache events are not enabled."""
llm_config = make_kv_aware_llm_config()
assert (
get_kv_event_routing_stats(
llm_config, block_size=16, max_num_batched_tokens=4096
)
== {}
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,807 @@
import asyncio
import random
import sys
from collections import OrderedDict
from dataclasses import asdict
from types import SimpleNamespace
import pytest
from vllm.outputs import CompletionOutput, RequestOutput
from vllm.sampling_params import RequestOutputKind, SamplingParams
import ray
import ray.cloudpickle
from ray import serve
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.routing_policies.kv_aware.constants import (
REQUEST_TRACKING_TTL_S,
)
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_actor import (
KV_ROUTER_ACTOR_NAME,
KVRouterActor,
get_worker_id,
)
from ray.llm._internal.serve.routing_policies.kv_aware.kv_aware_router import (
is_kv_aware,
)
from ray.llm._internal.serve.routing_policies.kv_aware.vllm.token_tracking import (
enable_token_tracking,
)
from ray.llm.tests.serve.mocks.mock_vllm_engine import MockAsyncLLM
from ray.serve.llm.request_router import KVAwareRouter
# The @ray.remote actors below are pickled by reference, so a worker must import
# this module -- but pytest imports it under a bare name the worker cannot import
# (ModuleNotFoundError). Pickling by value ships the class bodies instead.
ray.cloudpickle.register_pickle_by_value(sys.modules[__name__])
REPLICA_UNIQUE_ID = "test-replica-uid"
WORKER_ID = get_worker_id(REPLICA_UNIQUE_ID)
# A pre-tokenized prompt, as vLLM's serving layer always passes to generate.
PROMPT = {"prompt_token_ids": [1, 2, 3]}
# SamplingParams.max_tokens the engine reports as the request's expected output.
MAX_TOKENS = 20
@pytest.fixture(scope="module", autouse=True)
def ray_cluster():
if not ray.is_initialized():
ray.init()
def request_output(token_counts, prompt_len=5, finished=False):
"""A real vLLM RequestOutput: one CompletionOutput per entry in token_counts."""
return RequestOutput(
request_id="r",
prompt=None,
prompt_token_ids=list(range(prompt_len)),
prompt_logprobs=None,
outputs=[
CompletionOutput(
index=i,
text="",
token_ids=list(range(n)),
cumulative_logprob=None,
logprobs=None,
)
for i, n in enumerate(token_counts)
],
finished=finished,
)
def delta_steps(num_tokens, prompt_len=5):
"""A DELTA-kind stream: one new token per step, last step finished."""
return [
request_output([1], prompt_len=prompt_len, finished=i == num_tokens - 1)
for i in range(num_tokens)
]
class MockSelectionService:
"""Records the selection-service reservation calls the actor's lifecycle
hooks make, standing in for the Dynamo selection service. ``add_output_block``
is synchronous as in the real binding; the rest are async."""
def __init__(self):
self.calls = []
self.reservations = []
async def create_reservation(self, request):
self.reservations.append(dict(request))
self.calls.append(
(
"create_reservation",
request["reservation_id"],
request["worker_id"],
len(request["token_ids"]),
request.get("expected_output_tokens"),
)
)
async def prefill_complete(self, reservation_id):
self.calls.append(("prefill_complete", reservation_id))
def add_output_block(self, reservation_id, *, decay_fraction=None):
self.calls.append(("add_output_block", reservation_id, decay_fraction))
async def free_reservation(self, reservation_id):
self.calls.append(("free_reservation", reservation_id))
async def delete_worker(self, worker_id):
self.calls.append(("delete_worker", worker_id))
@ray.remote(num_cpus=0)
class RecordingKVRouterActor(KVRouterActor):
"""KVRouterActor that records the lifecycle events it receives for tests."""
def __init__(self, block_size):
self._block_size = block_size
self._replica_id_by_worker = {}
self._requests = OrderedDict()
self._request_ids_by_worker = {}
self._effective_prefill_tokens_by_request = {}
self._pending_tasks = set()
self._svc = MockSelectionService()
self._event_log = []
async def on_lifecycle_events(self, events):
self._event_log.extend(events)
await super().on_lifecycle_events(events)
def get_event_log(self):
return self._event_log
def get_selection_service_calls(self):
return self._svc.calls
async def get_request_lifecycle(self, request_id):
"""Return a snapshot of an in-flight request's state, or ``None``."""
state = self._requests.get(request_id)
if state is None:
return None
snapshot = asdict(state)
snapshot.pop("created_at", None) # internal TTL bookkeeping, not asserted on
return snapshot
async def get_active_request_ids(self):
"""Return ids of the in-flight requests."""
return list(self._requests)
async def get_worker_active_load(self, worker_id):
"""Return the number of in-flight requests attributed to ``worker_id``."""
return sum(1 for s in self._requests.values() if s.worker_id == worker_id)
@ray.remote(num_cpus=0)
class RaisingActor:
"""A KV-router stand-in whose event ingest always raises, to prove the
engine token stream is never disrupted."""
async def on_lifecycle_events(self, events):
raise RuntimeError("actor down")
class LocalKVRouterActor(KVRouterActor):
"""In-process KVRouterActor with the event plane + Serve LongPoll stripped."""
def __init__(self, block_size):
self._block_size = block_size
self._replica_id_by_worker = {}
self._requests = OrderedDict()
self._request_ids_by_worker = {}
self._effective_prefill_tokens_by_request = {}
self._pending_tasks = set()
self._svc = MockSelectionService()
async def get_request_lifecycle(self, request_id):
"""Return a snapshot of an in-flight request's state, or ``None``."""
state = self._requests.get(request_id)
if state is None:
return None
snapshot = asdict(state)
snapshot.pop("created_at", None) # internal TTL bookkeeping, not asserted on
return snapshot
async def get_active_request_ids(self):
"""Return ids of the in-flight requests."""
return list(self._requests)
async def get_worker_active_load(self, worker_id):
"""Return the number of in-flight requests attributed to ``worker_id``."""
return sum(1 for s in self._requests.values() if s.worker_id == worker_id)
@pytest.fixture
def build_token_tracking_engine(monkeypatch):
def _build(script, actor, **engine_kwargs):
def get_deployment_actor(name):
assert name == KV_ROUTER_ACTOR_NAME
return actor
monkeypatch.setattr(serve, "get_deployment_actor", get_deployment_actor)
monkeypatch.setattr(
serve,
"get_replica_context",
lambda: SimpleNamespace(
replica_id=SimpleNamespace(unique_id=REPLICA_UNIQUE_ID)
),
)
return enable_token_tracking(MockAsyncLLM)(script, **engine_kwargs)
return _build
async def consume(stream, limit=None):
"""Drain ``stream``, optionally closing it early after ``limit`` outputs."""
outputs = []
async for output in stream:
outputs.append(output)
if limit is not None and len(outputs) == limit:
await stream.aclose()
return outputs
async def drain(engine):
"""Wait for the engine forwarder's queued lifecycle batches to land."""
await engine._lifecycle_forwarder.flush()
def decode_counts(events):
return [args[1] for name, args in events if name == "on_decode_progress"]
def op_names(calls):
return [c[0] for c in calls]
@pytest.mark.asyncio
async def test_basic_lifecycle(build_token_tracking_engine):
"""A streamed request reports add -> prefill -> exact decode counts -> done."""
actor = RecordingKVRouterActor.remote(block_size=16)
engine = build_token_tracking_engine(delta_steps(3, prompt_len=10), actor)
prompt = {"prompt_token_ids": list(range(10))}
outputs = await consume(
engine.generate(
prompt,
SamplingParams(output_kind=RequestOutputKind.DELTA, max_tokens=MAX_TOKENS),
"req-1",
)
)
await drain(engine)
assert ray.get(actor.get_event_log.remote()) == [
("on_request_added", ("req-1", WORKER_ID, list(range(10)), MAX_TOKENS)),
("on_prefill_complete", ("req-1",)),
("on_decode_progress", ("req-1", 1)),
("on_decode_progress", ("req-1", 2)),
("on_decode_progress", ("req-1", 3)),
("on_request_completed", ("req-1",)),
]
assert outputs == engine.script
@pytest.mark.asyncio
async def test_lifecycle_uses_serve_request_id(build_token_tracking_engine):
"""Lifecycle events use the same Serve request id used by routing, even if
vLLM's engine-level id is different."""
actor = RecordingKVRouterActor.remote(block_size=16)
engine = build_token_tracking_engine(delta_steps(1, prompt_len=10), actor)
prompt = {"prompt_token_ids": list(range(10))}
serve.context._serve_request_context.set(
serve.context._RequestContext(request_id="serve-route-id")
)
try:
await consume(
engine.generate(
prompt,
SamplingParams(
output_kind=RequestOutputKind.DELTA, max_tokens=MAX_TOKENS
),
"chatcmpl-serve-route-id",
)
)
finally:
serve.context._serve_request_context.set(serve.context._RequestContext())
await drain(engine)
assert ray.get(actor.get_event_log.remote()) == [
(
"on_request_added",
("serve-route-id", WORKER_ID, list(range(10)), MAX_TOKENS),
),
("on_prefill_complete", ("serve-route-id",)),
("on_decode_progress", ("serve-route-id", 1)),
("on_request_completed", ("serve-route-id",)),
]
@pytest.mark.asyncio
async def test_in_order_reports(build_token_tracking_engine):
"""Back-to-back reports reach the actor in submission order."""
actor = RecordingKVRouterActor.remote(block_size=16)
engine = build_token_tracking_engine(delta_steps(200), actor)
await consume(
engine.generate(
PROMPT, SamplingParams(output_kind=RequestOutputKind.DELTA), "r"
)
)
await drain(engine)
events = ray.get(actor.get_event_log.remote())
assert events[0][0] == "on_request_added"
assert events[1][0] == "on_prefill_complete"
assert events[-1] == ("on_request_completed", ("r",))
assert decode_counts(events) == list(range(1, 201))
@pytest.mark.asyncio
async def test_streaming_accumulates_decode_progress(build_token_tracking_engine):
"""A DELTA (streaming) request sums each step's new tokens into a running
total reported as decode progress."""
actor = RecordingKVRouterActor.remote(block_size=16)
# Steps carry only new tokens: 1, then 2, then 1 -> cumulative 1, 3, 4.
script = [request_output([n]) for n in (1, 2, 1)]
engine = build_token_tracking_engine(script, actor)
await consume(
engine.generate(
PROMPT, SamplingParams(output_kind=RequestOutputKind.DELTA), "r"
)
)
await drain(engine)
assert decode_counts(ray.get(actor.get_event_log.remote())) == [1, 3, 4]
@pytest.mark.asyncio
async def test_non_streaming_reports_full_output_once(build_token_tracking_engine):
"""A FINAL_ONLY (non-streaming) request arrives as one finished chunk, with a
CompletionOutput per candidate, so progress is reported once at the summed
token count across candidates."""
actor = RecordingKVRouterActor.remote(block_size=16)
# FINAL_ONLY n=3: a single finished chunk carrying every candidate's output.
script = [request_output([2, 3, 4], finished=True)]
engine = build_token_tracking_engine(script, actor)
await consume(
engine.generate(
PROMPT, SamplingParams(output_kind=RequestOutputKind.FINAL_ONLY), "r"
)
)
await drain(engine)
events = ray.get(actor.get_event_log.remote())
assert decode_counts(events) == [9] # 2 + 3 + 4 summed across candidates
assert [name for name, _ in events] == [
"on_request_added",
"on_prefill_complete",
"on_decode_progress",
"on_request_completed",
]
@pytest.mark.asyncio
async def test_cumulative_skips_tracking(build_token_tracking_engine):
"""CUMULATIVE repeats output-so-far each chunk; tracking is skipped (not
summed) to avoid over-counting, so no lifecycle events are reported."""
actor = RecordingKVRouterActor.remote(block_size=16)
engine = build_token_tracking_engine(delta_steps(3), actor)
outputs = await consume(
engine.generate(
PROMPT, SamplingParams(output_kind=RequestOutputKind.CUMULATIVE), "r"
)
)
assert len(outputs) == 3 # stream still passes through untouched
assert ray.get(actor.get_event_log.remote()) == []
@pytest.mark.asyncio
async def test_empty_steps_ignored(build_token_tracking_engine):
"""Token-less outputs (e.g. a finish-only chunk) emit no progress hooks."""
actor = RecordingKVRouterActor.remote(block_size=16)
script = [
request_output([1]),
request_output([0]), # structural chunk: no new tokens
request_output([1], finished=True),
]
engine = build_token_tracking_engine(script, actor)
await consume(
engine.generate(
PROMPT, SamplingParams(output_kind=RequestOutputKind.DELTA), "r"
)
)
await drain(engine)
events = ray.get(actor.get_event_log.remote())
assert decode_counts(events) == [1, 2]
assert [e for e in events if e[0] == "on_prefill_complete"] == [
("on_prefill_complete", ("r",))
]
@pytest.mark.asyncio
@pytest.mark.parametrize("early_drop", [False, True])
async def test_completed_exactly_once(early_drop, build_token_tracking_engine):
"""Completion fires exactly once on normal end and on early stream close."""
actor = RecordingKVRouterActor.remote(block_size=16)
engine = build_token_tracking_engine(delta_steps(3), actor)
stream = engine.generate(
PROMPT, SamplingParams(output_kind=RequestOutputKind.DELTA), "r"
)
await consume(stream, limit=1 if early_drop else None)
await drain(engine)
events = ray.get(actor.get_event_log.remote())
assert [e for e in events if e[0] == "on_request_completed"] == [
("on_request_completed", ("r",))
]
@pytest.mark.asyncio
async def test_engine_error_still_completes(build_token_tracking_engine):
"""A mid-stream engine error propagates but still frees the request."""
actor = RecordingKVRouterActor.remote(block_size=16)
engine = build_token_tracking_engine(delta_steps(3), actor, error_after=1)
with pytest.raises(RuntimeError, match="engine failure"):
await consume(
engine.generate(
PROMPT, SamplingParams(output_kind=RequestOutputKind.DELTA), "r"
)
)
await drain(engine)
events = ray.get(actor.get_event_log.remote())
assert events[-1] == ("on_request_completed", ("r",))
@pytest.mark.asyncio
async def test_zero_token_request(build_token_tracking_engine):
"""An output-less request (e.g. validation abort) is added and freed only."""
actor = RecordingKVRouterActor.remote(block_size=16)
engine = build_token_tracking_engine([request_output([0], finished=True)], actor)
prompt = {"prompt_token_ids": [1, 2, 3]}
await consume(
engine.generate(
prompt,
SamplingParams(output_kind=RequestOutputKind.DELTA, max_tokens=MAX_TOKENS),
"r",
)
)
await drain(engine)
assert ray.get(actor.get_event_log.remote()) == [
("on_request_added", ("r", WORKER_ID, [1, 2, 3], MAX_TOKENS)),
("on_request_completed", ("r",)),
]
@pytest.mark.asyncio
async def test_actor_failure_isolation(build_token_tracking_engine):
"""A failing actor never disrupts the engine's output stream."""
engine = build_token_tracking_engine(delta_steps(2), RaisingActor.remote())
outputs = await consume(
engine.generate(
PROMPT, SamplingParams(output_kind=RequestOutputKind.DELTA), "r"
)
)
await drain(engine) # the failed batches are dropped without raising
assert len(outputs) == 2
def test_decorator_returns_subclass():
"""The decorator returns an isinstance-compatible subclass."""
assert issubclass(enable_token_tracking(MockAsyncLLM), MockAsyncLLM)
@pytest.mark.asyncio
async def test_passthrough_without_actor(monkeypatch):
"""Outside a replica (no actor resolvable) the engine is a pure pass-through."""
def _raise(name):
raise RuntimeError("no actor")
monkeypatch.setattr(serve, "get_deployment_actor", _raise)
engine = enable_token_tracking(MockAsyncLLM)(delta_steps(2))
outputs = await consume(
engine.generate(
PROMPT, SamplingParams(output_kind=RequestOutputKind.DELTA), "r"
)
)
assert len(outputs) == 2
assert engine._lifecycle_forwarder is None # resolution failed; retried next call
@pytest.mark.parametrize(
"request_router_config, expected",
[
({"request_router_class": KVAwareRouter}, True),
({}, False), # default (non-KV) router
(None, False), # no router configured
],
)
def test_is_kv_aware(request_router_config, expected):
"""The engine wraps with token tracking only on KVAwareRouter deployments,
so non-KV deployments never retry a missing actor lookup per request."""
deployment_config = {}
if request_router_config is not None:
deployment_config["request_router_config"] = request_router_config
llm_config = LLMConfig(
model_loading_config={
"model_id": "qwen3-0.6b",
"model_source": "Qwen/Qwen3-0.6B",
},
accelerator_type=None,
deployment_config=deployment_config,
)
assert is_kv_aware(llm_config) is expected
@pytest.mark.asyncio
async def test_block_boundary_crossings():
"""Each ceil((prompt+output)/block_size) increase advances total_blocks."""
actor = LocalKVRouterActor(block_size=16)
await actor.on_request_added("r", 1, list(range(10)))
assert (await actor.get_request_lifecycle("r"))["total_blocks"] == 1 # ceil(10/16)
await actor.on_decode_progress("r", 6) # 10+6=16 -> still 1 block
assert (await actor.get_request_lifecycle("r"))["total_blocks"] == 1
await actor.on_decode_progress("r", 7) # 17 -> crosses into block 2
assert (await actor.get_request_lifecycle("r"))["total_blocks"] == 2
await actor.on_decode_progress("r", 39) # 49 -> ceil=4, crosses two more at once
snapshot = await actor.get_request_lifecycle("r")
assert snapshot["total_blocks"] == 4
assert snapshot["output_tokens"] == 39
@pytest.mark.asyncio
async def test_active_load_tracking():
"""Active load is per-worker; completion evicts the request entirely."""
actor = LocalKVRouterActor(block_size=16)
await actor.on_request_added("a", 1, list(range(8)))
await actor.on_request_added("b", 1, [])
await actor.on_request_added("c", 2, [])
assert await actor.get_worker_active_load(1) == 2
assert await actor.get_worker_active_load(2) == 1
await actor.on_prefill_complete("a")
await actor.on_decode_progress("a", 5)
assert await actor.get_worker_active_load(1) == 2 # still active while decoding
assert await actor.get_request_lifecycle("a") == {
"worker_id": 1,
"prompt_tokens": 8,
"expected_output_tokens": None,
"prefill_completed": True,
"output_tokens": 5,
"total_blocks": 1,
}
# Completion evicts (bounding memory to in-flight requests).
await actor.on_request_completed("a")
assert await actor.get_worker_active_load(1) == 1
assert set(await actor.get_active_request_ids()) == {"b", "c"}
assert await actor.get_request_lifecycle("a") is None
# Hooks for an unknown request id are ignored.
await actor.on_prefill_complete("missing")
await actor.on_decode_progress("missing", 3)
await actor.on_request_completed("missing")
assert await actor.get_request_lifecycle("missing") is None
@pytest.mark.asyncio
async def test_tracking_drains_under_churn():
"""Memory chaos: a long run of interleaved submissions and completions across
workers grows the in-flight state and drains it back to nothing."""
rng = random.Random(20240708)
actor = LocalKVRouterActor(block_size=16)
workers = [1, 2, 3]
total = 400
inflight: set = set()
launched = 0
peak = 0
# Randomly interleave admitting new requests and completing live ones.
for _ in range(total * 3):
if launched < total and (not inflight or rng.random() < 0.6):
request_id = f"r{launched}"
launched += 1
# A routed request carries its effective prefill tokens from select();
# admission must drain that map too, not just _requests.
actor._effective_prefill_tokens_by_request[request_id] = rng.randint(0, 40)
await actor.on_request_added(
request_id,
rng.choice(workers),
list(range(rng.randint(1, 40))),
expected_output_tokens=rng.choice([None, 32]),
)
inflight.add(request_id)
elif inflight:
request_id = rng.choice(list(inflight))
if rng.random() < 0.5:
await actor.on_prefill_complete(request_id)
await actor.on_decode_progress(request_id, rng.randint(1, 80))
await actor.on_request_completed(request_id)
inflight.discard(request_id)
peak = max(peak, len(actor._requests))
for request_id in list(inflight):
await actor.on_request_completed(request_id)
assert launched == total
assert peak > 1 # state actually accumulated under concurrent load
# Everything drained: no request state, no index entries, no active load.
assert actor._requests == {}
assert actor._request_ids_by_worker == {}
assert actor._effective_prefill_tokens_by_request == {}
assert await actor.get_active_request_ids() == []
for worker_id in workers:
assert await actor.get_worker_active_load(worker_id) == 0
@pytest.mark.asyncio
async def test_remove_worker_evicts_requests():
"""A departed replica's in-flight request state is purged: its completion
events can never arrive, so the entries would otherwise leak forever."""
actor = LocalKVRouterActor(block_size=16)
await actor.on_request_added("a", 1, list(range(8)))
await actor.on_request_added("b", 1, list(range(8)))
await actor.on_request_added("c", 2, list(range(8)))
actor.remove_worker(1)
assert set(await actor.get_active_request_ids()) == {"c"}
assert await actor.get_worker_active_load(1) == 0
assert await actor.get_worker_active_load(2) == 1
# The reverse index drops the departed worker and keeps the survivor.
assert 1 not in actor._request_ids_by_worker
assert actor._request_ids_by_worker.get(2) == {"c"}
# remove_worker schedules delete_worker; drain the task and confirm.
await asyncio.gather(*list(actor._pending_tasks))
assert ("delete_worker", 1) in actor._svc.calls
@pytest.mark.asyncio
async def test_stale_request_evicted_after_ttl():
"""Backstop for a lost completion on a live replica: a request tracked past
the TTL is evicted and its reservation freed, so it cannot accumulate."""
actor = LocalKVRouterActor(block_size=16)
await actor.on_request_added("stale", 1, list(range(8)))
await actor.on_request_added("fresh_untriggered", 1, list(range(8)))
# Backdate the stale request's admission beyond the TTL (lost completion).
actor._requests["stale"].created_at -= REQUEST_TRACKING_TTL_S + 1
# A new admission triggers the lazy sweep of the oldest entries.
await actor.on_request_added("trigger", 2, list(range(8)))
assert "stale" not in await actor.get_active_request_ids()
assert ("free_reservation", "stale") in actor._svc.calls
# A still-fresh request is left untouched (sweep stops at the first fresh one).
assert set(await actor.get_active_request_ids()) == {"fresh_untriggered", "trigger"}
assert ("free_reservation", "fresh_untriggered") not in actor._svc.calls
@pytest.mark.asyncio
async def test_admission_race_frees_reservation():
"""If remove_worker evicts a request while create_reservation is in flight,
the orphaned reservation is freed rather than leaking in the service."""
actor = LocalKVRouterActor(block_size=16)
# Simulate the LongPoll remove_worker firing during the create_reservation await.
book_reservation = actor._svc.create_reservation
async def racing_create_reservation(request):
await book_reservation(request)
actor.remove_worker(request["worker_id"])
actor._svc.create_reservation = racing_create_reservation
await actor.on_request_added("r", 1, list(range(8)))
assert await actor.get_active_request_ids() == [] # evicted mid-flight
assert ("free_reservation", "r") in actor._svc.calls # reservation not orphaned
@pytest.mark.asyncio
async def test_tracks_streamed_request_state(build_token_tracking_engine):
"""End-to-end: exact token counts land as actor block state over ``.remote``."""
actor = RecordingKVRouterActor.remote(block_size=8)
# prompt 12, block_size 8: baseline ceil(12/8)=2; boundaries at cumulative
# output 5 and 13 -> 9 generated tokens cross only the first.
engine = build_token_tracking_engine(delta_steps(9, prompt_len=12), actor)
prompt = {"prompt_token_ids": list(range(12))}
stream = engine.generate(
prompt,
SamplingParams(output_kind=RequestOutputKind.DELTA, max_tokens=MAX_TOKENS),
"req-e2e",
)
for _ in range(9):
await stream.__anext__()
await drain(engine)
# All outputs consumed but the stream is still open: the request is
# tracked with its exact final counts.
assert await actor.get_request_lifecycle.remote("req-e2e") == {
"worker_id": WORKER_ID,
"prompt_tokens": 12,
"expected_output_tokens": MAX_TOKENS,
"prefill_completed": True,
"output_tokens": 9,
"total_blocks": 3,
}
assert await actor.get_worker_active_load.remote(WORKER_ID) == 1
# Stream end fires completion, which evicts the request.
with pytest.raises(StopAsyncIteration):
await stream.__anext__()
await drain(engine)
assert await actor.get_request_lifecycle.remote("req-e2e") is None
assert await actor.get_active_request_ids.remote() == []
assert await actor.get_worker_active_load.remote(WORKER_ID) == 0
@pytest.mark.asyncio
async def test_lifecycle_books_selection_service_load(build_token_tracking_engine):
"""A streamed request books create_reservation -> prefill_complete -> one
add_output_block per crossed decode block -> free_reservation, in order."""
actor = RecordingKVRouterActor.remote(block_size=8)
# prompt 12 (2 blocks); cumulative output crosses into block 3 at 5 tokens
# and block 4 at 13 tokens -> exactly two output blocks over 20 tokens.
engine = build_token_tracking_engine(delta_steps(20, prompt_len=12), actor)
prompt = {"prompt_token_ids": list(range(12))}
await consume(
engine.generate(
prompt,
SamplingParams(output_kind=RequestOutputKind.DELTA, max_tokens=MAX_TOKENS),
"req-1",
)
)
await drain(engine)
calls = ray.get(actor.get_selection_service_calls.remote())
assert op_names(calls) == (
["create_reservation", "prefill_complete"]
+ ["add_output_block"] * 2
+ ["free_reservation"]
)
assert calls[0] == ("create_reservation", "req-1", WORKER_ID, 12, MAX_TOKENS)
assert calls[-1] == ("free_reservation", "req-1")
@pytest.mark.asyncio
async def test_decode_blocks_book_add_output_block():
"""Each crossed decode block books one add_output_block in the service."""
actor = LocalKVRouterActor(block_size=16)
await actor.on_request_added("r", WORKER_ID, list(range(10))) # 1 prompt block
await actor.on_decode_progress("r", 6) # 16 -> still 1 block
await actor.on_decode_progress("r", 7) # 17 -> crosses into block 2
await actor.on_decode_progress("r", 39) # 49 -> ceil=4, crosses two more
assert (
op_names(actor._svc.calls) == ["create_reservation"] + ["add_output_block"] * 3
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"expected_output_tokens, expected_decay",
[
pytest.param(40, pytest.approx(1.0 - 8 / 40), id="with-estimate"),
pytest.param(None, None, id="no-estimate"),
],
)
async def test_expected_output_tokens_sets_decay_fraction(
expected_output_tokens, expected_decay
):
"""With an output-length estimate each booked decode block decays by the
remaining fraction; without one the block carries no decay."""
actor = LocalKVRouterActor(block_size=8)
await actor.on_request_added(
"r", WORKER_ID, list(range(8)), expected_output_tokens=expected_output_tokens
)
await actor.on_decode_progress("r", 8) # total 16 -> crosses into block 2
block_calls = [c for c in actor._svc.calls if c[0] == "add_output_block"]
assert block_calls == [("add_output_block", "r", expected_decay)]
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,805 @@
import os
import re
import signal
import subprocess
import sys
import tempfile
import pytest
import yaml
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray.llm._internal.serve.constants import DEFAULT_MAX_TARGET_ONGOING_REQUESTS
from ray.llm._internal.serve.core.configs.llm_config import (
LLMConfig,
ModelLoadingConfig,
)
from ray.llm._internal.serve.core.ingress.builder import (
IngressClsConfig,
LLMServingArgs,
build_openai_app,
)
from ray.llm._internal.serve.core.ingress.ingress import OpenAiIngress
from ray.llm._internal.serve.serving_patterns.data_parallel.builder import (
build_dp_openai_app,
)
from ray.llm._internal.serve.serving_patterns.data_parallel.dp_server import (
DPServer,
)
from ray.llm._internal.serve.serving_patterns.prefill_decode.builder import (
build_pd_openai_app,
)
from ray.llm._internal.serve.serving_patterns.prefill_decode.pd_server import (
DPPDDecodeServer,
DPPDPrefillServer,
PDDecodeServer,
PDPrefillServer,
)
from ray.serve._private.http_util import ASGIAppReplicaWrapper
from ray.serve.config import AutoscalingConfig, RequestRouterConfig
from ray.serve.experimental.consistent_hash_router import ConsistentHashRouter
from ray.serve.experimental.round_robin_router import RoundRobinRouter
@pytest.fixture
def get_llm_serve_args(llm_config_with_mock_engine):
yield LLMServingArgs(llm_configs=[llm_config_with_mock_engine])
@pytest.fixture()
def serve_config_separate_model_config_files():
config_dir = tempfile.mkdtemp()
serve_config_filename = "llm_app_separate_model_config_files.yaml"
config_root = os.path.join(os.path.dirname(__file__), "test_config_files")
serve_config_src = os.path.join(config_root, serve_config_filename)
serve_config_dst = os.path.join(config_dir, serve_config_filename)
with open(serve_config_src, "r") as f:
serve_config_yaml = yaml.safe_load(f)
for application in serve_config_yaml["applications"]:
llm_configs = application["args"]["llm_configs"]
tmp_llm_config_files = []
for llm_config in llm_configs:
llm_config_src = llm_config.replace(".", config_root, 1)
llm_config_dst = llm_config.replace(".", config_dir, 1)
tmp_llm_config_files.append(llm_config_dst)
with open(llm_config_src, "r") as f:
llm_config_yaml = yaml.safe_load(f)
# Make sure engine is mocked.
if llm_config_yaml.get("runtime_env", None) is None:
llm_config_yaml["runtime_env"] = {}
llm_config_yaml["runtime_env"]["env_vars"] = {
"RAYLLM_VLLM_ENGINE_CLS": "ray.llm.tests.serve.mocks.mock_vllm_engine.MockVLLMEngine"
}
# Explicitly set accelerator_type to None to avoid GPU placement groups
llm_config_yaml["accelerator_type"] = None
# Use placement_group_config to specify CPU-only bundles
llm_config_yaml["placement_group_config"] = {
"bundles": [{"CPU": 1, "GPU": 0}]
}
os.makedirs(os.path.dirname(llm_config_dst), exist_ok=True)
with open(llm_config_dst, "w") as f:
yaml.dump(llm_config_yaml, f)
application["args"]["llm_configs"] = tmp_llm_config_files
with open(serve_config_dst, "w") as f:
yaml.dump(serve_config_yaml, f)
yield serve_config_dst
class TestLLMServingArgs:
"""Test suite for LLMServingArgs data model."""
@pytest.fixture
def llm_config(self):
"""Basic LLMConfig for testing."""
return LLMConfig(
model_loading_config=ModelLoadingConfig(
model_id="test-model", model_source="test-source"
)
)
def test_basic_creation_and_defaults(self, llm_config):
"""Test creation with minimal config and verify defaults."""
args = LLMServingArgs(llm_configs=[llm_config])
# Verify llm_configs
assert len(args.llm_configs) == 1
assert isinstance(args.llm_configs[0], LLMConfig)
# Verify defaults
assert isinstance(args.ingress_cls_config, IngressClsConfig)
assert args.ingress_cls_config.ingress_cls == OpenAiIngress
assert args.ingress_deployment_config == {}
def test_flexible_input_types(self, llm_config):
"""Test accepts dicts, objects, and mixed types for llm_configs."""
config_dict = {
"model_loading_config": {
"model_id": "test-model-2",
"model_source": "test-source-2",
}
}
args = LLMServingArgs(llm_configs=[llm_config, config_dict])
assert len(args.llm_configs) == 2
assert all(isinstance(c, LLMConfig) for c in args.llm_configs)
def test_ingress_config_flexibility(self, llm_config):
"""Test ingress_cls_config: defaults, dict input, object input, and class loading."""
# Test defaults
args_default = LLMServingArgs(llm_configs=[llm_config])
assert isinstance(args_default.ingress_cls_config, IngressClsConfig)
assert args_default.ingress_cls_config.ingress_cls == OpenAiIngress
assert args_default.ingress_cls_config.ingress_extra_kwargs == {}
# Test as dict with custom kwargs
args_dict = LLMServingArgs(
llm_configs=[llm_config],
ingress_cls_config={"ingress_extra_kwargs": {"key": "value"}},
)
assert isinstance(args_dict.ingress_cls_config, IngressClsConfig)
assert args_dict.ingress_cls_config.ingress_extra_kwargs == {"key": "value"}
# Test as object
args_obj = LLMServingArgs(
llm_configs=[llm_config],
ingress_cls_config=IngressClsConfig(ingress_extra_kwargs={"key": "value"}),
)
assert isinstance(args_obj.ingress_cls_config, IngressClsConfig)
assert args_obj.ingress_cls_config.ingress_extra_kwargs == {"key": "value"}
# Test class loading from string
args_str = LLMServingArgs(
llm_configs=[llm_config],
ingress_cls_config={
"ingress_cls": "ray.llm._internal.serve.core.ingress.ingress:OpenAiIngress"
},
)
assert args_str.ingress_cls_config.ingress_cls == OpenAiIngress
def test_validation_rules(self):
"""Test validation: unique model IDs and non-empty list."""
# Duplicate model IDs
config1 = LLMConfig(
model_loading_config=ModelLoadingConfig(
model_id="same-id", model_source="source1"
)
)
config2 = LLMConfig(
model_loading_config=ModelLoadingConfig(
model_id="same-id", model_source="source2"
)
)
with pytest.raises(ValueError, match="Duplicate models found"):
LLMServingArgs(llm_configs=[config1, config2])
# Empty list
with pytest.raises(ValueError, match="List of models is empty"):
LLMServingArgs(llm_configs=[])
class TestBuildOpenaiApp:
@pytest.fixture
def llm_config(self):
"""Basic LLMConfig for testing."""
return LLMConfig(
model_loading_config=ModelLoadingConfig(
model_id="test-model", model_source="test-source"
)
)
def test_build_openai_app(
self, get_llm_serve_args, shutdown_ray_and_serve, disable_placement_bundles
):
"""Test `build_openai_app` can build app and run it with Serve."""
app = build_openai_app(
get_llm_serve_args,
)
assert isinstance(app, serve.Application)
serve.run(app)
def test_build_openai_app_with_config(
self,
serve_config_separate_model_config_files,
shutdown_ray_and_serve,
disable_placement_bundles,
):
"""Test `build_openai_app` can be used in serve config."""
def deployments_healthy():
status_response = subprocess.check_output(["serve", "status"])
print("[TEST] Status response: ", status_response)
applications = extract_applications_from_output(status_response)
if "llm-endpoint" not in applications:
print("[TEST] Application 'llm-endpoint' not found.")
return False
llm_endpoint_status = applications["llm-endpoint"]
if len(llm_endpoint_status["deployments"]) != 2:
print(
f"[TEST] Expected 2 deployments, found {len(llm_endpoint_status['deployments'])}"
)
return False
deployment_status = llm_endpoint_status["deployments"].values()
if not all([status["status"] == "HEALTHY" for status in deployment_status]):
print(f"[TEST] Not all deployments healthy: {deployment_status}")
return False
print("[TEST] All deployments healthy.")
return True
p = subprocess.Popen(["serve", "run", serve_config_separate_model_config_files])
wait_for_condition(deployments_healthy, timeout=60, retry_interval_ms=1000)
p.send_signal(signal.SIGINT) # Equivalent to ctrl-C
p.wait()
def test_router_built_with_autoscaling_configs(self, disable_placement_bundles):
"""Test that the router is built with the correct autoscaling configs that
will scale.
"""
llm_config_no_autoscaling_configured = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id="model_id_1"),
accelerator_type="L4",
)
llm_config_autoscaling_default = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id="model_id_2"),
accelerator_type="L4",
deployment_config={"autoscaling_config": AutoscalingConfig()},
)
llm_config_autoscaling_non_default = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id="model_id_3"),
accelerator_type="L4",
deployment_config={
"autoscaling_config": AutoscalingConfig(
min_replicas=2,
initial_replicas=3,
max_replicas=4,
)
},
)
app = build_openai_app(
LLMServingArgs(
llm_configs=[
llm_config_no_autoscaling_configured,
llm_config_autoscaling_default,
llm_config_autoscaling_non_default,
],
ingress_deployment_config={
"autoscaling_config": {
"min_replicas": 8,
"initial_replicas": 10,
"max_replicas": 12,
"target_ongoing_requests": 10,
}
},
)
)
router_autoscaling_config = (
app._bound_deployment._deployment_config.autoscaling_config
)
assert router_autoscaling_config.min_replicas == 8 # (1 + 1 + 2) * 2
assert router_autoscaling_config.initial_replicas == 10 # (1 + 1 + 3) * 2
assert router_autoscaling_config.max_replicas == 12 # (1 + 1 + 4) * 2
assert router_autoscaling_config.target_ongoing_requests == 10
def test_ingress_deployment_config_merging(
self, llm_config, disable_placement_bundles
):
"""Test that ingress_deployment_config is properly merged with default options.
This test ensures that deep_merge_dicts return value is properly assigned
and that nested dictionaries are properly deep-merged without losing default values.
"""
# Build app with custom ingress deployment config including nested options
app = build_openai_app(
dict(
llm_configs=[llm_config],
ingress_deployment_config={
"num_replicas": 3,
"ray_actor_options": {
"num_cpus": 4,
"memory": 1024,
},
"max_ongoing_requests": 200, # Override default
},
)
)
# Verify the custom config was applied
deployment = app._bound_deployment
assert deployment._deployment_config.num_replicas == 3
assert deployment.ray_actor_options["num_cpus"] == 4
assert deployment.ray_actor_options["memory"] == 1024
assert deployment._deployment_config.max_ongoing_requests == 200
def test_default_autoscaling_config_included_without_num_replicas(
self, llm_config, disable_placement_bundles
):
"""Test that default autoscaling_config with target_ongoing_requests is included
when num_replicas is not specified.
"""
app = build_openai_app(
dict(
llm_configs=[llm_config],
)
)
deployment = app._bound_deployment
autoscaling_config = deployment._deployment_config.autoscaling_config
assert autoscaling_config is not None
assert (
autoscaling_config.target_ongoing_requests
== DEFAULT_MAX_TARGET_ONGOING_REQUESTS
)
def test_autoscaling_config_removed_from_defaults_when_num_replicas_specified(
self, llm_config, disable_placement_bundles
):
"""Test that autoscaling_config from defaults is removed when user specifies
num_replicas, since Ray Serve does not allow both.
"""
app = build_openai_app(
dict(
llm_configs=[llm_config],
ingress_deployment_config={
"num_replicas": 2,
},
)
)
deployment = app._bound_deployment
assert deployment._deployment_config.num_replicas == 2
# autoscaling_config should be None since num_replicas is set
assert deployment._deployment_config.autoscaling_config is None
def test_user_target_ongoing_requests_respected(
self, llm_config, disable_placement_bundles
):
"""Test that user-specified target_ongoing_requests is respected and not
overridden by defaults.
"""
user_target = 50
app = build_openai_app(
dict(
llm_configs=[llm_config],
ingress_deployment_config={
"autoscaling_config": {
"target_ongoing_requests": user_target,
},
},
)
)
deployment = app._bound_deployment
autoscaling_config = deployment._deployment_config.autoscaling_config
assert autoscaling_config is not None
assert autoscaling_config.target_ongoing_requests == user_target
def test_direct_streaming_builds_ingress_with_router_attached(
self, llm_config, disable_placement_bundles, monkeypatch
):
monkeypatch.setattr(
"ray.llm._internal.serve.core.ingress.builder."
"RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING",
True,
)
app = build_openai_app(LLMServingArgs(llm_configs=[llm_config]))
ingress_request_router = app._ingress_request_router
assert app._bound_deployment.name == "LLMServer:test-model"
assert issubclass(app._bound_deployment.func_or_class, ASGIAppReplicaWrapper)
assert ingress_request_router is not None
assert ingress_request_router._bound_deployment.name == "LLMRouter"
assert ingress_request_router._bound_deployment.init_kwargs["server"] is app
# `RequestRouterConfig._serialize_request_router_cls` normalizes the
# class to its import path at config-build time.
request_router_config = (
app._bound_deployment._deployment_config.request_router_config
)
assert request_router_config.request_router_class == (
f"{RoundRobinRouter.__module__}.{RoundRobinRouter.__name__}"
)
def test_direct_streaming_user_request_router_config_wins(
self, llm_config, disable_placement_bundles, monkeypatch
):
"""A user-supplied ``request_router_config`` on ``LLMConfig`` must
survive direct-streaming wiring rather than being overwritten with the
default.
"""
monkeypatch.setattr(
"ray.llm._internal.serve.core.ingress.builder."
"RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING",
True,
)
llm_config.deployment_config["request_router_config"] = RequestRouterConfig(
request_router_class=ConsistentHashRouter,
)
app = build_openai_app(LLMServingArgs(llm_configs=[llm_config]))
request_router_config = (
app._bound_deployment._deployment_config.request_router_config
)
assert request_router_config.request_router_class == (
f"{ConsistentHashRouter.__module__}.{ConsistentHashRouter.__name__}"
)
def test_direct_streaming_rejects_multiple_llm_configs(
self, llm_config, disable_placement_bundles, monkeypatch
):
monkeypatch.setattr(
"ray.llm._internal.serve.core.ingress.builder."
"RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING",
True,
)
other_llm_config = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id="other-model")
)
with pytest.raises(
ValueError,
match="currently supports exactly one LLM config",
):
build_openai_app(LLMServingArgs(llm_configs=[llm_config, other_llm_config]))
@pytest.mark.parametrize(
("builder_kwargs", "match"),
[
(
{"ingress_deployment_config": {"num_replicas": 2}},
"does not support ingress_deployment_config",
),
(
{"ingress_cls_config": {"ingress_extra_kwargs": {"key": "value"}}},
"does not support ingress_cls_config",
),
],
)
def test_direct_streaming_rejects_ingress_config(
self,
llm_config,
disable_placement_bundles,
monkeypatch,
builder_kwargs,
match,
):
monkeypatch.setattr(
"ray.llm._internal.serve.core.ingress.builder."
"RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING",
True,
)
with pytest.raises(ValueError, match=match):
build_openai_app(LLMServingArgs(llm_configs=[llm_config], **builder_kwargs))
class TestDirectStreamingDP:
"""Direct-streaming wiring tests for the data-parallel builder.
Mirrors the ``test_direct_streaming_*`` tests on ``TestBuildOpenaiApp``
but exercises ``build_dp_openai_app`` so that regressions in the DP
wiring (deployment class, default request router) are caught at CPU
unit-test speed instead of in GPU integration / release tests.
"""
@pytest.fixture
def llm_config(self):
return LLMConfig(
model_loading_config=ModelLoadingConfig(
model_id="test-model", model_source="test-source"
)
)
def _enable_direct_streaming(self, monkeypatch):
monkeypatch.setattr(
"ray.llm._internal.serve.serving_patterns.data_parallel.builder."
"RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING",
True,
)
def test_dp_builds_dpserver_ingress_with_router_attached(
self, llm_config, disable_placement_bundles, monkeypatch
):
self._enable_direct_streaming(monkeypatch)
app = build_dp_openai_app({"llm_config": llm_config})
ingress_request_router = app._ingress_request_router
assert app._bound_deployment.name == "DPServer:test-model"
assert issubclass(app._bound_deployment.func_or_class, ASGIAppReplicaWrapper)
assert issubclass(app._bound_deployment.func_or_class, DPServer)
assert ingress_request_router is not None
assert ingress_request_router._bound_deployment.name == "LLMRouter"
assert ingress_request_router._bound_deployment.init_kwargs["server"] is app
request_router_config = (
app._bound_deployment._deployment_config.request_router_config
)
assert request_router_config.request_router_class == (
f"{RoundRobinRouter.__module__}.{RoundRobinRouter.__name__}"
)
def test_dp_user_request_router_config_wins(
self, llm_config, disable_placement_bundles, monkeypatch
):
"""A user-supplied ``request_router_config`` on ``LLMConfig`` must
survive DP direct-streaming wiring rather than being overwritten with
the default ``RoundRobinRouter``.
"""
self._enable_direct_streaming(monkeypatch)
llm_config.deployment_config["request_router_config"] = RequestRouterConfig(
request_router_class=ConsistentHashRouter,
)
app = build_dp_openai_app({"llm_config": llm_config})
request_router_config = (
app._bound_deployment._deployment_config.request_router_config
)
assert request_router_config.request_router_class == (
f"{ConsistentHashRouter.__module__}.{ConsistentHashRouter.__name__}"
)
class TestDirectStreamingPD:
"""Direct-streaming wiring tests for the prefill/decode builder.
Covers the decode-class selection (``PDDecodeServer`` vs
``DPPDDecodeServer`` based on ``decode_dp_size``), the prefill binding
into decode's init kwargs, and the ``LLMRouter`` ingress-request-router
hookup.
"""
@pytest.fixture
def pd_configs(self):
"""Prefill and decode configs with required kv_transfer_config."""
base_config = {
"model_loading_config": {
"model_id": "test-model",
"model_source": "test-source",
},
"engine_kwargs": {
"kv_transfer_config": {
"kv_connector": "NixlConnector",
"kv_role": "kv_both",
},
},
}
prefill = LLMConfig.model_validate(base_config)
decode = LLMConfig.model_validate(base_config)
return prefill, decode
def _enable_direct_streaming(self, monkeypatch):
monkeypatch.setattr(
"ray.llm._internal.serve.serving_patterns.prefill_decode.builder."
"RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING",
True,
)
@staticmethod
def _set_dp_size(llm_config, size):
llm_config.engine_kwargs["data_parallel_size"] = size
@pytest.mark.parametrize(
("prefill_dp", "decode_dp", "expected_prefill_cls", "expected_decode_cls"),
[
(1, 1, PDPrefillServer, PDDecodeServer),
(1, 4, PDPrefillServer, DPPDDecodeServer),
(4, 1, DPPDPrefillServer, PDDecodeServer),
(4, 4, DPPDPrefillServer, DPPDDecodeServer),
],
)
def test_pd_decode_class_selection(
self,
pd_configs,
disable_placement_bundles,
monkeypatch,
prefill_dp,
decode_dp,
expected_prefill_cls,
expected_decode_cls,
):
"""Verify the DP-vs-non-DP variants are picked based on
``data_parallel_size`` for both prefill and decode legs.
"""
self._enable_direct_streaming(monkeypatch)
prefill, decode = pd_configs
self._set_dp_size(prefill, prefill_dp)
self._set_dp_size(decode, decode_dp)
app = build_pd_openai_app({"prefill_config": prefill, "decode_config": decode})
decode_deployment = app._bound_deployment
assert issubclass(decode_deployment.func_or_class, ASGIAppReplicaWrapper)
assert issubclass(decode_deployment.func_or_class, expected_decode_cls)
prefill_app = decode_deployment.init_kwargs["prefill_server"]
prefill_deployment = prefill_app._bound_deployment
assert prefill_deployment.func_or_class is expected_prefill_cls
def test_pd_ingress_request_router_is_llmrouter(
self, pd_configs, disable_placement_bundles, monkeypatch
):
self._enable_direct_streaming(monkeypatch)
prefill, decode = pd_configs
app = build_pd_openai_app({"prefill_config": prefill, "decode_config": decode})
ingress_request_router = app._ingress_request_router
assert ingress_request_router is not None
assert ingress_request_router._bound_deployment.name == "LLMRouter"
assert ingress_request_router._bound_deployment.init_kwargs["server"] is app
request_router_config = (
app._bound_deployment._deployment_config.request_router_config
)
assert request_router_config.request_router_class == (
f"{RoundRobinRouter.__module__}.{RoundRobinRouter.__name__}"
)
def test_pd_user_request_router_config_wins(
self, pd_configs, disable_placement_bundles, monkeypatch
):
"""A user-supplied ``request_router_config`` on the decode
``LLMConfig`` must survive PD direct-streaming wiring rather than
being overwritten with the default ``RoundRobinRouter``.
"""
self._enable_direct_streaming(monkeypatch)
prefill, decode = pd_configs
decode.deployment_config["request_router_config"] = RequestRouterConfig(
request_router_class=ConsistentHashRouter,
)
app = build_pd_openai_app({"prefill_config": prefill, "decode_config": decode})
request_router_config = (
app._bound_deployment._deployment_config.request_router_config
)
assert request_router_config.request_router_class == (
f"{ConsistentHashRouter.__module__}.{ConsistentHashRouter.__name__}"
)
class TestIngressScaleToZero:
"""Tests for ingress scale-to-zero behavior when all models have min_replicas=0."""
def test_all_models_scale_to_zero(self, disable_placement_bundles):
"""When all models have min_replicas=0, ingress should also have min_replicas=0."""
llm_cfg_dict_autoscaling = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id="model_a"),
accelerator_type="L4",
deployment_config={
"autoscaling_config": {
"min_replicas": 0,
"max_replicas": 2,
}
},
)
llm_cfg_obj_autoscaling = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id="model_b"),
accelerator_type="L4",
deployment_config={
"autoscaling_config": AutoscalingConfig(
min_replicas=0,
max_replicas=4,
)
},
)
app = build_openai_app(
LLMServingArgs(
llm_configs=[llm_cfg_dict_autoscaling, llm_cfg_obj_autoscaling],
)
)
autoscaling_config = app._bound_deployment._deployment_config.autoscaling_config
assert autoscaling_config.min_replicas == 0
def test_mixed_min_replicas_keeps_default(self, disable_placement_bundles):
"""When some models have min_replicas>0, ingress should keep default min_replicas."""
llm_cfg_zero = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id="model_a"),
accelerator_type="L4",
deployment_config={
"autoscaling_config": {
"min_replicas": 0,
"max_replicas": 2,
}
},
)
llm_cfg_nonzero = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id="model_b"),
accelerator_type="L4",
deployment_config={
"autoscaling_config": AutoscalingConfig(
min_replicas=1,
max_replicas=4,
)
},
)
app = build_openai_app(
LLMServingArgs(
llm_configs=[llm_cfg_zero, llm_cfg_nonzero],
)
)
autoscaling_config = app._bound_deployment._deployment_config.autoscaling_config
# Default min_replicas from AutoscalingConfig is 1
assert autoscaling_config.min_replicas == 1
def test_no_autoscaling_config_keeps_default(self, disable_placement_bundles):
"""When models don't have autoscaling_config, ingress should keep default."""
llm_cfg = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id="model_a"),
accelerator_type="L4",
)
app = build_openai_app(
LLMServingArgs(llm_configs=[llm_cfg]),
)
autoscaling_config = app._bound_deployment._deployment_config.autoscaling_config
assert autoscaling_config.min_replicas == 1
def test_user_override_takes_precedence(self, disable_placement_bundles):
"""User-specified ingress min_replicas should override scale-to-zero logic."""
llm_cfg = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id="model_a"),
accelerator_type="L4",
deployment_config={
"autoscaling_config": {
"min_replicas": 0,
"max_replicas": 2,
}
},
)
app = build_openai_app(
LLMServingArgs(
llm_configs=[llm_cfg],
ingress_deployment_config={
"autoscaling_config": {
"min_replicas": 3,
"max_replicas": 5,
}
},
)
)
autoscaling_config = app._bound_deployment._deployment_config.autoscaling_config
assert autoscaling_config.min_replicas == 3
def extract_applications_from_output(output: bytes) -> dict:
"""
Extracts the 'applications' block from mixed output and returns it as a dict.
"""
# 1. Decode bytes to string
text = output.decode("utf-8", errors="ignore")
# 2. Regex to find the 'applications:' block and its indented content
# This matches 'applications:' and all following lines that are indented (YAML block)
match = re.search(r"(^applications:\n(?:^(?: {2,}|\t).*\n?)+)", text, re.MULTILINE)
if not match:
raise ValueError("Could not find 'applications:' block in output.")
applications_block = match.group(1)
# 3. Parse the YAML block
applications_dict = yaml.safe_load(applications_block)
return applications_dict["applications"]
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,7 @@
applications:
- args:
llm_configs:
- ./model_config/llm_config.yaml
import_path: ray.serve.llm:build_openai_app
name: llm-endpoint
route_prefix: /
@@ -0,0 +1,2 @@
model_loading_config:
model_id: model1
@@ -0,0 +1,322 @@
"""Tests for DevIngress control plane endpoints.
This module tests the HTTP endpoints exposed by DevIngress:
- POST /sleep, POST /wakeup, GET /is_sleeping
- POST /pause, POST /resume, GET /is_paused
- POST /reset_prefix_cache
These tests verify:
1. Endpoints are correctly registered and accessible
2. Broadcast API correctly broadcasts to replicas
3. Sleep/wakeup and pause/resume isolation between different models
"""
import sys
import httpx
import pytest
import ray
from ray import serve
from ray.llm._internal.serve.core.configs.openai_api_models import to_model_metadata
from ray.llm._internal.serve.core.ingress.dev_ingress import DEV_ENDPOINTS, DevIngress
from ray.llm._internal.serve.core.ingress.ingress import make_fastapi_ingress
from ray.llm._internal.serve.core.server.llm_server import LLMServer
from ray.llm.tests.serve.mocks.mock_vllm_engine import MockVLLMEngine
from ray.serve.llm import LLMConfig, ModelLoadingConfig
@pytest.fixture(scope="module")
def ray_instance():
"""Initialize Ray for the module."""
if not ray.is_initialized():
ray.init()
yield
serve.shutdown()
ray.shutdown()
@pytest.fixture
def single_model_dev_ingress(ray_instance, disable_placement_bundles):
"""Start a Serve app with one model and DevIngress endpoints."""
model_id = "test-model-1"
llm_config = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id=model_id),
runtime_env={},
log_engine_metrics=False,
)
# Create LLMServer deployment with mock engine
llm_deployment = serve.deployment(LLMServer).bind(
llm_config, engine_cls=MockVLLMEngine
)
# Create DevIngress with the dev endpoints
ingress_cls = make_fastapi_ingress(DevIngress, endpoint_map=DEV_ENDPOINTS)
ingress_options = DevIngress.get_deployment_options([llm_config])
ingress_app = serve.deployment(ingress_cls, **ingress_options).bind(
llm_deployments={model_id: llm_deployment},
model_cards={model_id: to_model_metadata(model_id, llm_config)},
)
serve.run(ingress_app, name="single-model-app")
yield model_id
serve.delete("single-model-app", _blocking=True)
@pytest.fixture
def two_model_dev_ingress(ray_instance, disable_placement_bundles):
"""Start a Serve app with TWO model deployments to test isolation."""
model_id_1 = "test-model-1"
model_id_2 = "test-model-2"
llm_config_1 = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id=model_id_1),
runtime_env={},
log_engine_metrics=False,
)
llm_config_2 = LLMConfig(
model_loading_config=ModelLoadingConfig(model_id=model_id_2),
runtime_env={},
log_engine_metrics=False,
)
# Create LLMServer deployments with mock engine
llm_deployment_1 = serve.deployment(LLMServer).bind(
llm_config_1, engine_cls=MockVLLMEngine
)
llm_deployment_2 = serve.deployment(LLMServer).bind(
llm_config_2, engine_cls=MockVLLMEngine
)
# Create DevIngress with the dev endpoints
ingress_cls = make_fastapi_ingress(DevIngress, endpoint_map=DEV_ENDPOINTS)
ingress_options = DevIngress.get_deployment_options([llm_config_1, llm_config_2])
ingress_app = serve.deployment(ingress_cls, **ingress_options).bind(
llm_deployments={
model_id_1: llm_deployment_1,
model_id_2: llm_deployment_2,
},
model_cards={
model_id_1: to_model_metadata(model_id_1, llm_config_1),
model_id_2: to_model_metadata(model_id_2, llm_config_2),
},
)
serve.run(ingress_app, name="two-model-app")
yield model_id_1, model_id_2
serve.delete("two-model-app", _blocking=True)
class TestDevIngressEndpoints:
"""Test DevIngress endpoints."""
@pytest.mark.asyncio
async def test_reset_prefix_cache_endpoint(self, single_model_dev_ingress):
"""Test POST /reset_prefix_cache endpoint."""
model_id = single_model_dev_ingress
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
"http://localhost:8000/reset_prefix_cache",
json={"model": model_id},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_sleep_wakeup_cycle(self, single_model_dev_ingress):
"""Test full sleep -> is_sleeping -> wakeup -> is_sleeping cycle."""
model_id = single_model_dev_ingress
async with httpx.AsyncClient(timeout=60.0) as client:
# Initial state - should not be sleeping
response = await client.get(
f"http://localhost:8000/is_sleeping?model={model_id}",
)
assert response.status_code == 200
assert response.json().get("is_sleeping") is False
# Sleep the engine
response = await client.post(
"http://localhost:8000/sleep",
json={"model": model_id, "options": {"level": 1}},
)
assert response.status_code == 200
# Check is_sleeping - should be True
response = await client.get(
f"http://localhost:8000/is_sleeping?model={model_id}",
)
assert response.status_code == 200
assert response.json().get("is_sleeping") is True
# Wake up the engine
response = await client.post(
"http://localhost:8000/wakeup",
json={"model": model_id, "options": {}},
)
assert response.status_code == 200
# Check is_sleeping - should be False again
response = await client.get(
f"http://localhost:8000/is_sleeping?model={model_id}",
)
assert response.status_code == 200
assert response.json().get("is_sleeping") is False
@pytest.mark.asyncio
async def test_pause_resume_cycle(self, single_model_dev_ingress):
"""Test full pause -> is_paused -> resume -> is_paused cycle."""
model_id = single_model_dev_ingress
async with httpx.AsyncClient(timeout=60.0) as client:
# Initial state - should not be paused
response = await client.get(
f"http://localhost:8000/is_paused?model={model_id}",
)
assert response.status_code == 200
assert response.json().get("is_paused") is False
# Pause the engine
response = await client.post(
"http://localhost:8000/pause",
json={"model": model_id, "options": {"clear_cache": True}},
)
assert response.status_code == 200
# Check is_paused - should be True
response = await client.get(
f"http://localhost:8000/is_paused?model={model_id}",
)
assert response.status_code == 200
assert response.json().get("is_paused") is True
# Resume the engine
response = await client.post(
"http://localhost:8000/resume",
json={"model": model_id, "options": {}},
)
assert response.status_code == 200
# Check is_paused - should be False again
response = await client.get(
f"http://localhost:8000/is_paused?model={model_id}",
)
assert response.status_code == 200
assert response.json().get("is_paused") is False
class TestDevIngressModelIsolation:
"""Test that control plane operations are isolated per model."""
@pytest.mark.asyncio
async def test_sleep_wakeup_isolation(self, two_model_dev_ingress):
"""Test that sleeping model_1 does NOT affect model_2."""
model_1, model_2 = two_model_dev_ingress
async with httpx.AsyncClient(timeout=60.0) as client:
# Both models should start awake
response = await client.get(
f"http://localhost:8000/is_sleeping?model={model_1}",
)
assert response.json().get("is_sleeping") is False
response = await client.get(
f"http://localhost:8000/is_sleeping?model={model_2}",
)
assert response.json().get("is_sleeping") is False
# Sleep model_1 only
response = await client.post(
"http://localhost:8000/sleep",
json={"model": model_1, "options": {"level": 1}},
)
assert response.status_code == 200
# model_1 should be sleeping
response = await client.get(
f"http://localhost:8000/is_sleeping?model={model_1}",
)
assert response.json().get("is_sleeping") is True
# model_2 should NOT be sleeping
response = await client.get(
f"http://localhost:8000/is_sleeping?model={model_2}",
)
assert response.json().get("is_sleeping") is False
# Wake up model_1
response = await client.post(
"http://localhost:8000/wakeup",
json={"model": model_1, "options": {}},
)
assert response.status_code == 200
# Both should now be awake
response = await client.get(
f"http://localhost:8000/is_sleeping?model={model_1}",
)
assert response.json().get("is_sleeping") is False
response = await client.get(
f"http://localhost:8000/is_sleeping?model={model_2}",
)
assert response.json().get("is_sleeping") is False
@pytest.mark.asyncio
async def test_pause_resume_isolation(self, two_model_dev_ingress):
"""Test that pausing model_1 does NOT affect model_2."""
model_1, model_2 = two_model_dev_ingress
async with httpx.AsyncClient(timeout=60.0) as client:
# Both models should start unpaused
response = await client.get(
f"http://localhost:8000/is_paused?model={model_1}",
)
assert response.json().get("is_paused") is False
response = await client.get(
f"http://localhost:8000/is_paused?model={model_2}",
)
assert response.json().get("is_paused") is False
# Pause model_1 only
response = await client.post(
"http://localhost:8000/pause",
json={"model": model_1, "options": {"clear_cache": True}},
)
assert response.status_code == 200
# model_1 should be paused
response = await client.get(
f"http://localhost:8000/is_paused?model={model_1}",
)
assert response.json().get("is_paused") is True
# model_2 should NOT be paused
response = await client.get(
f"http://localhost:8000/is_paused?model={model_2}",
)
assert response.json().get("is_paused") is False
# Resume model_1
response = await client.post(
"http://localhost:8000/resume",
json={"model": model_1, "options": {}},
)
assert response.status_code == 200
# Both should now be unpaused
response = await client.get(
f"http://localhost:8000/is_paused?model={model_1}",
)
assert response.json().get("is_paused") is False
response = await client.get(
f"http://localhost:8000/is_paused?model={model_2}",
)
assert response.json().get("is_paused") is False
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,163 @@
"""Tests for make_fastapi_ingress function."""
import inspect
import sys
import pytest
from fastapi import FastAPI, Request
from fastapi.routing import APIRoute
from ray.llm._internal.serve.core.ingress.ingress import (
DEFAULT_ENDPOINTS,
OpenAiIngress,
make_fastapi_ingress,
)
class TestMakeFastapiIngress:
"""Test suite for make_fastapi_ingress."""
def test_subclass_inherits_endpoints(self):
"""Test that subclassing OpenAiIngress works with make_fastapi_ingress."""
class MyCustomIngress(OpenAiIngress):
"""Custom ingress that inherits all OpenAI endpoints."""
pass
app = FastAPI()
# Create the ingress class - should not raise
ingress_cls = make_fastapi_ingress(MyCustomIngress, app=app)
# Verify the ingress class was created successfully
assert ingress_cls is not None
# Verify routes are registered (inherited from OpenAiIngress)
route_paths = [
route.path for route in app.routes if isinstance(route, APIRoute)
]
assert "/v1/models" in route_paths
assert "/v1/completions" in route_paths
def test_subclass_with_custom_method(self):
"""Test that custom methods added by subclass are also properly handled."""
class MyCustomIngress(OpenAiIngress):
"""Custom ingress with an additional endpoint."""
async def custom_endpoint(self, request: Request):
"""A custom endpoint added by the subclass."""
return {"status": "ok"}
custom_endpoints = {
"custom_endpoint": lambda app: app.post("/custom"),
**DEFAULT_ENDPOINTS,
}
app = FastAPI()
ingress_cls = make_fastapi_ingress(
MyCustomIngress, endpoint_map=custom_endpoints, app=app
)
# Verify the class was created and the custom route is registered
assert ingress_cls is not None
route_paths = [
route.path for route in app.routes if isinstance(route, APIRoute)
]
assert "/custom" in route_paths
def test_routes_registered_correctly(self):
"""Test that routes are registered with the FastAPI app."""
class MyCustomIngress(OpenAiIngress):
pass
app = FastAPI()
make_fastapi_ingress(MyCustomIngress, app=app)
# Get all registered routes
route_paths = [
route.path for route in app.routes if isinstance(route, APIRoute)
]
# Check that default endpoints are registered
assert "/v1/models" in route_paths
assert "/v1/completions" in route_paths
assert "/v1/chat/completions" in route_paths
def test_custom_endpoint_map_overrides_defaults(self):
"""Test that custom endpoint_map can override default endpoints."""
class MyCustomIngress(OpenAiIngress):
async def models(self):
"""Override the models endpoint."""
return {"custom": True}
# Only register models endpoint with a custom path
custom_endpoints = {
"models": lambda app: app.get("/custom/models"),
}
app = FastAPI()
make_fastapi_ingress(MyCustomIngress, endpoint_map=custom_endpoints, app=app)
route_paths = [
route.path for route in app.routes if isinstance(route, APIRoute)
]
# Should have custom path, not default
assert "/custom/models" in route_paths
assert "/v1/models" not in route_paths
def test_deeply_nested_inheritance(self):
"""Test that deeply nested inheritance works correctly."""
class IntermediateIngress(OpenAiIngress):
"""Intermediate class in inheritance chain."""
async def intermediate_method(self, request: Request):
return {"level": "intermediate"}
class FinalIngress(IntermediateIngress):
"""Final class in inheritance chain."""
async def final_method(self, request: Request):
return {"level": "final"}
custom_endpoints = {
"intermediate_method": lambda app: app.post("/intermediate"),
"final_method": lambda app: app.post("/final"),
**DEFAULT_ENDPOINTS,
}
app = FastAPI()
make_fastapi_ingress(FinalIngress, endpoint_map=custom_endpoints, app=app)
# Verify all routes are registered
route_paths = [
route.path for route in app.routes if isinstance(route, APIRoute)
]
assert "/intermediate" in route_paths
assert "/final" in route_paths
assert "/v1/completions" in route_paths
def test_method_signature_preserved(self):
"""Test that method signatures are preserved after decoration."""
class MyCustomIngress(OpenAiIngress):
pass
ingress_cls = make_fastapi_ingress(MyCustomIngress)
# Get the completions method and check its signature
completions_method = ingress_cls.completions
sig = inspect.signature(completions_method)
param_names = list(sig.parameters.keys())
# Should have 'self' and 'body' parameters
assert "self" in param_names
assert "body" in param_names
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,563 @@
import sys
from contextlib import asynccontextmanager
from types import SimpleNamespace
from typing import Optional
from unittest.mock import AsyncMock, MagicMock, patch
import openai
import pytest
from fastapi import HTTPException
from starlette.datastructures import Headers
from ray import serve
from ray.llm._internal.serve.core.configs.llm_config import (
LLMConfig,
ModelLoadingConfig,
)
from ray.llm._internal.serve.core.configs.openai_api_models import to_model_metadata
from ray.llm._internal.serve.core.ingress import router as router_module
from ray.llm._internal.serve.core.ingress.ingress import (
OpenAiIngress,
make_fastapi_ingress,
)
from ray.llm._internal.serve.core.ingress.router import (
LLMRouter,
_parse_routing_payload,
)
from ray.llm._internal.serve.core.server.llm_server import LLMServer
from ray.llm.tests.serve.mocks.mock_vllm_engine import MockVLLMEngine
from ray.serve._private.common import DeploymentID
from ray.serve.exceptions import DeploymentUnavailableError
class _DirectRouterReplicaId:
def __init__(self, unique_id: str, full_id: Optional[str] = None):
self.unique_id = unique_id
self._full_id = full_id or unique_id
def to_full_id_str(self) -> str:
return self._full_id
class _FakeRequest:
def __init__(self, body: bytes, headers: Optional[dict] = None):
self._body = body
self.headers = Headers(headers or {})
async def body(self) -> bytes:
return self._body
class _DirectRouterReplica:
"""RunningReplica stand-in for ``LLMRouter._pick_replica`` tests."""
def __init__(
self,
unique_id: str,
full_id: Optional[str] = None,
endpoint: Optional[tuple] = ("127.0.0.1", 8000),
):
self.replica_id = _DirectRouterReplicaId(unique_id, full_id)
self.backend_http_endpoint = endpoint
def _new_direct_router(handle=None):
router = LLMRouter.__new__(LLMRouter)
router._handle = handle or MagicMock()
# Routing tests don't exercise tokenization; that lives in test_tokenizer.py.
router._tokenizer = None
return router
def _selection_for(replica):
"""Build a ``ReplicaSelection``-shaped mock that ``_pick_replica`` reads."""
return MagicMock(replica_id=replica.replica_id.unique_id, _replica=replica)
def _choose_replica_returning(*replicas):
"""Patch ``handle.choose_replica`` to yield the given replicas in order.
Each call to ``choose_replica`` consumes one replica from the sequence and
yields its ``_DirectRouterReplica`` wrapped as a selection.
"""
selections = iter(_selection_for(r) for r in replicas)
@asynccontextmanager
async def fake_choose_replica(*args, **kwargs):
yield next(selections)
return fake_choose_replica
@pytest.fixture(name="llm_config")
def create_llm_config(stream_batching_interval_ms: Optional[int] = None):
if stream_batching_interval_ms is not None:
return LLMConfig(
model_loading_config=ModelLoadingConfig(
model_id="llm_model_id",
),
experimental_configs={
"stream_batching_interval_ms": stream_batching_interval_ms,
},
)
else:
return LLMConfig(
model_loading_config=ModelLoadingConfig(
model_id="llm_model_id",
),
)
@pytest.fixture(name="client")
def create_oai_client(llm_config: LLMConfig):
ServerDeployment = serve.deployment(LLMServer)
ingress_options = OpenAiIngress.get_deployment_options(llm_configs=[llm_config])
ingress_cls = make_fastapi_ingress(OpenAiIngress)
RouterDeployment = serve.deployment(ingress_cls, **ingress_options)
server = ServerDeployment.bind(llm_config, engine_cls=MockVLLMEngine)
router = RouterDeployment.bind(
llm_deployments={llm_config.model_id: server},
model_cards={
llm_config.model_id: to_model_metadata(llm_config.model_id, llm_config)
},
)
serve.run(router)
client = openai.Client(base_url="http://localhost:8000/v1", api_key="foo")
yield client
serve.shutdown()
class TestDirectStreamingLLMRouter:
@pytest.mark.asyncio
async def test_route_parses_body_into_routing_payload(self):
"""A parseable body becomes a routing payload passed positionally."""
router = _new_direct_router()
router._pick_replica = AsyncMock(
return_value=("127.0.0.1", 9001, "DeploymentName#replica")
)
body = b'{"model":"x","messages":[{"role":"user","content":"hi"}]}'
request = _FakeRequest(body)
result = await router.route(request)
assert result == {
"host": "127.0.0.1",
"port": 9001,
"replica_id": "DeploymentName#replica",
}
_, kwargs = router._pick_replica.call_args
assert kwargs["handle"] is router._handle
payload = kwargs["routing_payload"]
assert isinstance(payload, SimpleNamespace)
assert payload.messages == [{"role": "user", "content": "hi"}]
# The whole body is exposed, so a router can read any field.
assert payload.model == "x"
assert not hasattr(payload, "prompt")
# A parseable body must not trip the "no routing key" warning.
assert router._warned_no_routing_key is False
@pytest.mark.asyncio
async def test_route_truncated_body_yields_no_payload_and_warns_once(self):
"""A truncated body derives no key. ``route`` forwards ``None`` and
warns once per replica."""
router = _new_direct_router()
router._pick_replica = AsyncMock(
return_value=("127.0.0.1", 9001, "DeploymentName#replica")
)
# Truncated prefix is not valid JSON so json.loads fails.
body = b'{"model":"x","prompt":"' + (b"x" * 1024)
request = _FakeRequest(body, headers={"x-body-truncated": "1058/90000"})
with patch.object(router_module.logger, "warning") as mock_warning:
await router.route(request)
await router.route(request)
# routing_payload is None on both calls. Warning fires once.
for call in router._pick_replica.call_args_list:
assert call.kwargs["routing_payload"] is None
assert mock_warning.call_count == 1
assert router._warned_no_routing_key is True
@pytest.mark.asyncio
async def test_route_returns_503_on_pick_failure(self):
router = _new_direct_router()
router._pick_replica = AsyncMock(side_effect=RuntimeError("no replicas"))
with pytest.raises(HTTPException) as exc_info:
await router.route(_FakeRequest(b"{}"))
assert exc_info.value.status_code == 503
assert "no replicas" in exc_info.value.detail
@pytest.mark.asyncio
async def test_route_returns_400_on_bad_routing_request(self):
router = _new_direct_router()
router._pick_replica = AsyncMock(side_effect=ValueError("empty prompt"))
with pytest.raises(HTTPException) as exc_info:
await router.route(_FakeRequest(b"{}"))
assert exc_info.value.status_code == 400
assert "empty prompt" in exc_info.value.detail
@pytest.mark.asyncio
async def test_route_returns_503_on_deployment_unavailable(self):
err = DeploymentUnavailableError(DeploymentID(name="LLMServer:test"))
router = _new_direct_router()
router._pick_replica = AsyncMock(side_effect=err)
with pytest.raises(HTTPException) as exc_info:
await router.route(_FakeRequest(b"{}"))
assert exc_info.value.status_code == 503
assert "LLMServer:test" in exc_info.value.detail
@pytest.mark.asyncio
async def test_pick_replica_returns_backend_endpoint_from_handle(self):
"""``_pick_replica`` reads the endpoint off the selection's replica."""
replica = _DirectRouterReplica(
"r1",
full_id="DeploymentName#r1",
endpoint=("10.0.0.1", 8123),
)
handle = MagicMock()
handle.choose_replica = _choose_replica_returning(replica)
router = _new_direct_router(handle)
host, port, replica_id = await router._pick_replica(handle=handle)
assert (host, port, replica_id) == ("10.0.0.1", 8123, "DeploymentName#r1")
@pytest.mark.asyncio
async def test_pick_replica_forwards_payload_positionally(self):
"""A routing payload reaches ``choose_replica`` as the first positional
arg, alongside the ``_reserve=False`` fast-path flag."""
replica = _DirectRouterReplica("r1", full_id="d#r1")
captured = {}
@asynccontextmanager
async def fake_choose_replica(*args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
yield _selection_for(replica)
handle = MagicMock()
handle.choose_replica = fake_choose_replica
router = _new_direct_router(handle)
payload = SimpleNamespace(messages=[{"role": "user", "content": "hi"}])
await router._pick_replica(handle=handle, routing_payload=payload)
assert captured["args"] == (payload,)
assert captured["kwargs"] == {"_reserve": False}
@pytest.mark.asyncio
async def test_pick_replica_omits_positional_arg_when_no_payload(self):
"""With no routing payload, nothing is forwarded positionally. The
configured router then sees empty args and load-balances."""
replica = _DirectRouterReplica("r1", full_id="d#r1")
captured = {}
@asynccontextmanager
async def fake_choose_replica(*args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
yield _selection_for(replica)
handle = MagicMock()
handle.choose_replica = fake_choose_replica
router = _new_direct_router(handle)
await router._pick_replica(handle=handle, routing_payload=None)
assert captured["args"] == ()
assert captured["kwargs"] == {"_reserve": False}
@pytest.mark.asyncio
async def test_pick_replica_raises_when_endpoint_missing(self):
"""If the picked replica has no backend HTTP endpoint, surface a 503
via ``RuntimeError`` (same error contract as before)."""
replica = _DirectRouterReplica("r1", endpoint=None)
handle = MagicMock()
handle.choose_replica = _choose_replica_returning(replica)
router = _new_direct_router(handle)
with pytest.raises(RuntimeError, match="no backend HTTP endpoint"):
await router._pick_replica(handle=handle)
class TestRoutingPayload:
"""Unit coverage for wrapping a body as a routing namespace."""
def test_parses_chat_messages(self):
body = b'{"model":"x","messages":[{"role":"user","content":"hi"}]}'
payload = _parse_routing_payload(body)
assert isinstance(payload, SimpleNamespace)
assert payload.messages == [{"role": "user", "content": "hi"}]
# A chat body exposes no `prompt`, so `_extract_text_from_request`
# resolves it as a chat request. Other fields are still exposed.
assert not hasattr(payload, "prompt")
assert payload.model == "x"
def test_parses_completion_prompt(self):
payload = _parse_routing_payload(b'{"model":"x","prompt":"hello"}')
assert isinstance(payload, SimpleNamespace)
assert payload.prompt == "hello"
assert not hasattr(payload, "messages")
@pytest.mark.parametrize(
"body",
[
b"", # empty
b'{"model":"x","prompt":"' + (b"x" * 64), # truncated, invalid JSON
b"not json", # unparseable
b"[1, 2, 3]", # valid JSON but not an object
b'{"model":"x","max_tokens":8}', # object without messages or prompt
b'{"messages":[]}', # empty messages carry no routing signal
b'{"prompt":""}', # empty prompt carries no routing signal
b'{"model":"x","input":"hello"}', # other request type, no routing key
],
)
def test_returns_none_when_no_key_derivable(self, body):
assert _parse_routing_payload(body) is None
@pytest.mark.asyncio
async def test_payload_satisfies_prefix_router_contract(self):
"""The normalized payload is read by the real
``PrefixCacheAffinityRouter._extract_text_from_request``, the consumer
that regressed in #64326.
Async so a running event loop exists for the ``PendingRequest`` default
``asyncio.Future``.
"""
from ray.llm._internal.serve.routing_policies.prefix_aware.prefix_aware_router import ( # noqa: E501
PrefixCacheAffinityRouter,
)
from ray.serve._private.request_router.common import PendingRequest
# __new__ avoids the tree-actor setup in __init__. The method under test
# only uses self for the pure `_normalize_prompt_to_string` helper.
router = PrefixCacheAffinityRouter.__new__(PrefixCacheAffinityRouter)
chat = _parse_routing_payload(
b'{"messages":[{"role":"user","content":"hello world"}]}'
)
pr = PendingRequest(args=[chat], kwargs={}, metadata=MagicMock())
assert router._extract_text_from_request(pr) == "hello world"
completion = _parse_routing_payload(b'{"prompt":"hello world"}')
pr = PendingRequest(args=[completion], kwargs={}, metadata=MagicMock())
assert router._extract_text_from_request(pr) == "hello world"
class TestOpenAiIngress:
@pytest.mark.parametrize("stream_batching_interval_ms", [None, 0, 10000])
@pytest.mark.parametrize("stream", [True, False])
@pytest.mark.asyncio
async def test_chat(self, stream_batching_interval_ms, client, stream):
"""Tests chat streaming with different stream_batching_interval_ms values.
0ms super fast batching (no batching)
10000ms basically should be equivalent to non-streaming
None is default, which is some fixed non-zero value.
"""
# Generate 1000 chunks
n_tokens = 1000
response = client.chat.completions.create(
model="llm_model_id",
messages=[dict(role="user", content="Hello")],
stream=stream,
max_tokens=n_tokens,
)
if stream:
text = ""
role = None
for chunk in response:
if chunk.choices[0].delta.role is not None and role is None:
role = chunk.choices[0].delta.role
if chunk.choices[0].delta.content:
text += chunk.choices[0].delta.content
else:
text = response.choices[0].message.content
role = response.choices[0].message.role
assert role == "assistant"
assert text.strip() == " ".join([f"test_{i}" for i in range(n_tokens)])
@pytest.mark.parametrize("stream_batching_interval_ms", [None, 0, 10000])
@pytest.mark.parametrize("stream", [True, False])
@pytest.mark.asyncio
async def test_completion(self, stream_batching_interval_ms, client, stream):
"""Tests text completions streaming with different stream_batching_interval_ms values."""
# Generate tokens
n_tokens = 1000
response = client.completions.create(
model="llm_model_id",
prompt="Hello",
stream=stream,
max_tokens=n_tokens,
)
if stream:
text = ""
for chunk in response:
text += chunk.choices[0].text
else:
text = response.choices[0].text
# The mock engine produces "test_0 test_1 test_2 ..." pattern
expected_text = " ".join([f"test_{i}" for i in range(n_tokens)])
assert text.strip() == expected_text
@pytest.mark.parametrize("stream", [True, False])
@pytest.mark.asyncio
async def test_tool_call(self, client, stream):
response = client.chat.completions.create(
model="llm_model_id",
messages=[
{
"role": "user",
"content": "Can you tell me what the temperate will be in Dallas, in fahrenheit?",
},
{
"content": None,
"role": "assistant",
"tool_calls": [
{
"id": "RBS92VTjJ",
"function": {
"arguments": '{"city": "Dallas", "state": "TX", "unit": "fahrenheit"}',
"name": "get_current_weather",
},
"type": "function",
}
],
},
{
"role": "tool",
"content": "The weather in Dallas, TX is 85 degrees fahrenheit. It is partly cloudly, with highs in the 90's.",
"tool_call_id": "n3OMUpydP",
},
],
stream=stream,
max_tokens=200,
)
if stream:
text = ""
role = None
for chunk in response:
if chunk.choices[0].delta.role is not None and role is None:
role = chunk.choices[0].delta.role
if chunk.choices[0].delta.content:
text += chunk.choices[0].delta.content
else:
text = response.choices[0].message.content
role = response.choices[0].message.role
assert text
@pytest.mark.asyncio
async def test_check_health(self, llm_config: LLMConfig):
"""Test health check functionality."""
server = MagicMock()
server.check_health = MagicMock()
server.check_health.remote = AsyncMock()
router = OpenAiIngress(
llm_deployments={llm_config.model_id: server},
model_cards={
llm_config.model_id: to_model_metadata(llm_config.model_id, llm_config)
},
)
await router.check_health()
@pytest.mark.asyncio
async def test_raw_request_info_passed_to_deployment_handle(
self, llm_config: LLMConfig
):
"""Test that raw_request_info is passed to the deployment handle."""
from ray.llm._internal.serve.core.configs.openai_api_models import (
ChatCompletionRequest,
ChatCompletionResponse,
)
from ray.llm._internal.serve.core.protocol import RawRequestInfo
# Track if raw_request_info was received
captured_raw_request_infos = []
# Create a mock deployment handle that captures raw_request_info
async def mock_chat_generator(request, raw_request_info):
captured_raw_request_infos.append(raw_request_info)
# Return a valid response
yield ChatCompletionResponse(
id="test_id",
choices=[
{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop",
}
],
model="llm_model_id",
object="chat.completion",
usage={
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
)
mock_handle = MagicMock()
mock_handle.chat = MagicMock()
mock_handle.chat.remote = mock_chat_generator
# Make options() return the same mock so chat.remote is preserved
mock_handle.options.return_value = mock_handle
# Create router with mock handle
router = OpenAiIngress(
llm_deployments={llm_config.model_id: mock_handle},
model_cards={
llm_config.model_id: to_model_metadata(llm_config.model_id, llm_config)
},
)
# Create a mock FastAPI request
from starlette.datastructures import Headers
mock_request = MagicMock()
mock_headers = {
"content-type": "application/json",
"x-ray-serve-llm-test-header": "router-raw-request-info",
}
mock_request.headers = Headers(mock_headers)
# Make a request through the router
request_body = ChatCompletionRequest(
model="llm_model_id",
messages=[{"role": "user", "content": "Hello"}],
stream=False,
)
await router.chat(request_body, mock_request)
# Verify that raw_request_info was passed to the deployment handle
assert len(captured_raw_request_infos) == 1
assert isinstance(captured_raw_request_infos[0], RawRequestInfo)
assert captured_raw_request_infos[0].headers == mock_headers
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,65 @@
import sys
import pytest
from ray.llm._internal.serve.core.configs.llm_config import (
LLMConfig,
ModelLoadingConfig,
)
from ray.llm._internal.serve.core.ingress.builder import (
LLMServingArgs,
build_openai_app,
)
from ray.llm.tests.serve.cpu.deployments.utils.direct_streaming_utils import (
consistent_hash_deployment_config,
requires_direct_streaming,
run_app_through_haproxy,
session_chat_response,
)
@requires_direct_streaming
class TestDirectStreamingConsistentHashRouting:
"""Session affinity over the full direct-streaming path.
A request flows through HAProxy and the LLMRouter ``/internal/route``
decision (ConsistentHashRouter) to a backend replica. The session id
reaches the chosen replica, and one session pins to one replica.
"""
@pytest.fixture(name="llm_config")
def _llm_config(self):
return LLMConfig(model_loading_config=ModelLoadingConfig(model_id="test-model"))
@pytest.fixture(name="base_url")
def run_direct_streaming_app(
self,
llm_config_with_mock_engine,
shutdown_ray_and_serve,
disable_placement_bundles,
):
llm_config = llm_config_with_mock_engine
llm_config.deployment_config = consistent_hash_deployment_config()
yield run_app_through_haproxy(
build_openai_app(LLMServingArgs(llm_configs=[llm_config]))
)
def test_session_affinity(self, base_url):
replicas = {
session_chat_response(base_url, "test-session-id").headers["x-replica-id"]
for _ in range(10)
}
assert len(replicas) == 1
def test_different_sessions_spread(self, base_url):
replicas = {
session_chat_response(base_url, f"test-session-id-{i}").headers[
"x-replica-id"
]
for i in range(10)
}
assert len(replicas) > 1
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,282 @@
import sys
from typing import Optional
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from starlette.datastructures import Headers
from ray.llm._internal.serve.core.configs.llm_config import LLMConfig
from ray.llm._internal.serve.core.configs.openai_api_models import (
ErrorInfo,
ErrorResponse,
TokenizeChatRequest,
TokenizeCompletionRequest,
)
from ray.llm._internal.serve.core.ingress.builder import (
LLMServingArgs,
build_openai_app,
)
from ray.llm._internal.serve.core.ingress.router import LLMRouter
from ray.llm._internal.serve.core.ingress.tokenizer import TokenizeError, Tokenizer
from ray.serve.experimental.round_robin_router import RoundRobinRouter
from ray.serve.llm.request_router import KVAwareRouter
class _TokenizeResponse:
def __init__(self, tokens):
self.tokens = tokens
async def _tokenize_stream(response):
yield response
def _handle_returning(response):
"""A DeploymentHandle whose /tokenize streams ``response``; captures the
Tokenize* request it was called with under ``captured``."""
captured = {}
def tokenize_remote(tok_req, _):
captured["request"] = tok_req
return _tokenize_stream(response)
handle = MagicMock()
handle.options.return_value.tokenize.remote = tokenize_remote
return handle, captured
class TestTokenizer:
@pytest.mark.parametrize(
"payload",
[
{"model": "m", "prompt": ["a", "b"]}, # batch of prompts
{"model": "m", "prompt": [1, 2, 3]}, # pre-tokenized token ids
{"model": "m"}, # neither messages nor prompt
],
)
@pytest.mark.asyncio
async def test_untokenizable_payload_returns_none(self, payload):
"""A parsed payload with no single-string prompt yields None."""
assert await Tokenizer(MagicMock()).tokenize(payload) is None
@pytest.mark.parametrize(
"payload, expected_request_type",
[
(
{"model": "m", "messages": [{"role": "user", "content": "hi"}]},
TokenizeChatRequest,
),
({"model": "m", "prompt": "hello"}, TokenizeCompletionRequest),
],
)
@pytest.mark.asyncio
async def test_tokenizes_chat_and_completion(self, payload, expected_request_type):
"""A chat or completion payload is sent to /tokenize as the right
Tokenize* request and its returned token ids are surfaced."""
handle, captured = _handle_returning(_TokenizeResponse([5, 6, 7]))
tokens = await Tokenizer(handle).tokenize(payload)
assert tokens == [5, 6, 7]
assert isinstance(captured["request"], expected_request_type)
@pytest.mark.parametrize(
"payload, expected",
[
( # chat: template-rendering fields + request-provided prompt flags
{
"model": "m",
"messages": [{"role": "user", "content": "hi"}],
"tools": [
{
"type": "function",
"function": {"name": "f", "parameters": {}},
}
],
"chat_template": "TEMPLATE",
"chat_template_kwargs": {"enable_thinking": False},
"mm_processor_kwargs": {"num_crops": 4},
"add_generation_prompt": False,
"continue_final_message": True,
"temperature": 0.7,
},
{
"chat_template": "TEMPLATE",
"chat_template_kwargs": {"enable_thinking": False},
"mm_processor_kwargs": {"num_crops": 4},
"add_generation_prompt": False,
"continue_final_message": True,
},
),
( # completion: add_special_tokens comes from the request
{
"model": "m",
"prompt": "hi",
"add_special_tokens": False,
"temperature": 0.7,
},
{"add_special_tokens": False},
),
],
)
@pytest.mark.asyncio
async def test_forwards_prompt_fields_only(self, payload, expected):
"""Prompt-rendering fields come from the request (not hardcoded) and
sampling params are dropped, so routing ids match prefill."""
handle, captured = _handle_returning(_TokenizeResponse([1, 2]))
await Tokenizer(handle).tokenize(payload)
request = captured["request"]
for attr, value in expected.items():
assert getattr(request, attr) == value
assert "temperature" not in (request.model_extra or {})
@pytest.mark.asyncio
async def test_error_response_raises(self):
"""A /tokenize ErrorResponse surfaces as a TokenizeError carrying vLLM's
status code, message, and type."""
err = ErrorResponse(
error=ErrorInfo(message="bad model", type="NotFoundError", code=404)
)
handle, _ = _handle_returning(err)
with pytest.raises(TokenizeError) as exc_info:
await Tokenizer(handle).tokenize({"model": "m", "prompt": "hi"})
assert exc_info.value.status_code == 404
assert exc_info.value.message == "bad model"
assert exc_info.value.type == "NotFoundError"
@pytest.mark.asyncio
async def test_empty_response_raises(self):
"""An empty /tokenize stream raises rather than returning no tokens."""
async def _empty(*_args):
for _ in ():
yield
handle = MagicMock()
handle.options.return_value.tokenize.remote = _empty
with pytest.raises(TokenizeError) as exc_info:
await Tokenizer(handle).tokenize({"model": "m", "prompt": "hi"})
assert exc_info.value.status_code == 500
class TestRoute:
@pytest.mark.asyncio
async def test_no_tokenizer_forwards_none(self):
# A non-KV router has no tokenizer, so route forwards request_token_ids=None.
router = LLMRouter.__new__(LLMRouter)
router._handle = MagicMock()
router._tokenizer = None
router._pick_replica = AsyncMock(return_value=("h", 1, "rid"))
request = MagicMock()
request.body = AsyncMock(return_value=b'{"model": "m", "prompt": "hi"}')
request.headers = Headers({})
await router.route(request)
assert router._pick_replica.call_args.kwargs["request_token_ids"] is None
@pytest.mark.asyncio
async def test_forwards_token_ids(self):
# A successful tokenization forwards its token ids to _pick_replica.
router = LLMRouter.__new__(LLMRouter)
router._handle = MagicMock()
router._tokenizer = MagicMock()
router._tokenizer.tokenize = AsyncMock(return_value=[5, 6, 7])
router._pick_replica = AsyncMock(return_value=("h", 1, "rid"))
request = MagicMock()
request.body = AsyncMock(return_value=b'{"model": "m", "prompt": "hi"}')
request.headers = Headers({})
await router.route(request)
assert router._pick_replica.call_args.kwargs["request_token_ids"] == [5, 6, 7]
@pytest.mark.asyncio
async def test_unparseable_body_skips_tokenization(self):
# A truncated/unparseable body derives no routing payload, so the
# tokenizer is never called and request_token_ids stays None.
router = LLMRouter.__new__(LLMRouter)
router._handle = MagicMock()
router._tokenizer = MagicMock()
router._tokenizer.tokenize = AsyncMock(return_value=[5, 6, 7])
router._pick_replica = AsyncMock(return_value=("h", 1, "rid"))
request = MagicMock()
# Truncated prefix: not valid JSON, so it can't be parsed or tokenized.
request.body = AsyncMock(return_value=b'{"model": "m", "prompt": "' + b"x" * 8)
request.headers = Headers({"x-body-truncated": "8/90000"})
await router.route(request)
router._tokenizer.tokenize.assert_not_called()
assert router._pick_replica.call_args.kwargs["request_token_ids"] is None
@pytest.mark.asyncio
async def test_tokenize_error_becomes_http_error(self):
# A /tokenize rejection becomes an HTTPException with the same status
# code, and routing is not attempted.
router = LLMRouter.__new__(LLMRouter)
router._handle = MagicMock()
router._tokenizer = MagicMock()
router._tokenizer.tokenize = AsyncMock(
side_effect=TokenizeError(
"bad model", status_code=404, type="NotFoundError"
)
)
router._pick_replica = AsyncMock()
request = MagicMock()
request.body = AsyncMock(return_value=b'{"model": "m", "prompt": "hi"}')
request.headers = Headers({})
with pytest.raises(HTTPException) as exc_info:
await router.route(request)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "bad model"
router._pick_replica.assert_not_called()
def _build_llm_app(request_router_class):
"""Build a direct-streaming OpenAI app, optionally pinning a router class."""
deployment_config = {"autoscaling_config": {"min_replicas": 1, "max_replicas": 1}}
if request_router_class is not None:
deployment_config["request_router_config"] = {
"request_router_class": request_router_class
}
llm_config = LLMConfig(
model_loading_config={
"model_id": "qwen3-0.6b",
"model_source": "Qwen/Qwen3-0.6B",
},
accelerator_type=None,
deployment_config=deployment_config,
)
return build_openai_app(LLMServingArgs(llm_configs=[llm_config]))
def _pre_routing_tokenization(app) -> Optional[bool]:
init_kwargs = app._ingress_request_router._bound_deployment.init_kwargs
return init_kwargs["pre_routing_tokenization"]
class TestPreRoutingTokenization:
"""build_openai_app enables pre-routing tokenization iff the router is KV-aware."""
@pytest.fixture(autouse=True)
def enable_direct_streaming(self, monkeypatch):
monkeypatch.setattr(
"ray.llm._internal.serve.core.ingress.builder."
"RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING",
True,
)
@pytest.mark.parametrize(
"request_router_class, expected",
[
(KVAwareRouter, True),
(None, False),
(RoundRobinRouter, False),
],
)
def test_enabled_only_for_kv_aware_router(self, request_router_class, expected):
app = _build_llm_app(request_router_class)
assert _pre_routing_tokenization(app) is expected
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))