Your current environment
vLLM built from main (VLLM_TARGET_DEVICE=empty), CPU-only container, no GPU and no model
weights — the K3 tool parser is pure text processing, so the reproduction needs only a vLLM
install.
🐛 Describe the bug
KimiK3ToolParser._decode_call builds the tool-call ID from the XTML index attribute, which
is the call's position within one assistant message. It restarts every message, so the same
tool called on successive turns gets the same ID.
Reproduction
from vllm.tool_parsers.kimi_k3_tool_parser import KimiK3ToolParser
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
OPEN, CLOSE, SEP = "<|open|>", "<|close|>", "<|sep|>"
class DummyTokenizer:
def get_vocab(self): return {}
def encode(self, text, add_special_tokens=False): return [ord(c) for c in text]
def arg(k, t, v): return f'{OPEN}argument key="{k}" type="{t}"{SEP}{v}{CLOSE}argument{SEP}'
def call(tool, i, *a): return f'{OPEN}call tool="{tool}" index="{i}"{SEP}{"".join(a)}{CLOSE}call{SEP}'
def tools(*c): return f'{OPEN}tools{SEP}{"".join(c)}{CLOSE}tools{SEP}'
req = ChatCompletionRequest(
model="m", messages=[], tool_choice="auto",
tools=[{"type": "function",
"function": {"name": "get_weather",
"parameters": {"type": "object", "properties": {}}}}])
p = KimiK3ToolParser(DummyTokenizer())
# two separate assistant messages, each with one call at index 1
a = p.extract_tool_calls(tools(call("get_weather", 1, arg("city", "string", "Tokyo"))), req)
b = p.extract_tool_calls(tools(call("get_weather", 1, arg("city", "string", "Paris"))), req)
print("msg 1 ->", [c.id for c in a.tool_calls])
print("msg 2 ->", [c.id for c in b.tool_calls])
# one message, two calls
c = p.extract_tool_calls(tools(call("get_weather", 1, arg("city", "string", "Tokyo")),
call("get_weather", 2, arg("city", "string", "Paris"))), req)
print("one msg ->", [x.id for x in c.tool_calls])
Output:
msg 1 -> ['get_weather:0']
msg 2 -> ['get_weather:0']
one msg -> ['get_weather:0', 'get_weather:1']
vLLM itself is unaffected: the K3 renderer rebuilds tool_call_id_index per assistant message
and writes a recomputed positional index into XTML, so duplicate IDs never reach the model.
What breaks is downstream — consumers that key tool results by ID cannot tell the calls apart.
Changing the ID looks safe
The renderer uses the API-side ID only to match tool results back to the preceding assistant
calls; it never renders the literal value, and the XTML index is regenerated from call
position (renderer/kimi_k3/encoding.rs). Moonshot's
encoding_k3.py describes
the same contract — it matches results by an "opaque tool_call_id" and notes that "K3 drops
the func:index format requirement".
The change
Stop deriving the API-side ID from the XTML index, and let the existing fallbacks take over:
- Rust — in
rust/src/parser/src/unified/kimi_k3.rs, stop populating the ID so
tool_call_id() returns None; process_tool_item in
rust/src/chat/src/output/default/unified.rs then falls back to generate_tool_call_id().
(call_ids doubles as the emitted-call counter, so it stays.)
- Python — omit the explicit
id= when constructing ToolCall; its own
Field(default_factory=make_tool_call_id) yields chatcmpl-tool-<uuid>. The streaming path
carries the same value through DeltaToolCall(id=tc.id, ...).
The parser test assertions pin the current IDs and would move with it; the renderer fixtures
take the ID as opaque client input and are unaffected.
Not K2/K2.5 — there the ID is prompt text and the parser preserves the model-generated
value verbatim (rust/src/parser/src/tool/kimi_k2.rs).
K3 landed in #50104 (2026-07-28) and #50093 (2026-07-29); the latest release, v0.26.0, predates
both, so the ID format has not shipped yet.
Happy to open a PR.
Your current environment
vLLM built from
main(VLLM_TARGET_DEVICE=empty), CPU-only container, no GPU and no modelweights — the K3 tool parser is pure text processing, so the reproduction needs only a vLLM
install.
🐛 Describe the bug
KimiK3ToolParser._decode_callbuilds the tool-call ID from the XTMLindexattribute, whichis the call's position within one assistant message. It restarts every message, so the same
tool called on successive turns gets the same ID.
Reproduction
Output:
vLLM itself is unaffected: the K3 renderer rebuilds
tool_call_id_indexper assistant messageand writes a recomputed positional index into XTML, so duplicate IDs never reach the model.
What breaks is downstream — consumers that key tool results by ID cannot tell the calls apart.
Changing the ID looks safe
The renderer uses the API-side ID only to match tool results back to the preceding assistant
calls; it never renders the literal value, and the XTML
indexis regenerated from callposition (
renderer/kimi_k3/encoding.rs). Moonshot'sencoding_k3.pydescribesthe same contract — it matches results by an "opaque
tool_call_id" and notes that "K3 dropsthe
func:indexformat requirement".The change
Stop deriving the API-side ID from the XTML index, and let the existing fallbacks take over:
rust/src/parser/src/unified/kimi_k3.rs, stop populating the ID sotool_call_id()returnsNone;process_tool_iteminrust/src/chat/src/output/default/unified.rsthen falls back togenerate_tool_call_id().(
call_idsdoubles as the emitted-call counter, so it stays.)id=when constructingToolCall; its ownField(default_factory=make_tool_call_id)yieldschatcmpl-tool-<uuid>. The streaming pathcarries the same value through
DeltaToolCall(id=tc.id, ...).The parser test assertions pin the current IDs and would move with it; the renderer fixtures
take the ID as opaque client input and are unaffected.
Not K2/K2.5 — there the ID is prompt text and the parser preserves the model-generated
value verbatim (
rust/src/parser/src/tool/kimi_k2.rs).K3 landed in #50104 (2026-07-28) and #50093 (2026-07-29); the latest release, v0.26.0, predates
both, so the ID format has not shipped yet.
Happy to open a PR.