SkyRL
API ReferenceSkyRLSkyRL-Train Backend

Generator

Generator API — GeneratorInterface, InferenceEngineInterface.

Core APIs

class GeneratorInterface

Bases: ABC

Functions:

NameDescription
generateGenerate trajectories for the input batch.
Source code in skyrl/train/generators/base.py:78-90
class GeneratorInterface(ABC):
    @abstractmethod
    async def generate(self, input_batch: GeneratorInput) -> GeneratorOutput:
        """Generate trajectories for the input batch.

        Returns outputs in the same order as the input batch.

        Args:
            input_batch (GeneratorInput): Input batch
        Returns:
            GeneratorOutput: Generated trajectories
        """
        raise NotImplementedError

method async generate

generate(input_batch: GeneratorInput) -> GeneratorOutput

Generate trajectories for the input batch.

Returns outputs in the same order as the input batch.

Parameters:

NameTypeDescriptionDefault
input_batchGeneratorInputInput batchrequired

Returns: GeneratorOutput: Generated trajectories

Source code in skyrl/train/generators/base.py:79-90
    @abstractmethod
    async def generate(self, input_batch: GeneratorInput) -> GeneratorOutput:
        """Generate trajectories for the input batch.

        Returns outputs in the same order as the input batch.

        Args:
            input_batch (GeneratorInput): Input batch
        Returns:
            GeneratorOutput: Generated trajectories
        """
        raise NotImplementedError

class InferenceEngineInterface

Bases: ABC

Functions:

NameDescription
generate
get_endpoint_urlReturn the base URL of the data-plane (OpenAI-compatible) endpoint.
chat_completionHandles OpenAI-compatible HTTP endpoint.
render_chat_completionApply the chat template and tokenize without generating.
completionHandles OpenAI-compatible HTTP endpoint.
wake_up
sleep
update_named_weightsUpdate model weights. Used for full parameter fine-tuning
load_lora_adapterLoad LoRA adapter
teardown
reset_prefix_cache
pause_generationPause generation, freezing in-flight requests so they can be resumed later.
resume_generationResume generation after a pause, continuing any frozen in-flight requests.
finish_sessionNotify the inference server that a session (trajectory) is complete.
get_world_sizeReturn (total_world_size, world_size_per_server) across all inference workers.

Attributes:

NameTypeDescription
model_namestrThe base model identifier the inference server was started with.
Source code in skyrl/backends/skyrl_train/inference_servers/base.py:58-174
class InferenceEngineInterface(ABC):

    @property
    @abstractmethod
    def model_name(self) -> str:
        """The base model identifier the inference server was started with.

        Generators pass this as the ``model`` field on requests (e.g. when
        rendering a chat completion) when they don't otherwise specify one.
        """
        raise NotImplementedError

    @abstractmethod
    async def generate(
        self,
        input_batch: InferenceEngineInput,
        model: Optional[str] = None,
    ) -> InferenceEngineOutput:
        raise NotImplementedError

    @abstractmethod
    def get_endpoint_url(self) -> str:
        """Return the base URL of the data-plane (OpenAI-compatible) endpoint.

        Generators point external clients at the inference server/router through
        this URL (e.g. LiteLLM via ``OPENAI_BASE_URL``) without depending on the
        concrete client type or how the URL is derived.
        """
        raise NotImplementedError

    @abstractmethod
    async def chat_completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]:
        """Handles OpenAI-compatible HTTP endpoint.

        Accepts a JSON payload: {"json": <request-body>, "headers": <headers-dict>}.
        The request body will be used to construct a ChatCompletionRequest.
        Returns a plain dict, either a ChatCompletionResponse or an ErrorResponse.
        The specific fields of the response/request depend on the engine's backend (e.g. for vllm
        these are defined in vllm.entrypoints.openai.protocol).
        """
        raise NotImplementedError

    @abstractmethod
    async def render_chat_completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]:
        """Apply the chat template and tokenize without generating.

        Accepts the same ``{"json": <request-body>}`` payload as
        ``chat_completion`` and returns the rendered prompt / token IDs. Used by
        generators that need token-in/token-out rendering (e.g. multi-modal).
        """
        raise NotImplementedError

    @abstractmethod
    async def completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]:
        """Handles OpenAI-compatible HTTP endpoint.

        Accepts a JSON payload: {"json": <request-body>, "headers": <headers-dict>}.
        The request body will be used to construct a CompletionRequest.
        Returns a plain dict, either a CompletionResponse or an ErrorResponse.
        The specific fields of the response/request depend on the engine's backend (e.g. for vllm
        these are defined in vllm.entrypoints.openai.protocol).
        """
        raise NotImplementedError

    @abstractmethod
    async def wake_up(self, *args: Any, **kwargs: Any):
        raise NotImplementedError

    @abstractmethod
    async def sleep(self, *args: Any, **kwargs: Any):
        raise NotImplementedError

    @abstractmethod
    async def update_named_weights(self, request: "LoraLoadRequest | Dict[str, Any]"):
        """Update model weights. Used for full parameter fine-tuning"""
        raise NotImplementedError()

    @abstractmethod
    async def load_lora_adapter(
        self,
        lora_name: str,
        lora_path: str,
    ) -> Dict[str, Any]:
        """Load LoRA adapter"""
        raise NotImplementedError()

    @abstractmethod
    async def teardown(self):
        raise NotImplementedError

    @abstractmethod
    async def reset_prefix_cache(self, reset_running_requests: bool = False):
        raise NotImplementedError

    @abstractmethod
    async def pause_generation(self) -> None:
        """Pause generation, freezing in-flight requests so they can be resumed later."""
        raise NotImplementedError

    @abstractmethod
    async def resume_generation(self) -> None:
        """Resume generation after a pause, continuing any frozen in-flight requests."""
        raise NotImplementedError

    @abstractmethod
    async def finish_session(self, session_id: str) -> None:
        """Notify the inference server that a session (trajectory) is complete.

        Best-effort: lets session-aware routing release the replica capacity the
        session held. Generators call this in trajectory-cleanup paths.
        """
        raise NotImplementedError

    @abstractmethod
    async def get_world_size(self) -> Tuple[int, int]:
        """Return ``(total_world_size, world_size_per_server)`` across all inference workers."""
        raise NotImplementedError

attr abstractmethod property model_name

model_name: str

The base model identifier the inference server was started with.

Generators pass this as the model field on requests (e.g. when rendering a chat completion) when they don't otherwise specify one.

method async generate

generate(input_batch: InferenceEngineInput, model: Optional[str] = None) -> InferenceEngineOutput
Source code in skyrl/backends/skyrl_train/inference_servers/base.py:70-76
    @abstractmethod
    async def generate(
        self,
        input_batch: InferenceEngineInput,
        model: Optional[str] = None,
    ) -> InferenceEngineOutput:
        raise NotImplementedError

method abstractmethod get_endpoint_url

get_endpoint_url() -> str

Return the base URL of the data-plane (OpenAI-compatible) endpoint.

Generators point external clients at the inference server/router through this URL (e.g. LiteLLM via OPENAI_BASE_URL) without depending on the concrete client type or how the URL is derived.

Source code in skyrl/backends/skyrl_train/inference_servers/base.py:78-86
    @abstractmethod
    def get_endpoint_url(self) -> str:
        """Return the base URL of the data-plane (OpenAI-compatible) endpoint.

        Generators point external clients at the inference server/router through
        this URL (e.g. LiteLLM via ``OPENAI_BASE_URL``) without depending on the
        concrete client type or how the URL is derived.
        """
        raise NotImplementedError

method abstractmethod async chat_completion

chat_completion(request_payload: Dict[str, Any]) -> Dict[str, Any]

Handles OpenAI-compatible HTTP endpoint.

Accepts a JSON payload: {"json": <request-body>, "headers": <headers-dict>}. The request body will be used to construct a ChatCompletionRequest. Returns a plain dict, either a ChatCompletionResponse or an ErrorResponse. The specific fields of the response/request depend on the engine's backend (e.g. for vllm these are defined in vllm.entrypoints.openai.protocol).

Source code in skyrl/backends/skyrl_train/inference_servers/base.py:88-98
    @abstractmethod
    async def chat_completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]:
        """Handles OpenAI-compatible HTTP endpoint.

        Accepts a JSON payload: {"json": <request-body>, "headers": <headers-dict>}.
        The request body will be used to construct a ChatCompletionRequest.
        Returns a plain dict, either a ChatCompletionResponse or an ErrorResponse.
        The specific fields of the response/request depend on the engine's backend (e.g. for vllm
        these are defined in vllm.entrypoints.openai.protocol).
        """
        raise NotImplementedError

method abstractmethod async render_chat_completion

render_chat_completion(request_payload: Dict[str, Any]) -> Dict[str, Any]

Apply the chat template and tokenize without generating.

Accepts the same {"json": <request-body>} payload as chat_completion and returns the rendered prompt / token IDs. Used by generators that need token-in/token-out rendering (e.g. multi-modal).

Source code in skyrl/backends/skyrl_train/inference_servers/base.py:100-108
    @abstractmethod
    async def render_chat_completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]:
        """Apply the chat template and tokenize without generating.

        Accepts the same ``{"json": <request-body>}`` payload as
        ``chat_completion`` and returns the rendered prompt / token IDs. Used by
        generators that need token-in/token-out rendering (e.g. multi-modal).
        """
        raise NotImplementedError

method abstractmethod async completion

completion(request_payload: Dict[str, Any]) -> Dict[str, Any]

Handles OpenAI-compatible HTTP endpoint.

Accepts a JSON payload: {"json": <request-body>, "headers": <headers-dict>}. The request body will be used to construct a CompletionRequest. Returns a plain dict, either a CompletionResponse or an ErrorResponse. The specific fields of the response/request depend on the engine's backend (e.g. for vllm these are defined in vllm.entrypoints.openai.protocol).

Source code in skyrl/backends/skyrl_train/inference_servers/base.py:110-120
    @abstractmethod
    async def completion(self, request_payload: Dict[str, Any]) -> Dict[str, Any]:
        """Handles OpenAI-compatible HTTP endpoint.

        Accepts a JSON payload: {"json": <request-body>, "headers": <headers-dict>}.
        The request body will be used to construct a CompletionRequest.
        Returns a plain dict, either a CompletionResponse or an ErrorResponse.
        The specific fields of the response/request depend on the engine's backend (e.g. for vllm
        these are defined in vllm.entrypoints.openai.protocol).
        """
        raise NotImplementedError

method abstractmethod async wake_up

wake_up(*args: Any, **kwargs: Any)
Source code in skyrl/backends/skyrl_train/inference_servers/base.py:122-124
    @abstractmethod
    async def wake_up(self, *args: Any, **kwargs: Any):
        raise NotImplementedError

method abstractmethod async sleep

sleep(*args: Any, **kwargs: Any)
Source code in skyrl/backends/skyrl_train/inference_servers/base.py:126-128
    @abstractmethod
    async def sleep(self, *args: Any, **kwargs: Any):
        raise NotImplementedError

method abstractmethod async update_named_weights

update_named_weights(request: LoraLoadRequest | Dict[str, Any])

Update model weights. Used for full parameter fine-tuning

Source code in skyrl/backends/skyrl_train/inference_servers/base.py:130-133
    @abstractmethod
    async def update_named_weights(self, request: "LoraLoadRequest | Dict[str, Any]"):
        """Update model weights. Used for full parameter fine-tuning"""
        raise NotImplementedError()

method abstractmethod async load_lora_adapter

load_lora_adapter(lora_name: str, lora_path: str) -> Dict[str, Any]

Load LoRA adapter

Source code in skyrl/backends/skyrl_train/inference_servers/base.py:135-142
    @abstractmethod
    async def load_lora_adapter(
        self,
        lora_name: str,
        lora_path: str,
    ) -> Dict[str, Any]:
        """Load LoRA adapter"""
        raise NotImplementedError()

method abstractmethod async teardown

teardown()
Source code in skyrl/backends/skyrl_train/inference_servers/base.py:144-146
    @abstractmethod
    async def teardown(self):
        raise NotImplementedError

method abstractmethod async reset_prefix_cache

reset_prefix_cache(reset_running_requests: bool = False)
Source code in skyrl/backends/skyrl_train/inference_servers/base.py:148-150
    @abstractmethod
    async def reset_prefix_cache(self, reset_running_requests: bool = False):
        raise NotImplementedError

method abstractmethod async pause_generation

pause_generation() -> None

Pause generation, freezing in-flight requests so they can be resumed later.

Source code in skyrl/backends/skyrl_train/inference_servers/base.py:152-155
    @abstractmethod
    async def pause_generation(self) -> None:
        """Pause generation, freezing in-flight requests so they can be resumed later."""
        raise NotImplementedError

method abstractmethod async resume_generation

resume_generation() -> None

Resume generation after a pause, continuing any frozen in-flight requests.

Source code in skyrl/backends/skyrl_train/inference_servers/base.py:157-160
    @abstractmethod
    async def resume_generation(self) -> None:
        """Resume generation after a pause, continuing any frozen in-flight requests."""
        raise NotImplementedError

method abstractmethod async finish_session

finish_session(session_id: str) -> None

Notify the inference server that a session (trajectory) is complete.

Best-effort: lets session-aware routing release the replica capacity the session held. Generators call this in trajectory-cleanup paths.

Source code in skyrl/backends/skyrl_train/inference_servers/base.py:162-169
    @abstractmethod
    async def finish_session(self, session_id: str) -> None:
        """Notify the inference server that a session (trajectory) is complete.

        Best-effort: lets session-aware routing release the replica capacity the
        session held. Generators call this in trajectory-cleanup paths.
        """
        raise NotImplementedError

method abstractmethod async get_world_size

get_world_size() -> Tuple[int, int]

Return (total_world_size, world_size_per_server) across all inference workers.

Source code in skyrl/backends/skyrl_train/inference_servers/base.py:171-174
    @abstractmethod
    async def get_world_size(self) -> Tuple[int, int]:
        """Return ``(total_world_size, world_size_per_server)`` across all inference workers."""
        raise NotImplementedError

class RemoteInferenceClient

RemoteInferenceClient(proxy_url: str, server_urls: List[str], data_parallel_size: int, model_name: str = 'default', enable_return_routed_experts: bool = False, enable_return_sample_support_set: bool = False, uses_lora_weight_sync: bool = False, tokenizer: Optional[Any] = None, _generate_client: Optional[RemoteGenerateClient] = None, _world_size: Optional[Tuple[int, int]] = None, _gen_sem: Optional[asyncio.Semaphore] = None, _detok_sem: Optional[asyncio.Semaphore] = None, _sem_loop: Optional[asyncio.AbstractEventLoop] = None, _weight_version: int = 0) -> None

Bases: InferenceEngineInterface

Serializable HTTP client for inference. The concrete InferenceEngineInterface.

This class maintains two URL types:

  • proxy_url: Single URL for data plane operations (routed requests)
  • server_urls: List of backend URLs for control plane operations (fan-out)

The router (proxy_url) is expected to be a data-plane-only router (like VLLMRouter or an external router). Control plane operations are always fanned out to all backends directly by this client.

Usage:

client = RemoteInferenceClient( proxy_url="http://router:8080", # Data plane (router) server_urls=["http://backend1:8000", "http://backend2:8000"], # Control plane data_parallel_size=1, # data parallel size for deployments )

Functions:

NameDescription
increment_weight_versionAdvance the weight version. Called once per completed weight sync to the engines.
get_endpoint_urlData-plane endpoint base URL (the router/proxy that load-balances requests).
generateGenerate completions via /v1/completions.
sampleSample completions via /inference/v1/generate (Tinker API).
chat_completionChat completion via /v1/chat/completions.
render_chat_completionRender a chat completion (apply chat template + tokenize) via /v1/chat/completions/render.
completionCompletion via /v1/completions.
tokenizeTokenize texts.
detokenizeDetokenize token IDs.
finish_sessionNotify the router that a session (trajectory) is complete.
pausePause generation on all backends.
resumeResume generation on all backends.
pause_generationPause using keep mode.
resume_generationResume after pause.
sleepPut all backends to sleep (offload weights to CPU).
wake_upWake up all backends (load weights back to GPU).
sleep_for_weight_syncFree GPU memory for weight sync while keeping in-flight requests frozen.
wake_for_weight_syncRestore allocator pools by tag (see :meth:sleep_for_weight_sync).
reset_prefix_cacheReset KV cache on all backends.
update_named_weightsUpdate model weights via vLLM native /update_weights. Used for full parameter fine-tuning.
fetch_weightsFetch/apply a published checkpoint delta on every inference worker before
set_lora_receive_targetArm every inference worker's receive engine for one LoRA adapter round.
load_lora_adapterLoad (or reload) a LoRA adapter on all backend servers via the SkyRL
unload_lora_adapterUnload a previously-loaded LoRA adapter on all backend servers via /v1/unload_lora_adapter.
get_world_sizeGet total and per-server world size across all inference workers.
teardownClose HTTP session.
aclose
is_sleeping

Attributes:

NameTypeDescription
proxy_urlstrData plane URL (single endpoint - router or direct server).
server_urlsList[str]Control plane URLs (list of backend servers for fan-out).
data_parallel_sizeintData parallel size. Used to compute total inference world size correctly:
model_namestrThe model identifier accepted by the inference server for the base model.
enable_return_routed_expertsboolWhether to return routed expert indices (R3 / rollout router replay).
enable_return_sample_support_setboolWhether the engine may return the sampler's bounded top-k support per generated token.
uses_lora_weight_syncboolTrue when the trainer syncs LoRA adapters (rather than full/merged weights). When True,
tokenizerOptional[Any]Optional HF tokenizer for local tokenize/detokenize (avoids HTTP round-trips).
weight_versionintNumber of weight syncs to the engines so far (0 before the first sync); the policy version.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:466-1599
@dataclass
class RemoteInferenceClient(InferenceEngineInterface):
    """
    Serializable HTTP client for inference. The concrete InferenceEngineInterface.

    This class maintains two URL types:
    - proxy_url: Single URL for data plane operations (routed requests)
    - server_urls: List of backend URLs for control plane operations (fan-out)

    The router (proxy_url) is expected to be a data-plane-only router (like
    VLLMRouter or an external router). Control plane operations
    are always fanned out to all backends directly by this client.

    Usage:
        client = RemoteInferenceClient(
            proxy_url="http://router:8080",  # Data plane (router)
            server_urls=["http://backend1:8000", "http://backend2:8000"],  # Control plane
            data_parallel_size=1, # data parallel size for deployments
        )
    """

    proxy_url: str
    """Data plane URL (single endpoint - router or direct server)."""

    server_urls: List[str]
    """Control plane URLs (list of backend servers for fan-out)."""

    data_parallel_size: int
    """Data parallel size. Used to compute total inference world size correctly:
    server_urls contains num_engines * data_parallel_size entries, but vLLM already
    reports the full DP world size per server, so we divide by num_deployments."""

    model_name: str = "default"
    """The model identifier accepted by the inference server for the base model.

    This is usually the model path, but may be ``served_model_name`` when vLLM
    is started with an alias. It is never a LoRA adapter name. LoRA adapters are
    addressed by the names callers register them under via
    ``load_lora_adapter(name, path)``, and per-call routing is done by
    passing that name as ``model`` on the data-plane methods.

    Used internally only by ``tokenize``/``detokenize``, which are LoRA-
    agnostic but still require a ``model`` field per the OpenAI schema.
    """

    enable_return_routed_experts: bool = False
    """Whether to return routed expert indices (R3 / rollout router replay)."""

    enable_return_sample_support_set: bool = False
    """Whether the engine may return the sampler's bounded top-k support per generated token.
    Capture is per-request: callers opt a batch in with ``InferenceEngineInput.return_sample_support``."""

    uses_lora_weight_sync: bool = False
    """True when the trainer syncs LoRA adapters (rather than full/merged weights). When True,
    `sleep()` is forced to level=1: level=2 discards the base model from VRAM with no CPU backup,
    and LoRA-only broadcasts cannot repopulate it. Must be kept in sync with the same gate vLLM
    uses for `enable_lora` (see `_uses_lora_weight_sync` in inference_servers/utils.py)."""

    tokenizer: Optional[Any] = None
    """Optional HF tokenizer for local tokenize/detokenize (avoids HTTP round-trips)."""

    # Private fields excluded from repr for cleaner output
    _generate_client: Optional[RemoteGenerateClient] = field(default=None, repr=False)
    _world_size: Optional[Tuple[int, int]] = field(default=None, repr=False)
    _gen_sem: Optional[asyncio.Semaphore] = field(default=None, repr=False)
    _detok_sem: Optional[asyncio.Semaphore] = field(default=None, repr=False)
    _sem_loop: Optional[asyncio.AbstractEventLoop] = field(default=None, repr=False)
    # Monotonic counter of weight syncs (see `increment_weight_version`); source of the prefix-cache salt.
    _weight_version: int = field(default=0, repr=False)

    @property
    def weight_version(self) -> int:
        """Number of weight syncs to the engines so far (0 before the first sync); the policy version."""
        return self._weight_version

    def increment_weight_version(self) -> None:
        """Advance the weight version. Called once per completed weight sync to the engines."""
        self._weight_version += 1

    def __post_init__(self):
        if self.data_parallel_size <= 0:
            raise ValueError(f"Expected `data_parallel_size` >0, got {self.data_parallel_size}")

        if len(self.server_urls) % self.data_parallel_size != 0:
            raise ValueError(
                f"Expected number of servers to be divisible by data parallel size, got {self.server_urls} and {self.data_parallel_size}"
            )

    def get_endpoint_url(self) -> str:
        """Data-plane endpoint base URL (the router/proxy that load-balances requests)."""
        return self.proxy_url

    # ---------------------------
    # Session Management
    # ---------------------------

    def _get_semaphores(self) -> Tuple[Optional[asyncio.Semaphore], Optional[asyncio.Semaphore]]:
        """Get or create the shared generate/detokenize semaphores for this client.

        Semaphores are event-loop-bound (Python 3.10+). If the running loop has
        changed since they were created, recreate them.

        All concurrent generate() calls on the same client instance share these
        semaphores, capping total in-flight requests at
        SKYRL_GENERATE_CONCURRENCY_PER_ENGINE × num_engines.
        """
        current_loop = asyncio.get_running_loop()
        if self._sem_loop is not current_loop:
            if SKYRL_GENERATE_CONCURRENCY_PER_ENGINE > 0:
                concurrency = SKYRL_GENERATE_CONCURRENCY_PER_ENGINE * len(self.server_urls)
                logger.info(f"Capping concurrency for generation to a maximum of {concurrency} requests")
                self._gen_sem = asyncio.Semaphore(concurrency)
                self._detok_sem = asyncio.Semaphore(concurrency)
            else:
                self._gen_sem = None
                self._detok_sem = None
            self._sem_loop = current_loop
        return self._gen_sem, self._detok_sem

    def _get_generate_client(self) -> RemoteGenerateClient:
        if self._generate_client is None:
            self._generate_client = RemoteGenerateClient(proxy_url=self.proxy_url)
        return self._generate_client

    async def _get_session(self) -> aiohttp.ClientSession:
        return await self._get_generate_client()._get_session()

    async def _post(self, url: str, json: Dict[str, Any], headers: Optional[Dict[str, str]] = None) -> Any:
        return await self._get_generate_client()._post(url, json=json, headers=headers)

    # ---------------------------
    # Data Plane
    # ---------------------------

    def _resolve_model(self, model: Optional[str], method_name: str) -> str:
        """Pick the target model name for a data-plane call.

        - If ``model`` is non-empty, use it as-is.
        - Otherwise, when LoRA is in use (``uses_lora_weight_sync=True``) raise
          ``ValueError`` — the caller must name the adapter explicitly because
          falling back to the base model would silently bypass LoRA.
        - Otherwise return ``self.model_name`` (the base model the server was
          started with).
        """
        if model:
            return model
        if self.uses_lora_weight_sync:
            raise ValueError(
                f"RemoteInferenceClient.{method_name}: `model` is required when LoRA "
                f"is enabled (uses_lora_weight_sync=True). Pass the LoRA adapter name "
                f"explicitly so the request doesn't silently target the base model."
            )
        return self.model_name

    async def generate(
        self,
        input_batch: InferenceEngineInput,
        model: Optional[str] = None,
    ) -> InferenceEngineOutput:
        """
        Generate completions via /v1/completions.

        This is the interface for token-in-token-out workflows. Input will have
        token ids, and the output is token ids as well.

        Each prompt is sent as a separate request to allow the router to route
        based on session_id. All requests are made in parallel.

        With keep-mode pause, in-flight requests are frozen and resume
        transparently after /resume -- no client-side retry needed.

        Args:
            input_batch: Contains prompt_token_ids, sampling_params, and optional session_ids.
            model: Optional model identifier — the base model name or a loaded
                LoRA adapter name. When omitted, defaults to ``self.model_name``
                if LoRA is not in use; raises ``ValueError`` if it is.

        Returns:
            InferenceEngineOutput with responses, response_ids, and stop_reasons.
        """
        model = self._resolve_model(model, "generate")

        prompt_token_ids = input_batch.get("prompt_token_ids")
        if prompt_token_ids is None:
            raise ValueError("RemoteInferenceClient only accepts `prompt_token_ids`, not `prompts`.")

        sampling_params = input_batch.get("sampling_params") or {}
        if sampling_params.get("n", 1) > 1:
            raise ValueError("n > 1 is not supported. Use `config.generator.n_samples_per_prompt` instead.")

        session_ids = input_batch.get("session_ids")
        mm_features = input_batch.get("mm_features")
        cache_salt = input_batch.get("cache_salt")
        routed_experts_prompt_starts = input_batch.get("routed_experts_prompt_starts")
        if routed_experts_prompt_starts is not None:
            if not self.enable_return_routed_experts:
                raise ValueError("routed_experts_prompt_starts requires enable_return_routed_experts=True")
            if len(routed_experts_prompt_starts) != len(prompt_token_ids):
                raise ValueError("routed_experts_prompt_starts must have one entry per prompt")
        return_sample_support = self.enable_return_sample_support_set and input_batch.get(
            "return_sample_support", False
        )
        get_logprobs = sampling_params.get("logprobs") is not None

        # Two semaphores decouple the generate and detokenize stages:
        #   gen_sem:   limits concurrent in-flight generate requests so we don't
        #              overwhelm the router/vLLM scheduler.  Released as soon as
        #              generation finishes, so the GPU slot is freed immediately.
        #   detok_sem: limits concurrent detokenize calls independently.  Uses the
        #              same concurrency limit so detokenize never starves generate.
        # Semaphores are shared across all concurrent generate() calls on this client
        # instance, so total in-flight requests are capped at
        # SKYRL_GENERATE_CONCURRENCY_PER_ENGINE × num_engines regardless of how many
        # callers invoke generate() simultaneously.
        # TODO (sumanthrh) (RemoteInferenceClient data-plane-deprecation): We should move this outside of the client to a runner abstraction that will also parallelize client requests across processes.
        gen_sem, detok_sem = self._get_semaphores()
        batch_size = len(prompt_token_ids)

        async def _throttled_generate(idx: int) -> Dict[str, Any]:
            if gen_sem is None:
                return await self._generate_single(
                    prompt_token_ids=prompt_token_ids[idx],
                    sampling_params=sampling_params,
                    session_id=session_ids[idx] if session_ids and idx < len(session_ids) else None,
                    mm_features=mm_features[idx] if mm_features and idx < len(mm_features) else None,
                    routed_experts_prompt_start=(
                        routed_experts_prompt_starts[idx] if routed_experts_prompt_starts is not None else None
                    ),
                    return_sample_support=return_sample_support,
                    model=model,
                    cache_salt=cache_salt,
                )
            async with gen_sem:
                return await self._generate_single(
                    prompt_token_ids=prompt_token_ids[idx],
                    sampling_params=sampling_params,
                    session_id=session_ids[idx] if session_ids and idx < len(session_ids) else None,
                    mm_features=mm_features[idx] if mm_features and idx < len(mm_features) else None,
                    routed_experts_prompt_start=(
                        routed_experts_prompt_starts[idx] if routed_experts_prompt_starts is not None else None
                    ),
                    return_sample_support=return_sample_support,
                    model=model,
                    cache_salt=cache_salt,
                )

        async def _throttled_detokenize(token_ids: List[int]) -> str:
            if detok_sem is None:
                return (await self.detokenize([token_ids]))[0]
            async with detok_sem:
                return (await self.detokenize([token_ids]))[0]

        raw_results = await asyncio.gather(*[_throttled_generate(idx) for idx in range(batch_size)])
        responses = await asyncio.gather(*[_throttled_detokenize(r["response_ids"]) for r in raw_results])

        rollout_expert_indices = (
            [result["routed_experts"] for result in raw_results] if self.enable_return_routed_experts else None
        )
        rollout_sample_support = (
            [result[PackedField.ROLLOUT_SAMPLE_SUPPORT] for result in raw_results] if return_sample_support else None
        )

        return InferenceEngineOutput(
            responses=responses,
            stop_reasons=[r["stop_reason"] for r in raw_results],
            response_ids=[r["response_ids"] for r in raw_results],
            response_logprobs=[r["response_logprobs"] for r in raw_results] if get_logprobs else None,
            rollout_expert_indices=rollout_expert_indices,
            rollout_sample_support=rollout_sample_support,
        )

    async def _generate_single(
        self,
        prompt_token_ids: List[int],
        sampling_params: Dict[str, Any],
        session_id: Optional[Any],
        model: str,
        mm_features: Optional[MultiModalFeatures] = None,
        cache_salt: Optional[str] = None,
        routed_experts_prompt_start: Optional[int] = None,
        return_sample_support: bool = False,
    ) -> Dict[str, Any]:
        result = await self._get_generate_client().generate(
            prompt_token_ids=prompt_token_ids,
            sampling_params=sampling_params,
            session_id=session_id,
            model=model,
            return_routed_experts=self.enable_return_routed_experts,
            routed_experts_prompt_start=routed_experts_prompt_start,
            return_sample_support=return_sample_support,
            mm_features=mm_features,
            cache_salt=cache_salt,
        )
        return {
            "stop_reason": result.stop_reason,
            "response_ids": result.response_ids,
            "response_logprobs": result.response_logprobs,
            "routed_experts": result.routed_experts,
            PackedField.ROLLOUT_SAMPLE_SUPPORT.value: result.sample_support,
        }

    async def _render_for_sample(
        self,
        prompt: Dict[str, Any],
        session_id: Optional[str],
        model: str,
    ) -> Tuple[List[int], Optional[MultiModalFeatures]]:
        """Build token_ids and optional multi-modal features from a Tinker prompt.

        For text-only prompts this simply flattens chunk tokens (no HTTP call).
        When image chunks are present, calls /v1/chat/completions/render to
        process images, then splices the resulting placeholder tokens into the
        pre-tokenized text stream and adjusts placeholder offsets.

        Returns:
            (token_ids, features) where features is None for text-only prompts.
        """
        chunks = prompt.get("chunks", [])

        # No images → flatten text tokens directly.
        image_chunks = [c for c in chunks if c.get("type") in ("image", "image_asset_pointer")]
        if not image_chunks:
            token_ids = [tok for c in chunks for tok in c.get("tokens", [])]
            return token_ids, None

        # Build OpenAI chat template with only image_urls
        content_parts: List[Dict[str, Any]] = []
        for c in image_chunks:
            if c["type"] == "image":
                # model_dump() on Base64Bytes produces bytes with the b64 string.
                raw = c["data"]
                b64_str = raw.decode("ascii") if isinstance(raw, bytes) else raw
                url = f"data:image/{c.get('format', 'jpeg')};base64,{b64_str}"
            else:  # image_asset_pointer
                url = c["location"]
            content_parts.append({"type": "image_url", "image_url": {"url": url}})

        render_payload: Dict[str, Any] = {
            "json": {
                "model": model,
                "messages": [{"role": "user", "content": content_parts}],
            }
        }
        if session_id:
            render_payload["json"]["session_id"] = session_id

        render_resp = await self.render_chat_completion(render_payload)

        # Extract per-image placeholder token slices from the render output.
        features = render_resp.get("features") or {}
        render_token_ids = render_resp.get("token_ids", [])
        render_placeholders = features.get("mm_placeholders", {}).get("image", [])

        placeholder_token_slices: List[List[int]] = []
        for ph in render_placeholders:
            offset, length = ph["offset"], ph["length"]
            placeholder_token_slices.append(render_token_ids[offset : offset + length])

        if len(placeholder_token_slices) != len(image_chunks):
            raise ValueError(
                f"Expected {len(image_chunks)} placeholder token slices, got {len(placeholder_token_slices)}"
            )

        # Splice: walk chunks in order, substituting image placeholder tokens.
        final_token_ids: List[int] = []
        new_placeholders: List[MMPlaceholderRangeInfo] = []
        img_idx = 0

        for c in chunks:
            ctype = c.get("type", "encoded_text")
            if ctype == "encoded_text":
                final_token_ids.extend(c.get("tokens", []))
            elif ctype in ("image", "image_asset_pointer"):
                ph_tokens = placeholder_token_slices[img_idx]
                new_placeholders.append({"offset": len(final_token_ids), "length": len(ph_tokens)})
                final_token_ids.extend(ph_tokens)
                img_idx += 1

        # No need to decode, vllm handles decoding
        adjusted_features: MultiModalFeatures = {
            "mm_hashes": features.get("mm_hashes", {}),
            "mm_placeholders": {"image": new_placeholders},
            "kwargs_data": features.get("kwargs_data"),
        }

        return final_token_ids, adjusted_features

    async def sample(
        self,
        request_payload: SampleRequestPayload,
    ) -> SampleResponse:
        """
        Sample completions via /inference/v1/generate (Tinker API).

        Maps Tinker-style sample requests to the vLLM generate endpoint.
        Uses self._post() for automatic retry + backoff on transient errors.

        Args:
            request_payload: SampleRequestPayload with {"json": <request-body>}.
                Expected keys in json: prompt, num_samples, sampling_params,
                session_id, include_prompt_logprobs (bool), topk_prompt_logprobs (int).
                ``model`` is optional and resolved via ``_resolve_model``.

        Returns:
            SampleResponse with type="sample", sequences list, prompt_logprobs, and topk_prompt_logprobs.
        """
        session_id, body = _extract_session_id_and_body(request_payload)
        model = self._resolve_model(body.get("model"), "sample")
        body["model"] = model

        prompt = body.get("prompt", {})
        num_samples = body.get("num_samples", 1)
        tinker_params = body.get("sampling_params", {})

        # Note: Tinker SampleRequest uses "prompt_logprobs" (bool), while
        # SamplingClient.sample() uses "include_prompt_logprobs".
        include_prompt_logprobs = body.get("include_prompt_logprobs", body.get("prompt_logprobs", False))
        topk_prompt_logprobs_k = body.get("topk_prompt_logprobs", 0)

        # vLLM prompt logprob mapping
        prompt_logprobs_sp = None
        if include_prompt_logprobs:
            prompt_logprobs_sp = topk_prompt_logprobs_k if topk_prompt_logprobs_k > 0 else 0

        # Render prompt: flatten text tokens and, if images are present,
        # call the render endpoint to get placeholder tokens + features.
        token_ids, mm_features = await self._render_for_sample(prompt, session_id, model=model)

        # Map Tinker SamplingParams → vLLM format
        sampling_params: Dict[str, Any] = {
            "n": num_samples,
            "logprobs": 0,
            "output_kind": 2,
            "prompt_logprobs": prompt_logprobs_sp,
        }

        for tinker_key, vllm_key in _TINKER_SAMPLE_TO_VLLM_PARAM_MAP.items():
            val = tinker_params.get(tinker_key)
            if val is not None:
                sampling_params[vllm_key] = val

        payload: Dict[str, Any] = {
            "sampling_params": sampling_params,
            "model": model,
            "token_ids": token_ids,
        }
        if mm_features is not None:
            payload["features"] = mm_features

        headers = {"Content-Type": "application/json"}
        if session_id:
            headers["X-Session-ID"] = str(session_id)

        url = f"{self.proxy_url}/inference/v1/generate"
        gen_sem, _ = self._get_semaphores()
        if gen_sem is None:
            response = await self._post(url, json=payload, headers=headers)
        else:
            async with gen_sem:
                response = await self._post(url, json=payload, headers=headers)

        # vLLM returns: list[dict[str(token_id) → {"logprob": float, ...}] | None]
        result_prompt_logprobs: Optional[List[Optional[float]]] = None
        result_topk_prompt_logprobs: Optional[List[Optional[List[Tuple[int, float]]]]] = None

        if include_prompt_logprobs:
            result_prompt_logprobs, result_topk_prompt_logprobs = convert_vllm_prompt_logprobs(
                token_ids,
                response.get("prompt_logprobs"),
                topk=topk_prompt_logprobs_k,
            )

        # Transform response choices → sequences
        sequences = []
        for choice in response.get("choices", []):
            seq_logprobs: Optional[List[float]] = None
            logprobs_data = choice.get("logprobs")
            if logprobs_data is not None:
                logprobs_content = logprobs_data.get("content", [])
                if logprobs_content:
                    seq_logprobs = [lp["logprob"] for lp in logprobs_content]

            sequences.append(
                {
                    "tokens": choice["token_ids"],
                    "logprobs": seq_logprobs,
                    "stop_reason": choice.get("finish_reason"),
                }
            )

        return {
            "type": "sample",
            "sequences": sequences,
            "prompt_logprobs": result_prompt_logprobs,
            "topk_prompt_logprobs": result_topk_prompt_logprobs,
        }

    async def chat_completion(
        self,
        request_payload: Dict[str, Any],
    ) -> Dict[str, Any]:
        """
        Chat completion via /v1/chat/completions.

        Args:
            request_payload: Dict with {"json": <request-body>, "headers": <headers-dict>}.
                The request body must be an OpenAI-compatible chat completion
                request. ``model`` is optional and resolved via
                ``_resolve_model``; if omitted the body is mutated to inject the
                resolved value before forwarding to vLLM. ``session_id`` can be
                included in the body for consistent routing.

        Returns:
            OpenAI-compatible chat completion response.
        """
        session_id, body = _extract_session_id_and_body(request_payload)
        body["model"] = self._resolve_model(body.get("model"), "chat_completion")

        headers = {"Content-Type": "application/json"}
        if session_id:
            headers["X-Session-ID"] = str(session_id)

        url = f"{self.proxy_url}/v1/chat/completions"
        gen_sem, _ = self._get_semaphores()
        if gen_sem is None:
            return await self._post(url, json=body, headers=headers)
        else:
            async with gen_sem:
                return await self._post(url, json=body, headers=headers)

    async def render_chat_completion(
        self,
        request_payload: Dict[str, Any],
    ) -> Dict[str, Any]:
        """
        Render a chat completion (apply chat template + tokenize) via /v1/chat/completions/render.

        Args:
            request_payload: Dict with {"json": <request-body>}.
                The request body should be OpenAI-compatible chat completion
                request. ``model`` is optional and resolved via
                ``_resolve_model``. session_id can be included in json for
                consistent routing.

        Returns:
            Rendered chat completion response (template-applied prompt and token IDs).
        """
        session_id, body = _extract_session_id_and_body(request_payload)
        body["model"] = self._resolve_model(body.get("model"), "render_chat_completion")

        headers = {"Content-Type": "application/json"}
        if session_id:
            headers["X-Session-ID"] = str(session_id)

        url = f"{self.proxy_url}/v1/chat/completions/render"
        gen_sem, _ = self._get_semaphores()
        if gen_sem is None:
            return await self._post(url, json=body, headers=headers)
        else:
            async with gen_sem:
                return await self._post(url, json=body, headers=headers)

    async def completion(
        self,
        request_payload: Dict[str, Any],
    ) -> Dict[str, Any]:
        """
        Completion via /v1/completions.

        Args:
            request_payload: Dict with {"json": <request-body>, "headers": <headers-dict>}.
                The request body should be OpenAI-compatible completion
                request. ``model`` is optional and resolved via
                ``_resolve_model``. session_id can be included in json for
                consistent routing.

        Returns:
            OpenAI-compatible completion response.
        """
        session_id, body = _extract_session_id_and_body(request_payload)
        body["model"] = self._resolve_model(body.get("model"), "completion")

        headers = {"Content-Type": "application/json"}
        if session_id:
            headers["X-Session-ID"] = str(session_id)

        url = f"{self.proxy_url}/v1/completions"
        gen_sem, _ = self._get_semaphores()
        if gen_sem is None:
            return await self._post(url, json=body, headers=headers)
        else:
            async with gen_sem:
                return await self._post(url, json=body, headers=headers)

    async def tokenize(
        self,
        texts: List[str],
        add_special_tokens: bool = True,
    ) -> List[List[int]]:
        """
        Tokenize texts.

        Uses the local tokenizer if available, otherwise falls back to HTTP /tokenize.

        Args:
            texts: List of texts to tokenize.
            add_special_tokens: Whether to add special tokens.

        Returns:
            List of token ID lists.
        """
        if self.tokenizer is not None:
            return self.tokenizer(texts, add_special_tokens=add_special_tokens)["input_ids"]

        url = f"{self.proxy_url}/tokenize"

        # vLLM /tokenize expects individual requests, batch them
        results = []
        for text in texts:
            payload = {
                "model": self.model_name,
                "prompt": text,
                "add_special_tokens": add_special_tokens,
            }
            result = await self._post(url, json=payload)
            results.append(result.get("tokens", []))

        return results

    async def detokenize(
        self,
        token_ids: List[List[int]],
    ) -> List[str]:
        """
        Detokenize token IDs.

        Uses the local tokenizer if available, otherwise falls back to HTTP /detokenize.

        Args:
            token_ids: List of token ID lists.

        Returns:
            List of decoded texts.
        """
        if self.tokenizer is not None:
            return self.tokenizer.batch_decode(token_ids)

        url = f"{self.proxy_url}/detokenize"

        # vLLM /detokenize expects individual requests, batch them
        results = []
        for ids in token_ids:
            payload = {
                "model": self.model_name,
                "tokens": ids,
            }
            result = await self._post(url, json=payload)
            results.append(result.get("prompt", ""))

        return results

    async def finish_session(self, session_id: str) -> None:
        """Notify the router that a session (trajectory) is complete.

        Best-effort data-plane call to the router's ``/finish_session`` endpoint.
        Session-aware routing policies (e.g. ``sticky_least_loaded``)
        use this to release the replica capacity held by the session so that new
        trajectories are balanced onto less-busy engines.

        Failures are logged but never raised: this runs in trajectory cleanup
        paths (``finally`` blocks, cancellation handlers) and must not mask the
        original outcome. Routers/policies that don't track sessions treat this
        as a no-op, and unknown session ids are ignored server-side.
        """
        if not session_id:
            return
        url = f"{self.proxy_url}/finish_session"
        try:
            session = await self._get_session()
            # Bound this best-effort cleanup call: the shared session has no
            # timeout (total=None), so an unresponsive router would otherwise
            # hang the trajectory's finally block forever and wedge the loop.
            async with session.post(
                url,
                params={"session_id": str(session_id)},
                timeout=aiohttp.ClientTimeout(total=10.0),
            ) as resp:
                # Drain the body so the keep-alive connection can be reused.
                await resp.read()
                if resp.status >= 400:
                    logger.warning(f"finish_session for session_id={session_id!r} returned HTTP {resp.status}")
        except asyncio.TimeoutError:
            logger.warning(f"finish_session for session_id={session_id!r} timed out after 10s (router unresponsive)")
        except Exception as e:
            logger.warning(f"finish_session for session_id={session_id!r} failed: {e}")

    # ---------------------------
    # Control Plane (fan-out to all server_urls)
    # ---------------------------

    async def _call_server(
        self,
        server_url: str,
        endpoint: str,
        json: Optional[Dict[str, Any]] = None,
        method: str = "POST",
        params: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """
        Call endpoint on a single server.

        Args:
            server_url: Base URL of the server.
            endpoint: Endpoint path (e.g., "/pause").
            json: JSON payload to send as request body.
            method: HTTP method (default: POST).
            params: URL query parameters (e.g., for FastAPI Query() params).

        Returns:
            Tuple of (server_url, {"status": <int>, "body": <response>}).
        """
        session = await self._get_session()
        url = f"{server_url}{endpoint}"
        async with _control_plane_request(session, method, url, json=json, params=params) as resp:
            body = await _read_json_body(resp) if resp.content_length else None
            raise_for_status(resp, body)
            return server_url, {"status": resp.status, "body": body}

    async def _call_all_servers(
        self,
        endpoint: str,
        json: Optional[Dict[str, Any]] = None,
        method: str = "POST",
        params: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """
        Call endpoint on all server_urls concurrently.

        Args:
            endpoint: Endpoint path (e.g., "/pause").
            json: JSON payload to send as request body.
            method: HTTP method (default: POST).
            params: URL query parameters (e.g., for FastAPI Query() params).

        Returns:
            Dict mapping server_url to response.
        """
        with deadline.operation(endpoint):
            results = await asyncio.gather(
                *[self._call_server(url, endpoint, json, method, params) for url in self.server_urls]
            )
        return {url: resp for url, resp in results}

    async def pause(self, mode: Union[PauseMode, str] = PauseMode.KEEP, clear_cache: bool = False) -> Dict[str, Any]:
        """
        Pause generation on all backends.

        Args:
            mode: Pause mode determining how in-flight requests are handled.
                Can be a PauseMode enum or string ("abort", "keep", "wait").
                - KEEP / "keep": Freeze in-flight requests in the scheduler.
                    They resume where they left off on /resume. KV cache is
                    preserved. No retry needed. (default)
                - ABORT / "abort": Abort in-flight requests immediately. Clients
                    receive partial tokens and must retry with accumulated context.
                - WAIT / "wait": Wait for in-flight requests to complete before
                    pausing. New requests are blocked. No retry needed.
            clear_cache: Whether to clear the KV cache on pause. Defaults to False.

        Returns:
            Dict mapping server_url to response.
        """
        if isinstance(mode, str):
            mode = PauseMode(mode.lower())

        params: Dict[str, Any] = {"mode": mode.value, "clear_cache": str(clear_cache).lower()}

        return await self._call_all_servers("/pause", params=params)

    async def resume(self) -> Dict[str, Any]:
        """Resume generation on all backends."""
        return await self._call_all_servers("/resume")

    async def pause_generation(self, clear_cache: bool = False) -> Dict[str, Any]:
        """Pause using keep mode."""
        return await self.pause(mode=PauseMode.KEEP, clear_cache=clear_cache)

    async def resume_generation(self) -> Dict[str, Any]:
        """Resume after pause."""
        return await self.resume()

    async def sleep(self, level: int = 2, tags: Optional[List[str]] = None) -> Dict[str, Any]:
        """
        Put all backends to sleep (offload weights to CPU).

        Args:
            level: Sleep level (1 or 2). Level 2 offloads more aggressively.
            tags: Optional list of tags to sleep specific resources.
                Common tags: ["weights"], ["kv_cache"], or None for all.

        Returns:
            Dict mapping server_url to response.
        """
        # Mirror BaseVLLMInferenceEngine.sleep: when the trainer syncs LoRA adapters
        # only, force level=1 so the base model survives via CPU backup. level=2
        # discards weights with no source to restore from on wake_up(["weights"]).
        if self.uses_lora_weight_sync and level != 1:
            logger.info(
                "Forcing sleep level=1 (uses_lora_weight_sync=True); requested level=%d would discard the base model.",
                level,
            )
            level = 1
        # vLLM's /sleep pauses the scheduler (default mode "abort") and, at level >= 1, resets
        # the prefix cache including running requests. With routed-experts capture enabled
        # (`enable_return_routed_experts`), vLLM 0.31 only allows that reset after a "keep"
        # pause, so a plain /sleep fails with "AuxOutput Connector only supports resetting
        # running requests after pause(mode='keep')". Abort in-flight requests explicitly
        # first, then sleep in "keep" mode: the same end state for every engine config.
        await self.pause(mode=PauseMode.ABORT, clear_cache=False)
        params: Dict[str, Any] = {"level": str(level), "mode": PauseMode.KEEP.value}
        if tags:
            params["tags"] = tags
        return await self._call_all_servers("/sleep", params=params)

    async def wake_up(self, tags: Optional[List[str]] = None) -> Dict[str, Any]:
        """
        Wake up all backends (load weights back to GPU).

        Args:
            tags: Optional list of tags to wake up specific resources.
                Common tags: ["weights"], ["kv_cache"], or None for all.
        """
        params = {"tags": tags} if tags else {}
        return await self._call_all_servers("/wake_up", params=params)

    async def sleep_for_weight_sync(self, offload_kv: bool = True) -> Dict[str, Any]:
        """Free GPU memory for weight sync while keeping in-flight requests frozen.

        Sleeps the allocator via ``/collective_rpc`` without touching the scheduler
        (unlike :meth:`sleep`, which preempts running requests and clears the prefix
        cache). ``offload_kv`` offloads the KV cache to CPU so frozen requests resume
        from it; set False to discard it (e.g. when the sync will reset it anyway).
        Pair with :meth:`wake_for_weight_sync`; the caller must KEEP-pause before and
        ``resume`` after.
        """
        return await self._call_all_servers(
            "/collective_rpc",
            {"method": "skyrl_sleep_for_weight_sync", "kwargs": {"offload_kv": offload_kv}},
        )

    async def wake_for_weight_sync(self, tags: List[str]) -> Dict[str, Any]:
        """Restore allocator pools by tag (see :meth:`sleep_for_weight_sync`).

        Wake ``["weights"]`` before the broadcast and ``["kv_cache"]`` after. Does
        not resume generation -- call :meth:`resume_generation` once KV is back.
        """
        return await self._call_all_servers(
            "/collective_rpc",
            {"method": "skyrl_wake_for_weight_sync", "kwargs": {"tags": tags}},
        )

    async def reset_prefix_cache(
        self,
        reset_running_requests: bool = False,
    ) -> Dict[str, Any]:
        """
        Reset KV cache on all backends.

        Args:
            reset_running_requests: Whether to reset running requests.

        Returns:
            Dict mapping server_url to response.
        """
        return await self._call_all_servers("/reset_prefix_cache", {"reset_running_requests": reset_running_requests})

    # ---------------------------
    # Weight Sync (control plane - fan-out)
    # ---------------------------

    async def update_named_weights(
        self,
        update_info: Dict[str, Any],
    ) -> Dict[str, Any]:
        """
        Update model weights via vLLM native /update_weights. Used for full parameter fine-tuning.

        For LoRA weight sync, use load_lora_adapter() instead.

        Args:
            update_info: Dict with keys expected by vLLM (names, dtype_names, shapes, packed, etc.)

        Returns:
            Dict mapping server_url to response.
        """
        return await self._call_all_servers(
            "/update_weights",
            {"update_info": update_info},
        )

    async def fetch_weights(
        self,
        target_version: int,
        sync_dir: Optional[str] = None,
        uri: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Fetch/apply a published checkpoint delta on every inference worker before
        pausing generation for reload.
        """
        kwargs: Dict[str, Any] = {"target_version": target_version}
        if sync_dir is not None:
            kwargs["sync_dir"] = sync_dir
        if uri is not None:
            kwargs["uri"] = uri
        return await self._call_all_servers("/fetch_weights", kwargs)

    # The weight-sync lifecycle (/start_weight_update, /update_weights,
    # /finish_weight_update) is driven by the trainer-side engines through the
    # blocking SkyrlWeightSyncClient (weight_sync/control_plane.py), which they
    # need because the protocol is synchronous and they run off the event loop.
    # What is left here is what the driver drives: pause/resume, prefix-cache
    # reset, /fetch_weights, LoRA, and /get_world_size at init.

    async def set_lora_receive_target(self, receive_target: Dict[str, Any]) -> Dict[str, Any]:
        """Arm every inference worker's receive engine for one LoRA adapter round.

        ``lora.sync_mode=memory`` ships a PEFT adapter down the ordinary weight
        transport, whose per-round payload carries only names, dtypes and shapes.
        The adapter's name, config and alias map travel here instead, over
        ``/collective_rpc``, and must land before the trainer calls
        ``send_weights()`` (see ``weight_sync/lora_target.py``).
        """
        return await self._call_all_servers(
            "/collective_rpc",
            {"method": "skyrl_set_lora_receive_target", "kwargs": {"receive_target": receive_target}},
        )

    async def load_lora_adapter(
        self,
        lora_name: str,
        lora_path: Optional[str] = None,
        *,
        in_memory: bool = False,
    ) -> Dict[str, Any]:
        """
        Load (or reload) a LoRA adapter on all backend servers via the SkyRL
        custom /skyrl/v1/load_lora_adapter endpoint.

        After loading, generation/chat/completion requests can target this LoRA
        by passing ``model=lora_name``.

        TODO(aaron): switch back to vLLM's /v1/load_lora_adapter once the
        upstream fix in https://github.com/vllm-project/vllm/pull/41482 lands
        in a vLLM release we depend on.

        The custom endpoint (defined in vllm_server_actor.py) wraps add_lora
        with load_inplace=True (so the engine reloads the freshly-written
        safetensors) and then resets the cached LoRARequest's load_inplace=False
        (so subsequent generates don't reload from disk on every step). This
        avoids two vLLM 0.19.0 bugs that surface under colocate_all + tp=1 +
        num_engines>=2 — see vllm_server_actor.py:_skyrl_load_lora_adapter for
        the detailed explanation.

        Args:
            lora_name: Name to register the adapter under on each server.
            lora_path: Path to the LoRA adapter on disk (must be accessible from servers).
            in_memory: Build the adapter from tensors already staged in every
                worker by a weight update armed with a LoRA receive target
                (``lora.sync_mode=memory``); no path is read. Mutually exclusive
                with ``lora_path``.

        Returns:
            Dict mapping server_url to response.
        """
        if in_memory == (lora_path is not None):
            raise ValueError("load_lora_adapter takes exactly one of lora_path or in_memory=True")
        session = await self._get_session()

        async def _load_on_server(server_url: str):
            url = f"{server_url}/skyrl/v1/load_lora_adapter"
            payload = (
                {"lora_name": lora_name, "in_memory": True}
                if in_memory
                else {"lora_name": lora_name, "lora_path": lora_path}
            )
            async with _control_plane_request(session, "POST", url, json=payload) as resp:
                if resp.status >= 400:
                    body = await _read_json_body(resp)
                    raise_for_status(resp, body)
                return server_url, {"status": resp.status, "body": await resp.text()}

        results = await asyncio.gather(*[_load_on_server(url) for url in self.server_urls])

        logger.info(f"Loaded LoRA adapter '{lora_name}' from {'staged GPU tensors' if in_memory else lora_path}")

        return {url: resp for url, resp in results}

    async def unload_lora_adapter(self, lora_name: str) -> Dict[str, Any]:
        """
        Unload a previously-loaded LoRA adapter on all backend servers via /v1/unload_lora_adapter.

        After unloading, ``lora_name`` is no longer accepted as a ``model``
        target on any server. The underlying CPU/GPU LRU entries on vLLM age
        out naturally as new adapters are loaded.

        Args:
            lora_name: Name of the adapter to unload.

        Returns:
            Dict mapping server_url to response.
        """
        payload = {"lora_name": lora_name}

        # Mirror load_lora_adapter: vLLM returns plain text on success and JSON
        # ErrorResponse (e.g. 404) on failure.
        session = await self._get_session()

        async def _unload_on_server(server_url: str):
            url = f"{server_url}/v1/unload_lora_adapter"
            async with _control_plane_request(session, "POST", url, json=payload) as resp:
                if resp.status >= 400:
                    body = await _read_json_body(resp)
                    raise_for_status(resp, body)
                return server_url, {"status": resp.status, "body": await resp.text()}

        results = await asyncio.gather(*[_unload_on_server(url) for url in self.server_urls])

        # An adapter published with lora.sync_mode=memory also holds staged GPU
        # tensors in every worker (kept after the load so vLLM can rebuild it
        # after an LRU eviction). Best-effort, like the unload itself.
        try:
            await self._call_all_servers(
                "/collective_rpc",
                {"method": "skyrl_discard_in_memory_lora", "kwargs": {"lora_name": lora_name}},
            )
        except Exception as e:
            logger.debug(f"Could not discard staged in-memory LoRA tensors for '{lora_name}': {e}")

        logger.info(f"Unloaded LoRA adapter '{lora_name}'")

        return {url: resp for url, resp in results}

    # ---------------------------
    # Info
    # ---------------------------

    async def get_world_size(self) -> Tuple[int, int]:
        """
        Get total and per-server world size across all inference workers.

        Fetches from vLLM's /get_world_size endpoint on each server.
        All servers are expected to have the same world size.
        Result is cached after first call.

        When data_parallel_size > 1, server_urls contains num_engines * dp_size entries.
        vLLM reports the full DP * TP world size per server, which already
        covers all DP ranks in one deployment. To avoid double-counting,
        total_world_size = per_server_ws * num_deployments (not num_servers).

        Returns:
            Tuple of (total_world_size, world_size_per_server).
        """
        if self._world_size is not None:
            return self._world_size

        results = await self._call_all_servers("/get_world_size", {}, method="GET")

        per_server = []
        for server_url in self.server_urls:
            resp = results.get(server_url)
            if resp is None:
                raise RuntimeError(f"No response for server {server_url}")
            body = resp.get("body", {})
            world_size = body.get("world_size")
            if world_size is None:
                raise RuntimeError(f"Missing world_size in response from {server_url}")
            per_server.append(world_size)

        assert all(
            ws == per_server[0] for ws in per_server
        ), f"All servers must have the same world_size, got {per_server}"

        # Each server is one DP rank. vLLM reports world_size = dp_size * tp_size * pp_size,
        # which is the worker count across ALL DP ranks in one deployment.
        # num_deployments = num_servers / dp_size (each deployment has dp_size servers).
        # Total unique workers = per_server_ws * num_deployments.
        num_deployments = len(self.server_urls) // self.data_parallel_size
        self._world_size = (per_server[0] * num_deployments, per_server[0])
        return self._world_size

    # ---------------------------
    # Lifecycle
    # ---------------------------

    async def teardown(self) -> None:
        """Close HTTP session."""
        if self._generate_client is not None:
            await self._generate_client.aclose()

    async def __aenter__(self) -> "RemoteInferenceClient":
        """Async context manager entry."""
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
        """Async context manager exit."""
        await self.teardown()

    # ---------------------------
    # Serialization
    # ---------------------------

    def __getstate__(self) -> dict:
        """Exclude non-serializable fields from pickle."""
        state = self.__dict__.copy()
        state["_generate_client"] = None
        state["_gen_sem"] = None
        state["_detok_sem"] = None
        state["_sem_loop"] = None
        return state

    def __setstate__(self, state: dict) -> None:
        """Restore state after unpickling."""
        self.__dict__.update(state)
        self._gen_sem = None
        self._detok_sem = None
        self._sem_loop = None

    async def aclose(self) -> None:
        await self.teardown()

    async def is_sleeping(self):
        ret = await self._call_all_servers("/is_sleeping", method="GET")
        return all(response["body"]["is_sleeping"] for response in ret.values())

attr proxy_url

proxy_url: str

Data plane URL (single endpoint - router or direct server).

attr server_urls

server_urls: List[str]

Control plane URLs (list of backend servers for fan-out).

attr data_parallel_size

data_parallel_size: int

Data parallel size. Used to compute total inference world size correctly: server_urls contains num_engines * data_parallel_size entries, but vLLM already reports the full DP world size per server, so we divide by num_deployments.

attr abstractmethod property model_name

model_name: str = 'default'

The model identifier accepted by the inference server for the base model.

This is usually the model path, but may be served_model_name when vLLM is started with an alias. It is never a LoRA adapter name. LoRA adapters are addressed by the names callers register them under via load_lora_adapter(name, path), and per-call routing is done by passing that name as model on the data-plane methods.

Used internally only by tokenize/detokenize, which are LoRA- agnostic but still require a model field per the OpenAI schema.

attr enable_return_routed_experts

enable_return_routed_experts: bool = False

Whether to return routed expert indices (R3 / rollout router replay).

attr enable_return_sample_support_set

enable_return_sample_support_set: bool = False

Whether the engine may return the sampler's bounded top-k support per generated token. Capture is per-request: callers opt a batch in with InferenceEngineInput.return_sample_support.

attr uses_lora_weight_sync

uses_lora_weight_sync: bool = False

True when the trainer syncs LoRA adapters (rather than full/merged weights). When True, sleep() is forced to level=1: level=2 discards the base model from VRAM with no CPU backup, and LoRA-only broadcasts cannot repopulate it. Must be kept in sync with the same gate vLLM uses for enable_lora (see _uses_lora_weight_sync in inference_servers/utils.py).

attr tokenizer

tokenizer: Optional[Any] = None

Optional HF tokenizer for local tokenize/detokenize (avoids HTTP round-trips).

attr property weight_version

weight_version: int

Number of weight syncs to the engines so far (0 before the first sync); the policy version.

method increment_weight_version

increment_weight_version() -> None

Advance the weight version. Called once per completed weight sync to the engines.

Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:541-543
    def increment_weight_version(self) -> None:
        """Advance the weight version. Called once per completed weight sync to the engines."""
        self._weight_version += 1

method abstractmethod get_endpoint_url

get_endpoint_url() -> str

Data-plane endpoint base URL (the router/proxy that load-balances requests).

Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:554-556
    def get_endpoint_url(self) -> str:
        """Data-plane endpoint base URL (the router/proxy that load-balances requests)."""
        return self.proxy_url

method async generate

generate(input_batch: InferenceEngineInput, model: Optional[str] = None) -> InferenceEngineOutput

Generate completions via /v1/completions.

This is the interface for token-in-token-out workflows. Input will have token ids, and the output is token ids as well.

Each prompt is sent as a separate request to allow the router to route based on session_id. All requests are made in parallel.

With keep-mode pause, in-flight requests are frozen and resume transparently after /resume -- no client-side retry needed.

Parameters:

NameTypeDescriptionDefault
input_batchInferenceEngineInputContains prompt_token_ids, sampling_params, and optional session_ids.required
modelOptional[str]Optional model identifier — the base model name or a loaded LoRA adapter name. When omitted, defaults to self.model_name if LoRA is not in use; raises ValueError if it is.None

Returns:

TypeDescription
InferenceEngineOutputInferenceEngineOutput with responses, response_ids, and stop_reasons.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:620-735
    async def generate(
        self,
        input_batch: InferenceEngineInput,
        model: Optional[str] = None,
    ) -> InferenceEngineOutput:
        """
        Generate completions via /v1/completions.

        This is the interface for token-in-token-out workflows. Input will have
        token ids, and the output is token ids as well.

        Each prompt is sent as a separate request to allow the router to route
        based on session_id. All requests are made in parallel.

        With keep-mode pause, in-flight requests are frozen and resume
        transparently after /resume -- no client-side retry needed.

        Args:
            input_batch: Contains prompt_token_ids, sampling_params, and optional session_ids.
            model: Optional model identifier — the base model name or a loaded
                LoRA adapter name. When omitted, defaults to ``self.model_name``
                if LoRA is not in use; raises ``ValueError`` if it is.

        Returns:
            InferenceEngineOutput with responses, response_ids, and stop_reasons.
        """
        model = self._resolve_model(model, "generate")

        prompt_token_ids = input_batch.get("prompt_token_ids")
        if prompt_token_ids is None:
            raise ValueError("RemoteInferenceClient only accepts `prompt_token_ids`, not `prompts`.")

        sampling_params = input_batch.get("sampling_params") or {}
        if sampling_params.get("n", 1) > 1:
            raise ValueError("n > 1 is not supported. Use `config.generator.n_samples_per_prompt` instead.")

        session_ids = input_batch.get("session_ids")
        mm_features = input_batch.get("mm_features")
        cache_salt = input_batch.get("cache_salt")
        routed_experts_prompt_starts = input_batch.get("routed_experts_prompt_starts")
        if routed_experts_prompt_starts is not None:
            if not self.enable_return_routed_experts:
                raise ValueError("routed_experts_prompt_starts requires enable_return_routed_experts=True")
            if len(routed_experts_prompt_starts) != len(prompt_token_ids):
                raise ValueError("routed_experts_prompt_starts must have one entry per prompt")
        return_sample_support = self.enable_return_sample_support_set and input_batch.get(
            "return_sample_support", False
        )
        get_logprobs = sampling_params.get("logprobs") is not None

        # Two semaphores decouple the generate and detokenize stages:
        #   gen_sem:   limits concurrent in-flight generate requests so we don't
        #              overwhelm the router/vLLM scheduler.  Released as soon as
        #              generation finishes, so the GPU slot is freed immediately.
        #   detok_sem: limits concurrent detokenize calls independently.  Uses the
        #              same concurrency limit so detokenize never starves generate.
        # Semaphores are shared across all concurrent generate() calls on this client
        # instance, so total in-flight requests are capped at
        # SKYRL_GENERATE_CONCURRENCY_PER_ENGINE × num_engines regardless of how many
        # callers invoke generate() simultaneously.
        # TODO (sumanthrh) (RemoteInferenceClient data-plane-deprecation): We should move this outside of the client to a runner abstraction that will also parallelize client requests across processes.
        gen_sem, detok_sem = self._get_semaphores()
        batch_size = len(prompt_token_ids)

        async def _throttled_generate(idx: int) -> Dict[str, Any]:
            if gen_sem is None:
                return await self._generate_single(
                    prompt_token_ids=prompt_token_ids[idx],
                    sampling_params=sampling_params,
                    session_id=session_ids[idx] if session_ids and idx < len(session_ids) else None,
                    mm_features=mm_features[idx] if mm_features and idx < len(mm_features) else None,
                    routed_experts_prompt_start=(
                        routed_experts_prompt_starts[idx] if routed_experts_prompt_starts is not None else None
                    ),
                    return_sample_support=return_sample_support,
                    model=model,
                    cache_salt=cache_salt,
                )
            async with gen_sem:
                return await self._generate_single(
                    prompt_token_ids=prompt_token_ids[idx],
                    sampling_params=sampling_params,
                    session_id=session_ids[idx] if session_ids and idx < len(session_ids) else None,
                    mm_features=mm_features[idx] if mm_features and idx < len(mm_features) else None,
                    routed_experts_prompt_start=(
                        routed_experts_prompt_starts[idx] if routed_experts_prompt_starts is not None else None
                    ),
                    return_sample_support=return_sample_support,
                    model=model,
                    cache_salt=cache_salt,
                )

        async def _throttled_detokenize(token_ids: List[int]) -> str:
            if detok_sem is None:
                return (await self.detokenize([token_ids]))[0]
            async with detok_sem:
                return (await self.detokenize([token_ids]))[0]

        raw_results = await asyncio.gather(*[_throttled_generate(idx) for idx in range(batch_size)])
        responses = await asyncio.gather(*[_throttled_detokenize(r["response_ids"]) for r in raw_results])

        rollout_expert_indices = (
            [result["routed_experts"] for result in raw_results] if self.enable_return_routed_experts else None
        )
        rollout_sample_support = (
            [result[PackedField.ROLLOUT_SAMPLE_SUPPORT] for result in raw_results] if return_sample_support else None
        )

        return InferenceEngineOutput(
            responses=responses,
            stop_reasons=[r["stop_reason"] for r in raw_results],
            response_ids=[r["response_ids"] for r in raw_results],
            response_logprobs=[r["response_logprobs"] for r in raw_results] if get_logprobs else None,
            rollout_expert_indices=rollout_expert_indices,
            rollout_sample_support=rollout_sample_support,
        )

method abstractmethod sample

sample(request_payload: SampleRequestPayload) -> SampleResponse

Sample completions via /inference/v1/generate (Tinker API).

Maps Tinker-style sample requests to the vLLM generate endpoint. Uses self._post() for automatic retry + backoff on transient errors.

Parameters:

NameTypeDescriptionDefault
request_payloadSampleRequestPayloadSampleRequestPayload with {"json": <request-body>}. Expected keys in json: prompt, num_samples, sampling_params, session_id, include_prompt_logprobs (bool), topk_prompt_logprobs (int). model is optional and resolved via _resolve_model.required

Returns:

TypeDescription
SampleResponseSampleResponse with type="sample", sequences list, prompt_logprobs, and topk_prompt_logprobs.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:853-961
    async def sample(
        self,
        request_payload: SampleRequestPayload,
    ) -> SampleResponse:
        """
        Sample completions via /inference/v1/generate (Tinker API).

        Maps Tinker-style sample requests to the vLLM generate endpoint.
        Uses self._post() for automatic retry + backoff on transient errors.

        Args:
            request_payload: SampleRequestPayload with {"json": <request-body>}.
                Expected keys in json: prompt, num_samples, sampling_params,
                session_id, include_prompt_logprobs (bool), topk_prompt_logprobs (int).
                ``model`` is optional and resolved via ``_resolve_model``.

        Returns:
            SampleResponse with type="sample", sequences list, prompt_logprobs, and topk_prompt_logprobs.
        """
        session_id, body = _extract_session_id_and_body(request_payload)
        model = self._resolve_model(body.get("model"), "sample")
        body["model"] = model

        prompt = body.get("prompt", {})
        num_samples = body.get("num_samples", 1)
        tinker_params = body.get("sampling_params", {})

        # Note: Tinker SampleRequest uses "prompt_logprobs" (bool), while
        # SamplingClient.sample() uses "include_prompt_logprobs".
        include_prompt_logprobs = body.get("include_prompt_logprobs", body.get("prompt_logprobs", False))
        topk_prompt_logprobs_k = body.get("topk_prompt_logprobs", 0)

        # vLLM prompt logprob mapping
        prompt_logprobs_sp = None
        if include_prompt_logprobs:
            prompt_logprobs_sp = topk_prompt_logprobs_k if topk_prompt_logprobs_k > 0 else 0

        # Render prompt: flatten text tokens and, if images are present,
        # call the render endpoint to get placeholder tokens + features.
        token_ids, mm_features = await self._render_for_sample(prompt, session_id, model=model)

        # Map Tinker SamplingParams → vLLM format
        sampling_params: Dict[str, Any] = {
            "n": num_samples,
            "logprobs": 0,
            "output_kind": 2,
            "prompt_logprobs": prompt_logprobs_sp,
        }

        for tinker_key, vllm_key in _TINKER_SAMPLE_TO_VLLM_PARAM_MAP.items():
            val = tinker_params.get(tinker_key)
            if val is not None:
                sampling_params[vllm_key] = val

        payload: Dict[str, Any] = {
            "sampling_params": sampling_params,
            "model": model,
            "token_ids": token_ids,
        }
        if mm_features is not None:
            payload["features"] = mm_features

        headers = {"Content-Type": "application/json"}
        if session_id:
            headers["X-Session-ID"] = str(session_id)

        url = f"{self.proxy_url}/inference/v1/generate"
        gen_sem, _ = self._get_semaphores()
        if gen_sem is None:
            response = await self._post(url, json=payload, headers=headers)
        else:
            async with gen_sem:
                response = await self._post(url, json=payload, headers=headers)

        # vLLM returns: list[dict[str(token_id) → {"logprob": float, ...}] | None]
        result_prompt_logprobs: Optional[List[Optional[float]]] = None
        result_topk_prompt_logprobs: Optional[List[Optional[List[Tuple[int, float]]]]] = None

        if include_prompt_logprobs:
            result_prompt_logprobs, result_topk_prompt_logprobs = convert_vllm_prompt_logprobs(
                token_ids,
                response.get("prompt_logprobs"),
                topk=topk_prompt_logprobs_k,
            )

        # Transform response choices → sequences
        sequences = []
        for choice in response.get("choices", []):
            seq_logprobs: Optional[List[float]] = None
            logprobs_data = choice.get("logprobs")
            if logprobs_data is not None:
                logprobs_content = logprobs_data.get("content", [])
                if logprobs_content:
                    seq_logprobs = [lp["logprob"] for lp in logprobs_content]

            sequences.append(
                {
                    "tokens": choice["token_ids"],
                    "logprobs": seq_logprobs,
                    "stop_reason": choice.get("finish_reason"),
                }
            )

        return {
            "type": "sample",
            "sequences": sequences,
            "prompt_logprobs": result_prompt_logprobs,
            "topk_prompt_logprobs": result_topk_prompt_logprobs,
        }

method abstractmethod async chat_completion

chat_completion(request_payload: Dict[str, Any]) -> Dict[str, Any]

Chat completion via /v1/chat/completions.

Parameters:

NameTypeDescriptionDefault
request_payloadDict[str, Any]Dict with {"json": <request-body>, "headers": <headers-dict>}. The request body must be an OpenAI-compatible chat completion request. model is optional and resolved via _resolve_model; if omitted the body is mutated to inject the resolved value before forwarding to vLLM. session_id can be included in the body for consistent routing.required

Returns:

TypeDescription
Dict[str, Any]OpenAI-compatible chat completion response.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:963-994
    async def chat_completion(
        self,
        request_payload: Dict[str, Any],
    ) -> Dict[str, Any]:
        """
        Chat completion via /v1/chat/completions.

        Args:
            request_payload: Dict with {"json": <request-body>, "headers": <headers-dict>}.
                The request body must be an OpenAI-compatible chat completion
                request. ``model`` is optional and resolved via
                ``_resolve_model``; if omitted the body is mutated to inject the
                resolved value before forwarding to vLLM. ``session_id`` can be
                included in the body for consistent routing.

        Returns:
            OpenAI-compatible chat completion response.
        """
        session_id, body = _extract_session_id_and_body(request_payload)
        body["model"] = self._resolve_model(body.get("model"), "chat_completion")

        headers = {"Content-Type": "application/json"}
        if session_id:
            headers["X-Session-ID"] = str(session_id)

        url = f"{self.proxy_url}/v1/chat/completions"
        gen_sem, _ = self._get_semaphores()
        if gen_sem is None:
            return await self._post(url, json=body, headers=headers)
        else:
            async with gen_sem:
                return await self._post(url, json=body, headers=headers)

method abstractmethod async render_chat_completion

render_chat_completion(request_payload: Dict[str, Any]) -> Dict[str, Any]

Render a chat completion (apply chat template + tokenize) via /v1/chat/completions/render.

Parameters:

NameTypeDescriptionDefault
request_payloadDict[str, Any]Dict with {"json": <request-body>}. The request body should be OpenAI-compatible chat completion request. model is optional and resolved via _resolve_model. session_id can be included in json for consistent routing.required

Returns:

TypeDescription
Dict[str, Any]Rendered chat completion response (template-applied prompt and token IDs).
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:996-1026
    async def render_chat_completion(
        self,
        request_payload: Dict[str, Any],
    ) -> Dict[str, Any]:
        """
        Render a chat completion (apply chat template + tokenize) via /v1/chat/completions/render.

        Args:
            request_payload: Dict with {"json": <request-body>}.
                The request body should be OpenAI-compatible chat completion
                request. ``model`` is optional and resolved via
                ``_resolve_model``. session_id can be included in json for
                consistent routing.

        Returns:
            Rendered chat completion response (template-applied prompt and token IDs).
        """
        session_id, body = _extract_session_id_and_body(request_payload)
        body["model"] = self._resolve_model(body.get("model"), "render_chat_completion")

        headers = {"Content-Type": "application/json"}
        if session_id:
            headers["X-Session-ID"] = str(session_id)

        url = f"{self.proxy_url}/v1/chat/completions/render"
        gen_sem, _ = self._get_semaphores()
        if gen_sem is None:
            return await self._post(url, json=body, headers=headers)
        else:
            async with gen_sem:
                return await self._post(url, json=body, headers=headers)

method abstractmethod async completion

completion(request_payload: Dict[str, Any]) -> Dict[str, Any]

Completion via /v1/completions.

Parameters:

NameTypeDescriptionDefault
request_payloadDict[str, Any]Dict with {"json": <request-body>, "headers": <headers-dict>}. The request body should be OpenAI-compatible completion request. model is optional and resolved via _resolve_model. session_id can be included in json for consistent routing.required

Returns:

TypeDescription
Dict[str, Any]OpenAI-compatible completion response.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1028-1058
    async def completion(
        self,
        request_payload: Dict[str, Any],
    ) -> Dict[str, Any]:
        """
        Completion via /v1/completions.

        Args:
            request_payload: Dict with {"json": <request-body>, "headers": <headers-dict>}.
                The request body should be OpenAI-compatible completion
                request. ``model`` is optional and resolved via
                ``_resolve_model``. session_id can be included in json for
                consistent routing.

        Returns:
            OpenAI-compatible completion response.
        """
        session_id, body = _extract_session_id_and_body(request_payload)
        body["model"] = self._resolve_model(body.get("model"), "completion")

        headers = {"Content-Type": "application/json"}
        if session_id:
            headers["X-Session-ID"] = str(session_id)

        url = f"{self.proxy_url}/v1/completions"
        gen_sem, _ = self._get_semaphores()
        if gen_sem is None:
            return await self._post(url, json=body, headers=headers)
        else:
            async with gen_sem:
                return await self._post(url, json=body, headers=headers)

method async tokenize

tokenize(texts: List[str], add_special_tokens: bool = True) -> List[List[int]]

Tokenize texts.

Uses the local tokenizer if available, otherwise falls back to HTTP /tokenize.

Parameters:

NameTypeDescriptionDefault
textsList[str]List of texts to tokenize.required
add_special_tokensboolWhether to add special tokens.True

Returns:

TypeDescription
List[List[int]]List of token ID lists.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1060-1093
    async def tokenize(
        self,
        texts: List[str],
        add_special_tokens: bool = True,
    ) -> List[List[int]]:
        """
        Tokenize texts.

        Uses the local tokenizer if available, otherwise falls back to HTTP /tokenize.

        Args:
            texts: List of texts to tokenize.
            add_special_tokens: Whether to add special tokens.

        Returns:
            List of token ID lists.
        """
        if self.tokenizer is not None:
            return self.tokenizer(texts, add_special_tokens=add_special_tokens)["input_ids"]

        url = f"{self.proxy_url}/tokenize"

        # vLLM /tokenize expects individual requests, batch them
        results = []
        for text in texts:
            payload = {
                "model": self.model_name,
                "prompt": text,
                "add_special_tokens": add_special_tokens,
            }
            result = await self._post(url, json=payload)
            results.append(result.get("tokens", []))

        return results

method async detokenize

detokenize(token_ids: List[List[int]]) -> List[str]

Detokenize token IDs.

Uses the local tokenizer if available, otherwise falls back to HTTP /detokenize.

Parameters:

NameTypeDescriptionDefault
token_idsList[List[int]]List of token ID lists.required

Returns:

TypeDescription
List[str]List of decoded texts.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1095-1125
    async def detokenize(
        self,
        token_ids: List[List[int]],
    ) -> List[str]:
        """
        Detokenize token IDs.

        Uses the local tokenizer if available, otherwise falls back to HTTP /detokenize.

        Args:
            token_ids: List of token ID lists.

        Returns:
            List of decoded texts.
        """
        if self.tokenizer is not None:
            return self.tokenizer.batch_decode(token_ids)

        url = f"{self.proxy_url}/detokenize"

        # vLLM /detokenize expects individual requests, batch them
        results = []
        for ids in token_ids:
            payload = {
                "model": self.model_name,
                "tokens": ids,
            }
            result = await self._post(url, json=payload)
            results.append(result.get("prompt", ""))

        return results

method abstractmethod async finish_session

finish_session(session_id: str) -> None

Notify the router that a session (trajectory) is complete.

Best-effort data-plane call to the router's /finish_session endpoint. Session-aware routing policies (e.g. sticky_least_loaded) use this to release the replica capacity held by the session so that new trajectories are balanced onto less-busy engines.

Failures are logged but never raised: this runs in trajectory cleanup paths (finally blocks, cancellation handlers) and must not mask the original outcome. Routers/policies that don't track sessions treat this as a no-op, and unknown session ids are ignored server-side.

Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1127-1160
    async def finish_session(self, session_id: str) -> None:
        """Notify the router that a session (trajectory) is complete.

        Best-effort data-plane call to the router's ``/finish_session`` endpoint.
        Session-aware routing policies (e.g. ``sticky_least_loaded``)
        use this to release the replica capacity held by the session so that new
        trajectories are balanced onto less-busy engines.

        Failures are logged but never raised: this runs in trajectory cleanup
        paths (``finally`` blocks, cancellation handlers) and must not mask the
        original outcome. Routers/policies that don't track sessions treat this
        as a no-op, and unknown session ids are ignored server-side.
        """
        if not session_id:
            return
        url = f"{self.proxy_url}/finish_session"
        try:
            session = await self._get_session()
            # Bound this best-effort cleanup call: the shared session has no
            # timeout (total=None), so an unresponsive router would otherwise
            # hang the trajectory's finally block forever and wedge the loop.
            async with session.post(
                url,
                params={"session_id": str(session_id)},
                timeout=aiohttp.ClientTimeout(total=10.0),
            ) as resp:
                # Drain the body so the keep-alive connection can be reused.
                await resp.read()
                if resp.status >= 400:
                    logger.warning(f"finish_session for session_id={session_id!r} returned HTTP {resp.status}")
        except asyncio.TimeoutError:
            logger.warning(f"finish_session for session_id={session_id!r} timed out after 10s (router unresponsive)")
        except Exception as e:
            logger.warning(f"finish_session for session_id={session_id!r} failed: {e}")

method async pause

pause(mode: Union[PauseMode, str] = PauseMode.KEEP, clear_cache: bool = False) -> Dict[str, Any]

Pause generation on all backends.

Parameters:

NameTypeDescriptionDefault
modeUnion[PauseMode, str]Pause mode determining how in-flight requests are handled. Can be a PauseMode enum or string ("abort", "keep", "wait"). - KEEP / "keep": Freeze in-flight requests in the scheduler. They resume where they left off on /resume. KV cache is preserved. No retry needed. (default) - ABORT / "abort": Abort in-flight requests immediately. Clients receive partial tokens and must retry with accumulated context. - WAIT / "wait": Wait for in-flight requests to complete before pausing. New requests are blocked. No retry needed.KEEP
clear_cacheboolWhether to clear the KV cache on pause. Defaults to False.False

Returns:

TypeDescription
Dict[str, Any]Dict mapping server_url to response.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1219-1243
    async def pause(self, mode: Union[PauseMode, str] = PauseMode.KEEP, clear_cache: bool = False) -> Dict[str, Any]:
        """
        Pause generation on all backends.

        Args:
            mode: Pause mode determining how in-flight requests are handled.
                Can be a PauseMode enum or string ("abort", "keep", "wait").
                - KEEP / "keep": Freeze in-flight requests in the scheduler.
                    They resume where they left off on /resume. KV cache is
                    preserved. No retry needed. (default)
                - ABORT / "abort": Abort in-flight requests immediately. Clients
                    receive partial tokens and must retry with accumulated context.
                - WAIT / "wait": Wait for in-flight requests to complete before
                    pausing. New requests are blocked. No retry needed.
            clear_cache: Whether to clear the KV cache on pause. Defaults to False.

        Returns:
            Dict mapping server_url to response.
        """
        if isinstance(mode, str):
            mode = PauseMode(mode.lower())

        params: Dict[str, Any] = {"mode": mode.value, "clear_cache": str(clear_cache).lower()}

        return await self._call_all_servers("/pause", params=params)

method async resume

resume() -> Dict[str, Any]

Resume generation on all backends.

Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1245-1247
    async def resume(self) -> Dict[str, Any]:
        """Resume generation on all backends."""
        return await self._call_all_servers("/resume")

method abstractmethod async pause_generation

pause_generation(clear_cache: bool = False) -> Dict[str, Any]

Pause using keep mode.

Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1249-1251
    async def pause_generation(self, clear_cache: bool = False) -> Dict[str, Any]:
        """Pause using keep mode."""
        return await self.pause(mode=PauseMode.KEEP, clear_cache=clear_cache)

method abstractmethod async resume_generation

resume_generation() -> Dict[str, Any]

Resume after pause.

Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1253-1255
    async def resume_generation(self) -> Dict[str, Any]:
        """Resume after pause."""
        return await self.resume()

method abstractmethod async sleep

sleep(level: int = 2, tags: Optional[List[str]] = None) -> Dict[str, Any]

Put all backends to sleep (offload weights to CPU).

Parameters:

NameTypeDescriptionDefault
levelintSleep level (1 or 2). Level 2 offloads more aggressively.2
tagsOptional[List[str]]Optional list of tags to sleep specific resources. Common tags: ["weights"], ["kv_cache"], or None for all.None

Returns:

TypeDescription
Dict[str, Any]Dict mapping server_url to response.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1257-1288
    async def sleep(self, level: int = 2, tags: Optional[List[str]] = None) -> Dict[str, Any]:
        """
        Put all backends to sleep (offload weights to CPU).

        Args:
            level: Sleep level (1 or 2). Level 2 offloads more aggressively.
            tags: Optional list of tags to sleep specific resources.
                Common tags: ["weights"], ["kv_cache"], or None for all.

        Returns:
            Dict mapping server_url to response.
        """
        # Mirror BaseVLLMInferenceEngine.sleep: when the trainer syncs LoRA adapters
        # only, force level=1 so the base model survives via CPU backup. level=2
        # discards weights with no source to restore from on wake_up(["weights"]).
        if self.uses_lora_weight_sync and level != 1:
            logger.info(
                "Forcing sleep level=1 (uses_lora_weight_sync=True); requested level=%d would discard the base model.",
                level,
            )
            level = 1
        # vLLM's /sleep pauses the scheduler (default mode "abort") and, at level >= 1, resets
        # the prefix cache including running requests. With routed-experts capture enabled
        # (`enable_return_routed_experts`), vLLM 0.31 only allows that reset after a "keep"
        # pause, so a plain /sleep fails with "AuxOutput Connector only supports resetting
        # running requests after pause(mode='keep')". Abort in-flight requests explicitly
        # first, then sleep in "keep" mode: the same end state for every engine config.
        await self.pause(mode=PauseMode.ABORT, clear_cache=False)
        params: Dict[str, Any] = {"level": str(level), "mode": PauseMode.KEEP.value}
        if tags:
            params["tags"] = tags
        return await self._call_all_servers("/sleep", params=params)

method abstractmethod async wake_up

wake_up(tags: Optional[List[str]] = None) -> Dict[str, Any]

Wake up all backends (load weights back to GPU).

Parameters:

NameTypeDescriptionDefault
tagsOptional[List[str]]Optional list of tags to wake up specific resources. Common tags: ["weights"], ["kv_cache"], or None for all.None
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1290-1299
    async def wake_up(self, tags: Optional[List[str]] = None) -> Dict[str, Any]:
        """
        Wake up all backends (load weights back to GPU).

        Args:
            tags: Optional list of tags to wake up specific resources.
                Common tags: ["weights"], ["kv_cache"], or None for all.
        """
        params = {"tags": tags} if tags else {}
        return await self._call_all_servers("/wake_up", params=params)

method async sleep_for_weight_sync

sleep_for_weight_sync(offload_kv: bool = True) -> Dict[str, Any]

Free GPU memory for weight sync while keeping in-flight requests frozen.

Sleeps the allocator via /collective_rpc without touching the scheduler (unlike :meth:sleep, which preempts running requests and clears the prefix cache). offload_kv offloads the KV cache to CPU so frozen requests resume from it; set False to discard it (e.g. when the sync will reset it anyway). Pair with :meth:wake_for_weight_sync; the caller must KEEP-pause before and resume after.

Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1301-1314
    async def sleep_for_weight_sync(self, offload_kv: bool = True) -> Dict[str, Any]:
        """Free GPU memory for weight sync while keeping in-flight requests frozen.

        Sleeps the allocator via ``/collective_rpc`` without touching the scheduler
        (unlike :meth:`sleep`, which preempts running requests and clears the prefix
        cache). ``offload_kv`` offloads the KV cache to CPU so frozen requests resume
        from it; set False to discard it (e.g. when the sync will reset it anyway).
        Pair with :meth:`wake_for_weight_sync`; the caller must KEEP-pause before and
        ``resume`` after.
        """
        return await self._call_all_servers(
            "/collective_rpc",
            {"method": "skyrl_sleep_for_weight_sync", "kwargs": {"offload_kv": offload_kv}},
        )

method async wake_for_weight_sync

wake_for_weight_sync(tags: List[str]) -> Dict[str, Any]

Restore allocator pools by tag (see :meth:sleep_for_weight_sync).

Wake ["weights"] before the broadcast and ["kv_cache"] after. Does not resume generation -- call :meth:resume_generation once KV is back.

Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1316-1325
    async def wake_for_weight_sync(self, tags: List[str]) -> Dict[str, Any]:
        """Restore allocator pools by tag (see :meth:`sleep_for_weight_sync`).

        Wake ``["weights"]`` before the broadcast and ``["kv_cache"]`` after. Does
        not resume generation -- call :meth:`resume_generation` once KV is back.
        """
        return await self._call_all_servers(
            "/collective_rpc",
            {"method": "skyrl_wake_for_weight_sync", "kwargs": {"tags": tags}},
        )

method abstractmethod async reset_prefix_cache

reset_prefix_cache(reset_running_requests: bool = False) -> Dict[str, Any]

Reset KV cache on all backends.

Parameters:

NameTypeDescriptionDefault
reset_running_requestsboolWhether to reset running requests.False

Returns:

TypeDescription
Dict[str, Any]Dict mapping server_url to response.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1327-1340
    async def reset_prefix_cache(
        self,
        reset_running_requests: bool = False,
    ) -> Dict[str, Any]:
        """
        Reset KV cache on all backends.

        Args:
            reset_running_requests: Whether to reset running requests.

        Returns:
            Dict mapping server_url to response.
        """
        return await self._call_all_servers("/reset_prefix_cache", {"reset_running_requests": reset_running_requests})

method abstractmethod async update_named_weights

update_named_weights(update_info: Dict[str, Any]) -> Dict[str, Any]

Update model weights via vLLM native /update_weights. Used for full parameter fine-tuning.

For LoRA weight sync, use load_lora_adapter() instead.

Parameters:

NameTypeDescriptionDefault
update_infoDict[str, Any]Dict with keys expected by vLLM (names, dtype_names, shapes, packed, etc.)required

Returns:

TypeDescription
Dict[str, Any]Dict mapping server_url to response.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1346-1364
    async def update_named_weights(
        self,
        update_info: Dict[str, Any],
    ) -> Dict[str, Any]:
        """
        Update model weights via vLLM native /update_weights. Used for full parameter fine-tuning.

        For LoRA weight sync, use load_lora_adapter() instead.

        Args:
            update_info: Dict with keys expected by vLLM (names, dtype_names, shapes, packed, etc.)

        Returns:
            Dict mapping server_url to response.
        """
        return await self._call_all_servers(
            "/update_weights",
            {"update_info": update_info},
        )

method async fetch_weights

fetch_weights(target_version: int, sync_dir: Optional[str] = None, uri: Optional[str] = None) -> Dict[str, Any]

Fetch/apply a published checkpoint delta on every inference worker before pausing generation for reload.

Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1366-1381
    async def fetch_weights(
        self,
        target_version: int,
        sync_dir: Optional[str] = None,
        uri: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Fetch/apply a published checkpoint delta on every inference worker before
        pausing generation for reload.
        """
        kwargs: Dict[str, Any] = {"target_version": target_version}
        if sync_dir is not None:
            kwargs["sync_dir"] = sync_dir
        if uri is not None:
            kwargs["uri"] = uri
        return await self._call_all_servers("/fetch_weights", kwargs)

method async set_lora_receive_target

set_lora_receive_target(receive_target: Dict[str, Any]) -> Dict[str, Any]

Arm every inference worker's receive engine for one LoRA adapter round.

lora.sync_mode=memory ships a PEFT adapter down the ordinary weight transport, whose per-round payload carries only names, dtypes and shapes. The adapter's name, config and alias map travel here instead, over /collective_rpc, and must land before the trainer calls send_weights() (see weight_sync/lora_target.py).

Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1390-1402
    async def set_lora_receive_target(self, receive_target: Dict[str, Any]) -> Dict[str, Any]:
        """Arm every inference worker's receive engine for one LoRA adapter round.

        ``lora.sync_mode=memory`` ships a PEFT adapter down the ordinary weight
        transport, whose per-round payload carries only names, dtypes and shapes.
        The adapter's name, config and alias map travel here instead, over
        ``/collective_rpc``, and must land before the trainer calls
        ``send_weights()`` (see ``weight_sync/lora_target.py``).
        """
        return await self._call_all_servers(
            "/collective_rpc",
            {"method": "skyrl_set_lora_receive_target", "kwargs": {"receive_target": receive_target}},
        )

method abstractmethod async load_lora_adapter

load_lora_adapter(lora_name: str, lora_path: Optional[str] = None, *, in_memory: bool = False) -> Dict[str, Any]

Load (or reload) a LoRA adapter on all backend servers via the SkyRL custom /skyrl/v1/load_lora_adapter endpoint.

After loading, generation/chat/completion requests can target this LoRA by passing model=lora_name.

TODO(aaron): switch back to vLLM's /v1/load_lora_adapter once the upstream fix in https://github.com/vllm-project/vllm/pull/41482 lands in a vLLM release we depend on.

The custom endpoint (defined in vllm_server_actor.py) wraps add_lora with load_inplace=True (so the engine reloads the freshly-written safetensors) and then resets the cached LoRARequest's load_inplace=False (so subsequent generates don't reload from disk on every step). This avoids two vLLM 0.19.0 bugs that surface under colocate_all + tp=1 + num_engines>=2 — see vllm_server_actor.py:_skyrl_load_lora_adapter for the detailed explanation.

Parameters:

NameTypeDescriptionDefault
lora_namestrName to register the adapter under on each server.required
lora_pathOptional[str]Path to the LoRA adapter on disk (must be accessible from servers).None
in_memoryboolBuild the adapter from tensors already staged in every worker by a weight update armed with a LoRA receive target (lora.sync_mode=memory); no path is read. Mutually exclusive with lora_path.False

Returns:

TypeDescription
Dict[str, Any]Dict mapping server_url to response.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1404-1462
    async def load_lora_adapter(
        self,
        lora_name: str,
        lora_path: Optional[str] = None,
        *,
        in_memory: bool = False,
    ) -> Dict[str, Any]:
        """
        Load (or reload) a LoRA adapter on all backend servers via the SkyRL
        custom /skyrl/v1/load_lora_adapter endpoint.

        After loading, generation/chat/completion requests can target this LoRA
        by passing ``model=lora_name``.

        TODO(aaron): switch back to vLLM's /v1/load_lora_adapter once the
        upstream fix in https://github.com/vllm-project/vllm/pull/41482 lands
        in a vLLM release we depend on.

        The custom endpoint (defined in vllm_server_actor.py) wraps add_lora
        with load_inplace=True (so the engine reloads the freshly-written
        safetensors) and then resets the cached LoRARequest's load_inplace=False
        (so subsequent generates don't reload from disk on every step). This
        avoids two vLLM 0.19.0 bugs that surface under colocate_all + tp=1 +
        num_engines>=2 — see vllm_server_actor.py:_skyrl_load_lora_adapter for
        the detailed explanation.

        Args:
            lora_name: Name to register the adapter under on each server.
            lora_path: Path to the LoRA adapter on disk (must be accessible from servers).
            in_memory: Build the adapter from tensors already staged in every
                worker by a weight update armed with a LoRA receive target
                (``lora.sync_mode=memory``); no path is read. Mutually exclusive
                with ``lora_path``.

        Returns:
            Dict mapping server_url to response.
        """
        if in_memory == (lora_path is not None):
            raise ValueError("load_lora_adapter takes exactly one of lora_path or in_memory=True")
        session = await self._get_session()

        async def _load_on_server(server_url: str):
            url = f"{server_url}/skyrl/v1/load_lora_adapter"
            payload = (
                {"lora_name": lora_name, "in_memory": True}
                if in_memory
                else {"lora_name": lora_name, "lora_path": lora_path}
            )
            async with _control_plane_request(session, "POST", url, json=payload) as resp:
                if resp.status >= 400:
                    body = await _read_json_body(resp)
                    raise_for_status(resp, body)
                return server_url, {"status": resp.status, "body": await resp.text()}

        results = await asyncio.gather(*[_load_on_server(url) for url in self.server_urls])

        logger.info(f"Loaded LoRA adapter '{lora_name}' from {'staged GPU tensors' if in_memory else lora_path}")

        return {url: resp for url, resp in results}

method async unload_lora_adapter

unload_lora_adapter(lora_name: str) -> Dict[str, Any]

Unload a previously-loaded LoRA adapter on all backend servers via /v1/unload_lora_adapter.

After unloading, lora_name is no longer accepted as a model target on any server. The underlying CPU/GPU LRU entries on vLLM age out naturally as new adapters are loaded.

Parameters:

NameTypeDescriptionDefault
lora_namestrName of the adapter to unload.required

Returns:

TypeDescription
Dict[str, Any]Dict mapping server_url to response.
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1464-1507
    async def unload_lora_adapter(self, lora_name: str) -> Dict[str, Any]:
        """
        Unload a previously-loaded LoRA adapter on all backend servers via /v1/unload_lora_adapter.

        After unloading, ``lora_name`` is no longer accepted as a ``model``
        target on any server. The underlying CPU/GPU LRU entries on vLLM age
        out naturally as new adapters are loaded.

        Args:
            lora_name: Name of the adapter to unload.

        Returns:
            Dict mapping server_url to response.
        """
        payload = {"lora_name": lora_name}

        # Mirror load_lora_adapter: vLLM returns plain text on success and JSON
        # ErrorResponse (e.g. 404) on failure.
        session = await self._get_session()

        async def _unload_on_server(server_url: str):
            url = f"{server_url}/v1/unload_lora_adapter"
            async with _control_plane_request(session, "POST", url, json=payload) as resp:
                if resp.status >= 400:
                    body = await _read_json_body(resp)
                    raise_for_status(resp, body)
                return server_url, {"status": resp.status, "body": await resp.text()}

        results = await asyncio.gather(*[_unload_on_server(url) for url in self.server_urls])

        # An adapter published with lora.sync_mode=memory also holds staged GPU
        # tensors in every worker (kept after the load so vLLM can rebuild it
        # after an LRU eviction). Best-effort, like the unload itself.
        try:
            await self._call_all_servers(
                "/collective_rpc",
                {"method": "skyrl_discard_in_memory_lora", "kwargs": {"lora_name": lora_name}},
            )
        except Exception as e:
            logger.debug(f"Could not discard staged in-memory LoRA tensors for '{lora_name}': {e}")

        logger.info(f"Unloaded LoRA adapter '{lora_name}'")

        return {url: resp for url, resp in results}

method abstractmethod async get_world_size

get_world_size() -> Tuple[int, int]

Get total and per-server world size across all inference workers.

Fetches from vLLM's /get_world_size endpoint on each server. All servers are expected to have the same world size. Result is cached after first call.

When data_parallel_size > 1, server_urls contains num_engines * dp_size entries. vLLM reports the full DP * TP world size per server, which already covers all DP ranks in one deployment. To avoid double-counting, total_world_size = per_server_ws * num_deployments (not num_servers).

Returns:

TypeDescription
Tuple[int, int]Tuple of (total_world_size, world_size_per_server).
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1513-1555
    async def get_world_size(self) -> Tuple[int, int]:
        """
        Get total and per-server world size across all inference workers.

        Fetches from vLLM's /get_world_size endpoint on each server.
        All servers are expected to have the same world size.
        Result is cached after first call.

        When data_parallel_size > 1, server_urls contains num_engines * dp_size entries.
        vLLM reports the full DP * TP world size per server, which already
        covers all DP ranks in one deployment. To avoid double-counting,
        total_world_size = per_server_ws * num_deployments (not num_servers).

        Returns:
            Tuple of (total_world_size, world_size_per_server).
        """
        if self._world_size is not None:
            return self._world_size

        results = await self._call_all_servers("/get_world_size", {}, method="GET")

        per_server = []
        for server_url in self.server_urls:
            resp = results.get(server_url)
            if resp is None:
                raise RuntimeError(f"No response for server {server_url}")
            body = resp.get("body", {})
            world_size = body.get("world_size")
            if world_size is None:
                raise RuntimeError(f"Missing world_size in response from {server_url}")
            per_server.append(world_size)

        assert all(
            ws == per_server[0] for ws in per_server
        ), f"All servers must have the same world_size, got {per_server}"

        # Each server is one DP rank. vLLM reports world_size = dp_size * tp_size * pp_size,
        # which is the worker count across ALL DP ranks in one deployment.
        # num_deployments = num_servers / dp_size (each deployment has dp_size servers).
        # Total unique workers = per_server_ws * num_deployments.
        num_deployments = len(self.server_urls) // self.data_parallel_size
        self._world_size = (per_server[0] * num_deployments, per_server[0])
        return self._world_size

method abstractmethod async teardown

teardown() -> None

Close HTTP session.

Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1561-1564
    async def teardown(self) -> None:
        """Close HTTP session."""
        if self._generate_client is not None:
            await self._generate_client.aclose()

method async aclose

aclose() -> None
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1594-1595
    async def aclose(self) -> None:
        await self.teardown()

method async is_sleeping

is_sleeping()
Source code in skyrl/backends/skyrl_train/inference_servers/remote_inference_client.py:1597-1599
    async def is_sleeping(self):
        ret = await self._call_all_servers("/is_sleeping", method="GET")
        return all(response["body"]["is_sleeping"] for response in ret.values())

On this page

Core APIsclass GeneratorInterfacemethod async generateclass InferenceEngineInterfaceattr abstractmethod property model_namemethod async generatemethod abstractmethod get_endpoint_urlmethod abstractmethod async chat_completionmethod abstractmethod async render_chat_completionmethod abstractmethod async completionmethod abstractmethod async wake_upmethod abstractmethod async sleepmethod abstractmethod async update_named_weightsmethod abstractmethod async load_lora_adaptermethod abstractmethod async teardownmethod abstractmethod async reset_prefix_cachemethod abstractmethod async pause_generationmethod abstractmethod async resume_generationmethod abstractmethod async finish_sessionmethod abstractmethod async get_world_sizeclass RemoteInferenceClientattr proxy_urlattr server_urlsattr data_parallel_sizeattr abstractmethod property model_nameattr enable_return_routed_expertsattr enable_return_sample_support_setattr uses_lora_weight_syncattr tokenizerattr property weight_versionmethod increment_weight_versionmethod abstractmethod get_endpoint_urlmethod async generatemethod abstractmethod samplemethod abstractmethod async chat_completionmethod abstractmethod async render_chat_completionmethod abstractmethod async completionmethod async tokenizemethod async detokenizemethod abstractmethod async finish_sessionmethod async pausemethod async resumemethod abstractmethod async pause_generationmethod abstractmethod async resume_generationmethod abstractmethod async sleepmethod abstractmethod async wake_upmethod async sleep_for_weight_syncmethod async wake_for_weight_syncmethod abstractmethod async reset_prefix_cachemethod abstractmethod async update_named_weightsmethod async fetch_weightsmethod async set_lora_receive_targetmethod abstractmethod async load_lora_adaptermethod async unload_lora_adaptermethod abstractmethod async get_world_sizemethod abstractmethod async teardownmethod async aclosemethod async is_sleeping