[Frontend] Add routed-experts prompt offset (#51906)

Signed-off-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

aoshen02 committed Aug 13, 2026 at 23:09 UTC 7553aac77b678d58f6f8034b4f25300933f0bd84
13 files changed +114 -61
tests/entrypoints/openai/completion/test_completion.py
+16
@@ -822,3 +822,19 @@ def test_completion_request_bad_words_default_empty():
822 default_sampling_params={},
823 )
824 assert sampling_params.bad_words == []
825 +
826 +
827 +def test_completion_request_forwards_routed_experts_prompt_start():
828 + request = CompletionRequest(
829 + model="test-model",
830 + prompt="Hello",
831 + max_tokens=10,
832 + routed_experts_prompt_start=3,
833 + )
834 +
835 + sampling_params = request.to_sampling_params(
836 + max_tokens=10,
837 + default_sampling_params={},
838 + )
839 +
840 + assert sampling_params.routed_experts_prompt_start == 3
tests/entrypoints/openai/test_return_routed_experts.py
+13 -11
@@ -20,6 +20,18 @@ NUM_EXPERTS_PER_TOK = 2
20 NUM_HIDDEN_LAYERS = 2
21
22
23 +def assert_valid_routed_experts(encoded: str | None) -> None:
24 + assert encoded is not None
25 + routed_experts = np.load(io.BytesIO(base64.b64decode(encoded)))
26 + assert routed_experts.ndim == 3
27 + num_tokens, num_layers, topk = routed_experts.shape
28 + assert num_tokens > 0
29 + assert num_layers == NUM_HIDDEN_LAYERS
30 + assert topk == NUM_EXPERTS_PER_TOK
31 + assert (routed_experts >= 0).all()
32 + assert (routed_experts < NUM_LOCAL_EXPERTS).all()
33 +
34 +
35 @pytest.fixture(scope="module")
36 def server():
37 args = [
@@ -50,15 +62,5 @@ async def test_routed_experts(server):
62
63 choice = result.model_dump()["choices"][0]
64
53 - assert choice["routed_experts"] is not None
65 assert choice["token_ids"] is not None
55 -
56 - # routed_experts is base64-encoded .npy bytes; decode to ndarray.
57 - routed_experts = np.load(io.BytesIO(base64.b64decode(choice["routed_experts"])))
58 - assert routed_experts.ndim == 3
59 - num_tokens, num_layers, topk = routed_experts.shape
60 - assert num_tokens > 0
61 - assert num_layers == NUM_HIDDEN_LAYERS
62 - assert topk == NUM_EXPERTS_PER_TOK
63 - assert (routed_experts >= 0).all()
64 - assert (routed_experts < NUM_LOCAL_EXPERTS).all()
66 + assert_valid_routed_experts(choice["routed_experts"])
tests/entrypoints/openai/test_stop_token_ids.py
+14
@@ -87,6 +87,20 @@ class TestChatCompletionStopTokenIds:
87
88 assert set(sampling_params.stop_token_ids) == {42, 43}
89
90 + def test_routed_experts_prompt_start_is_forwarded(self):
91 + request = ChatCompletionRequest(
92 + model="test-model",
93 + messages=[{"role": "user", "content": "hello"}],
94 + routed_experts_prompt_start=3,
95 + )
96 +
97 + sampling_params = request.to_sampling_params(
98 + max_tokens=100,
99 + default_sampling_params={},
100 + )
101 +
102 + assert sampling_params.routed_experts_prompt_start == 3
103 +
104 def test_duplicate_stop_token_ids_deduplicated(self):
105 """Overlapping stop_token_ids between client and server are deduplicated."""
106 request = ChatCompletionRequest(
tests/utils_/test_serial_utils.py
+16
@@ -1,5 +1,9 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import io
4 +
5 +import numpy as np
6 +import pybase64
7 import pytest
8 import torch
9
@@ -12,6 +16,7 @@ from vllm.utils.serial_utils import (
16 Endianness,
17 MmMetadataDType,
18 binary2tensor,
19 + numpy2base64,
20 tensor2binary,
21 )
22
@@ -19,6 +24,17 @@ FLOAT_EMBED_DTYPES = tuple(EMBED_DTYPES.keys())
24 INTEGER_EMBED_DTYPES = tuple(MM_METADATA_DTYPES.keys())
25
26
27 +def test_numpy2base64_round_trip():
28 + array = np.arange(24, dtype=np.uint8).reshape(2, 3, 4)
29 +
30 + decoded = np.load(
31 + io.BytesIO(pybase64.b64decode(numpy2base64(array))),
32 + allow_pickle=False,
33 + )
34 +
35 + np.testing.assert_array_equal(decoded, array)
36 +
37 +
38 def _build_integer_tensor(
39 embed_dtype: MmMetadataDType, shape: tuple[int, ...]
40 ) -> torch.Tensor:
vllm/config/vllm.py
+8
@@ -1074,6 +1074,14 @@ class VllmConfig:
1074 "--enable-return-routed-experts is incompatible with "
1075 "pipeline parallelism (PP > 1)."
1076 )
1077 + if (
1078 + self.parallel_config.decode_context_parallel_size > 1
1079 + or self.parallel_config.prefill_context_parallel_size > 1
1080 + ):
1081 + raise ValueError(
1082 + "--enable-return-routed-experts is incompatible with context "
1083 + "parallelism (DCP > 1 or PCP > 1)."
1084 + )
1085
1086 # Incompatible with any KV connector — covers both PD disaggregation
1087 # (kv_producer/kv_consumer: routing captured on P can't reach D) and
vllm/entrypoints/openai/chat_completion/protocol.py
+7 -1
@@ -114,7 +114,7 @@ class ChatCompletionResponseChoice(OpenAIBaseModel):
114 token_ids: list[int] | None = None
115 # Per-token expert routing decisions, base64-encoded ``.npy`` bytes
116 # (numpy serialization). Shape after decode:
117 - # (num_tokens - 1, num_layers, num_experts_per_tok) dtype uint8/uint16
117 + # (num_tokens - 1, num_layers, num_experts_per_tok) dtype uint8/uint16/int32
118 # ``num_tokens - 1`` because the last sampled token has not been
119 # forwarded yet and therefore has no routing data.
120 # Decode:
@@ -422,6 +422,11 @@ class ChatCompletionRequest(OpenAIBaseModel):
422 "need to map generated text back to input tokens."
423 ),
424 )
425 + routed_experts_prompt_start: int = Field(
426 + default=0,
427 + ge=0,
428 + description="Skip the first N prompt tokens from returned routed-expert data.",
429 + )
430 return_token_offsets: bool | None = Field(
431 default=False,
432 description=(
@@ -741,6 +746,7 @@ class ChatCompletionRequest(OpenAIBaseModel):
746 extra_args=extra_args or None,
747 skip_clone=True, # Created fresh per request, safe to skip clone
748 repetition_detection=self.repetition_detection,
749 + routed_experts_prompt_start=self.routed_experts_prompt_start,
750 )
751
752 @model_validator(mode="before")
vllm/entrypoints/openai/chat_completion/serving.py
+6 -12
@@ -2,15 +2,12 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import asyncio
5 -import io
5 import time
6 from collections.abc import AsyncGenerator, AsyncIterator
7 from collections.abc import Sequence as GenericSequence
8 from http import HTTPStatus
9 from typing import Any, Final, cast
10
12 -import numpy as np
13 -import pybase64 as base64
11 from fastapi import Request
12
13 from vllm.engine.protocol import EngineClient
@@ -64,6 +61,7 @@ from vllm.renderers.online_renderer import OnlineRenderer
61 from vllm.sampling_params import BeamSearchParams, SamplingParams
62 from vllm.tokenizers import TokenizerLike
63 from vllm.utils.collection_utils import as_list
64 +from vllm.utils.serial_utils import numpy2base64
65
66 logger = init_logger(__name__)
67
@@ -1007,15 +1005,11 @@ class OpenAIServingChat(GenerateBaseServing):
1005 and output.finish_reason == "stop"
1006 )
1007
1010 - # Encode routed_experts for transport. JSON can't carry raw
1011 - # bytes, so we write the ndarray as a ``.npy`` byte stream
1012 - # and base64-encode it. ``pybase64`` is ~3x faster than the
1013 - # stdlib ``base64`` on large payloads thanks to SIMD.
1014 - routed_experts_b64 = None
1015 - if output.routed_experts is not None:
1016 - buf = io.BytesIO()
1017 - np.save(buf, output.routed_experts)
1018 - routed_experts_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
1008 + routed_experts_b64 = (
1009 + numpy2base64(output.routed_experts)
1010 + if output.routed_experts is not None
1011 + else None
1012 + )
1013
1014 choice_data = ChatCompletionResponseChoice(
1015 index=output.index,
vllm/entrypoints/openai/completion/protocol.py
+7 -1
@@ -175,6 +175,11 @@ class CompletionRequest(OpenAIBaseModel):
175 "need to map generated text back to input tokens."
176 ),
177 )
178 + routed_experts_prompt_start: int = Field(
179 + default=0,
180 + ge=0,
181 + description="Skip the first N prompt tokens from returned routed-expert data.",
182 + )
183 return_token_offsets: bool | None = Field(
184 default=False,
185 description=(
@@ -394,6 +399,7 @@ class CompletionRequest(OpenAIBaseModel):
399 skip_clone=True, # Created fresh per request, safe to skip clone
400 repetition_detection=self.repetition_detection,
401 thinking_token_budget=self.thinking_token_budget,
402 + routed_experts_prompt_start=self.routed_experts_prompt_start,
403 )
404
405 @model_validator(mode="before")
@@ -611,7 +617,7 @@ class CompletionResponseChoice(OpenAIBaseModel):
617 prompt_token_ids: list[int] | None = None # For prompt
618 # Per-token expert routing decisions, base64-encoded ``.npy`` bytes
619 # (numpy serialization). Shape after decode:
614 - # (num_tokens - 1, num_layers, num_experts_per_tok) dtype uint8/uint16
620 + # (num_tokens - 1, num_layers, num_experts_per_tok) dtype uint8/uint16/int32
621 # ``num_tokens - 1`` because the last sampled token has not been
622 # forwarded yet and therefore has no routing data.
623 # Decode:
vllm/entrypoints/openai/completion/serving.py
+6 -14
@@ -2,14 +2,11 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import asyncio
5 -import io
5 import time
6 from collections.abc import AsyncGenerator, AsyncIterator
7 from collections.abc import Sequence as GenericSequence
8 from typing import cast
9
11 -import numpy as np
12 -import pybase64 as base64
10 from fastapi import Request
11
12 from vllm.engine.protocol import EngineClient
@@ -48,6 +45,7 @@ from vllm.sampling_params import BeamSearchParams, SamplingParams
45 from vllm.tokenizers import TokenizerLike
46 from vllm.utils.async_utils import merge_async_iterators
47 from vllm.utils.collection_utils import as_list
48 +from vllm.utils.serial_utils import numpy2base64
49
50 logger = init_logger(__name__)
51
@@ -570,17 +568,11 @@ class OpenAIServingCompletion(GenerateBaseServing):
568 else:
569 logprobs = None
570
573 - # Encode routed_experts for transport. JSON can't carry raw
574 - # bytes, so we write the ndarray as a ``.npy`` byte stream
575 - # and base64-encode it. ``pybase64`` is ~3x faster than the
576 - # stdlib ``base64`` on large payloads thanks to SIMD.
577 - routed_experts_b64 = None
578 - if output.routed_experts is not None:
579 - buf = io.BytesIO()
580 - np.save(buf, output.routed_experts)
581 - routed_experts_b64 = base64.b64encode(buf.getvalue()).decode(
582 - "ascii"
583 - )
571 + routed_experts_b64 = (
572 + numpy2base64(output.routed_experts)
573 + if output.routed_experts is not None
574 + else None
575 + )
576
577 choice_data = CompletionResponseChoice(
578 index=len(choices),
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+1 -1
@@ -202,7 +202,7 @@ class GenerateResponseChoice(BaseModel):
202 token_ids: list[int] | None = None
203 # Per-token expert routing decisions, base64-encoded ``.npy`` bytes
204 # (numpy serialization). Shape after decode:
205 - # (num_tokens - 1, num_layers, num_experts_per_tok) dtype uint8/uint16
205 + # (num_tokens - 1, num_layers, num_experts_per_tok) dtype uint8/uint16/int32
206 # ``num_tokens - 1`` because the last sampled token has not been
207 # forwarded yet and therefore has no routing data.
208 # Decode:
vllm/entrypoints/scale_out/token_in_token_out/serving.py
+11 -21
@@ -3,14 +3,11 @@
3
4
5 import asyncio
6 -import io
6 import time
7 from collections.abc import AsyncGenerator
8 from collections.abc import Sequence as GenericSequence
9
10 import msgspec
12 -import numpy as np
13 -import pybase64 as base64
11 from fastapi import Request
12
13 from vllm.engine.protocol import EngineClient
@@ -46,6 +43,7 @@ from vllm.outputs import RequestOutput
43 from vllm.renderers.online_renderer import OnlineRenderer
44 from vllm.sampling_params import RequestOutputKind, SamplingParams
45 from vllm.utils.collection_utils import as_list
46 +from vllm.utils.serial_utils import numpy2base64
47
48 from .mm_serde import decode_mm_kwargs_item
49 from .protocol import (
@@ -303,17 +301,11 @@ class ServingTokens(GenerateBaseServing):
301 else:
302 logprobs = None
303
306 - # Encode routed_experts for transport. JSON can't carry raw
307 - # bytes, so we write the ndarray as a ``.npy`` byte stream
308 - # and base64-encode it. ``pybase64`` is ~3x faster than the
309 - # stdlib ``base64`` on large payloads thanks to SIMD.
310 - # This is the only base64 hop in the pipeline -- the
311 - # engine<->API-server link is binary msgpack + zmq.
312 - routed_experts_b64 = None
313 - if output.routed_experts is not None:
314 - buf = io.BytesIO()
315 - np.save(buf, output.routed_experts)
316 - routed_experts_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
304 + routed_experts_b64 = (
305 + numpy2base64(output.routed_experts)
306 + if output.routed_experts is not None
307 + else None
308 + )
309
310 sampling_mask = None
311 if output.sampling_mask is not None:
@@ -435,13 +427,11 @@ class ServingTokens(GenerateBaseServing):
427 else:
428 logprobs = None
429
438 - routed_experts_b64 = None
439 - if output.routed_experts is not None:
440 - buf = io.BytesIO()
441 - np.save(buf, output.routed_experts)
442 - routed_experts_b64 = base64.b64encode(buf.getvalue()).decode(
443 - "ascii"
444 - )
430 + routed_experts_b64 = (
431 + numpy2base64(output.routed_experts)
432 + if output.routed_experts is not None
433 + else None
434 + )
435
436 chunk = GenerateStreamResponse(
437 request_id=request_id,
vllm/sampling_params.py
+2
@@ -406,6 +406,7 @@ class SamplingParams(
406 skip_clone: bool = False,
407 repetition_detection: RepetitionDetectionParams | None = None,
408 logprob_token_ids: list[int] | None = None,
409 + routed_experts_prompt_start: int = 0,
410 ) -> "SamplingParams":
411 if logit_bias is not None:
412 # Fast path uses a dict comprehension; on failure we iterate once
@@ -468,6 +469,7 @@ class SamplingParams(
469 extra_args=extra_args,
470 skip_clone=skip_clone,
471 repetition_detection=repetition_detection,
472 + routed_experts_prompt_start=routed_experts_prompt_start,
473 )
474
475 def __post_init__(self) -> None:
vllm/utils/serial_utils.py
+7
@@ -64,6 +64,13 @@ def tensor2base64(x: torch.Tensor) -> str:
64 return pybase64.b64encode(binary_data).decode("utf-8")
65
66
67 +def numpy2base64(array: np.ndarray) -> str:
68 + """Encode a NumPy array using its `.npy` representation."""
69 + with io.BytesIO() as buffer:
70 + np.save(buffer, array, allow_pickle=False)
71 + return pybase64.b64encode(buffer.getbuffer()).decode("ascii")
72 +
73 +
74 def tensor2binary(
75 tensor: torch.Tensor,
76 embed_dtype: "EmbedDType | MmMetadataDType",