Skip to content

feat(reasoning): add unified kimi_k2 reasoning parser - #1992

Open
ighutake-debug wants to merge 5 commits into
smg-project:mainfrom
ighutake-debug:feat/reasoning-kimi-k2
Open

feat(reasoning): add unified kimi_k2 reasoning parser#1992
ighutake-debug wants to merge 5 commits into
smg-project:mainfrom
ighutake-debug:feat/reasoning-kimi-k2

Conversation

@ighutake-debug

@ighutake-debug ighutake-debug commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Description

Problem

#1873: SMG ships three Kimi reasoning parsers (kimi, kimi_k25, kimi_thinking) with a static per-SKU always_in_reasoning flag, while the rest of the ecosystem (vLLM, SGLang, Moonshot's deploy guidance) uses one kimi_k2 parser for the whole K2 family. K2.5+ chat templates prefill <think> at the generation prompt (thinking on by default), so model output begins inside reasoning — and kimi_k25 (always_in_reasoning: false) waits for an opening <think> that never comes, mis-splitting reasoning vs content (the nightly τ²-bench 0/100 regression).

This is PR 1 of the sequence proposed in #1873 (comment): the new parser itself. Factory pattern-map changes, deprecated aliases, and the tool-parser rename land in follow-ups; the chat_template_kwargs.thinking toggle is the open design question there.

Refs: #1873

Solution

New KimiK2Parser in crates/reasoning_parser, matching current vLLM main (vllm/parser/kimi_k2.py) semantics:

  • Prefill-robust: starts in reasoning; a leading <think> is consumed when present (vLLM's self-correcting start-token behavior, both directions covered)
  • Dual end markers: reasoning ends on </think> or <|tool_calls_section_begin|> — Kimi can go straight from reasoning into a tool section without closing the think block. The tool-section marker is forwarded as content so SMG's downstream tool parser can parse it
  • Streaming-safe: holds back trailing suffixes that are proper prefixes of either end marker, so markers split across chunks never leak into reasoning text (a gap the shared BaseReasoningParser has)

Registered as kimi_k2 in the reasoning factory — selectable via --reasoning-parser kimi_k2. No behavior change to existing parsers.

Changes

  • crates/reasoning_parser/src/parsers/kimi_k2.rs — new parser + 8 tests
  • crates/reasoning_parser/src/parsers/mod.rs — module export
  • crates/reasoning_parser/src/factory.rs — register kimi_k2

Test Plan

  • Golden conformance test (kimi_k2_golden_k26_output_split): frozen K2.6-style output (starts mid-reasoning, </think>, then a full tool section) — asserts the exact reasoning/content split. This is the regression class that previously only surfaced as a benchmark score
  • kimi_k2_ends_reasoning_at_tool_section_without_think_end — reasoning runs straight into <|tool_calls_section_begin|>; marker forwarded verbatim
  • kimi_k2_consumes_leading_think_start_when_present + streaming variant — non-prefilled templates work too
  • kimi_k2_streaming_chunked_matches_non_streaming — golden output fed in chunks that split both end markers; streamed split identical to one-shot
  • kimi_k2_truncated_reasoning_is_all_reasoning, kimi_k2_reset_restores_initial_state, kimi_k2_model_type
  • TDD: 7/8 tests failed against the stub for the right reason (the 8th caught a bug in my own test data — chunks didn't reconstruct the golden string)

Gate output (macOS, rustc 1.97.1 stable):

  • cargo test -p reasoning-parser --lib102 passed (94 existing + 8 new)
  • cargo test -p smg --lib1323 passed, 0 failed
  • cargo +nightly fmt --all — silent success
  • cargo clippy -p reasoning-parser --all-targets -- -D warnings — zero warnings
Checklist
  • cargo +nightly fmt passes
  • cargo clippy --all-targets --all-features -- -D warnings passes (changed crate clean)
  • (Optional) Documentation updated — parser docs live in code; user-facing naming lands with the factory PR
  • (Optional) Please join us on Slack #sig-smg to discuss, review, and merge PRs

One prefill-robust parser for the whole Kimi K2 family, matching
vLLM/SGLang's kimi_k2 semantics: starts in reasoning (a leading
<think> is consumed when present), and reasoning ends on </think> or
<|tool_calls_section_begin|> -- Kimi can go straight from reasoning
into a tool section without closing the think block. The tool-section
marker is forwarded as content so the downstream tool parser can parse
it.

Replaces the per-SKU guessing of kimi_k25/kimi_thinking (static
always_in_reasoning flag) that mis-split K2.5/K2.6 output when
thinking was on -- the regression behind the nightly tau2-bench 0/100
in smg-project#1873. Streaming holds back trailing partial end markers so split
tokens never leak into reasoning text.

Registered as kimi_k2 (selectable via --reasoning-parser kimi_k2).
Factory pattern-map changes, deprecated aliases, and the tool-parser
rename land in follow-up PRs.

Refs: smg-project#1873
Signed-off-by: ishan <ishanvgf@gmail.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions github-actions Bot added the reasoning-parser Reasoning parser changes label Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

New Features

  • Added support for parsing reasoning output from Kimi K2 models.
  • Supports streaming and non-streaming responses, including prefilled reasoning.
  • Recognizes reasoning boundaries, tool-call sections, split markers, and truncated output.

Bug Fixes

  • Improved handling of parser resets and resumed reasoning streams.

Tests

  • Added coverage for parsing modes, streaming behavior, termination markers, and model identification.

Walkthrough

Adds KimiK2Parser with one-shot and streaming reasoning parsing. It handles think and tool-section markers, supports reset and state tracking, exposes the parser publicly, and registers it under kimi_k2.

Changes

Kimi K2 parser

Layer / File(s) Summary
Parser contract and exports
crates/reasoning_parser/src/parsers/kimi_k2.rs, crates/reasoning_parser/src/parsers/mod.rs
Defines Kimi K2 markers, parser state, boundary helpers, and public exports.
Parsing behavior and validation
crates/reasoning_parser/src/parsers/kimi_k2.rs
Adds one-shot and streaming parsing, split-marker buffering, truncation handling, reset behavior, state queries, and coverage tests.
Factory registration
crates/reasoning_parser/src/factory.rs
Registers KimiK2Parser under the kimi_k2 factory name.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ParserFactory
  participant KimiK2Parser
  participant ReasoningParser
  ParserFactory->>KimiK2Parser: construct kimi_k2 parser
  ReasoningParser->>KimiK2Parser: provide one-shot or streaming content
  KimiK2Parser-->>ReasoningParser: return reasoning and normal content
Loading

Possibly related issues

  • smg-project/smg issue 1873 — Covers the Kimi K2 reasoning parser, prefilled reasoning, and tool-section termination.

Possibly related PRs

Suggested reviewers: catherinesue, slin1237

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: adding a unified Kimi K2 reasoning parser.
Description check ✅ Passed The description explains the parser's purpose, behavior, registration, tests, and validation results, all of which match the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d03bbda99a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +154 to +156
let normal = match kind {
EndKind::ThinkEnd => self.buffer[idx + THINK_END.len()..].to_string(),
EndKind::ToolSection => self.buffer[idx..].to_string(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve split tool markers after the think terminator

When a streaming chunk ends with something like </think><|tool_calls_se, this branch immediately emits the incomplete tool-section prefix as normal text and marks reasoning as ended, so the continuation arrives separately. In the inspected gRPC streaming pipeline, that first fragment is passed directly to KimiK2Parser::parse_incremental, whose no-marker path drains it as user-visible text; consequently the marker never reassembles and the tool call is not parsed. Hold a trailing prefix of TOOL_SECTION_START across the transition just as the reasoning path does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 71f1caa — verified the claim against KimiK2Parser::parse_incremental first (its no-marker path mem::takes the buffer into normal_text, so a split marker is indeed lost). The think-end transition and the post-reasoning flush now hold back trailing partial tool-section prefixes, same hold-back as inside reasoning; new test kimi_k2_streaming_preserves_tool_marker_split_at_transition covers the exact chunk split you described. Worth noting for context: in production the section marker is a single special token so detokenization never actually splits it — this is defensive conformance for synthetic chunk boundaries. Crate suite 103 passed, clippy clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/reasoning_parser/src/factory.rs (1)

165-190: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Wire a K2-family model-ID pattern to kimi_k2.

kimi_k2 is registered and replaces the per-SKU parsers, but build_default_patterns still maps kimi-k2-thinkingkimi_thinking and kimi-k2.5kimi_k25, and no pattern maps to kimi_k2. Automatic model selection therefore falls through to passthrough; add/update the K2-family patterns to route these models to the unified parser now.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reasoning_parser/src/factory.rs` around lines 165 - 190, Update
build_default_patterns so K2-family model IDs, including kimi-k2-thinking and
kimi-k2.5, map to the registered kimi_k2 parser instead of kimi_thinking or
kimi_k25. Ensure automatic model selection routes these variants to KimiK2Parser
rather than passthrough.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/reasoning_parser/src/factory.rs`:
- Around line 165-190: Update build_default_patterns so K2-family model IDs,
including kimi-k2-thinking and kimi-k2.5, map to the registered kimi_k2 parser
instead of kimi_thinking or kimi_k25. Ensure automatic model selection routes
these variants to KimiK2Parser rather than passthrough.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1af90c45-8901-4519-be41-aa7db8929df8

📥 Commits

Reviewing files that changed from the base of the PR and between c5de762 and d03bbda.

📒 Files selected for processing (3)
  • crates/reasoning_parser/src/factory.rs
  • crates/reasoning_parser/src/parsers/kimi_k2.rs
  • crates/reasoning_parser/src/parsers/mod.rs

Review on smg-project#1992: when a streaming chunk ends right after </think> with
a partial <|tool_calls_section_begin|>, the fragment was emitted as
normal text. Downstream tool parsers drain marker-less deltas as
user-visible text, so the marker never reassembled and the tool call
was lost. Hold back trailing partial tool-section prefixes at the
think-end transition and in the post-reasoning flush, matching the
hold-back already used inside reasoning.

Refs: smg-project#1873
Signed-off-by: ishan <ishanvgf@gmail.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71f1caa3fc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +179 to +181
let hold = Self::trailing_prefix_of(&self.buffer, &[THINK_END, TOOL_SECTION_START]);
let end = self.buffer.len() - hold;
let reasoning: String = self.buffer.drain(..end).collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Flush a held delimiter prefix at end of stream

When a streamed response ends with a proper prefix of either delimiter—for example, valid reasoning whose final character is <, or output truncated at </thi—this code retains the suffix in self.buffer. The inspected gRPC streaming paths only invoke parse_reasoning_streaming_incremental for decoded chunks and provide no end-of-stream parser flush, so the retained text is silently lost and streaming disagrees with the one-shot truncated-reasoning behavior. Add an EOF/finalization mechanism that emits the held prefix while continuing to hold it between live chunks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid edge, and it is pre-existing ecosystem-wide rather than introduced by this parser: BaseReasoningParser drops a stream-ending partial identically (base.rs is_partial_token holds </th forever), so every base-derived parser has the same leak — the trait simply has no EOF signal. A proper fix is a trait-level finalization hook plus a call from the streaming paths, which would touch all parsers + model_gateway streaming — well beyond this PR. Filed as #1998 with a proposed flush() design; keeping this PR scoped per one-concern-per-PR.

@ighutake-debug

Copy link
Copy Markdown
Contributor Author

Friendly ping — this has been green and CodeRabbit-approved for a few days; anything you would like changed? Happy to adjust. (This is PR 1 of the #1873 sequence; PRs 2 and 3 are ready to follow once this lands.)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
crates/reasoning_parser/src/factory.rs (1)

187-190: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

🟡 Nit: Add a factory-level registration test for "kimi_k2".

Direct KimiK2Parser::model_type() coverage exists, but the --reasoning-parser kimi_k2 contract is covered only by ParserFactory::new()/ParserRegistry flow tests for the other parsers. Add a test that calls ParserFactory::new().create("<kimi-k2 model>") and asserts model_type() == "kimi_k2".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reasoning_parser/src/factory.rs` around lines 187 - 190, Add a
factory-level test near the existing ParserFactory/ParserRegistry flow tests
that calls ParserFactory::new().create with a Kimi K2 model identifier and
asserts the returned parser’s model_type() equals "kimi_k2".

Source: Coding guidelines

crates/reasoning_parser/src/parsers/kimi_k2.rs (1)

91-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🟡 Nit Test both buffer-overflow paths.

The test suite does not execute the new one-shot or streaming ParseError::BufferOverflow branches. Add one oversized one-shot input test. Add one streaming test that retains a partial start marker, then exceeds the cumulative limit. Assert Err(ParseError::BufferOverflow(_)) in both cases.

As per coding guidelines, “Run the pr-test-analyzer agent to verify that tests adequately cover new or changed functionality.”

Also applies to: 129-131

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reasoning_parser/src/parsers/kimi_k2.rs` around lines 91 - 93, Add
coverage for both ParseError::BufferOverflow paths in the Kimi K2 parser: test
an oversized one-shot input and assert Err(ParseError::BufferOverflow(_)), then
test streaming input that preserves a partial start marker before exceeding the
cumulative max_buffer_size and assert the same error. Run the pr-test-analyzer
agent to verify coverage of these changed branches.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/reasoning_parser/src/parsers/kimi_k2.rs`:
- Around line 200-202: Update mark_reasoning_started() to reset reasoning_ended
and clear any stale buffered marker state whenever a new reasoning block begins,
while preserving the existing in_reasoning transition. Add a regression test
covering one completed reasoning block followed by mark_reasoning_started() and
parsing a second block, verifying the second block is classified as reasoning
rather than normal content.

---

Nitpick comments:
In `@crates/reasoning_parser/src/factory.rs`:
- Around line 187-190: Add a factory-level test near the existing
ParserFactory/ParserRegistry flow tests that calls ParserFactory::new().create
with a Kimi K2 model identifier and asserts the returned parser’s model_type()
equals "kimi_k2".

In `@crates/reasoning_parser/src/parsers/kimi_k2.rs`:
- Around line 91-93: Add coverage for both ParseError::BufferOverflow paths in
the Kimi K2 parser: test an oversized one-shot input and assert
Err(ParseError::BufferOverflow(_)), then test streaming input that preserves a
partial start marker before exceeding the cumulative max_buffer_size and assert
the same error. Run the pr-test-analyzer agent to verify coverage of these
changed branches.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ee21ac9f-1c22-49b5-8e76-f8141f02d00e

📥 Commits

Reviewing files that changed from the base of the PR and between def90a2 and 5038ec5.

📒 Files selected for processing (3)
  • crates/reasoning_parser/src/factory.rs
  • crates/reasoning_parser/src/parsers/kimi_k2.rs
  • crates/reasoning_parser/src/parsers/mod.rs

Comment thread crates/reasoning_parser/src/parsers/kimi_k2.rs
CodeRabbit on smg-project#1992: after reasoning ended, mark_reasoning_started()
set in_reasoning but left reasoning_ended latched, so a reused parser
reported reasoning mode while classifying all text as normal content.
mark_reasoning_started() now fully restarts turn state: clears the
terminal latch and stale buffer, and re-resolves the leading <think>
question for the new output.

Refs: smg-project#1873
Signed-off-by: ishan <ishanvgf@gmail.com>
@coderabbitai
coderabbitai Bot requested a review from slin1237 August 7, 2026 05:51

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/reasoning_parser/src/parsers/kimi_k2.rs (1)

91-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🟡 Nit Cover both BufferOverflow branches.

No test reaches the one-shot guard at Lines 91-93 or the cumulative streaming guard at Lines 129-131. Add one oversized one-shot input test and one streaming test that buffers a partial <think> prefix before exceeding max_buffer_size. Assert Err(ParseError::BufferOverflow(_)) in both cases.

As per coding guidelines, “Run the pr-test-analyzer agent to verify that tests adequately cover new or changed functionality.”

Also applies to: 129-131

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reasoning_parser/src/parsers/kimi_k2.rs` around lines 91 - 93, Add
tests covering both BufferOverflow paths: an oversized one-shot input exercising
the guard in the parser method containing the text.len() check, and a streaming
case that buffers a partial “<think>” prefix before exceeding max_buffer_size
and reaches the cumulative guard. Assert Err(ParseError::BufferOverflow(_)) for
both, then run the pr-test-analyzer agent to verify coverage.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/reasoning_parser/src/parsers/kimi_k2.rs`:
- Around line 91-93: Add tests covering both BufferOverflow paths: an oversized
one-shot input exercising the guard in the parser method containing the
text.len() check, and a streaming case that buffers a partial “<think>” prefix
before exceeding max_buffer_size and reaches the cumulative guard. Assert
Err(ParseError::BufferOverflow(_)) for both, then run the pr-test-analyzer agent
to verify coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 55140b07-e2db-401a-88c8-e183740471b3

📥 Commits

Reviewing files that changed from the base of the PR and between 5038ec5 and b36a777.

📒 Files selected for processing (1)
  • crates/reasoning_parser/src/parsers/kimi_k2.rs

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

Labels

reasoning-parser Reasoning parser changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants