spark_server/reasoning_parser/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Reasoning parser — model-agnostic thinking/reasoning block detection.
4//!
5//! Each served model family delimits its chain-of-thought differently.
6//! This module abstracts that behind the [`ReasoningParser`] trait so the
7//! server can extract reasoning vs. final content for any model:
8//!
9//! | Format                  | Delimiters                  | Models |
10//! |-------------------------|-----------------------------|--------|
11//! | [`ReasoningFormat::Qwen`]       | `<think>` / `</think>`      | Qwen3.5/3.6/Next/VL |
12//! | [`ReasoningFormat::DeepSeekR1`] | `<think>` / `</think>`      | Nemotron-H / Nano-3 / Super (`nano_v3`) |
13//! | [`ReasoningFormat::MiniMax`]    | `<think>` / `</think>`      | MiniMax M2 / M2.7 |
14//! | [`ReasoningFormat::Mistral`]    | `[THINK]` / `[/THINK]`      | Mistral Small 4 / Magistral |
15//! | [`ReasoningFormat::Gemma4`]     | `<|channel>` / `<channel|>` | Gemma 4 (channel format) |
16//!
17//! The Qwen / DeepSeek-R1 / MiniMax families all use `<think>` tags with
18//! the *same* extraction contract — the chat template injects the opening
19//! tag into the prompt, so output begins inside the reasoning block — and
20//! share one implementation, configured per-family with a distinct
21//! identity. Mistral differs structurally: the model emits its own
22//! `[THINK]`, so the parser does not assume an open block. Gemma 4 uses a
23//! channel format entirely unlike `<think>` tags and has its own parser.
24//!
25//! Follows the same trait + enum + TOML-auto-detect pattern as
26//! `ToolCallParser`.
27
28mod parsers;
29#[cfg(test)]
30mod tests;
31
32use std::str::FromStr;
33
34use crate::tokenizer::ChatTokenizer;
35
36/// Parses reasoning/thinking blocks from completed model output.
37pub trait ReasoningParser: Send + Sync {
38    /// Parser name for logging (e.g. `"qwen"`, `"deepseek_r1"`).
39    fn name(&self) -> &str;
40
41    /// Opening delimiter (e.g. `"<think>"`, `"[THINK]"`, `"<|channel>"`).
42    fn start_tag(&self) -> &str;
43
44    /// Closing delimiter (e.g. `"</think>"`, `"[/THINK]"`, `"<channel|>"`).
45    fn end_tag(&self) -> &str;
46
47    /// Resolve the end-of-thinking token ID from the tokenizer.
48    /// Returns `None` if the end tag doesn't encode to a single token.
49    fn end_token_id(&self, tokenizer: &ChatTokenizer) -> Option<u32> {
50        match tokenizer.encode(self.end_tag()) {
51            Ok(ids) if ids.len() == 1 => Some(ids[0]),
52            _ => None,
53        }
54    }
55
56    /// Split completed generation text into `(reasoning, content)`.
57    ///
58    /// `enable_thinking` is the resolved per-request thinking state.
59    /// When `false` the reasoning is discarded (`None`); the answer is
60    /// always returned in `content`.
61    fn extract_thinking(&self, text: &str, enable_thinking: bool) -> (Option<String>, String);
62}
63
64/// Supported reasoning-block formats. One variant per model family — see
65/// the module docs for the delimiter/contract of each.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ReasoningFormat {
68    /// `<think>...</think>` — Qwen3 family.
69    Qwen,
70    /// `<think>...</think>` — DeepSeek-R1 / NVIDIA Nemotron (`nano_v3`).
71    DeepSeekR1,
72    /// `<think>...</think>` — MiniMax M2 / M2.7.
73    MiniMax,
74    /// `[THINK]...[/THINK]` — Mistral / Magistral.
75    Mistral,
76    /// `<|channel>thought ... <channel|> ...` — Gemma 4 channel format.
77    Gemma4,
78}
79
80impl FromStr for ReasoningFormat {
81    type Err = String;
82    fn from_str(s: &str) -> Result<Self, Self::Err> {
83        match s.to_lowercase().as_str() {
84            "qwen" | "qwen3" => Ok(Self::Qwen),
85            "deepseek_r1" | "deepseek" | "nemotron" | "nemotron_h" | "nano_v3" => {
86                Ok(Self::DeepSeekR1)
87            }
88            "minimax" | "minimax_m2" => Ok(Self::MiniMax),
89            "mistral" => Ok(Self::Mistral),
90            "gemma4" | "gemma" => Ok(Self::Gemma4),
91            other => Err(format!(
92                "Unknown reasoning parser '{other}'. Supported: qwen, \
93                 deepseek_r1, minimax, mistral, gemma4"
94            )),
95        }
96    }
97}
98
99impl ReasoningFormat {
100    /// Create a boxed parser for this format.
101    pub fn into_parser(self) -> Box<dyn ReasoningParser> {
102        parsers::build(self)
103    }
104}