Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions crates/reasoning_parser/src/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ use parking_lot::RwLock;
use crate::{
parsers::{
BaseReasoningParser, CohereCmdParser, DeepSeekR1Parser, Glm45Parser, KimiParser,
MiniMaxParser, NanoV3Parser, PassthroughParser, Qwen3Parser, QwenThinkingParser,
Step3Parser,
MiniMaxParser, MistralParser, NanoV3Parser, PassthroughParser, Qwen3Parser,
QwenThinkingParser, Step3Parser,
},
traits::{ParserConfig, ReasoningParser, DEFAULT_MAX_BUFFER_SIZE},
};
Expand Down Expand Up @@ -143,6 +143,9 @@ impl ParserFactory {
// appends <think> token at the beginning
registry.register_parser("minimax", || Box::new(MiniMaxParser::new()));

// Magistral reasoning models use [THINK] / [/THINK], always_in_reasoning=false
registry.register_parser("mistral", || Box::new(MistralParser::new()));

// uses <|START_THINKING|> / <|END_THINKING|>
registry.register_parser("cohere_cmd", || Box::new(CohereCmdParser::new()));

Expand Down Expand Up @@ -199,6 +202,11 @@ impl ParserFactory {
registry.register_pattern("minimax-m2", "minimax");
registry.register_pattern("mm-m2", "minimax");

// Magistral is the reasoning member of the Mistral family. Match only
// `magistral` (not a broad `mistral`/`mixtral` substring) so plain
// Mistral-Small / Mixtral non-reasoning models are not misrouted here.
registry.register_pattern("magistral", "mistral");

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 Route Ministral reasoning models to the parser

Auto-detection only registers the magistral substring, so official Mistral reasoning model IDs such as mistralai/Ministral-3-8B-Reasoning-2512 fall through to passthrough even though vLLM documents serving them with --reasoning-parser mistral (https://docs.vllm.ai/projects/recipes/en/latest/Mistral/Ministral-3-Reasoning.html). In deployments relying on model-name auto-detection, their [THINK]...[/THINK] blocks will be emitted as normal content; add a narrow ministral + reasoning style pattern rather than broad mistral.

Useful? React with 👍 / 👎.


// Cohere Command models use <|START_THINKING|> / <|END_THINKING|>
registry.register_pattern("command-r", "cohere_cmd");
registry.register_pattern("command-a", "cohere_cmd");
Expand Down Expand Up @@ -318,6 +326,30 @@ mod tests {
assert_eq!(mm.model_type(), "minimax");
}

#[test]
fn test_magistral_model() {
let factory = ParserFactory::new();

let magistral = factory.create("magistral-small-2506");
assert_eq!(magistral.model_type(), "mistral");

let magistral_cased = factory.create("Mistral/Magistral-Small");
assert_eq!(magistral_cased.model_type(), "mistral");
}

#[test]
fn test_plain_mistral_is_not_reasoning() {
// Plain Mistral / Mixtral models are NOT reasoning models and must fall
// back to passthrough rather than the Magistral reasoning parser.
let factory = ParserFactory::new();

let mistral_small = factory.create("mistral-small-3.1");
assert_eq!(mistral_small.model_type(), "passthrough");

let mixtral = factory.create("mixtral-8x7b");
assert_eq!(mixtral.model_type(), "passthrough");
}

#[test]
fn test_nano_v3_model() {
let factory = ParserFactory::new();
Expand Down
2 changes: 1 addition & 1 deletion crates/reasoning_parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ pub mod traits;
pub use factory::{ParserFactory, ParserRegistry};
pub use parsers::{
BaseReasoningParser, CohereCmdParser, DeepSeekR1Parser, Glm45Parser, KimiParser, MiniMaxParser,
NanoV3Parser, PassthroughParser, Qwen3Parser, QwenThinkingParser, Step3Parser,
MistralParser, NanoV3Parser, PassthroughParser, Qwen3Parser, QwenThinkingParser, Step3Parser,
};
pub use traits::{
ParseError, ParserConfig, ParserResult, ReasoningParser, DEFAULT_MAX_BUFFER_SIZE,
Expand Down
236 changes: 236 additions & 0 deletions crates/reasoning_parser/src/parsers/mistral.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Mistral / Magistral specific reasoning parser.
// Mistral/Magistral reasoning models emit reasoning between `[THINK]` and
// `[/THINK]` markers. A valid reasoning trace always starts with `[THINK]`;
// if the `[THINK]` token is never generated, all output is normal content.
// The template does NOT prefill the start token, so always_in_reasoning=false.

use crate::{
parsers::BaseReasoningParser,
traits::{ParseError, ParserConfig, ParserResult, ReasoningParser, DEFAULT_MAX_BUFFER_SIZE},
};

/// Mistral / Magistral reasoning parser.
///
/// Uses `[THINK]` / `[/THINK]` markers (vLLM `SpecialTokens.begin_think` /
/// `SpecialTokens.end_think`). Output starts as normal content; reasoning is
/// only entered when an explicit `[THINK]` token appears, so this parser uses
/// `always_in_reasoning=false`.
pub struct MistralParser {
base: BaseReasoningParser,
}
Comment on lines +18 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To adhere to Rust API guidelines and general best practices, public structs should implement common traits like Debug and Clone whenever possible. Since BaseReasoningParser already implements both, we can easily derive them for MistralParser to improve usability and debuggability.

Suggested change
pub struct MistralParser {
base: BaseReasoningParser,
}
#[derive(Debug, Clone)]
pub struct MistralParser {
base: BaseReasoningParser,
}
References
  1. Prefer deriving 'Debug' using '#[derive(Debug)]' instead of manually implementing 'std::fmt::Debug' in Rust, unless manual implementation is necessary (e.g., when some fields do not implement 'Debug').


impl MistralParser {
/// Create a new Mistral / Magistral parser.
pub fn new() -> Self {
let config = ParserConfig {
think_start_token: "[THINK]".to_string(),
think_end_token: "[/THINK]".to_string(),
stream_reasoning: true,
max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
always_in_reasoning: false,
};

Self {
base: BaseReasoningParser::new(config).with_model_type("mistral".to_string()),
}
}
}

impl Default for MistralParser {
fn default() -> Self {
Self::new()
}
}

impl ReasoningParser for MistralParser {
fn detect_and_parse_reasoning(&mut self, text: &str) -> Result<ParserResult, ParseError> {
self.base.detect_and_parse_reasoning(text)
}

fn parse_reasoning_streaming_incremental(
&mut self,
text: &str,
) -> Result<ParserResult, ParseError> {
self.base.parse_reasoning_streaming_incremental(text)

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 Buffer partial end tags after reasoning text

When a streaming chunk ends with only the beginning of the end marker after some reasoning text, this delegation to BaseReasoningParser emits and clears the whole buffer instead of retaining the partial marker. For example [THINK]thought -> more[/TH -> INK]answer will emit more[/TH as reasoning, lose the buffered prefix, and then never recognize [/THINK], so the final answer stays hidden as reasoning. The new tests only cover a partial marker when it is the entire chunk; real chunk boundaries can split after content as well.

Useful? React with 👍 / 👎.

}

fn reset(&mut self) {
self.base.reset();
}

fn model_type(&self) -> &str {
self.base.model_type()
}

fn is_in_reasoning(&self) -> bool {
self.base.is_in_reasoning()
}

fn mark_reasoning_started(&mut self) {
self.base.mark_reasoning_started();
}

fn mark_think_start_stripped(&mut self) {
self.base.mark_think_start_stripped();
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_model_type() {
let parser = MistralParser::new();
assert_eq!(parser.model_type(), "mistral");
}

#[test]
fn test_fresh_parser_not_in_reasoning() {
// always_in_reasoning=false: a fresh parser starts outside reasoning.
let parser = MistralParser::new();
assert!(!parser.is_in_reasoning());
}

#[test]
fn test_complete_extraction() {
let mut parser = MistralParser::new();
let result = parser
.detect_and_parse_reasoning("[THINK]reasoning here[/THINK]normal text")
.unwrap();
assert_eq!(result.reasoning_text, "reasoning here");
assert_eq!(result.normal_text, "normal text");
}

#[test]
fn test_complete_extraction_preserves_whitespace() {
let mut parser = MistralParser::new();
let result = parser
.detect_and_parse_reasoning("[THINK]foo[/THINK] hello world")
.unwrap();
assert_eq!(result.reasoning_text, "foo");
assert_eq!(result.normal_text, " hello world");
}

#[test]
fn test_no_reasoning_passthrough() {
// No [THINK] token at all => everything is normal content.
let mut parser = MistralParser::new();
let result = parser
.detect_and_parse_reasoning("just a plain answer with no reasoning")
.unwrap();
assert_eq!(result.reasoning_text, "");
assert_eq!(result.normal_text, "just a plain answer with no reasoning");
}

#[test]
fn test_truncated_reasoning_no_end_token() {
let mut parser = MistralParser::new();
let result = parser
.detect_and_parse_reasoning("[THINK]reasoning was cut off")
.unwrap();
assert_eq!(result.reasoning_text, "reasoning was cut off");
assert_eq!(result.normal_text, "");
}

#[test]
fn test_streaming_incremental() {
let mut parser = MistralParser::new();

// Start token + reasoning content in one chunk.
let result1 = parser
.parse_reasoning_streaming_incremental("[THINK]thinking about")
.unwrap();
assert_eq!(result1.reasoning_text, "thinking about");
assert_eq!(result1.normal_text, "");

// More reasoning content.
let result2 = parser
.parse_reasoning_streaming_incremental(" the problem")
.unwrap();
assert_eq!(result2.reasoning_text, " the problem");
assert_eq!(result2.normal_text, "");

// End token followed by normal text.
let result3 = parser
.parse_reasoning_streaming_incremental("[/THINK]the answer")
.unwrap();
assert_eq!(result3.reasoning_text, "");
assert_eq!(result3.normal_text, "the answer");
}

#[test]
fn test_streaming_split_inside_start_marker() {
let mut parser = MistralParser::new();

// "[THINK]" arrives split across chunk boundaries: "[TH" then "INK]".
let r1 = parser.parse_reasoning_streaming_incremental("[TH").unwrap();
// Partial start token must be buffered, emitting nothing yet.
assert_eq!(r1.reasoning_text, "");
assert_eq!(r1.normal_text, "");

let r2 = parser
.parse_reasoning_streaming_incremental("INK]reasoning")
.unwrap();
assert_eq!(r2.reasoning_text, "reasoning");
assert_eq!(r2.normal_text, "");
}

#[test]
fn test_streaming_split_inside_end_marker() {
let mut parser = MistralParser::new();

// Enter reasoning.
let r1 = parser
.parse_reasoning_streaming_incremental("[THINK]deep thought")
.unwrap();
assert_eq!(r1.reasoning_text, "deep thought");
assert_eq!(r1.normal_text, "");

// End marker "[/THINK]" split across boundaries: "[/TH" then "INK]done".
let r2 = parser
.parse_reasoning_streaming_incremental("[/TH")
.unwrap();
// Partial end token must be buffered, emitting nothing yet.
assert_eq!(r2.reasoning_text, "");
assert_eq!(r2.normal_text, "");

let r3 = parser
.parse_reasoning_streaming_incremental("INK]done")
.unwrap();
assert_eq!(r3.reasoning_text, "");
assert_eq!(r3.normal_text, "done");
}

#[test]
fn test_streaming_no_reasoning_passthrough() {
let mut parser = MistralParser::new();
let result = parser
.parse_reasoning_streaming_incremental("plain content")
.unwrap();
assert_eq!(result.reasoning_text, "");
assert_eq!(result.normal_text, "plain content");
}

#[test]
fn test_reset_behavior() {
let mut parser = MistralParser::new();

// Drive the parser into reasoning state.
parser
.parse_reasoning_streaming_incremental("[THINK]partial reasoning")
.unwrap();
assert!(parser.is_in_reasoning());

// Reset restores the always_in_reasoning=false starting state.
parser.reset();
assert!(!parser.is_in_reasoning());

// After reset, parsing starts cleanly again.
let result = parser
.detect_and_parse_reasoning("[THINK]again[/THINK]answer")
.unwrap();
assert_eq!(result.reasoning_text, "again");
assert_eq!(result.normal_text, "answer");
}
}
2 changes: 2 additions & 0 deletions crates/reasoning_parser/src/parsers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod deepseek_r1;
pub mod glm45;
pub mod kimi;
pub mod minimax;
pub mod mistral;
pub mod nano_v3;
pub mod passthrough;
pub mod qwen3;
Expand All @@ -15,6 +16,7 @@ pub use deepseek_r1::DeepSeekR1Parser;
pub use glm45::Glm45Parser;
pub use kimi::KimiParser;
pub use minimax::MiniMaxParser;
pub use mistral::MistralParser;
pub use nano_v3::NanoV3Parser;
pub use passthrough::PassthroughParser;
pub use qwen3::{Qwen3Parser, QwenThinkingParser};
Expand Down
Loading