metrale_server/reasoning_parser/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Split a model's completed output into reasoning and answer, per reasoning-block format.
4//!
5//! | Format | Delimiters |
6//! |---|---|
7//! | [`ReasoningFormat::Qwen`], [`ReasoningFormat::DeepSeekR1`], [`ReasoningFormat::MiniMax`] | `<think>` / `</think>` |
8//! | [`ReasoningFormat::Mistral`] | `[THINK]` / `[/THINK]` |
9//! | [`ReasoningFormat::Gemma4`] | `<|channel>` / `<channel|>` |
10//!
11//! The three `<think>` formats share one implementation and differ only in
12//! name; they assume the prompt already opened the block. Mistral uses the
13//! same implementation but expects the model to emit its own `[THINK]`.
14//! Gemma 4 has its own parser. At serve time the format comes from the
15//! `[reasoning]` table of `tool_defaults.toml`, keyed by `model_type`, and a
16//! thinking-capable model without an entry gets `qwen`
17//! (`main_modules/serve_phases/tokenizer_runtime.rs`).
18//!
19//! Owner: server.
20//! Invariants: none beyond the types.
21
22mod parsers;
23#[cfg(test)]
24mod tests;
25
26use std::str::FromStr;
27
28use crate::tokenizer::ChatTokenizer;
29
30/// 2026-09-26: Splits reasoning blocks out of completed model output.
31pub trait ReasoningParser: Send + Sync {
32    /// 2026-09-26: Parser name for logging (e.g. `"qwen"`, `"deepseek_r1"`).
33    fn name(&self) -> &str;
34
35    /// 2026-09-26: Opening delimiter (e.g. `"<think>"`, `"[THINK]"`, `"<|channel>"`).
36    fn start_tag(&self) -> &str;
37
38    /// 2026-09-26: Closing delimiter (e.g. `"</think>"`, `"[/THINK]"`, `"<channel|>"`).
39    fn end_tag(&self) -> &str;
40
41    /// 2026-09-26: The end tag's token id, or `None` unless the tag encodes to
42    /// exactly one token.
43    fn end_token_id(&self, tokenizer: &ChatTokenizer) -> Option<u32> {
44        match tokenizer.encode(self.end_tag()) {
45            Ok(ids) if ids.len() == 1 => Some(ids[0]),
46            _ => None,
47        }
48    }
49
50    /// 2026-09-26: Split completed text into `(reasoning, content)`.
51    ///
52    /// `enable_thinking` is the request's resolved thinking state. When it is
53    /// `false` the reasoning is `None`; the answer is always in `content`.
54    fn extract_thinking(&self, text: &str, enable_thinking: bool) -> (Option<String>, String);
55}
56
57/// 2026-09-26: Supported reasoning-block formats; the module doc lists each
58/// one's delimiters.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ReasoningFormat {
61    /// 2026-09-26: `<think>...</think>`, parsed as `qwen`.
62    Qwen,
63    /// 2026-09-26: `<think>...</think>`, parsed as `deepseek_r1`; also named
64    /// `deepseek`, `nemotron`, `nemotron_h` and `nano_v3`.
65    DeepSeekR1,
66    /// 2026-09-26: `<think>...</think>`, parsed as `minimax`.
67    MiniMax,
68    /// 2026-09-26: `[THINK]...[/THINK]`, opened by the model.
69    Mistral,
70    /// 2026-09-26: `<|channel>thought ... <channel|> ...`, Gemma 4's channel format.
71    Gemma4,
72}
73
74impl FromStr for ReasoningFormat {
75    type Err = String;
76    fn from_str(s: &str) -> Result<Self, Self::Err> {
77        match s.to_lowercase().as_str() {
78            "qwen" | "qwen3" => Ok(Self::Qwen),
79            "deepseek_r1" | "deepseek" | "nemotron" | "nemotron_h" | "nano_v3" => {
80                Ok(Self::DeepSeekR1)
81            }
82            "minimax" | "minimax_m2" => Ok(Self::MiniMax),
83            "mistral" => Ok(Self::Mistral),
84            "gemma4" | "gemma" => Ok(Self::Gemma4),
85            other => Err(format!(
86                "Unknown reasoning parser '{other}'. Supported: qwen, \
87                 deepseek_r1, minimax, mistral, gemma4"
88            )),
89        }
90    }
91}
92
93impl ReasoningFormat {
94    /// 2026-09-26: A boxed parser for this format.
95    pub fn into_parser(self) -> Box<dyn ReasoningParser> {
96        parsers::build(self)
97    }
98}