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}