Repository navigation
[Bugfix][Frontend] Preserve Kimi native tool-call IDs across Responses API multi-turn round trip (Fixes #50768) - #50782
Conversation
…s API multi-turn round trip
Kimi's chat template renders a tool-call ID verbatim, both as the
`<|tool_call_begin|>` header and as the `## Return of {id}` tool-result
header, and renders the function name nowhere else. The Responses API
hands clients OpenAI-style `call_<uuid>` IDs, so when a client echoes
history back, prior turns lose which function was called and the model
learns to emit `call_<uuid>` headers that `KimiK2Parser._TOOL_ID_RE`
rejects -- the tool call is then dropped and the turn comes back empty.
Restore the native ID on the way in: for Kimi models, renumber history
tool-call IDs that are not already native to `functions.{name}:{idx}`,
applying the same mapping to the paired tool message so both headers
agree. `idx` is a single counter over the whole input, matching
`count_response_history_tool_calls`, which is what the parser uses to
number the next tool call. Wire `call_id`s are unchanged, and non-Kimi
models keep their existing behaviour.
Fixes vllm-project#50768
Signed-off-by: Jason Yao <wsyjh8@gmail.com>
…opping them silently
A `<|tool_call_begin|>` header that is not in the native
`functions.{name}:{idx}` form carries no function name, so the parser has
no choice but to drop the call. Today that happens with no trace: the
special tokens are consumed by the state machine and the client just sees
an empty assistant turn, which is hard to diagnose from the outside.
Name the offending header in a warning before dropping. Kimi-scoped; the
shared ParserEngine is untouched.
Signed-off-by: Jason Yao <wsyjh8@gmail.com>
getkimchi#1066) Closes getkimchi#1063 (read the issue first — its earlier root-cause framing was retracted; this is the corrected fix. Supersedes the closed getkimchi#1064.) ## Root cause kimi-k2.x models abandon turns (immediate end-of-turn with zero content) when conversation history carries non-canonical `tool_call.id` values. Moonshot trains the K2 family on `functions.{tool_name}:{idx}` — k2.x's own calls in the affected session were exactly `functions.bash:0..N`. After a mid-session switch to glm-5.2-fp8 (which emits OpenAI-style `call_<hex>`), the history goes off-distribution and the model goes silent. Corroborated upstream: [MoonshotAI/Kimi-K2#128](MoonshotAI/Kimi-K2#128), [vllm#50782](vllm-project/vllm#50782), and [Kimi-K2 tool_call_guidance](https://github.com/MoonshotAI/Kimi-K2/blob/main/docs/tool_call_guidance.md). Moonshot's hosted gateway rewrites IDs server-side; our LiteLLM gateway does not (verified: litellm mainline has no Kimi-side ID rewriting — the unrelated strict-provider PR BerriAI/litellm#22318 was closed unmerged, and `moonshot/chat/transformation.py` only clamps temperature / injects placeholder `reasoning_content`). ## Evidence (replay of the reconstructed 141-message / ~75k-token session context against the gateway) | Arm | kimi-k2.7 result | |---|---| | Baseline ×10 | 1/10 acted — mostly completely empty turns | | **This normalization ×12** | **12/12 on-task tool calls** | | kimi-k3 control ×4 (same context, no normalization needed) | 4/4 tool calls | | Tool-less codeword recall probe ×4 | 4/4 — k2.7 attends to long context fine | ## Fix `normalize-kimi-tool-call-ids.ts`: when the active model is `kimi-k2.*` (not k3), rewrite every historical `toolCall.id` and the paired `toolResult.toolCallId` to `functions.{name}:{globalIdx}`, copy-on-write, wired into the existing prompt-enrichment `context` transforms (main + subagent paths). ## Testing - 18 unit tests: id matcher, pairing, global indexing, canonical renumbering, orphan results, pass-by-reference no-ops, no input mutation. - `pnpm exec vitest run src/extensions/prompt-construction/` → 189 passed; lint and typecheck clean. --------- Co-authored-by: Kimchi <noreply@kimchi.dev>
|
This pull request has merge conflicts that must be resolved before it can be |
|
There is an issue with the proposed implementaiton. _NATIVE_TOOL_CALL_ID_RE = re.compile(r".+:\d+") matches any string ending with :digit, not just the Kimi-native functions.{name}:{idx} format. An OpenAI-style ID like call_abc123:0 (unlikely but possible if a client reuses an ID from a prior response) would be incorrectly treated as native, skipping the rewrite to functions.{name}:{idx}. The chat template would then render call_abc123:0 verbatim as the <|tool_call_begin|> header, which the KimiK2Parser cannot parse, causing the tool call to be dropped. Consider tightening the regex to r"functions..+:\d+" to match only the actual native format. |
|
During my testing I found out that the proposed implementation won't fix the real issue. The problem in the current implementation is that in Check and test this before merging. |
Purpose
Fixes #50768. On
/v1/responseswith a Kimi model and--tool-call-parser kimi_k2, multi-turn agentic sessions intermittently lose the model's tool call and the turn comes back empty — the reporter's "performs work but never answers", at a rate that climbs sharply with the number of turns (9/15 on short tasks, 0-3/15 on long ones).Scope note on the issue: the reporter also captured a payload with raw
<|tool_call_begin|> call_...markup insideoutput_text. This PR does not explain that half. On their exact build (e2fa28594, which is 5 commits behindthis base and byte-identical across
vllm/parser/,vllm/entrypoints/openai/responses/,vllm/entrypoints/chat_utils.py,vllm/tool_parsers/) acall_<uuid>header produces a dropped tool call with the markers consumed, not leaked. The leak is likely a second, separate problem — #48940 covers special-token leakage whentools/tool_choiceare omitted on alater agent-loop turn, which is a plausible candidate but unconfirmed here. I've asked the reporter for the detail that would separate the two.
Root cause (verified on
main@0055b8bfa)Kimi's chat template renders a tool-call ID verbatim, in two places, and renders the function name nowhere else:
Verified identical on these two lines across
Kimi-K2-Instruct,Kimi-K2-Thinking,Kimi-K2.5andKimi-K2.7-Code(the last two are what the reporter runs). All of K2.5 / K2.6 / K2.7-Code reportmodel_type: kimi_k25withtext_config.model_type: kimi_k2, both of which are already in_KIMI_MODEL_TYPES, soget_tool_call_id_typegates them correctly. (Moonshot's owntool_call_guidance.mddocuments the ID format asfunctions.{func_name}:{idx}"from which we can parse the function name".)The chain:
emit_simple_tool_call_open(vllm/entrypoints/openai/responses/streaming_events.py) setsstate.tool_call_id = f"call_{random_uuid()}", discarding the native ID the parser put on theDeltaToolCall(ParserEngine._emit_name_delta→_ensure_tool_id). The client seescall_<uuid>.construct_chat_messages_with_tool_call(
vllm/entrypoints/openai/responses/utils.py) passedcall_idstraight through onto bothtool_calls[].idand the tool message'stool_call_id.<|tool_call_begin|>call_<uuid>and## Return of call_<uuid>. Because the function name lives only inside the ID, prior turns become unreadable to the model — it can see that a tool ran and what it returned, but not which tool. It also learns to imitate thecall_<uuid>header shape.KimiK2Parser._extract_tool_id_and_namematches_TOOL_ID_RE = r"(?P<id>.+:\d+)".call_4144fac2c5e1f3e0has no:N, so it returns(None, None)andParserEngine._build_extracted_resultskips the slot — the tool call is dropped silently. That silent drop is the "no answer" symptom, and it compounds with turn count because every extra turn adds more contaminated history.This is the Responses-path twin of #32768, which fixed chat completions via
make_tool_call_id(id_type="kimi_k2")/_KIMI_MODEL_TYPESinvllm/entrypoints/chat_utils.py. Chat completions never needed a rewrite because they hand the native ID to the client and get it back unchanged; the Responses path substitutes its own ID, so the round trip has to be undone on the way in.Fix
Input side only. When the served model is Kimi-family,
construct_chat_messages_with_tool_callrenumbers any history tool-call ID that is not already native tofunctions.{tool_name}:{idx}, and applies the sameold -> newmapping to thematching tool message's
tool_call_idso the pair stays consistent (the template needs both).idxis a single counter over the whole input array, matchingcount_response_history_tool_callsinvllm/parser/utils.py— the same counter the parser uses to number the next tool call. Per-turn numbering would produce duplicate## Return of functions.foo:0headers and collide with the parser's next index; there is a test pinning the no-collision invariant.Behaviour is idempotent (already-native IDs pass through untouched) and byte-for-byte unchanged for non-Kimi models —
get_tool_call_id_typereturns"random"for everything else and the rewriter is an identity function in that case.Second commit:
KimiK2Parsernow logs awarning_oncenaming an unparseable<|tool_call_begin|>header before dropping the call, so this class of failure is diagnosable from server logs instead of silent. No change to the sharedParserEngine.Why input-side rewrite, not preserving the native ID on the wire
The alternative is to stop minting
call_<uuid>instreaming_events.pyand emitfunctions.exec_command:0as the Responsescall_id. Rejected here:call_idshape. Input-side rewriting is invisible to clients.call_ids, or a proxy that rewrites them, would still poison the prompt. The input-side rewrite covers those too.build_response_output_itemsusestool_call.id or ...), so the wire format is currently inconsistent between streaming and non-streaming. The input-side fix makes both correct without having to pick a wire format first.Worth doing separately: making the streaming and non-streaming wire IDs agree. Deliberately out of scope here.
Scope note
This describes the mechanism as verified on current
main. No bisect was run, so this makes no claim about which commit changed the behaviour relative to v0.26.0; the reporter's observation that v0.26.0 behaved differently is recorded as their observation only.Known limitation, not addressed here
When a tool call is dropped (malformed header, first in a section), the surviving calls keep their raw slot indices, so the stream emits e.g.
index=1with noindex=0before it. Renumbering slots is aParserEngine-wide change; leaving it for a separate PR. The new parser test places the malformed call last and documents why.Test Plan
.venv/bin/python -m pytest tests/entrypoints/openai/responses/test_responses_utils.py -v .venv/bin/python -m pytest tests/tool_parsers/test_kimi_k2_tool_parser.py -v .venv/bin/python -m pytest tests/entrypoints/openai/responses/ tests/tool_parsers/ \ tests/reasoning/test_kimi_k2_reasoning_parser.py \ tests/entrypoints/unit_tests/test_chat_utils.py -q pre-commit run --files vllm/entrypoints/chat_utils.py \ vllm/entrypoints/openai/responses/utils.py \ vllm/entrypoints/openai/responses/serving.py vllm/parser/kimi_k2.py \ tests/entrypoints/openai/responses/test_responses_utils.py \ tests/tool_parsers/test_kimi_k2_tool_parser.pyNew coverage in
tests/entrypoints/openai/responses/test_responses_utils.py(TestNativeToolCallIds):call_<uuid>history on a Kimi model →functions.{name}:{idx}on both the assistanttool_calls[].idand the paired tool messagetool_call_id; two calls in one turn get:0/:1.count_response_history_tool_calls).call_<uuid>preserved, output identical to the default.function_call_outputitems rewritten (these are not coerced to typed items byResponsesRequest.input_item_parsing, unlikefunction_call).functions.{namespace}__{name}:{idx}name.construct_input_messagesthreads the ID type through.In
tests/tool_parsers/test_kimi_k2_tool_parser.py: acall_<uuid>header drops only that call, leaks no<|tool_call_*|>markers into content, leaves a sibling well-formed call parseable (streaming and non-streaming), and is logged.Test Result
Focused suites:
Regression sweep over everything that touches the changed code (
tests/entrypoints/openai/responses/,tests/tool_parsers/,tests/reasoning/test_kimi_k2_reasoning_parser.py,tests/entrypoints/unit_tests/test_chat_utils.py):main(baseline)+10 = the new tests; the error count is unchanged. All 120 errors are pre-existing setup failures for tests that need a live server or a GPU (
test_simple.py,test_stateful.py,test_structured_output.py, the multimodaltest_chat_utils.pycases) — the run was on a CPU-only box.pre-commit run --files <the 6 changed files>→ all hooks pass, exit 0 (ruff check, ruff format, typos, mypy-3.10, SPDX, forbidden imports, …).Model evaluation
This changes what is rendered into the prompt for Kimi models, so it is output-affecting. I could not run a Kimi K2/K2.5 eval — the model needs an 8×B200-class host that I do not have. What is verified instead is deterministic and needs no GPU: the rendered prompt itself. Feeding the same two-turn
function_call/function_call_outputhistory through the realmoonshotai/Kimi-K2.7-Code/chat_template.jinja(the reporter's model):The "after" form is exactly what the chat-completions path (fixed in #32768) already produces and what
KimiK2Parser._TOOL_ID_REaccepts; the "before" form returns(None, None)from_extract_tool_id_and_name.A reviewer with Kimi hardware should confirm the end-to-end fix by running the reporter's scenario — Codex CLI against
/v1/responses, streaming,tool_choice: auto, a multi-turn task — and checking that the "no answer" rate drops. @GeorgiaM-honestly, if you can retest with this patch, that would settle it.Duplicate check
Not a duplicate. As of 2026-08-02 there is no PR referencing #50768, no open PR touches
vllm/entrypoints/openai/responses/utils.pyorvllm/parser/kimi_k2.py, and none of the 30 open PRs matching "kimi tool call" addresses tool-call ID preservation on the Responses path. #32768 (merged) fixed the chat-completions half of this; the Responses path was missed.AI assistance
AI assistance was used to investigate and draft this change. Every claim above was checked against the code and against the upstream chat template, and the test commands were run locally.
Essential Elements of an Effective PR Description Checklist
supported_models.mdandexamplesfor a new model.