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}