Signed-off-by: maithilijoshi20 <97733343+maithilijoshi20@users.noreply.github.com>
235 lines
9.2 KiB
Python
235 lines
9.2 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
|
|
"""Serving layer for robot policy inference via `/v1/realtime/robot/openpi`.
|
|
|
|
Flow: raw obs → engine request → actions.
|
|
The loaded policy model owns dataset transforms inside its pipeline.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from itertools import count
|
|
from typing import Any, TypeAlias
|
|
|
|
import numpy as np
|
|
from omegaconf import OmegaConf
|
|
from vllm.logger import init_logger
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
ActionOutput: TypeAlias = np.ndarray | dict[str, np.ndarray]
|
|
|
|
|
|
def _to_builtin_container(value: Any) -> Any:
|
|
if OmegaConf.is_config(value):
|
|
return OmegaConf.to_container(value, resolve=True)
|
|
if isinstance(value, Mapping):
|
|
return {key: _to_builtin_container(item) for key, item in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_to_builtin_container(item) for item in value]
|
|
return value
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PolicyServerConfig:
|
|
"""OpenPI policy server handshake config.
|
|
|
|
Values are model-specific and must be provided by the loaded policy model.
|
|
"""
|
|
|
|
values: dict[str, Any]
|
|
|
|
@classmethod
|
|
def from_model_config(cls, model_config: Any) -> PolicyServerConfig:
|
|
if isinstance(model_config, Mapping):
|
|
raw_config = model_config.get("policy_server_config")
|
|
else:
|
|
raw_config = getattr(model_config, "policy_server_config", None)
|
|
|
|
if raw_config is None:
|
|
raise ValueError("Robot OpenPI serving requires policy_server_config.")
|
|
if isinstance(raw_config, cls):
|
|
return raw_config
|
|
if not isinstance(raw_config, Mapping):
|
|
raise ValueError("Robot OpenPI serving requires policy_server_config.")
|
|
return cls(_to_builtin_container(raw_config))
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return _to_builtin_container(self.values)
|
|
|
|
|
|
class ServingRealtimeRobotOpenPI:
|
|
"""Robot policy serving layer for OpenPI protocol.
|
|
|
|
Model-specific transform/state lives in the diffusion pipeline.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
engine_client: Any,
|
|
model_name: str | None = None,
|
|
) -> None:
|
|
self.engine_client = engine_client
|
|
self.model_name = model_name
|
|
self.policy_server_config = self._get_policy_server_config(engine_client)
|
|
self._request_counter = count()
|
|
|
|
@classmethod
|
|
def create_policy_server(
|
|
cls,
|
|
engine_client: Any,
|
|
model_name: str | None = None,
|
|
) -> ServingRealtimeRobotOpenPI | None:
|
|
try:
|
|
return cls(engine_client=engine_client, model_name=model_name)
|
|
except ValueError as exc:
|
|
if "policy_server_config" not in str(exc):
|
|
raise
|
|
logger.info("Robot OpenPI serving disabled for model %s", model_name)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _get_policy_server_config(engine_client: Any) -> PolicyServerConfig:
|
|
model_config = None
|
|
get_od_config = getattr(engine_client, "get_diffusion_od_config", None)
|
|
if callable(get_od_config):
|
|
od_config = get_od_config()
|
|
model_config = getattr(od_config, "model_config", None)
|
|
|
|
if model_config is None:
|
|
for stage_config in getattr(engine_client, "stage_configs", []) or []:
|
|
if getattr(stage_config, "stage_type", None) != "diffusion":
|
|
continue
|
|
# Typed diffusion stages keep model-owned OpenPI handshake
|
|
# metadata in diffusion_config.model_config. The out-of-process
|
|
# head only has this stage view because full od_config lives in
|
|
# the worker.
|
|
diffusion_config = getattr(stage_config, "diffusion_config", None)
|
|
model_config = getattr(diffusion_config, "model_config", None)
|
|
if model_config is not None:
|
|
break
|
|
engine_args = getattr(stage_config, "engine_args", None)
|
|
model_config = getattr(engine_args, "model_config", None)
|
|
if model_config is not None:
|
|
break
|
|
|
|
if model_config is None:
|
|
od_config = getattr(engine_client, "od_config", None)
|
|
model_config = getattr(od_config, "model_config", None)
|
|
|
|
if model_config is None:
|
|
model_config = getattr(engine_client, "model_config", None)
|
|
return PolicyServerConfig.from_model_config(model_config)
|
|
|
|
def reset(self, obs: dict) -> None:
|
|
"""Compatibility hook; per-connection state lives in RobotRealtimeConnection."""
|
|
|
|
def drop_session(self, session_id: str) -> None:
|
|
"""Best-effort release of model-side session state for a closed rollout."""
|
|
drop = getattr(self.engine_client, "drop_session", None)
|
|
if callable(drop):
|
|
drop(session_id)
|
|
return
|
|
pipeline = self._pipeline()
|
|
for name in ("close_ar_diffusion_session", "drop_session_state"):
|
|
close = getattr(pipeline, name, None)
|
|
if callable(close):
|
|
close(session_id)
|
|
return
|
|
|
|
def _pipeline(self) -> Any:
|
|
engine = self.engine_client
|
|
for attr in ("model_runner", "runner", "diffusion_model_runner"):
|
|
runner = getattr(engine, attr, None)
|
|
pipeline = getattr(runner, "pipeline", None) if runner is not None else None
|
|
if pipeline is not None:
|
|
return pipeline
|
|
return getattr(engine, "pipeline", None)
|
|
|
|
async def infer(self, obs: dict, *, session_id: str, reset: bool) -> ActionOutput:
|
|
"""raw obs → engine → actions."""
|
|
# Build request, run inference through AsyncOmni
|
|
request = self.build_request(obs, session_id=session_id, reset=reset)
|
|
result = None
|
|
# OpenPI policy serving is one request -> one action reply. AsyncOmni
|
|
# exposes an async iterator, so consume it to completion and use the
|
|
# final output, matching other non-streaming OpenAI serving paths.
|
|
async for output in self.engine_client.generate(
|
|
prompt=request.prompt,
|
|
request_id=request.request_id,
|
|
sampling_params_list=[request.sampling_params],
|
|
):
|
|
result = output
|
|
if result is None:
|
|
raise RuntimeError("Robot OpenPI request produced no output.")
|
|
|
|
return self._extract_actions(result)
|
|
|
|
def _next_request_id(self, session_id: str) -> str:
|
|
return f"robot-{session_id}-{next(self._request_counter)}"
|
|
|
|
def build_request(self, obs: dict, *, session_id: str, reset: bool) -> Any:
|
|
"""Build an engine request from raw robot obs."""
|
|
return self._build_request(obs, session_id=session_id, reset=reset)
|
|
|
|
def _build_request(self, obs: dict, *, session_id: str, reset: bool) -> Any:
|
|
"""Build engine request from raw robot obs.
|
|
|
|
Returns an `OmniDiffusionRequest` payload consumed by
|
|
`AsyncOmni.generate()` and routed to the diffusion stage.
|
|
"""
|
|
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
|
from vllm_omni.entrypoints.openai.stage_params import (
|
|
clone_sampling_params,
|
|
get_default_sampling_params_list,
|
|
)
|
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
|
|
|
# The engine applies stage default_sampling_params only to requests
|
|
# that carry no explicit params; this endpoint always passes explicit
|
|
# params, so start from a clone of the diffusion stage's defaults
|
|
# (e.g. a policy deploy yaml's ``extra_args``) and layer the OpenPI
|
|
# protocol fields on top.
|
|
seed = obs.pop("seed", None)
|
|
sampling_params = OmniDiffusionSamplingParams()
|
|
for default_params in get_default_sampling_params_list(self.engine_client):
|
|
if isinstance(default_params, OmniDiffusionSamplingParams):
|
|
sampling_params = clone_sampling_params(default_params)
|
|
break
|
|
|
|
extra_args = sampling_params.extra_args or {}
|
|
extra_args.update(
|
|
{
|
|
"reset": reset,
|
|
"session_id": session_id,
|
|
"robot_obs": obs,
|
|
}
|
|
)
|
|
|
|
prompt = obs.get("prompt", "")
|
|
sampling_params = OmniDiffusionSamplingParams(
|
|
seed=int(seed) if seed is not None else None,
|
|
extra_args=extra_args,
|
|
)
|
|
return OmniDiffusionRequest(
|
|
prompt=prompt,
|
|
sampling_params=sampling_params,
|
|
request_id=self._next_request_id(session_id),
|
|
)
|
|
|
|
def _extract_actions(self, result: Any) -> ActionOutput:
|
|
"""Extract actions from engine result."""
|
|
multimodal_output = getattr(result, "multimodal_output", None)
|
|
if not isinstance(multimodal_output, Mapping):
|
|
raise RuntimeError("Missing multimodal_output in robot policy result")
|
|
|
|
actions = multimodal_output.get("actions")
|
|
if actions is None:
|
|
raise RuntimeError("Missing multimodal_output['actions'] in robot policy result")
|
|
if isinstance(actions, Mapping):
|
|
return {str(key): np.asarray(value, dtype=np.float32) for key, value in actions.items()}
|
|
return np.asarray(actions, dtype=np.float32)
|