Skip to main content

dynamo_parsers/reasoning/
mod.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3use std::collections::HashMap;
4use std::sync::OnceLock;
5
6mod base_parser;
7mod gemma4_parser;
8mod gpt_oss_parser;
9mod granite_parser;
10mod inkling_parser;
11mod minimax_append_think_parser;
12
13// Re-export main types and functions for convenience
14pub(crate) use crate::tool_calling::config::MINIMAX_M3_TOOL_NAMESPACE;
15pub use base_parser::BasicReasoningParser;
16pub use gemma4_parser::Gemma4ReasoningParser;
17pub use gpt_oss_parser::{GptOssReasoningParser, harmony_terminator_token_ids};
18pub use granite_parser::GraniteReasoningParser;
19pub use inkling_parser::InklingReasoningParser;
20pub use minimax_append_think_parser::MiniMaxAppendThinkParser;
21
22/// Kimi-K2/K2.5 tool-call section marker. Shared between the `kimi_k25` reasoning-parser
23/// registration and its test fixtures so both stay in sync. Mirrors
24/// `KimiK2ParserConfig::default().section_start` in `crate::tool_calling::config`.
25pub(crate) const KIMI_K2_TOOL_SECTION_BEGIN: &str = "<|tool_calls_section_begin|>";
26pub(crate) const DEEPSEEK_TOOL_BLOCK_BEGIN: &str = "<|DSML|tool_calls>";
27pub(crate) const DEEPSEEK_TOOL_INVOKE_BEGIN: &str = "<|DSML|invoke name=";
28
29static REASONING_PARSER_MAP: OnceLock<HashMap<&'static str, ReasoningParserType>> = OnceLock::new();
30
31/// Initialize the global reasoning parser map
32fn get_reasoning_parser_map() -> &'static HashMap<&'static str, ReasoningParserType> {
33    REASONING_PARSER_MAP.get_or_init(|| {
34        let mut map = HashMap::new();
35        map.insert("deepseek_r1", ReasoningParserType::DeepseekR1);
36        // DeepSeek V3.x thinking mode uses the same output shape as R1:
37        // reasoning text is already in progress and the completion emits
38        // `</think>` before the final answer. The V3.x tool-call parsers are
39        // distinct because their tool-call wire formats differ, but reasoning
40        // shares this forced think-tag parser.
41        map.insert("deepseek_v3", ReasoningParserType::DeepseekR1);
42        map.insert("deepseek_v3_1", ReasoningParserType::DeepseekR1);
43        map.insert("deepseek_v3_2", ReasoningParserType::DeepseekR1);
44        map.insert("basic", ReasoningParserType::Basic);
45        map.insert("gpt_oss", ReasoningParserType::GptOss);
46        map.insert("qwen3", ReasoningParserType::Qwen);
47        // DeepSeek-V4 uses the same `<think>` / `</think>` delimiters as Qwen
48        // (confirmed against deepseek-ai/DeepSeek-V4-Pro's encoding_dsv4.py)
49        // so it delegates to the same `BasicReasoningParser` config today. We
50        // still route through a dedicated `DeepSeekV4` variant rather than
51        // hard-aliasing to `Qwen` so future divergence (different special
52        // tokens, max-thinking mode, etc.) has a place to land without rippling
53        // through Qwen's own config.
54        //
55        // The three name aliases exist because callers set this via
56        // `--dyn-reasoning-parser` / `--reasoning-parser` with whatever string
57        // the HF model / vLLM recipe / chat-template author picked. We accept
58        // all three separator conventions (snake / kebab / concat) rather than
59        // force a single canonical form on users.
60        map.insert("deepseek_v4", ReasoningParserType::DeepSeekV4);
61        map.insert("deepseek-v4", ReasoningParserType::DeepSeekV4);
62        map.insert("deepseekv4", ReasoningParserType::DeepSeekV4);
63        map.insert("nemotron_deci", ReasoningParserType::NemotronDeci);
64        map.insert("kimi", ReasoningParserType::Kimi);
65        map.insert("kimi_k25", ReasoningParserType::KimiK25);
66        // Kimi K3 uses XTML channel markers. The generation prompt normally
67        // consumes the opening `think` channel, so callers should pair this
68        // parser with `set_in_reasoning(true)` when thinking is enabled.
69        map.insert("kimi_k3", ReasoningParserType::KimiK3);
70        map.insert("kimi-k3", ReasoningParserType::KimiK3);
71        map.insert("step3", ReasoningParserType::Step3);
72        map.insert("mistral", ReasoningParserType::Mistral);
73        map.insert("granite", ReasoningParserType::Granite);
74        map.insert("nemotron_nano", ReasoningParserType::DeepseekR1); // nemotron nano is ...</think>
75        map.insert("nemotron3", ReasoningParserType::DeepseekR1);
76        map.insert("nemotron_v3", ReasoningParserType::DeepseekR1);
77        map.insert("glm45", ReasoningParserType::NemotronDeci); // GLM-4.5/5 is <think>...</think>, no force_reasoning
78        map.insert(
79            "minimax_append_think",
80            ReasoningParserType::MiniMaxAppendThink,
81        );
82        map.insert("minimax_m2", ReasoningParserType::MiniMaxM2);
83        // MiniMax M3 thinking blocks use `<mm:think>...</mm:think>`. The chat
84        // template pre-fills the opener, so the completion typically emits only
85        // the closing marker; dangling-end recovery handles that.
86        map.insert("minimax_m3", ReasoningParserType::MiniMaxM3);
87        map.insert("minimax-m3", ReasoningParserType::MiniMaxM3);
88        // Gemma 4 thinking models: reasoning is wrapped in `<|channel>...<channel|>`
89        // with a `thought\n` role label that this parser strips. Pair with
90        // `--dyn-tool-call-parser gemma4` for end-to-end Gemma 4 support.
91        map.insert("gemma4", ReasoningParserType::Gemma4);
92        map.insert("gemma-4", ReasoningParserType::Gemma4);
93        // Block-structured, not a `<think>` prefix, so not a BasicReasoningParser.
94        map.insert("inkling", ReasoningParserType::Inkling);
95        map
96    })
97}
98
99/// Get all available reasoning parser names
100pub fn get_available_reasoning_parsers() -> Vec<&'static str> {
101    get_reasoning_parser_map().keys().copied().collect()
102}
103
104#[derive(Debug, Clone, Default)]
105pub struct ParserResult {
106    /// The normal text outside of reasoning blocks.
107    pub normal_text: String,
108
109    /// The extracted reasoning text from within reasoning blocks.
110    pub reasoning_text: String,
111}
112
113impl ParserResult {
114    pub fn get_some_reasoning(&self) -> Option<String> {
115        if self.reasoning_text.is_empty() {
116            None
117        } else {
118            Some(self.reasoning_text.clone())
119        }
120    }
121
122    pub fn get_some_normal_text(&self) -> Option<String> {
123        if self.normal_text.is_empty() {
124            None
125        } else {
126            Some(self.normal_text.clone())
127        }
128    }
129}
130
131pub trait ReasoningParser: Send + std::fmt::Debug {
132    /// Parses a standalone, non-streaming input chunk. Implementations may reset or ignore
133    /// internal streaming state and should return the split of normal vs reasoning text for
134    /// this complete input. Marker tokens must not be included in either output.
135    fn detect_and_parse_reasoning(&mut self, text: &str, token_ids: &[u32]) -> ParserResult;
136
137    /// Parses a streaming chunk and updates internal state. The return value should be the
138    /// delta: only the newly discovered normal and reasoning text attributable to this chunk
139    /// (not the cumulative totals). Marker tokens must not be included in either output.
140    fn parse_reasoning_streaming_incremental(
141        &mut self,
142        text: &str,
143        token_ids: &[u32],
144    ) -> ParserResult;
145
146    /// Finalizes a stream after the last chunk, before parser state is dropped.
147    ///
148    /// Incremental parsing may buffer a partial delimiter prefix instead of
149    /// emitting it immediately because the next chunk could complete a marker
150    /// like `<think>` or `</think>`. At EOF, no next chunk is coming, so the
151    /// parser must flush the undecided bytes as normal or reasoning text based
152    /// on its current state.
153    fn finish_reasoning_stream(&mut self) -> ParserResult {
154        ParserResult::default()
155    }
156
157    /// Override the parser's initial reasoning state. When called with `true`, the parser
158    /// starts in reasoning mode without waiting for the start token in the completion stream.
159    /// Use this when the chat template already injected the start token (e.g., `<think>`)
160    /// into the prompt, so it won't appear in the model's output.
161    fn set_in_reasoning(&mut self, _in_reasoning: bool) {
162        // Default no-op for parsers that don't support per-request overrides.
163    }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167#[non_exhaustive]
168pub enum ReasoningParserType {
169    DeepseekR1,
170    Step3,
171    Basic,
172    GptOss,
173    Qwen,
174    /// DeepSeek-V4-Pro / V4-Flash. Currently uses the same `<think>` /
175    /// `</think>` `BasicReasoningParser` config as Qwen (V4 never appends
176    /// `<think>` in the completion — the chat template always pre-injects it,
177    /// so the parser starts via `set_in_reasoning(true)` rather than
178    /// `force_reasoning`). A dedicated variant keeps future V4-specific
179    /// divergence (different delimiters, thinking-effort modes) from leaking
180    /// into Qwen's behavior.
181    DeepSeekV4,
182    NemotronDeci,
183    Kimi,
184    KimiK25,
185    /// Kimi K3 XTML reasoning channel:
186    /// `<|open|>think<|sep|>...<|close|>think<|sep|>`.
187    KimiK3,
188    Mistral,
189    Granite,
190    MiniMaxAppendThink,
191    /// MiniMax M2 emits reasoning from token one and may transition directly
192    /// into its XML tool-call envelope without a closing reasoning marker.
193    MiniMaxM2,
194    /// MiniMax M3 thinking models. `<mm:think>...</mm:think>` delimiters with
195    /// streaming dangling-end recovery (the chat template pre-fills the opener).
196    MiniMaxM3,
197    /// Google Gemma 4 thinking models. Custom `<|channel>...<channel|>`
198    /// delimiters with a `thought\n` role-label prefix stripped by the parser.
199    Gemma4,
200    /// Inkling (thinkingmachines/Inkling-NVFP4): block-structured reasoning, tool-call
201    /// blocks passed through verbatim. See [`crate::reasoning::inkling_parser`].
202    Inkling,
203}
204
205#[derive(std::fmt::Debug)]
206pub struct ReasoningParserWrapper {
207    parser: Box<dyn ReasoningParser>,
208}
209
210impl ReasoningParser for ReasoningParserWrapper {
211    fn detect_and_parse_reasoning(&mut self, text: &str, token_ids: &[u32]) -> ParserResult {
212        self.parser.detect_and_parse_reasoning(text, token_ids)
213    }
214
215    fn parse_reasoning_streaming_incremental(
216        &mut self,
217        text: &str,
218        token_ids: &[u32],
219    ) -> ParserResult {
220        self.parser
221            .parse_reasoning_streaming_incremental(text, token_ids)
222    }
223
224    fn finish_reasoning_stream(&mut self) -> ParserResult {
225        self.parser.finish_reasoning_stream()
226    }
227
228    fn set_in_reasoning(&mut self, in_reasoning: bool) {
229        self.parser.set_in_reasoning(in_reasoning)
230    }
231}
232
233impl ReasoningParserType {
234    pub fn get_reasoning_parser(self) -> ReasoningParserWrapper {
235        let basic_parser =
236            BasicReasoningParser::new("<think>".into(), "</think>".into(), false, true);
237        let force_reasoning_basic_parser =
238            BasicReasoningParser::new("<think>".into(), "</think>".into(), true, true);
239        match self {
240            ReasoningParserType::DeepseekR1 => ReasoningParserWrapper {
241                parser: Box::new(force_reasoning_basic_parser),
242            },
243            ReasoningParserType::Step3 => ReasoningParserWrapper {
244                parser: Box::new(force_reasoning_basic_parser),
245            },
246            ReasoningParserType::Basic => ReasoningParserWrapper {
247                parser: Box::new(basic_parser),
248            },
249            ReasoningParserType::Qwen => ReasoningParserWrapper {
250                parser: Box::new(basic_parser),
251            },
252            // Same `<think>` / `</think>` config as Qwen today; kept as a
253            // distinct variant so V4-specific divergence has somewhere to land.
254            // See `ReasoningParserType::DeepSeekV4` docstring for rationale.
255            ReasoningParserType::DeepSeekV4 => ReasoningParserWrapper {
256                parser: Box::new(
257                    BasicReasoningParser::new("<think>".into(), "</think>".into(), false, true)
258                        .with_tool_start_token(DEEPSEEK_TOOL_BLOCK_BEGIN)
259                        .with_tool_start_token(DEEPSEEK_TOOL_INVOKE_BEGIN),
260                ),
261            },
262            ReasoningParserType::NemotronDeci => ReasoningParserWrapper {
263                parser: Box::new(basic_parser),
264            },
265            ReasoningParserType::Kimi => ReasoningParserWrapper {
266                parser: Box::new(BasicReasoningParser::new(
267                    "◁think▷".into(),
268                    "◁/think▷".into(),
269                    false,
270                    true,
271                )),
272            },
273            ReasoningParserType::KimiK25 => ReasoningParserWrapper {
274                parser: Box::new(
275                    BasicReasoningParser::new("<think>".into(), "</think>".into(), true, true)
276                        .with_tool_start_token(KIMI_K2_TOOL_SECTION_BEGIN),
277                ),
278            },
279            ReasoningParserType::KimiK3 => {
280                let mut parser = BasicReasoningParser::new(
281                    "<|open|>think<|sep|>".into(),
282                    "<|close|>think<|sep|>".into(),
283                    false,
284                    true,
285                );
286                // All K3 structural channels begin with one of these reserved
287                // control tokens. Three prefix probes cover canonical and
288                // engine-spaced forms without scanning the reasoning text once
289                // for every response/tools/call/argument marker.
290                parser = parser
291                    .with_tool_start_token("<|open|>")
292                    .with_tool_start_token("<|close|>")
293                    .with_tool_start_token("<|end_of_msg|>");
294                ReasoningParserWrapper {
295                    parser: Box::new(
296                        parser
297                            // K3 markers all begin with the unambiguous `<|`
298                            // prefix, so retain a lone trailing `<` until the
299                            // next backend chunk completes or disproves it.
300                            .with_single_char_marker_buffering()
301                            // The prompt normally consumes the think opener.
302                            .with_dangling_end_recovery(),
303                    ),
304                }
305            }
306            ReasoningParserType::Mistral => ReasoningParserWrapper {
307                parser: Box::new(BasicReasoningParser::new(
308                    "[THINK]".into(),
309                    "[/THINK]".into(),
310                    true,
311                    true,
312                )),
313            },
314            ReasoningParserType::GptOss => match GptOssReasoningParser::new() {
315                Ok(parser) => ReasoningParserWrapper {
316                    parser: Box::new(parser),
317                },
318                Err(e) => {
319                    tracing::warn!(
320                        "GptOssReasoningParser could not be initialized, falling back to Basic Reasoning Parser: {e}"
321                    );
322                    ReasoningParserWrapper {
323                        parser: Box::new(BasicReasoningParser::new(
324                            "<think>".into(),
325                            "</think>".into(),
326                            false,
327                            true,
328                        )),
329                    }
330                }
331            },
332            ReasoningParserType::Granite => ReasoningParserWrapper {
333                parser: Box::new(GraniteReasoningParser::new()),
334            },
335            ReasoningParserType::MiniMaxAppendThink => ReasoningParserWrapper {
336                parser: Box::new(MiniMaxAppendThinkParser::new()),
337            },
338            ReasoningParserType::MiniMaxM2 => ReasoningParserWrapper {
339                parser: Box::new(
340                    BasicReasoningParser::new("<think>".into(), "</think>".into(), true, true)
341                        .with_tool_start_token("<minimax:tool_call>")
342                        .with_tool_start_token("<invoke name="),
343                ),
344            },
345            ReasoningParserType::MiniMaxM3 => ReasoningParserWrapper {
346                parser: Box::new(
347                    BasicReasoningParser::new(
348                        "<mm:think>".into(),
349                        "</mm:think>".into(),
350                        false,
351                        true,
352                    )
353                    .with_dangling_end_recovery()
354                    .with_implicit_tool_start_recovery()
355                    // M3 can begin a native tool call without `</mm:think>`.
356                    .with_tool_start_token(MINIMAX_M3_TOOL_NAMESPACE),
357                ),
358            },
359            ReasoningParserType::Gemma4 => ReasoningParserWrapper {
360                parser: Box::new(Gemma4ReasoningParser::new()),
361            },
362            ReasoningParserType::Inkling => ReasoningParserWrapper {
363                parser: Box::new(InklingReasoningParser::new()),
364            },
365        }
366    }
367
368    pub fn get_reasoning_parser_from_name(name: &str) -> ReasoningParserWrapper {
369        tracing::debug!("Selected reasoning parser: {}", name);
370
371        let parser_map = get_reasoning_parser_map();
372        let normalized_name = name.to_lowercase();
373
374        match parser_map.get(normalized_name.as_str()) {
375            Some(parser_type) => parser_type.get_reasoning_parser(),
376            None => {
377                tracing::warn!(
378                    parser_name = name,
379                    "Unknown reasoning parser type, falling back to Basic Reasoning Parser",
380                );
381                Self::Basic.get_reasoning_parser()
382            }
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test] // registry helper
392    fn test_get_available_reasoning_parsers() {
393        let parsers = get_available_reasoning_parsers();
394        assert!(!parsers.is_empty());
395        // Update this list when adding a new parser
396        let available_parsers = [
397            "deepseek_r1",
398            "deepseek_v3",
399            "deepseek_v3_1",
400            "deepseek_v3_2",
401            "basic",
402            "gpt_oss",
403            "qwen3",
404            "deepseek_v4",
405            "deepseek-v4",
406            "deepseekv4",
407            "nemotron_deci",
408            "kimi",
409            "kimi_k25",
410            "kimi_k3",
411            "kimi-k3",
412            "step3",
413            "mistral",
414            "granite",
415            "nemotron_nano",
416            "nemotron3",
417            "nemotron_v3",
418            "glm45",
419            "minimax_append_think",
420            "minimax_m2",
421            "minimax_m3",
422            "minimax-m3",
423            "gemma4",
424            "gemma-4",
425            "inkling",
426        ];
427        for parser in available_parsers {
428            assert!(parsers.contains(&parser));
429        }
430    }
431
432    #[test]
433    fn test_deepseek_v4_detect_and_parse_exits_reasoning_on_dsml_tool_start() {
434        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4");
435        let result = parser.detect_and_parse_reasoning(
436            &format!(
437                "<think>need a tool{DEEPSEEK_TOOL_BLOCK_BEGIN}\n<|DSML|invoke name=\"bash\">"
438            ),
439            &[],
440        );
441
442        assert_eq!(result.reasoning_text, "need a tool");
443        assert_eq!(
444            result.normal_text,
445            "<|DSML|tool_calls>\n<|DSML|invoke name=\"bash\">"
446        );
447    }
448
449    #[test]
450    fn test_deepseek_v4_detect_and_parse_exits_reasoning_without_visible_opener_on_dsml_tool_start()
451    {
452        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4");
453        parser.set_in_reasoning(true);
454
455        let result = parser.detect_and_parse_reasoning(
456            &format!(
457                "What is the weather now?{DEEPSEEK_TOOL_BLOCK_BEGIN}\n<|DSML|invoke name=\"get_weather\">"
458            ),
459            &[],
460        );
461
462        assert_eq!(result.reasoning_text, "What is the weather now?");
463        assert_eq!(
464            result.normal_text,
465            "<|DSML|tool_calls>\n<|DSML|invoke name=\"get_weather\">"
466        );
467    }
468
469    #[test]
470    fn test_deepseek_v4_detect_and_parse_exits_reasoning_on_bare_dsml_invoke() {
471        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4");
472        let tool_call = format!(
473            "{DEEPSEEK_TOOL_INVOKE_BEGIN}\"bash\">\n<|DSML|parameter name=\"cmd\" string=\"true\">pwd</|DSML|parameter>\n</|DSML|invoke>"
474        );
475        let result =
476            parser.detect_and_parse_reasoning(&format!("<think>need a tool{tool_call}"), &[]);
477
478        assert_eq!(result.reasoning_text, "need a tool");
479        assert_eq!(result.normal_text, tool_call);
480    }
481
482    #[test]
483    fn test_deepseek_v4_detect_and_parse_keeps_partial_dsml_tool_prefix_at_eof() {
484        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4");
485        let result = parser.detect_and_parse_reasoning("<think>need a tool<|DSML|tool_", &[]);
486
487        assert_eq!(result.reasoning_text, "need a tool<|DSML|tool_");
488        assert_eq!(result.normal_text, "");
489    }
490
491    #[test]
492    fn test_deepseek_v4_streaming_exits_reasoning_on_split_dsml_tool_start() {
493        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4");
494        parser.set_in_reasoning(true);
495
496        let chunks = ["need a tool", "<", "|DSML|tool_calls>\nX"];
497        let mut reasoning = String::new();
498        let mut normal = String::new();
499
500        for chunk in chunks {
501            let result = parser.parse_reasoning_streaming_incremental(chunk, &[]);
502            reasoning.push_str(&result.reasoning_text);
503            normal.push_str(&result.normal_text);
504        }
505
506        assert_eq!(reasoning, "need a tool");
507        assert_eq!(normal, "<|DSML|tool_calls>\nX");
508    }
509
510    #[test]
511    fn test_deepseek_v4_streaming_exits_reasoning_on_split_dsml_invoke() {
512        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4");
513        parser.set_in_reasoning(true);
514
515        let chunks = ["need a tool", "<|DSML|inv", "oke name=\"bash\">\nX"];
516        let mut reasoning = String::new();
517        let mut normal = String::new();
518
519        for chunk in chunks {
520            let result = parser.parse_reasoning_streaming_incremental(chunk, &[]);
521            reasoning.push_str(&result.reasoning_text);
522            normal.push_str(&result.normal_text);
523        }
524
525        assert_eq!(reasoning, "need a tool");
526        assert_eq!(normal, "<|DSML|invoke name=\"bash\">\nX");
527    }
528
529    #[test]
530    fn test_deepseek_v4_streaming_keeps_non_tool_dsml_prefix_in_reasoning() {
531        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4");
532        parser.set_in_reasoning(true);
533
534        let chunks = ["literal", "<|DSML|", "not_a_tool"];
535        let mut reasoning = String::new();
536        let mut normal = String::new();
537
538        for chunk in chunks {
539            let result = parser.parse_reasoning_streaming_incremental(chunk, &[]);
540            reasoning.push_str(&result.reasoning_text);
541            normal.push_str(&result.normal_text);
542        }
543
544        assert_eq!(reasoning, "literal<|DSML|not_a_tool");
545        assert_eq!(normal, "");
546    }
547
548    #[test]
549    fn test_deepseek_v4_streaming_does_not_drop_non_dsml_lone_angle() {
550        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4");
551        parser.set_in_reasoning(true);
552
553        let chunks = ["literal", "<", "not dsml"];
554        let mut reasoning = String::new();
555        let mut normal = String::new();
556
557        for chunk in chunks {
558            let result = parser.parse_reasoning_streaming_incremental(chunk, &[]);
559            reasoning.push_str(&result.reasoning_text);
560            normal.push_str(&result.normal_text);
561        }
562
563        assert_eq!(reasoning, "literal<not dsml");
564        assert_eq!(normal, "");
565    }
566
567    #[test] // Inkling
568    fn test_inkling_detect_and_parse() {
569        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("inkling");
570        let result = parser.detect_and_parse_reasoning(
571            "<|message_model|><|content_thinking|>thinking<|end_message|><|message_model|><|content_text|>answer<|end_message|><|content_model_end_sampling|>",
572            &[],
573        );
574        assert_eq!(result.reasoning_text, "thinking");
575        assert_eq!(result.normal_text, "answer");
576    }
577
578    #[test] // Inkling
579    fn test_inkling_preserves_tool_block() {
580        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("inkling");
581        let result = parser.detect_and_parse_reasoning(
582            r#"<|message_model|><|content_thinking|>use weather</a><|end_message|><|message_model|>get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{"location":"Paris"}}<|end_message|>"#,
583            &[],
584        );
585        assert_eq!(result.reasoning_text, "use weather</a>");
586        assert_eq!(
587            result.normal_text,
588            r#"<|message_model|>get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{"location":"Paris"}}<|end_message|>"#
589        );
590    }
591
592    #[test] // MiniMax M2
593    fn test_minimax_m2_force_reasoning_and_tool_transition() {
594        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m2");
595        let result = parser.detect_and_parse_reasoning(
596            "I should call weather.</think><minimax:tool_call><invoke name=\"get_weather\"></invoke></minimax:tool_call>",
597            &[],
598        );
599
600        assert_eq!(result.reasoning_text, "I should call weather.");
601        assert_eq!(
602            result.normal_text,
603            "<minimax:tool_call><invoke name=\"get_weather\"></invoke></minimax:tool_call>"
604        );
605    }
606
607    #[test] // MiniMax M2
608    fn test_minimax_m2_tool_start_exits_force_reasoning() {
609        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m2");
610        let tool_call =
611            "<minimax:tool_call><invoke name=\"get_weather\"></invoke></minimax:tool_call>";
612        let result = parser.detect_and_parse_reasoning(tool_call, &[]);
613
614        assert_eq!(result.reasoning_text, "");
615        assert_eq!(result.normal_text, tool_call);
616    }
617
618    #[test] // MiniMax M2
619    fn test_minimax_m2_bare_invoke_exits_force_reasoning() {
620        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m2");
621        let tool_call =
622            "<invoke name=\"get_weather\"><parameter name=\"city\">NYC</parameter></invoke>";
623        let result =
624            parser.detect_and_parse_reasoning(&format!("I should call weather.{tool_call}"), &[]);
625
626        assert_eq!(result.reasoning_text, "I should call weather.");
627        assert_eq!(result.normal_text, tool_call);
628    }
629
630    #[test] // MiniMax M2
631    fn test_minimax_m2_bare_invoke_streaming_exits_force_reasoning() {
632        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m2");
633
634        let r1 = parser.parse_reasoning_streaming_incremental("I should call ", &[]);
635        assert_eq!(r1.reasoning_text, "I should call ");
636        assert_eq!(r1.normal_text, "");
637
638        let r2 = parser.parse_reasoning_streaming_incremental("weather.<invoke na", &[]);
639        assert_eq!(r2.reasoning_text, "weather.");
640        assert_eq!(r2.normal_text, "");
641
642        let tail = "me=\"get_weather\"><parameter name=\"city\">NYC</parameter></invoke>";
643        let r3 = parser.parse_reasoning_streaming_incremental(tail, &[]);
644        assert_eq!(r3.reasoning_text, "");
645        assert_eq!(
646            r3.normal_text,
647            "<invoke name=\"get_weather\"><parameter name=\"city\">NYC</parameter></invoke>"
648        );
649    }
650
651    #[test] // MiniMax M3
652    fn test_minimax_m3_detect_and_parse() {
653        for parser_name in ["minimax_m3", "minimax-m3"] {
654            let mut parser = ReasoningParserType::get_reasoning_parser_from_name(parser_name);
655            let result =
656                parser.detect_and_parse_reasoning("<mm:think>thinking</mm:think>answer", &[]);
657            assert_eq!(result.reasoning_text, "thinking");
658            assert_eq!(result.normal_text, "answer");
659        }
660    }
661
662    #[test] // MiniMax M3
663    fn test_minimax_m3_streaming() {
664        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
665
666        let r1 = parser.parse_reasoning_streaming_incremental("<mm:think>rea", &[]);
667        let r2 = parser.parse_reasoning_streaming_incremental("son</mm:think>answer", &[]);
668
669        assert_eq!(
670            format!("{}{}", r1.reasoning_text, r2.reasoning_text),
671            "reason"
672        );
673        assert_eq!(format!("{}{}", r1.normal_text, r2.normal_text), "answer");
674    }
675
676    #[test] // MiniMax M3
677    fn test_minimax_m3_streaming_with_prompt_prefilled_start_marker() {
678        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
679        parser.set_in_reasoning(true);
680
681        let result = parser.parse_reasoning_streaming_incremental("reason</mm:think>answer", &[]);
682
683        assert_eq!(result.reasoning_text, "reason");
684        assert_eq!(result.normal_text, "answer");
685    }
686
687    #[test] // MiniMax M3
688    fn test_minimax_m3_native_tool_namespace_ends_prompt_prefilled_reasoning() {
689        for parser_name in ["minimax_m3", "minimax-m3"] {
690            for prompt_prefilled_state_set in [false, true] {
691                let mut parser = ReasoningParserType::get_reasoning_parser_from_name(parser_name);
692                if prompt_prefilled_state_set {
693                    parser.set_in_reasoning(true);
694                }
695
696                let input = format!(
697                    "reasoning{MINIMAX_M3_TOOL_NAMESPACE}<tool_call>\n\
698                     {MINIMAX_M3_TOOL_NAMESPACE}<invoke name=\"get_weather\">"
699                );
700                let result = parser.detect_and_parse_reasoning(&input, &[]);
701
702                assert_eq!(
703                    result.reasoning_text, "reasoning",
704                    "parser {parser_name}, prompt-prefilled state set: \
705                     {prompt_prefilled_state_set}"
706                );
707                assert_eq!(
708                    result.normal_text,
709                    format!(
710                        "{MINIMAX_M3_TOOL_NAMESPACE}<tool_call>\n\
711                         {MINIMAX_M3_TOOL_NAMESPACE}<invoke name=\"get_weather\">"
712                    ),
713                    "parser {parser_name}, prompt-prefilled state set: \
714                     {prompt_prefilled_state_set}"
715                );
716            }
717        }
718    }
719
720    #[test] // MiniMax M3
721    fn test_minimax_m3_native_tool_namespace_is_lossless_across_stream_chunks() {
722        let mut chunkings = vec![vec![
723            "reasoning".to_string(),
724            MINIMAX_M3_TOOL_NAMESPACE.to_string(),
725            "<tool_call>".to_string(),
726        ]];
727        chunkings.extend((1..MINIMAX_M3_TOOL_NAMESPACE.len()).map(|split| {
728            let (namespace_prefix, namespace_suffix) = MINIMAX_M3_TOOL_NAMESPACE.split_at(split);
729            vec![
730                format!("reasoning{namespace_prefix}"),
731                format!("{namespace_suffix}<tool_call>"),
732            ]
733        }));
734
735        for chunks in chunkings {
736            let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
737            parser.set_in_reasoning(true);
738
739            let results: Vec<_> = chunks
740                .iter()
741                .map(|chunk| parser.parse_reasoning_streaming_incremental(chunk, &[]))
742                .collect();
743
744            assert_eq!(
745                results
746                    .iter()
747                    .map(|result| result.reasoning_text.as_str())
748                    .collect::<String>(),
749                "reasoning",
750                "namespace chunking {chunks:?} leaked into reasoning"
751            );
752            assert_eq!(
753                results
754                    .iter()
755                    .map(|result| result.normal_text.as_str())
756                    .collect::<String>(),
757                format!("{MINIMAX_M3_TOOL_NAMESPACE}<tool_call>"),
758                "namespace chunking {chunks:?} was not preserved for the tool parser"
759            );
760        }
761    }
762
763    #[test] // MiniMax M3
764    fn test_minimax_m3_native_tool_namespace_recovers_implicit_streaming_reasoning() {
765        for parser_name in ["minimax_m3", "minimax-m3"] {
766            let cases = [
767                (
768                    vec![format!("reasoning{MINIMAX_M3_TOOL_NAMESPACE}<tool_call>")],
769                    "reasoning",
770                ),
771                (
772                    vec![
773                        "reasoning]".to_string(),
774                        "<]minimax[>[<tool_call>".to_string(),
775                    ],
776                    "reasoning",
777                ),
778                (
779                    vec![
780                        "reasoning".to_string(),
781                        format!("{MINIMAX_M3_TOOL_NAMESPACE}<tool_call>"),
782                    ],
783                    "reasoning",
784                ),
785                (
786                    vec![
787                        "let ".to_string(),
788                        "me ".to_string(),
789                        "think".to_string(),
790                        MINIMAX_M3_TOOL_NAMESPACE.to_string(),
791                        "<tool_call>".to_string(),
792                    ],
793                    "let me think",
794                ),
795            ];
796
797            for (chunks, expected_reasoning) in cases {
798                let mut parser = ReasoningParserType::get_reasoning_parser_from_name(parser_name);
799                let results: Vec<_> = chunks
800                    .iter()
801                    .map(|chunk| parser.parse_reasoning_streaming_incremental(chunk, &[]))
802                    .collect();
803
804                assert_eq!(
805                    results
806                        .iter()
807                        .map(|result| result.reasoning_text.as_str())
808                        .collect::<String>(),
809                    expected_reasoning,
810                    "parser {parser_name}, chunks {chunks:?}"
811                );
812                assert_eq!(
813                    results
814                        .iter()
815                        .map(|result| result.normal_text.as_str())
816                        .collect::<String>(),
817                    format!("{MINIMAX_M3_TOOL_NAMESPACE}<tool_call>"),
818                    "parser {parser_name}, chunks {chunks:?}"
819                );
820            }
821        }
822    }
823
824    #[test] // MiniMax M3
825    fn test_minimax_m3_implicit_streaming_recovery_waits_for_a_decisive_boundary() {
826        let chunks = ["see item [1]", " and [2]", " and [3]", " done."];
827        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
828
829        for chunk in chunks {
830            let result = parser.parse_reasoning_streaming_incremental(chunk, &[]);
831            assert_eq!(result.reasoning_text, "");
832            assert_eq!(result.normal_text, "");
833        }
834
835        let finished = parser.finish_reasoning_stream();
836        assert_eq!(finished.reasoning_text, "");
837        assert_eq!(finished.normal_text, "see item [1] and [2] and [3] done.");
838    }
839
840    #[test] // MiniMax M3
841    fn test_minimax_m3_partial_implicit_namespace_fakeout_is_normal() {
842        for finish_after_prefix in [false, true] {
843            let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
844
845            let first = parser.parse_reasoning_streaming_incremental("plain]", &[]);
846            let second = if finish_after_prefix {
847                parser.finish_reasoning_stream()
848            } else {
849                parser.parse_reasoning_streaming_incremental("not-a-namespace", &[])
850            };
851            let finished = if finish_after_prefix {
852                ParserResult::default()
853            } else {
854                parser.finish_reasoning_stream()
855            };
856
857            assert_eq!(
858                format!(
859                    "{}{}{}",
860                    first.reasoning_text, second.reasoning_text, finished.reasoning_text
861                ),
862                ""
863            );
864            assert_eq!(
865                format!(
866                    "{}{}{}",
867                    first.normal_text, second.normal_text, finished.normal_text
868                ),
869                if finish_after_prefix {
870                    "plain]"
871                } else {
872                    "plain]not-a-namespace"
873                }
874            );
875        }
876    }
877
878    #[test] // MiniMax M3
879    fn test_minimax_m3_explicit_normal_state_streams_each_chunk_immediately() {
880        let chunks = ["see item [1]", " and [2]", " and [3]", " done."];
881        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
882        parser.set_in_reasoning(false);
883
884        for chunk in chunks {
885            let result = parser.parse_reasoning_streaming_incremental(chunk, &[]);
886            assert_eq!(result.reasoning_text, "");
887            assert_eq!(result.normal_text, chunk);
888        }
889
890        let finished = parser.finish_reasoning_stream();
891        assert_eq!(finished.reasoning_text, "");
892        assert_eq!(finished.normal_text, "");
893    }
894
895    #[test] // MiniMax M3
896    fn test_minimax_m3_explicit_normal_state_disables_implicit_namespace_recovery() {
897        let input = format!("normal{MINIMAX_M3_TOOL_NAMESPACE}<tool_call>");
898
899        let mut batch_parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
900        batch_parser.set_in_reasoning(false);
901        let batch = batch_parser.detect_and_parse_reasoning(&input, &[]);
902
903        let mut stream_parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
904        stream_parser.set_in_reasoning(false);
905        let streamed = stream_parser.parse_reasoning_streaming_incremental(&input, &[]);
906
907        for result in [batch, streamed] {
908            assert_eq!(result.reasoning_text, "");
909            assert_eq!(result.normal_text, input);
910        }
911    }
912
913    #[test] // MiniMax M3
914    fn test_minimax_m3_native_tool_namespace_with_empty_reasoning() {
915        let input = format!("{MINIMAX_M3_TOOL_NAMESPACE}<tool_call>");
916
917        let mut batch_parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
918        batch_parser.set_in_reasoning(true);
919        let batch = batch_parser.detect_and_parse_reasoning(&input, &[]);
920
921        let mut stream_parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
922        stream_parser.set_in_reasoning(true);
923        let streamed = stream_parser.parse_reasoning_streaming_incremental(&input, &[]);
924
925        for result in [batch, streamed] {
926            assert_eq!(result.reasoning_text, "");
927            assert_eq!(result.normal_text, input);
928        }
929    }
930
931    #[test] // MiniMax M3
932    fn test_minimax_m3_namespace_after_explicit_end_keeps_normal_prefix() {
933        let input = format!("reason</mm:think>answer{MINIMAX_M3_TOOL_NAMESPACE}<tool_call>");
934        let expected_normal = format!("answer{MINIMAX_M3_TOOL_NAMESPACE}<tool_call>");
935
936        let mut batch_parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
937        let batch = batch_parser.detect_and_parse_reasoning(&input, &[]);
938
939        let mut stream_parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
940        let streamed = stream_parser.parse_reasoning_streaming_incremental(&input, &[]);
941
942        for result in [batch, streamed] {
943            assert_eq!(result.reasoning_text, "reason");
944            assert_eq!(result.normal_text, expected_normal);
945        }
946    }
947
948    #[test] // MiniMax M3
949    fn test_minimax_m3_detect_and_parse_dangling_end_marker() {
950        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
951        let result = parser.detect_and_parse_reasoning("reason</mm:think>answer", &[]);
952
953        assert_eq!(result.reasoning_text, "reason");
954        assert_eq!(result.normal_text, "answer");
955    }
956
957    #[test] // MiniMax M3
958    fn test_minimax_m3_streaming_dangling_end_marker() {
959        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
960
961        let r1 = parser.parse_reasoning_streaming_incremental("reason</mm:", &[]);
962        let r2 = parser.parse_reasoning_streaming_incremental("think>answer", &[]);
963
964        assert_eq!(
965            format!("{}{}", r1.reasoning_text, r2.reasoning_text),
966            "reason"
967        );
968        assert_eq!(format!("{}{}", r1.normal_text, r2.normal_text), "answer");
969    }
970
971    #[test] // MiniMax M3
972    fn test_minimax_m3_streaming_close_marker_only_is_stripped() {
973        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
974
975        let result = parser.parse_reasoning_streaming_incremental(
976            "</mm:think>I'll check the content of both files.",
977            &[],
978        );
979
980        assert_eq!(result.reasoning_text, "");
981        assert_eq!(result.normal_text, "I'll check the content of both files.");
982    }
983
984    #[test] // MiniMax M3
985    fn test_minimax_m3_detect_and_parse_multiple_spans() {
986        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
987        let result = parser.detect_and_parse_reasoning(
988            "<mm:think>first</mm:think> middle <mm:think>second</mm:think> done",
989            &[],
990        );
991
992        assert_eq!(result.reasoning_text, "firstsecond");
993        assert_eq!(result.normal_text, "middle  done");
994    }
995
996    #[test] // MiniMax M3
997    fn test_minimax_m3_streaming_prompt_prefilled_close_after_complete_span() {
998        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
999
1000        let r1 = parser.parse_reasoning_streaming_incremental("<mm:think>preamble</mm:think>", &[]);
1001        let r2 = parser.parse_reasoning_streaming_incremental("body</mm:think>", &[]);
1002        let r3 = parser.parse_reasoning_streaming_incremental("answer", &[]);
1003
1004        assert_eq!(
1005            format!(
1006                "{}{}{}",
1007                r1.reasoning_text, r2.reasoning_text, r3.reasoning_text
1008            ),
1009            "preamblebody"
1010        );
1011        assert_eq!(
1012            format!("{}{}{}", r1.normal_text, r2.normal_text, r3.normal_text),
1013            "answer"
1014        );
1015    }
1016
1017    #[test] // MiniMax M3
1018    fn test_minimax_m3_streaming_partial_start_prefix_becomes_normal_text_at_eof() {
1019        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m3");
1020
1021        let r1 = parser.parse_reasoning_streaming_incremental("plain <mm:th", &[]);
1022        let r2 = parser.parse_reasoning_streaming_incremental("esis answer", &[]);
1023        let finished = parser.finish_reasoning_stream();
1024
1025        assert_eq!(r1.reasoning_text, "");
1026        assert_eq!(r1.normal_text, "");
1027        assert_eq!(r2.reasoning_text, "");
1028        assert_eq!(r2.normal_text, "");
1029        assert_eq!(finished.reasoning_text, "");
1030        assert_eq!(finished.normal_text, "plain <mm:thesis answer");
1031    }
1032
1033    #[test] // REASONING.batch.2.c
1034    fn test_deepseek_v4_detect_and_parse() {
1035        for parser_name in ["deepseek_v4", "deepseek-v4", "deepseekv4"] {
1036            let mut parser = ReasoningParserType::get_reasoning_parser_from_name(parser_name);
1037            let result = parser.detect_and_parse_reasoning("<think>thinking</think>answer", &[]);
1038            assert_eq!(result.reasoning_text, "thinking");
1039            assert_eq!(result.normal_text, "answer");
1040        }
1041    }
1042
1043    #[test] // REASONING.batch.1.b
1044    fn test_deepseek_v4_no_forced_reasoning_without_tags() {
1045        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4");
1046        let result = parser.detect_and_parse_reasoning("answer only", &[]);
1047        assert_eq!(result.reasoning_text, "");
1048        assert_eq!(result.normal_text, "answer only");
1049    }
1050
1051    #[test] // REASONING.stream.2.a, REASONING.batch.2.c
1052    fn test_deepseek_v4_streaming() {
1053        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4");
1054
1055        let chunks = ["<think>rea", "son</think>answer"];
1056        let mut reasoning = String::new();
1057        let mut normal = String::new();
1058
1059        for chunk in chunks {
1060            let result = parser.parse_reasoning_streaming_incremental(chunk, &[]);
1061            reasoning.push_str(&result.reasoning_text);
1062            normal.push_str(&result.normal_text);
1063        }
1064
1065        assert_eq!(reasoning, "reason");
1066        assert_eq!(normal, "answer");
1067    }
1068
1069    #[test] // REASONING.batch.2.a, REASONING.batch.2.c, REASONING.batch.2.e
1070    fn test_kimi_k25_detect_and_parse() {
1071        // (description, input, expected_reasoning, expected_normal)
1072        let cases = [
1073            (
1074                "force reasoning: no think tags",
1075                "no think tags here",
1076                "no think tags here",
1077                "",
1078            ),
1079            (
1080                "standard think tags",
1081                "<think>Let me reason about this.</think>Hello!",
1082                "Let me reason about this.",
1083                "Hello!",
1084            ),
1085            (
1086                "empty think block (instant mode)",
1087                "<think></think>Hello from instant mode!",
1088                "",
1089                "Hello from instant mode!",
1090            ),
1091            (
1092                "empty think block with newline",
1093                "<think>\n</think>Hello from instant mode!",
1094                "",
1095                "Hello from instant mode!",
1096            ),
1097        ];
1098
1099        for (desc, input, expected_reasoning, expected_normal) in cases {
1100            let mut parser = ReasoningParserType::KimiK25.get_reasoning_parser();
1101            let result = parser.detect_and_parse_reasoning(input, &[]);
1102            assert_eq!(
1103                result.reasoning_text, expected_reasoning,
1104                "FAILED reasoning: {desc}"
1105            );
1106            assert_eq!(result.normal_text, expected_normal, "FAILED normal: {desc}");
1107        }
1108    }
1109
1110    #[test] // REASONING.stream.3.a, REASONING.stream.3.b, REASONING.batch.2.c
1111    fn test_kimi_k25_streaming_force_reasoning() {
1112        // Streaming: force_reasoning means tokens before <think> are treated as reasoning
1113        let mut parser = ReasoningParserType::KimiK25.get_reasoning_parser();
1114
1115        // First chunk: partial think tag — buffered because it's a prefix of "<think>"
1116        let r1 = parser.parse_reasoning_streaming_incremental("<thi", &[]);
1117        assert_eq!(r1.reasoning_text, "");
1118        assert_eq!(r1.normal_text, "");
1119
1120        // Second chunk: completes the think tag + reasoning content
1121        let r2 = parser.parse_reasoning_streaming_incremental("nk>reasoning here", &[]);
1122        assert_eq!(r2.reasoning_text, "reasoning here");
1123        assert_eq!(r2.normal_text, "");
1124
1125        // Third chunk: close tag + normal content
1126        let r3 = parser.parse_reasoning_streaming_incremental("</think>Hello!", &[]);
1127        assert_eq!(r3.reasoning_text, "");
1128        assert_eq!(r3.normal_text, "Hello!");
1129    }
1130
1131    #[test] // REASONING.stream.2.a, REASONING.batch.2.c, REASONING.batch.2.e
1132    fn test_kimi_k25_streaming() {
1133        // (description, tokens, expected_reasoning, expected_content)
1134        let cases: Vec<(&str, &[&str], &str, &str)> = vec![
1135            (
1136                "complete response",
1137                &[
1138                    "<think>",
1139                    "I need to",
1140                    " think about",
1141                    " this carefully.",
1142                    "</think>",
1143                    "Bonjour",
1144                    "!",
1145                ],
1146                "I need to think about this carefully.",
1147                "Bonjour!",
1148            ),
1149            (
1150                "empty think (instant mode)",
1151                &["<think>", "</think>", "Direct answer."],
1152                "",
1153                "Direct answer.",
1154            ),
1155        ];
1156
1157        for (desc, tokens, expected_reasoning, expected_content) in cases {
1158            let mut parser = ReasoningParserType::KimiK25.get_reasoning_parser();
1159            let mut all_reasoning = String::new();
1160            let mut all_content = String::new();
1161            for token in tokens {
1162                let r = parser.parse_reasoning_streaming_incremental(token, &[]);
1163                all_reasoning.push_str(&r.reasoning_text);
1164                all_content.push_str(&r.normal_text);
1165            }
1166            assert_eq!(
1167                all_reasoning, expected_reasoning,
1168                "FAILED reasoning: {desc}"
1169            );
1170            assert_eq!(all_content, expected_content, "FAILED content: {desc}");
1171        }
1172    }
1173
1174    #[test] // registry lookup
1175    fn test_kimi_k25_parser_lookup_by_name() {
1176        // Verify the parser can be looked up by name
1177        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("kimi_k25");
1178        let result = parser.detect_and_parse_reasoning("<think>thinking</think>answer", &[]);
1179        assert_eq!(result.reasoning_text, "thinking");
1180        assert_eq!(result.normal_text, "answer");
1181    }
1182
1183    #[test]
1184    fn test_kimi_k3_explicit_reasoning_channel() {
1185        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("kimi_k3");
1186        let result = parser.detect_and_parse_reasoning(
1187            "<|open|>think<|sep|>check weather<|close|>think<|sep|><|open|>response<|sep|>It is raining.<|close|>response<|sep|>",
1188            &[],
1189        );
1190
1191        assert_eq!(result.reasoning_text, "check weather");
1192        assert_eq!(
1193            result.normal_text,
1194            "<|open|>response<|sep|>It is raining.<|close|>response<|sep|>"
1195        );
1196    }
1197
1198    #[test]
1199    fn test_kimi_k3_prompt_prefilled_reasoning_channel() {
1200        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("kimi-k3");
1201        parser.set_in_reasoning(true);
1202
1203        let result = parser.parse_reasoning_streaming_incremental(
1204            "check weather<|close|>think<|sep|><|open|>response<|sep|>It is raining.",
1205            &[],
1206        );
1207
1208        assert_eq!(result.reasoning_text, "check weather");
1209        assert_eq!(result.normal_text, "<|open|>response<|sep|>It is raining.");
1210    }
1211
1212    #[test]
1213    fn test_kimi_k3_streaming_dangling_close_split_across_chunks() {
1214        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("kimi_k3");
1215
1216        let first = parser.parse_reasoning_streaming_incremental("check weather<|close|>thi", &[]);
1217        let second = parser.parse_reasoning_streaming_incremental(
1218            "nk<|sep|><|open|>response<|sep|>It is raining.",
1219            &[],
1220        );
1221
1222        assert_eq!(
1223            format!("{}{}", first.reasoning_text, second.reasoning_text),
1224            "check weather"
1225        );
1226        assert_eq!(
1227            format!("{}{}", first.normal_text, second.normal_text),
1228            "<|open|>response<|sep|>It is raining."
1229        );
1230    }
1231
1232    #[test]
1233    fn test_kimi_k3_thinking_disabled_is_normal_text() {
1234        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("kimi_k3");
1235        let response = "answer<|close|>response<|sep|><|close|>message<|sep|>";
1236        let result = parser.detect_and_parse_reasoning(response, &[]);
1237
1238        assert_eq!(result.reasoning_text, "");
1239        assert_eq!(result.normal_text, response);
1240    }
1241
1242    #[test]
1243    fn test_kimi_k3_response_marker_recovers_missing_think_close() {
1244        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("kimi_k3");
1245        parser.set_in_reasoning(true);
1246        let result = parser
1247            .detect_and_parse_reasoning("check weather<|open|>response<|sep|>It is raining.", &[]);
1248
1249        assert_eq!(result.reasoning_text, "check weather");
1250        assert_eq!(result.normal_text, "<|open|>response<|sep|>It is raining.");
1251    }
1252
1253    #[test]
1254    fn test_kimi_k3_reasoning_output_composes_with_tool_parser() {
1255        let output = concat!(
1256            "<|open|>think<|sep|>use the calculator<|close|>think<|sep|>",
1257            "<|open|>response<|sep|>I will calculate.<|close|>response<|sep|>",
1258            "<|open|>tools<|sep|>",
1259            "<|open|>call tool=\"calc\" index=\"1\"<|sep|>",
1260            "<|open|>argument key=\"x\" type=\"number\"<|sep|>42",
1261            "<|close|>argument<|sep|><|close|>call<|sep|>",
1262            "<|close|>tools<|sep|><|close|>message<|sep|><|end_of_msg|>"
1263        );
1264        let mut reasoning = ReasoningParserType::KimiK3.get_reasoning_parser();
1265        let split = reasoning.detect_and_parse_reasoning(output, &[]);
1266
1267        let (calls, content) = crate::tool_calling::try_tool_call_parse_kimi_k3(
1268            &split.normal_text,
1269            &crate::tool_calling::KimiK3ParserConfig::default(),
1270            None,
1271        )
1272        .unwrap();
1273
1274        assert_eq!(split.reasoning_text, "use the calculator");
1275        assert_eq!(content.as_deref(), Some("I will calculate."));
1276        assert_eq!(calls.len(), 1);
1277        assert_eq!(calls[0].id, "calc:0");
1278        assert_eq!(calls[0].function.name, "calc");
1279        assert_eq!(calls[0].function.arguments, r#"{"x":42}"#);
1280    }
1281
1282    fn parse_streamed_kimi_k3(
1283        completion: &str,
1284        split: usize,
1285    ) -> (String, Vec<crate::tool_calling::ToolCallResponse>, String) {
1286        parse_streamed_kimi_k3_chunks(&[&completion[..split], &completion[split..]])
1287    }
1288
1289    fn parse_streamed_kimi_k3_chunks(
1290        chunks: &[&str],
1291    ) -> (String, Vec<crate::tool_calling::ToolCallResponse>, String) {
1292        let mut parser = ReasoningParserType::KimiK3.get_reasoning_parser();
1293        parser.set_in_reasoning(true);
1294
1295        let mut reasoning = String::new();
1296        let mut xtml = String::new();
1297        for chunk in chunks {
1298            let parsed = parser.parse_reasoning_streaming_incremental(chunk, &[]);
1299            reasoning.push_str(&parsed.reasoning_text);
1300            xtml.push_str(&parsed.normal_text);
1301        }
1302        let finished = parser.finish_reasoning_stream();
1303        reasoning.push_str(&finished.reasoning_text);
1304        xtml.push_str(&finished.normal_text);
1305
1306        let (calls, content) = crate::tool_calling::try_tool_call_parse_kimi_k3(
1307            &xtml,
1308            &crate::tool_calling::KimiK3ParserConfig::default(),
1309            None,
1310        )
1311        .unwrap();
1312        (reasoning, calls, content.unwrap_or_default())
1313    }
1314
1315    fn assert_kimi_k3_all_splits<F>(completion: &str, mut assert_result: F)
1316    where
1317        F: FnMut(usize, &str, &[crate::tool_calling::ToolCallResponse], &str),
1318    {
1319        for split in completion
1320            .char_indices()
1321            .map(|(position, _)| position)
1322            .skip(1)
1323            .chain(std::iter::once(completion.len()))
1324        {
1325            let (reasoning, calls, content) = parse_streamed_kimi_k3(completion, split);
1326            assert_result(split, &reasoning, &calls, &content);
1327        }
1328    }
1329
1330    #[test]
1331    fn test_kimi_k3_think_close_all_splits_preserve_response_content() {
1332        const THINK_CLOSE: &str = "<|close|>think<|sep|>";
1333
1334        for answer in ["17", r#"{"answer":81}"#] {
1335            for split in THINK_CLOSE
1336                .char_indices()
1337                .map(|(position, _)| position)
1338                .chain(std::iter::once(THINK_CLOSE.len()))
1339            {
1340                let (reasoning, calls, content) = parse_streamed_kimi_k3_chunks(&[
1341                    "reasoning text",
1342                    &THINK_CLOSE[..split],
1343                    &THINK_CLOSE[split..],
1344                    answer,
1345                ]);
1346
1347                assert_eq!(reasoning, "reasoning text", "split at byte {split}");
1348                assert!(calls.is_empty(), "split at byte {split}");
1349                assert_eq!(content, answer, "split at byte {split}");
1350            }
1351        }
1352    }
1353
1354    #[test]
1355    fn test_kimi_k3_think_close_all_splits_preserve_native_tool_call() {
1356        const THINK_CLOSE: &str = "<|close|>think<|sep|>";
1357        let tool_call = concat!(
1358            "<|open|>tools<|sep|>",
1359            "<|open|>call tool=\"calc\" index=\"1\"<|sep|>",
1360            "<|open|>argument key=\"x\" type=\"number\"<|sep|>5",
1361            "<|close|>argument<|sep|><|close|>call<|sep|>",
1362            "<|close|>tools<|sep|><|close|>message<|sep|><|end_of_msg|>"
1363        );
1364
1365        for split in THINK_CLOSE
1366            .char_indices()
1367            .map(|(position, _)| position)
1368            .chain(std::iter::once(THINK_CLOSE.len()))
1369        {
1370            let (reasoning, calls, content) = parse_streamed_kimi_k3_chunks(&[
1371                "reasoning text",
1372                &THINK_CLOSE[..split],
1373                &THINK_CLOSE[split..],
1374                tool_call,
1375            ]);
1376
1377            assert_eq!(reasoning, "reasoning text", "split at byte {split}");
1378            assert_eq!(content, "", "split at byte {split}");
1379            assert_eq!(calls.len(), 1, "split at byte {split}");
1380            assert_eq!(calls[0].function.name, "calc");
1381            assert_eq!(calls[0].function.arguments, r#"{"x":5}"#);
1382        }
1383    }
1384
1385    #[test]
1386    fn test_kimi_k3_aggregated_think_close_does_not_leak() {
1387        let mut reasoning = ReasoningParserType::KimiK3.get_reasoning_parser();
1388        let split = reasoning.detect_and_parse_reasoning(
1389            concat!(
1390                "<|open|>think<|sep|>reasoning text",
1391                "<|close|>think<|sep|>answer"
1392            ),
1393            &[],
1394        );
1395
1396        let (calls, content) = crate::tool_calling::try_tool_call_parse_kimi_k3(
1397            &split.normal_text,
1398            &crate::tool_calling::KimiK3ParserConfig::default(),
1399            None,
1400        )
1401        .unwrap();
1402
1403        assert_eq!(split.reasoning_text, "reasoning text");
1404        assert!(calls.is_empty());
1405        assert_eq!(content.as_deref(), Some("answer"));
1406    }
1407
1408    #[test]
1409    fn test_kimi_k3_live_response_handoff_is_chunk_boundary_independent() {
1410        let completion = concat!(
1411            "Final exactly '4'.",
1412            "<|open|>response<|sep|>4",
1413            "<|close|>response<|sep|>",
1414            "<|close|>message<|sep|>",
1415            "<|end_of_msg|>"
1416        );
1417
1418        assert_kimi_k3_all_splits(completion, |split, reasoning, calls, content| {
1419            assert_eq!(
1420                reasoning, "Final exactly '4'.",
1421                "split at byte {split} leaked the response channel into reasoning"
1422            );
1423            assert!(calls.is_empty(), "split at byte {split}");
1424            assert_eq!(content, "4", "split at byte {split}");
1425        });
1426    }
1427
1428    #[test]
1429    fn test_kimi_k3_live_multi_argument_call_is_chunk_boundary_independent() {
1430        let completion = concat!(
1431            "Now call add_numbers.",
1432            "<|open|>tools<|sep|>",
1433            "<|open|>call tool=\"add_numbers\" index=\"1\"<|sep|>",
1434            "<|open|>argument key=\"a\" type=\"number\"<|sep|>17",
1435            "<|close|>argument<|sep|>",
1436            "<|open|>argument key=\"b\" type=\"number\"<|sep|>19",
1437            "<|close|>argument<|sep|>",
1438            "<|close|>call<|sep|>",
1439            "<|close|>tools<|sep|>",
1440            "<|close|>message<|sep|>",
1441            "<|end_of_msg|>"
1442        );
1443
1444        assert_kimi_k3_all_splits(completion, |split, reasoning, calls, content| {
1445            assert_eq!(
1446                reasoning, "Now call add_numbers.",
1447                "split at byte {split} leaked argument framing into reasoning"
1448            );
1449            assert_eq!(content, "", "split at byte {split}");
1450            assert_eq!(calls.len(), 1, "split at byte {split}");
1451            assert_eq!(calls[0].function.name, "add_numbers");
1452            assert_eq!(calls[0].function.arguments, r#"{"a":17,"b":19}"#);
1453        });
1454    }
1455
1456    #[test]
1457    fn test_kimi_k3_live_parallel_calls_are_chunk_boundary_independent() {
1458        let completion = concat!(
1459            "Fetch both URLs.",
1460            "<|open|>tools<|sep|>",
1461            "<|open|>call tool=\"fetch_url\" index=\"1\"<|sep|>",
1462            "<|open|>argument key=\"url\" type=\"string\"<|sep|>https://a.example/x",
1463            "<|close|>argument<|sep|><|close|>call<|sep|>",
1464            "<|open|>call tool=\"fetch_url\" index=\"2\"<|sep|>",
1465            "<|open|>argument key=\"url\" type=\"string\"<|sep|>https://b.example/y",
1466            "<|close|>argument<|sep|><|close|>call<|sep|>",
1467            "<|close|>tools<|sep|>",
1468            "<|close|>message<|sep|><|end_of_msg|>"
1469        );
1470
1471        assert_kimi_k3_all_splits(completion, |split, reasoning, calls, content| {
1472            assert_eq!(
1473                reasoning, "Fetch both URLs.",
1474                "split at byte {split} leaked the second call into reasoning"
1475            );
1476            assert_eq!(content, "", "split at byte {split}");
1477            assert_eq!(calls.len(), 2, "split at byte {split}");
1478            assert_eq!(
1479                calls[0].function.arguments,
1480                r#"{"url":"https://a.example/x"}"#
1481            );
1482            assert_eq!(
1483                calls[1].function.arguments,
1484                r#"{"url":"https://b.example/y"}"#
1485            );
1486        });
1487    }
1488
1489    #[test]
1490    fn test_kimi_k3_live_bare_call_recovers_missing_outer_tools_wrapper() {
1491        let completion = concat!(
1492            "Call the calculator.",
1493            "<|open|>call tool=\"calc\" index=\"1\"<|sep|>",
1494            "<|open|>argument key=\"x\" type=\"number\"<|sep|>5",
1495            "<|close|>argument<|sep|><|close|>call<|sep|>",
1496            "<|close|>message<|sep|><|end_of_msg|>"
1497        );
1498
1499        assert_kimi_k3_all_splits(completion, |split, reasoning, calls, content| {
1500            assert_eq!(reasoning, "Call the calculator.", "split at byte {split}");
1501            assert_eq!(content, "", "split at byte {split}");
1502            assert_eq!(calls.len(), 1, "split at byte {split}");
1503            assert_eq!(calls[0].function.name, "calc");
1504            assert_eq!(calls[0].function.arguments, r#"{"x":5}"#);
1505        });
1506    }
1507
1508    #[test]
1509    fn test_kimi_k3_live_orphan_argument_is_quarantined_not_leaked() {
1510        let completion = concat!(
1511            "Choose the unit.",
1512            "<|open|>argument key=\"unit\" type=\"string\"<|sep|>celsius",
1513            "<|close|>argument<|sep|>",
1514            "<|close|>call<|sep|><|close|>tools<|sep|>"
1515        );
1516
1517        assert_kimi_k3_all_splits(completion, |split, reasoning, calls, content| {
1518            assert_eq!(reasoning, "Choose the unit.", "split at byte {split}");
1519            assert!(calls.is_empty(), "split at byte {split}");
1520            assert_eq!(content, "", "split at byte {split}");
1521        });
1522    }
1523
1524    #[test]
1525    fn test_kimi_k3_added_token_spacing_is_chunk_boundary_independent() {
1526        let completion = concat!(
1527            "Use the calculator.",
1528            "<|open|> tools <|sep|>",
1529            "<|open|> call tool=\"calc\" index=\"1\" <|sep|>",
1530            "<|open|> argument key=\"x\" type=\"number\" <|sep|>5",
1531            "<|close|> argument <|sep|>",
1532            "<|close|> call <|sep|>",
1533            "<|close|> tools <|sep|>",
1534            "<|close|> message <|sep|>",
1535            "<|end_of_msg|>"
1536        );
1537
1538        assert_kimi_k3_all_splits(completion, |split, reasoning, calls, content| {
1539            assert_eq!(reasoning, "Use the calculator.", "split at byte {split}");
1540            assert_eq!(content, "", "split at byte {split}");
1541            assert_eq!(calls.len(), 1, "split at byte {split}");
1542            assert_eq!(calls[0].function.name, "calc");
1543            assert_eq!(calls[0].function.arguments, r#"{"x":5}"#);
1544        });
1545    }
1546
1547    #[test]
1548    fn test_kimi_k3_single_char_marker_buffering_preserves_literal_at_eof() {
1549        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("kimi_k3");
1550        parser.set_in_reasoning(true);
1551
1552        let streamed = parser.parse_reasoning_streaming_incremental("literal<", &[]);
1553        let finished = parser.finish_reasoning_stream();
1554
1555        assert_eq!(streamed.reasoning_text, "literal");
1556        assert_eq!(streamed.normal_text, "");
1557        assert_eq!(finished.reasoning_text, "<");
1558        assert_eq!(finished.normal_text, "");
1559    }
1560
1561    #[test]
1562    fn test_non_k3_single_char_marker_behavior_is_unchanged() {
1563        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("qwen3");
1564        parser.set_in_reasoning(true);
1565
1566        let streamed = parser.parse_reasoning_streaming_incremental("literal<", &[]);
1567        let finished = parser.finish_reasoning_stream();
1568
1569        assert_eq!(streamed.reasoning_text, "literal<");
1570        assert_eq!(streamed.normal_text, "");
1571        assert_eq!(finished.reasoning_text, "");
1572        assert_eq!(finished.normal_text, "");
1573    }
1574
1575    #[test] // TOOLCALLING.fmt.3 — token-spelling differences across model variants
1576    fn test_kimi_vs_kimi_k25_different_tags() {
1577        // Kimi (original) uses ◁think▷/◁/think▷, KimiK25 uses <think>/</think>
1578        let mut kimi = ReasoningParserType::Kimi.get_reasoning_parser();
1579        let mut kimi_k25 = ReasoningParserType::KimiK25.get_reasoning_parser();
1580
1581        // Kimi original does NOT parse <think> tags
1582        let r_kimi = kimi.detect_and_parse_reasoning("<think>reasoning</think>answer", &[]);
1583        assert_eq!(r_kimi.normal_text, "<think>reasoning</think>answer");
1584        assert_eq!(r_kimi.reasoning_text, "");
1585
1586        // KimiK25 does parse <think> tags
1587        let r_k25 = kimi_k25.detect_and_parse_reasoning("<think>reasoning</think>answer", &[]);
1588        assert_eq!(r_k25.reasoning_text, "reasoning");
1589        assert_eq!(r_k25.normal_text, "answer");
1590    }
1591
1592    // Scenario 1: Normal streaming flow with force_reasoning + set_in_reasoning.
1593    // Simulates the OpenAI path where the preprocessor detects prompt-injected
1594    // reasoning and calls set_in_reasoning(true). The parser should correctly
1595    // transition from reasoning to content when </think> arrives.
1596    #[test] // REASONING.stream.2.a, REASONING.batch.2.c — force-mode
1597    fn test_nemotron_streaming_with_set_in_reasoning() {
1598        let mut parser = ReasoningParserType::DeepseekR1.get_reasoning_parser();
1599        parser.set_in_reasoning(true); // OpenAI path calls this
1600
1601        let tokens = &["Think", "ing about", " this", ".\n\n", "</think>", "Four"];
1602
1603        let mut all_reasoning = String::new();
1604        let mut all_content = String::new();
1605        for token in tokens {
1606            let r = parser.parse_reasoning_streaming_incremental(token, &[]);
1607            all_reasoning.push_str(&r.reasoning_text);
1608            all_content.push_str(&r.normal_text);
1609        }
1610        assert_eq!(all_reasoning, "Thinking about this.\n\n");
1611        assert_eq!(all_content, "Four");
1612    }
1613
1614    // Scenario 2: Streaming with force_reasoning but WITHOUT set_in_reasoning.
1615    // Simulates the Anthropic path bug where thinking_enabled=false and
1616    // set_in_reasoning is never called. The parser still starts in reasoning
1617    // mode (force_reasoning=true) but stripped_think_start=false. The </think>
1618    // boundary must still be detected correctly.
1619    #[test] // REASONING.stream.2.a, REASONING.batch.2.c — force-mode
1620    fn test_nemotron_streaming_force_reasoning_without_set_in_reasoning() {
1621        // DeepseekR1 has force_reasoning=true but we do NOT call set_in_reasoning
1622        let mut parser = ReasoningParserType::DeepseekR1.get_reasoning_parser();
1623
1624        let tokens = &["Think", "ing about", " this", ".\n\n", "</think>", "Four"];
1625
1626        let mut all_reasoning = String::new();
1627        let mut all_content = String::new();
1628        for token in tokens {
1629            let r = parser.parse_reasoning_streaming_incremental(token, &[]);
1630            all_reasoning.push_str(&r.reasoning_text);
1631            all_content.push_str(&r.normal_text);
1632        }
1633        assert_eq!(all_reasoning, "Thinking about this.\n\n");
1634        assert_eq!(all_content, "Four");
1635    }
1636
1637    // Scenario 3: Token-by-token </think> split across chunks.
1638    // The '<' in '</think>' is a prefix of '<think>'. When stripped_think_start
1639    // is false, the parser's prefix-check could buffer '<' and interfere with
1640    // </think> detection. This test verifies the boundary is detected even when
1641    // </think> arrives as individual characters.
1642    #[test] // REASONING.stream.3.b, helper
1643    fn test_nemotron_streaming_split_end_think_tokens() {
1644        let mut parser = ReasoningParserType::DeepseekR1.get_reasoning_parser();
1645        parser.set_in_reasoning(true);
1646
1647        // Simulate token-by-token arrival including </think> split across chunks
1648        let tokens = &[
1649            "reason", "ing", " done", ".", "</", "think", ">", "Hello", " world",
1650        ];
1651
1652        let mut all_reasoning = String::new();
1653        let mut all_content = String::new();
1654        for token in tokens {
1655            let r = parser.parse_reasoning_streaming_incremental(token, &[]);
1656            all_reasoning.push_str(&r.reasoning_text);
1657            all_content.push_str(&r.normal_text);
1658        }
1659        assert_eq!(all_reasoning, "reasoning done.");
1660        assert_eq!(all_content, "Hello world");
1661    }
1662
1663    // Scenario: vLLM's Nemotron v3 parser is force-reasoning. The model may
1664    // begin directly with reasoning text and only emit the closing </think>
1665    // boundary, or it may emit a full <think>...</think> block. Both forms
1666    // should split into reasoning_content before </think> and normal content
1667    // after it.
1668    #[test] // CASE.10 — vLLM nemotron_v3 parity
1669    fn test_nemotron_v3_detect_and_parse_vllm_cases() {
1670        let cases = [
1671            (
1672                "without start token",
1673                "This is a reasoning section</think>This is the rest",
1674                "This is a reasoning section",
1675                "This is the rest",
1676            ),
1677            (
1678                "with start token",
1679                "<think>This is a reasoning section</think>This is the rest",
1680                "This is a reasoning section",
1681                "This is the rest",
1682            ),
1683        ];
1684
1685        for (desc, input, expected_reasoning, expected_content) in cases {
1686            let mut parser = ReasoningParserType::get_reasoning_parser_from_name("nemotron_v3");
1687            let result = parser.detect_and_parse_reasoning(input, &[]);
1688            assert_eq!(
1689                result.reasoning_text, expected_reasoning,
1690                "FAILED reasoning: {desc}"
1691            );
1692            assert_eq!(
1693                result.normal_text, expected_content,
1694                "FAILED content: {desc}"
1695            );
1696        }
1697    }
1698
1699    // Scenario: same vLLM Nemotron v3 contract as the non-streaming test, but
1700    // exercised as streaming deltas. This verifies the parser keeps state
1701    // across chunks and handles both prompt-injected reasoning (no opening
1702    // <think> in output) and explicit <think> output.
1703    #[test] // CASE.8, CASE.10 — vLLM nemotron_v3 parity
1704    fn test_nemotron_v3_streaming_vllm_cases() {
1705        let cases: Vec<(&str, &[&str], &str, &str)> = vec![
1706            (
1707                "without start token",
1708                &[
1709                    "This is a reasoning section",
1710                    "</think>",
1711                    "This is the rest",
1712                ],
1713                "This is a reasoning section",
1714                "This is the rest",
1715            ),
1716            (
1717                "with start token",
1718                &[
1719                    "<think>",
1720                    "This is a reasoning section",
1721                    "</think>",
1722                    "This is the rest",
1723                ],
1724                "This is a reasoning section",
1725                "This is the rest",
1726            ),
1727        ];
1728
1729        for (desc, tokens, expected_reasoning, expected_content) in cases {
1730            let mut parser = ReasoningParserType::get_reasoning_parser_from_name("nemotron_v3");
1731            let mut all_reasoning = String::new();
1732            let mut all_content = String::new();
1733            for token in tokens {
1734                let result = parser.parse_reasoning_streaming_incremental(token, &[]);
1735                all_reasoning.push_str(&result.reasoning_text);
1736                all_content.push_str(&result.normal_text);
1737            }
1738            assert_eq!(
1739                all_reasoning, expected_reasoning,
1740                "FAILED reasoning: {desc}"
1741            );
1742            assert_eq!(all_content, expected_content, "FAILED content: {desc}");
1743        }
1744    }
1745
1746    // P2-1: V4 production regime where the prompt ends in <think>, so the stream
1747    // begins INSIDE a reasoning block (no opening <think> sentinel). The caller
1748    // initializes the parser via set_in_reasoning(true); bytes before </think>
1749    // must route to reasoning_content, bytes after to normal content.
1750    #[test]
1751    fn test_deepseek_v4_streaming_with_set_in_reasoning() {
1752        let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4");
1753        parser.set_in_reasoning(true);
1754
1755        // Token-by-token stream, starting with raw reasoning (no <think> prefix),
1756        // </think> in the middle, then normal content.
1757        let tokens = &[
1758            "Wei", "gh", "ing ", "options", ".", "</think>", "Bei", "jing", " is", " sunny.",
1759        ];
1760
1761        let mut all_reasoning = String::new();
1762        let mut all_content = String::new();
1763        for token in tokens {
1764            let r = parser.parse_reasoning_streaming_incremental(token, &[]);
1765            all_reasoning.push_str(&r.reasoning_text);
1766            all_content.push_str(&r.normal_text);
1767        }
1768        assert_eq!(all_reasoning, "Weighing options.");
1769        assert_eq!(all_content, "Beijing is sunny.");
1770    }
1771}