Skip to content

[None][fix] Kimi-K3 on the Responses API: apply its request options and history rules through its serving extension - #21805

Draft
JunyiXu-nv wants to merge 13 commits into
NVIDIA:mainfrom
JunyiXu-nv:dev-junyix-fix-kimi-k3-responses-extension
Draft

JunyiXu-nv wants to merge 13 commits into
NVIDIA:mainfrom
JunyiXu-nv:dev-junyix-fix-kimi-k3-responses-extension

Conversation

@JunyiXu-nv

Copy link
Copy Markdown
Collaborator

Stacked on #21406 ([TRTLLM-17261][test] Add route, middleware-order and shim-identity snapshots and a CPU test seam for trtllm-serve) and on #21804 ([None][feat] Responses: serving-extension hooks for model-specific request handling): the first commits below belong to those PRs and disappear from this diff when they merge; this PR's own change is the last commit.

Description

A Responses request (/v1/responses) for a Kimi-K3 checkpoint was prepared like any other model's: the generic reasoning_effort / thinking chat-template kwargs, which the K3 template ignores, no tool_choice or response_format control messages, no sampling-parameter policy, and a replayed conversation the K3 renderer could not always bind. Seen in production agent traffic with Kimi-K3 behind a Responses client that replays its whole history every turn: a tool result returned in several items shifted the index of every later result in the turn, and a single result whose call was no longer in the history failed every later turn of the session with a 400.

KimiK3ServingExtension now implements the Responses hooks of ServingExtension, so every Kimi-K3 rule lives in the extension and the Responses path keeps no model-name branch:

  • apply_responses_extensions: reasoning.effort maps onto K3's thinking_effort (minimal/low -> low, medium/high -> high, xhigh/max -> max, each the nearest K3 level at or above the one asked for; "none" turns thinking off; no effort leaves the template default). tool_choice "required" / "none" with tools declared and text.format json_object / json_schema (with its schema) become the kwargs the template reads. Explicit client chat_template_kwargs win over derived values, as on chat. The same kwargs steer the kimi_k3 reasoning parser's initial channel, so the parser starts in the channel the prompt opened. The TRTLLM_KIMI_PARAM_POLICY sampling policy (off by default) applies to the fields this endpoint has: top_p unset or 1.0 is pinned to 0.95, other values and an out-of-range temperature are rejected with a 400.
  • prepare_responses_history: within a run of tool results each call keeps one result message (the segments of a split result are joined in order; the K3 renderer numbers results by counting messages), and a result no call before it claims is replayed as a user note carrying its output, after its run, so the rest of the run still binds by id. An assistant turn without calls ends the binding, as in the renderer. Nothing a client sent is dropped, and the input conversation is not mutated.

On the Responses surface medium therefore means thinking_effort=high (and minimal -> low, xhigh -> max), as the Kimi-K3 deployment this comes from did; the chat surface keeps leaving those values to the template default. No chat behavior changes in this PR.

The sampling policy is the single implementation chat already uses (enforce_kimi_param_policy), generalized to check only the sampling fields a request type declares: a chat request has them all, a Responses request only temperature and top_p. Chat behavior and messages are unchanged (the existing chat policy tests pass untouched, and the new tests assert the chat messages verbatim).

replay_stored_turn is left on its default for Kimi-K3: the store writer never puts reasoning and text in one stored message, so the "strip only the reasoning" rule and the default replay produce the same conversation on this branch; #21058 makes that rule the generic one.

The handler no longer reports tool_choice="required" as "accepted but not enforced" for a model whose extension enforces it through its template (required_tool_choice_mode() == "template") when tools are declared; other models, grammar-mode extensions (whose grammar the Responses path does not attach) and required without tools are reported as before.

The serve test factory can build a kimi_k3 server: its fake checkpoint is the composite (text + vision) config, which is the shape that resolves to the kimi_k3 model type.

Behavior change: for model_type == "kimi_k3" only. Responses requests now render K3's thinking-effort, tool-choice and response-format control messages from the request fields, replay split or orphan tool results as described above, and (only with TRTLLM_KIMI_PARAM_POLICY=1) pin top_p and reject out-of-policy sampling values. Other models are unchanged. The KV-aware router follows: with the hooks PR it runs the same Responses hooks on a copy of the request, so its routing ids equal the worker's prompt ids. Measured with a probe template mirroring the K3 renderer's control-message placement (32-token routing blocks; before = this change on a router that rendered without the hooks, after = this branch):

case before: first divergent token / matching blocks after
effort high none / 13 of 13 none / 13 of 13
effort medium 1 / 0 of 13 none / 13 of 13
effort minimal 1 / 0 of 13 none / 13 of 13
effort xhigh, none, no effort (not measured) none / 13 of 13
tool_choice required (+tools) 418 / 13 of 14 none / 14 of 14
text.format json_schema 418 / 13 of 15 none / 15 of 15
split results 456 / 16 of 18 none / 18 of 18
orphan result 473 / 15 of 21 none / 21 of 21

Interactions with open PRs: #21058 changes the generic stored-turn rule (see above). #21479 edits serialize_tool, not touched here.

Test Coverage

New tests/unittest/serve/test_responses_kimi_k3.py (collected through the unittest/serve entry of l0_cpu.yml), CPU only. The handler tests build a kimi_k3 server with isolated_serve_env and give it a probe chat template on a byte-level tokenizer, so the prompt the fake engine receives shows every kwarg and message the renderer was handed:

  • the six effort levels, no effort, and "none" (prompt and parser: the reply is a message, not reasoning; streaming and not);
  • tool_choice required/none with tools, auto, and required without tools; text.format json_schema (schema in the prompt), json_object, text; explicit chat_template_kwargs winning;
  • the policy pinning top_p (unset and 1.0), rejecting a temperature of 1.5 before any engine call, and staying off by default;
  • split and orphan tool results on the K3 server versus a model without an extension;
  • the tool_choice="required" warning: absent for K3 with tools, present for K3 without tools, for a generic model and for a grammar-mode extension (captured with a handler on the TRT-LLM logger);
  • a stored turn replays its text and not its reasoning across previous_response_id;
  • the KV-aware router's ids equal the worker's prompt ids for every case of the table above, and the routed request stays as sent;
  • the hooks directly on inline conversations: the derived-kwargs table, the joining rules (separator, trailing newline, empty segment, content parts, later runs), orphan notes and their placement, an assistant turn without calls ending the binding, the input conversation left unmutated, and the policy on both request types.

Verification (CPU host; a test-only plugin declares the C++ op the compiled artifacts predate): the new file (69 passed); tests/unittest/serve (#21406's object.__new__ ratchet and route snapshot included), test_kimi_serve_extensions.py, test_serving_extensions.py, test_render_callers.py, test_responses_input_preprocess.py, test_responses_store_replay.py, test_responses_usage.py, test_responses_serving_extensions.py, test_responses_streaming_events.py, api_stability/test_serve_routes.py and the two Responses routing tests of disaggregated/test_router.py (690 passed together); the route snapshot regenerator reports no differences. Mutation gate: sixteen planted inverses of the change (a wrong effort level, "none" ignored, tool_choice derived without tools, the schema replaced, derived values beating explicit kwargs, the policy not applied, the policy reading undeclared fields, no merge, no orphan notes, a lost separator, a note before its run, the warning not suppressed, the warning suppressed without tools, call ids surviving an assistant turn without calls, the warning suppressed for grammar mode, the merge mutating its input) each turn the new tests red. Hypothesis properties over generated conversations and parameters (no result text lost, every remaining result binds by id within its run, one kept result per call then the run's notes between the same non-tool messages, idempotence, the policy agreeing between chat and Responses on the shared fields, effort never derived lower than asked, tool_choice derived only with tools, exactly one newline between joined segments) pass; the mutations on the paths they state break them, the handler-warning mutations and the schema value are caught by the example tests. Rendering through the real K3 checkpoint renderer needs the checkpoint files and is left to a GPU end-to-end run.

PR Checklist

Please review the following before submitting your PR:

  • PR description clearly explains what and why. If using CodeRabbit's summary, please make sure it makes sense.

  • PR Follows TRT-LLM CODING GUIDELINES to the best of your knowledge.

  • Test cases are provided for new code paths (see test instructions)

  • Any new dependencies have been scanned for license and vulnerabilities

  • CODEOWNERS updated if ownership changes

  • Documentation updated as needed

  • Update tava architecture diagram if there is a significant design change in PR.

  • The reviewers assigned automatically/manually are appropriate for the PR.

  • Please check this after reviewing the above items as appropriate for this PR.

Reviewers by CODEOWNERS: trt-llm-runtime-devs (tensorrt_llm/serve/), trt-llm-devs (tests/unittest/serve/, tests/test_common/). tensorrt_llm/serve/openai_server.py and tensorrt_llm/serve/extensions/ are in the multi-GPU trigger list of jenkins/L0_MergeRequest.groovy, so this PR needs the ci: full pre-merge approved label to run the full pre-merge pipeline.

🤖 Generated with Claude Code

…apshots and a CPU test seam for trtllm-serve

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
… baselines at the merge base

The route reference is generated from the live apps; the object.__new__
baseline and the gRPC import allowance record what exists on main today
and only shrink.

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
…n the snapshot matrix

An attached frontend of a multi-frontend server registers ten launcher
forwarding routes before the local table; seven share a method and path
with local handlers and three use a path parameter that matches every
batch id, so registration order decides which handler serves them. The
new configuration records all of it, a live invariant pins the
forwarding-before-local order independently of the reference, and the
route-class failure message now says why the msgpack route class matters.

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
- AGENTS.md: the CPU test command sets LLM_MODELS_ROOT, which the runner
  requires, and the stage-mapping note says the mapper matches list-entry
  text, so a file listed through its directory maps by the directory.
- check_contracts matches each from-import as <module>.<name>, so
  'from pkg import sub' and 'from .. import sub' count as imports of the
  submodule; new test for both forms and for the allowance entry.
- The VisualGen /v1/models test asserts the 200 status.
- The AGENTS.md command check also recognizes commands that start with
  LLM_MODELS_ROOT=.

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
…on the current main

The disaggregated frontend now registers its routes with KvHintsRoute
(NVIDIA#20269), which strips kv_hints from request bodies unless the server
trusts its ingress. Regenerate the route reference: the six
disaggregated configurations record KvHintsRoute where they recorded
APIRoute; their route sets and order are unchanged.

The route-class invariant, which a regenerated reference cannot hide,
now requires KvHintsRoute on every API route of the disaggregated
frontend and APIRoute on the coordinator app. Dropping the KV-hint
policy from the frontend fails it even after a regeneration, and both
apps still reject the aggregated server's msgpack route class.

The object.__new__ baseline records the sites on main at the merge base:
test_kv_hints.py builds one server that way (NVIDIA#20269), and
test_harmony_parsing.py no longer builds any (NVIDIA#21176).

The harmony parsing tests are now registered as a whole file (NVIDIA#21411),
so the node-id entry that the CPU-selection test used as its example is
gone. The node-id check now runs select_entries on a one-entry list of
its own, so it no longer depends on which node-id entries l0_cpu.yml
holds.

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
…ail on silent regressions

The route snapshot now requires every configuration other than the three
invalid ones to build, so a configuration that starts failing at construction
cannot pass through a regenerated reference. The object.__new__ ratchet
resolves 'from ... import <server class> as <name>' aliases and records each
baseline site as its enclosing function or class plus the server class, so
swapping one site for another in the same file no longer passes. The baseline
keeps the same 52 sites in the same 26 files.

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
…r-snapshot-tests

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
…r-snapshot-tests

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
…r-snapshot-tests

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
…r-snapshot-tests

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
The embedding server now sizes its encode batcher from the encoder
executor's max_batch_size, max_num_tokens and max_seq_len instead of
reading them from encoder_executor.model_engine. Give
FakeEncoderExecutor the same attributes so the EMBEDDING role builds
again; the real EncoderExecutor no longer has a model_engine.

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
…quest handling

Add three hooks to ServingExtension for the Responses API path, with
defaults that reproduce the generic behavior:

- apply_responses_extensions(request): the Responses counterpart of
  apply_chat_extensions. request_preprocess calls it first, before the
  sampling parameters and the prompt are built, for relayed prompts too.
- replay_stored_turn(message): how one stored message of a
  previous_response_id conversation is replayed. The default keeps a
  message as stored and replays a reasoning message as its tool calls only.
- prepare_responses_history(messages): adapts the converted chat messages
  before they are rendered and stored. The default returns them unchanged.

chat_messages_from_responses_input and _create_input_messages take the
extension; _create_input_tokens resolves it from the render resources, and
request_preprocess resolves it the same way (gpt-oss on the Harmony path).

The KV-aware router renders a Responses request the way the worker does: it
runs the model's Responses hooks on a copy of the request, converts the
history with the same extension, and renders the rebuilt chat request with
the model's tool serialization but without the chat request hook
(render_prepared_chat). A hook that rejects the request leaves routing on
the request as sent; the worker answers the rejection.

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
(cherry picked from commit da5340f)
…nd history rules through its serving extension

A Responses request for a kimi_k3 checkpoint used to be prepared like any
other model's: the generic reasoning_effort / thinking kwargs its template
ignores, no tool_choice or response_format control messages, no sampling
policy, and a replayed conversation the K3 renderer could not bind (a tool
result returned in several items shifted every later result's index; a
result without a call failed the whole turn). KimiK3ServingExtension now
implements the Responses hooks:

- apply_responses_extensions: reasoning.effort maps onto K3's thinking_effort
  (minimal/low -> low, medium/high -> high, xhigh/max -> max; "none" turns
  thinking off), tool_choice "required"/"none" with tools declared and
  text.format json_object/json_schema become the kwargs the template reads,
  explicit chat_template_kwargs win, and the TRTLLM_KIMI_PARAM_POLICY
  sampling policy applies to temperature and top_p. The policy is the one
  implementation chat uses; it now checks only the sampling fields a request
  type declares, so chat behavior and messages are unchanged.
- prepare_responses_history: within a run of tool results each call keeps one
  result message (segments joined in order), and a result no call claims is
  replayed as a user note after its run, so the rest of the run still binds
  by id.

The Responses handler no longer reports tool_choice="required" as unenforced
for a model whose template enforces it through its extension. The serve test
factory can build a kimi_k3 server (the composite checkpoint config, which is
what resolves to that model type).

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
(cherry picked from commit 7ad9f9117ec44c658761aec70f86c3af49d8890f)
(cherry picked from commit 5cad4ebd840b5d484b24988fd743cdb0c131fedf)
(cherry picked from commit c73f779eca5c0093ce39aff9b4f039a02d388979)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant