[Router][Bugfix] prefixaware: accept token-id prompts - #1037
Conversation
/v1/completions accepts token ids as well as text, so request_json["prompt"]
may be list[int] or list[list[int]]. PrefixAwareRouter passed it straight to
HashTrie, which slices the value by characters and hashes each slice with
xxhash:
xxhash.xxh64(request[i : i + self.chunk_size])
On a list the slice is a sublist and xxhash raises
TypeError: unicode/bytes-like object expected, so every token-id completion
request fails with a 500 when --routing-logic prefixaware is enabled.
Normalize the prompt to a string before it reaches the trie. The algorithm is
unchanged: prompts sharing a token prefix still share a character prefix, so
prefix affinity keeps working -- only the alphabet differs. list[str] is
joined rather than stringified so text batches keep matching as before.
Tested by 6 new cases in src/tests/test_prefixaware_router.py, 5 of which fail
on main with the TypeError above. One of them drives the real (unmocked)
HashTrie to assert that a continuation of a token-id prompt still routes back
to the endpoint that served its prefix.
Signed-off-by: kzlin <linkzh2024@shanghaitech.edu.cn>
There was a problem hiding this comment.
Code Review
This pull request introduces prompt normalization in the prefix-aware router to handle non-string prompt types (such as token IDs and batches) and prevent type errors during trie lookup. The review feedback highlights two key improvements: first, adding namespace prefixes to normalized keys to prevent collisions between different prompt types (e.g., list of token IDs vs. raw strings) which would otherwise cause KV cache misses; and second, using safe dictionary access (.get()) when retrieving the prompt to avoid potential KeyError exceptions when the prompt is omitted.
| def _prompt_to_trie_key(prompt) -> str: | ||
| """Normalize a `/v1/completions` prompt into a string for `HashTrie`. | ||
|
|
||
| The OpenAI completions API accepts token ids as well as text: `prompt` may | ||
| be `str`, `list[str]`, `list[int]`, or `list[list[int]]`. `HashTrie` slices | ||
| the value by characters and feeds each slice to `xxhash`, so anything that | ||
| is not a string raises `TypeError` and fails the request. | ||
|
|
||
| Rendering token ids to a stable decimal string keeps the trie's algorithm | ||
| unchanged -- prompts sharing a token prefix still share a character prefix, | ||
| so prefix affinity works as intended, only the alphabet differs. | ||
| """ | ||
| if isinstance(prompt, str): | ||
| return prompt | ||
| if isinstance(prompt, list): | ||
| if not prompt: | ||
| return "" | ||
| # list[list[int]]: a batch of token-id sequences. | ||
| if isinstance(prompt[0], list): | ||
| return " ".join(",".join(str(t) for t in seq) for seq in prompt) | ||
| # list[str]: already text. list[int]: token ids. | ||
| if isinstance(prompt[0], str): | ||
| return " ".join(prompt) | ||
| return ",".join(str(t) for t in prompt) | ||
| return str(prompt) |
There was a problem hiding this comment.
Without namespaces or prefixes, different prompt types can collide and produce identical trie keys. For example, a token ID prompt like [1, 2, 3] and a string prompt like "1,2,3" would both normalize to "1,2,3". Similarly, a list of strings ["a", "b"] and a single string "a b" would both normalize to "a b".
When these collisions occur, the router will incorrectly assume they share a prefix and route them to the same backend engine to reuse the KV cache. However, because the actual tokens are completely different, this will result in a KV cache miss on the backend, defeating the purpose of prefix-aware routing.
To prevent this, we should prefix non-string keys with a namespace indicator (e.g., token_ids:, token_ids_batch:, str_batch:) while keeping plain string prompts as-is to maintain backward compatibility.
def _prompt_to_trie_key(prompt) -> str:
"""Normalize a `/v1/completions` prompt into a string for `HashTrie`.
The OpenAI completions API accepts token ids as well as text: `prompt` may
be `str`, `list[str]`, `list[int]`, or `list[list[int]]`. `HashTrie` slices
the value by characters and feeds each slice to `xxhash`, so anything that
is not a string raises `TypeError` and fails the request.
Rendering token ids to a stable decimal string keeps the trie's algorithm
unchanged -- prompts sharing a token prefix still share a character prefix,
so prefix affinity works as intended, only the alphabet differs.
"""
if isinstance(prompt, str):
return prompt
if isinstance(prompt, list):
if not prompt:
return ""
# list[list[int]]: a batch of token-id sequences.
if isinstance(prompt[0], list):
return "token_ids_batch:" + " ".join(",".join(str(t) for t in seq) for seq in prompt)
# list[str]: already text.
if isinstance(prompt[0], str):
return "str_batch:" + " ".join(prompt)
# list[int]: token ids.
return "token_ids:" + ",".join(str(t) for t in prompt)
return str(prompt)| # Handle regular completions | ||
| prompt = request_json["prompt"] | ||
| prompt = _prompt_to_trie_key(request_json["prompt"]) |
There was a problem hiding this comment.
In the OpenAI completions API, the prompt field is optional and can be omitted. Accessing request_json["prompt"] directly will raise a KeyError if the prompt is missing, leading to a 500 Internal Server Error. Using .get("prompt", "") provides a safe fallback and aligns with how prompts are extracted elsewhere in this file.
else:
# Handle regular completions
prompt = _prompt_to_trie_key(request_json.get("prompt", ""))| @pytest.mark.parametrize( | ||
| "prompt, expected_key", | ||
| [ | ||
| pytest.param([1, 2, 3], "1,2,3", id="token-ids"), | ||
| pytest.param([[1, 2], [3]], "1,2 3", id="token-id-batch"), | ||
| pytest.param(["a", "b"], "a b", id="string-list"), | ||
| pytest.param("plain text", "plain text", id="string"), | ||
| pytest.param([], "", id="empty-list"), | ||
| ], | ||
| ) |
There was a problem hiding this comment.
Update the test expectations to match the new prefixed trie keys to ensure namespaces are correctly validated.
| @pytest.mark.parametrize( | |
| "prompt, expected_key", | |
| [ | |
| pytest.param([1, 2, 3], "1,2,3", id="token-ids"), | |
| pytest.param([[1, 2], [3]], "1,2 3", id="token-id-batch"), | |
| pytest.param(["a", "b"], "a b", id="string-list"), | |
| pytest.param("plain text", "plain text", id="string"), | |
| pytest.param([], "", id="empty-list"), | |
| ], | |
| ) | |
| @pytest.mark.parametrize( | |
| "prompt, expected_key", | |
| [ | |
| pytest.param([1, 2, 3], "token_ids:1,2,3", id="token-ids"), | |
| pytest.param([[1, 2], [3]], "token_ids_batch:1,2 3", id="token-id-batch"), | |
| pytest.param(["a", "b"], "str_batch:a b", id="string-list"), | |
| pytest.param("plain text", "plain text", id="string"), | |
| pytest.param([], "", id="empty-list"), | |
| ], | |
| ) |
Summary
--routing-logic prefixawarereturns a 500 for any/v1/completionsrequest that sends token ids instead of text.The OpenAI completions API accepts
promptasstr,list[str],list[int]orlist[list[int]].PrefixAwareRouter.route_requestpasses the raw value toHashTrie, which slices it by characters and hashes each slice:For a list,
request[i:i+chunk_size]is a sublist, and xxhash raisesTypeError: unicode/bytes-like object expected. The exception propagates out of routing and the request fails.Reproduce on
mainvllm-router --routing-logic prefixaware --service-discovery static \ --static-backends http://127.0.0.1:8000 --static-models <model> curl -s http://127.0.0.1:8000/v1/completions \ -H 'Content-Type: application/json' \ -d '{"model":"<model>","prompt":[1,2,3,4],"max_tokens":1}' # -> 500; router log shows TypeError at hashtrie.py:57The same request succeeds with
--routing-logic roundrobin, so it is specific to the prefix-aware path.Fix
Normalize the prompt to a string before it reaches the trie (
_prompt_to_trie_key). The routing algorithm is unchanged — prompts sharing a token prefix still share a character prefix, so prefix affinity behaves as before, only the alphabet differs.list[str]is joined rather thanstr()-ified so existing text batches keep matching exactly as they do today.There is precedent for this normalization: SGLang's model gateway renders token-id input to a space-joined decimal string for its own routing decisions on the native
/generateendpoint (openai-protocol,generate.rs,extract_text_for_routing). Hashing a rendering of the ids is not as good as hashing the ids themselves, but it preserves prefix structure, which is all the character-chunked trie needs.Test plan
Six new cases. Five of them fail on
mainwith theTypeErrorabove:maintoken-ids([1,2,3])token-id-batch([[1,2],[3]])string-list(["a","b"])empty-list([])string(unchanged path)HashTrieThe last one is not a mock: it drives the real
HashTriewith a token-id prompt and then a continuation of it, asserting the continuation routes back to the endpoint that served the prefix. That is what guards the "affinity still works after normalization" claim rather than just "it no longer crashes".Test result
black,isort,ruffandcodespellclean on both changed files.