metrale_server/tokenizer/
chat_impl.rs1use anyhow::Result;
10use std::path::Path;
11use tokenizers::Tokenizer;
12
13#[path = "chat_impl/vision_pads.rs"]
14mod vision_pads;
15
16use super::{
17 ChatEncoding, ChatTokenizer, StreamingDecoder, autoclose_assistant_think,
18 normalize_tool_call_arguments, remap_developer_role, resolve_think_control,
19};
20
21pub(crate) fn preprocess_for_render(
30 messages: &[serde_json::Value],
31 enable_thinking: bool,
32) -> (Vec<serde_json::Value>, bool) {
33 let prepared = normalize_tool_call_arguments(messages);
34 let mut prepared = remap_developer_role(prepared);
35 autoclose_assistant_think(&mut prepared);
36 let (prepared, control_override) = resolve_think_control(&prepared);
37 let effective_thinking = control_override.unwrap_or(enable_thinking);
38 (prepared, effective_thinking)
39}
40
41impl ChatTokenizer {
42 pub fn from_model_dir(
43 model_dir: &Path,
44 eos_token_id: u32,
45 supports_thinking: bool,
46 model_type: &str,
47 repo_root: Option<&Path>,
48 disable_template_overrides: bool,
49 ) -> Result<Self> {
50 let official_k3 = super::kimi_k3::uses_xtml(model_dir, model_type)?;
51 let tokenizer_path = model_dir.join("tokenizer.json");
52 let mut tokenizer = Tokenizer::from_file(&tokenizer_path)
53 .map_err(|e| anyhow::anyhow!("Failed to load tokenizer: {e}"))?;
54 tokenizer
55 .with_truncation(None)
56 .map_err(|e| anyhow::anyhow!("Failed to disable tokenizer truncation: {e}"))?;
57
58 let override_tmpl = if disable_template_overrides {
63 None
64 } else {
65 super::jinja_helpers::load_override_template(model_type, repo_root)
66 };
67 let (chat_template, checkpoint_template) = if official_k3 {
68 tracing::warn!("Official Kimi K3: raw completions only; XTML chat is unavailable");
69 (String::new(), false)
70 } else if let Some(override_tmpl) = override_tmpl {
71 (override_tmpl, false)
72 } else if let Some(config_tmpl) = super::jinja_helpers::load_config_template(model_dir)? {
73 (config_tmpl, true)
74 } else {
75 tracing::warn!("No chat template found — using default ChatML");
76 (
77 super::jinja_helpers::default_chatml_template(supports_thinking),
78 false,
79 )
80 };
81
82 let jinja_env = super::jinja_helpers::build_jinja_env(&chat_template)?;
83
84 let openai_jinja_env = super::jinja_helpers::load_openai_template(model_type, repo_root)
86 .and_then(|tmpl| {
87 tracing::info!("Loaded OpenAI-variant Jinja template for {model_type}");
88 super::jinja_helpers::build_jinja_env(&tmpl).ok()
89 });
90 let chat_encoding = if official_k3 {
91 ChatEncoding::KimiK3XtmlUnsupported
92 } else if model_type == "deepseek_v4" || model_type == "deepseek_v41" {
93 tracing::info!("Using checkpoint-native DeepSeek-V4 message encoding");
94 ChatEncoding::DeepseekV4
95 } else {
96 ChatEncoding::Jinja
97 };
98
99 let native_qwen_tool_template = checkpoint_owns_qwen_tool_prompt(
100 model_type,
101 checkpoint_template,
102 openai_jinja_env.is_some(),
103 ) && jinja_env
104 .get_template("chat")?
105 .undeclared_variables(false)
106 .contains("tools");
107 tracing::info!("Loaded tokenizer from {}", tokenizer_path.display());
108 Ok(Self {
109 tokenizer,
110 eos_token_id,
111 supports_thinking,
112 chat_encoding,
113 native_qwen_tool_template,
114 chat_template,
115 jinja_env,
116 openai_jinja_env,
117 })
118 }
119
120 pub(crate) fn uses_native_qwen_tool_template(&self) -> bool {
121 self.native_qwen_tool_template
122 }
123
124 pub fn inner(&self) -> &tokenizers::Tokenizer {
126 &self.tokenizer
127 }
128
129 pub fn encode(&self, text: &str) -> Result<Vec<u32>> {
130 let encoding = self
131 .tokenizer
132 .encode(text, false)
133 .map_err(|e| anyhow::anyhow!("Tokenizer encode error: {e}"))?;
134 Ok(encoding.get_ids().to_vec())
135 }
136
137 pub fn decode(&self, ids: &[u32]) -> Result<String> {
138 self.tokenizer
139 .decode(ids, true)
140 .map_err(|e| anyhow::anyhow!("Tokenizer decode error: {e}"))
141 }
142
143 pub fn decode_with_special(&self, ids: &[u32]) -> Result<String> {
145 self.tokenizer
146 .decode(ids, false)
147 .map_err(|e| anyhow::anyhow!("Tokenizer decode error: {e}"))
148 }
149
150 pub fn incremental_decode(
159 &self,
160 toks: &[u32],
161 prefix_offset: &mut usize,
162 read_offset: &mut usize,
163 ) -> String {
164 if *read_offset > toks.len() || *prefix_offset > *read_offset {
165 *prefix_offset = 0;
166 *read_offset = 0;
167 }
168 let prefix_text = self
169 .decode(&toks[*prefix_offset..*read_offset])
170 .unwrap_or_default();
171 let new_text = self.decode(&toks[*prefix_offset..]).unwrap_or_default();
172 if new_text.len() > prefix_text.len()
173 && !new_text.ends_with('\u{FFFD}')
174 && let Some(delta) = new_text.get(prefix_text.len()..)
175 {
176 let delta = delta.to_string();
177 *prefix_offset = *read_offset;
178 *read_offset = toks.len();
179 return delta;
180 }
181 String::new()
182 }
183
184 pub fn streaming_decoder(&self, skip_special_tokens: bool) -> StreamingDecoder<'_> {
186 StreamingDecoder {
187 inner: self.tokenizer.decode_stream(skip_special_tokens),
188 }
189 }
190
191 pub fn apply_chat_template_jinja(
194 &self,
195 messages: &[serde_json::Value],
196 tools: Option<&[serde_json::Value]>,
197 enable_thinking: bool,
198 disable_tool_steering: bool,
199 ) -> Result<Vec<u32>> {
200 self.apply_chat_template_jinja_with_effort(
201 messages,
202 tools,
203 enable_thinking,
204 disable_tool_steering,
205 None,
206 None,
207 )
208 }
209
210 pub fn apply_chat_template_jinja_with_effort(
211 &self,
212 messages: &[serde_json::Value],
213 tools: Option<&[serde_json::Value]>,
214 enable_thinking: bool,
215 disable_tool_steering: bool,
216 reasoning_effort: Option<&str>,
217 preserve_thinking: Option<bool>,
218 ) -> Result<Vec<u32>> {
219 super::kimi_k3::require_chat_support(self.chat_encoding)?;
220 if self.chat_encoding == ChatEncoding::DeepseekV4 {
221 let rendered = super::deepseek_v4::encode_messages(
222 messages,
223 tools,
224 enable_thinking,
225 reasoning_effort,
226 )?;
227 return self.encode(&rendered);
228 }
229
230 let rendered = super::chat_render::render_chat(
231 &self.jinja_env,
232 messages,
233 tools,
234 super::chat_render::RenderFlags {
235 enable_thinking,
236 disable_tool_steering,
237 reasoning_effort,
238 preserve_thinking,
239 allow_continue_final: true,
240 },
241 )?;
242
243 if rendered.len() < 2000 {
244 let tail_start = rendered.floor_char_boundary(rendered.len().saturating_sub(200));
245 tracing::info!(
246 "Jinja rendered ({} chars): {:?}",
247 rendered.len(),
248 &rendered[tail_start..]
249 );
250 }
251
252 self.encode(&rendered)
253 }
254
255 pub fn apply_chat_template_openai(
258 &self,
259 messages: &[serde_json::Value],
260 tools: Option<&[serde_json::Value]>,
261 enable_thinking: bool,
262 disable_tool_steering: bool,
263 ) -> Result<Vec<u32>> {
264 self.apply_chat_template_openai_with_effort(
265 messages,
266 tools,
267 enable_thinking,
268 disable_tool_steering,
269 None,
270 None,
271 )
272 }
273
274 pub fn apply_chat_template_openai_with_effort(
275 &self,
276 messages: &[serde_json::Value],
277 tools: Option<&[serde_json::Value]>,
278 enable_thinking: bool,
279 disable_tool_steering: bool,
280 reasoning_effort: Option<&str>,
281 preserve_thinking: Option<bool>,
282 ) -> Result<Vec<u32>> {
283 super::kimi_k3::require_chat_support(self.chat_encoding)?;
284 if self.chat_encoding == ChatEncoding::DeepseekV4 {
285 return self.apply_chat_template_jinja_with_effort(
286 messages,
287 tools,
288 enable_thinking,
289 disable_tool_steering,
290 reasoning_effort,
291 preserve_thinking,
292 );
293 }
294 if let Some(ref env) = self.openai_jinja_env {
295 let rendered = super::chat_render::render_chat(
296 env,
297 messages,
298 tools,
299 super::chat_render::RenderFlags {
300 enable_thinking,
301 disable_tool_steering,
302 reasoning_effort,
303 preserve_thinking,
304 allow_continue_final: false,
305 },
306 )
307 .map_err(|e| anyhow::anyhow!("Failed to render OpenAI Jinja template: {e}"))?;
308 self.encode(&rendered)
309 } else {
310 self.apply_chat_template_jinja_with_effort(
311 messages,
312 tools,
313 enable_thinking,
314 disable_tool_steering,
315 reasoning_effort,
316 preserve_thinking,
317 )
318 }
319 }
320
321 pub fn apply_chat_template(
324 &self,
325 messages: &[(String, String)],
326 enable_thinking: bool,
327 _image_pad_counts: &[usize],
328 ) -> Result<Vec<u32>> {
329 let json_messages: Vec<serde_json::Value> = messages
330 .iter()
331 .map(|(role, content)| {
332 serde_json::json!({
333 "role": role,
334 "content": content,
335 })
336 })
337 .collect();
338
339 self.apply_chat_template_jinja(&json_messages, None, enable_thinking, false)
340 }
341
342 pub fn eos_token_id(&self) -> u32 {
343 self.eos_token_id
344 }
345
346 pub fn think_end_token_id(&self) -> Option<u32> {
347 if !self.supports_thinking {
348 return None;
349 }
350 match self.encode("</think>") {
351 Ok(ids) if ids.len() == 1 => Some(ids[0]),
352 _ => None,
353 }
354 }
355
356 pub fn supports_thinking(&self) -> bool {
357 self.supports_thinking
358 }
359
360 pub fn uses_deepseek_v4_encoding(&self) -> bool {
361 self.chat_encoding == ChatEncoding::DeepseekV4
362 }
363}
364
365fn checkpoint_owns_qwen_tool_prompt(
369 model_type: &str,
370 checkpoint: bool,
371 openai_override: bool,
372) -> bool {
373 checkpoint
374 && !openai_override
375 && matches!(
376 model_type,
377 "qwen3_5" | "qwen3_5_moe" | "qwen3_6" | "qwen3_6_moe"
378 )
379}