spark_server/tokenizer/chat_impl.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `impl ChatTokenizer` body.
4
5use anyhow::Result;
6use std::path::Path;
7use tokenizers::Tokenizer;
8
9use super::{
10 ChatEncoding, ChatTokenizer, StreamingDecoder, autoclose_assistant_think,
11 normalize_tool_call_arguments, remap_developer_role, resolve_think_control,
12};
13
14/// Run Atlas's cross-cutting message preprocessing (formerly encoded in
15/// per-model jinja overrides) so it applies to EVERY model's own template:
16/// 1. parse stringified `tool_calls[*].function.arguments` (F76),
17/// 2. auto-close an unclosed `<think>` before a `<tool_call>` in
18/// assistant history,
19/// 3. strip inline `<|think_on|>`/`<|think_off|>` control tokens and
20/// resolve the effective `enable_thinking`.
21///
22/// Returns the rewritten messages plus the thinking flag to render with
23/// (the inline control tokens override the caller's value when present).
24pub(crate) fn preprocess_for_render(
25 messages: &[serde_json::Value],
26 enable_thinking: bool,
27) -> (Vec<serde_json::Value>, bool) {
28 // F76: stringified tool-call args → dicts (see normalize_tool_call_arguments).
29 let prepared = normalize_tool_call_arguments(messages);
30 // Behavior 0: developer→system role remap (model templates reject `developer`;
31 // folds developer+system into one leading system message).
32 let mut prepared = remap_developer_role(prepared);
33 // Behavior 1: auto-close dangling <think> before <tool_call> in history.
34 autoclose_assistant_think(&mut prepared);
35 // Behavior 2: resolve + strip inline think-control tokens.
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 tokenizer_path = model_dir.join("tokenizer.json");
51 let mut tokenizer = Tokenizer::from_file(&tokenizer_path)
52 .map_err(|e| anyhow::anyhow!("Failed to load tokenizer: {e}"))?;
53 tokenizer
54 .with_truncation(None)
55 .map_err(|e| anyhow::anyhow!("Failed to disable tokenizer truncation: {e}"))?;
56
57 // Template-source priority.
58 //
59 // Conceptually the default is now MODEL-FIRST: render off the
60 // model's OWN `chat_template.jinja` / `tokenizer_config.json`.
61 // Atlas's cross-cutting behaviors (autoclose-think,
62 // think-control, F76 arg-parse) are applied in Rust
63 // message-preprocessing (see `preprocess_for_render`), so a model
64 // no longer needs a bespoke `jinja-templates/{model_type}.jinja`
65 // override that is otherwise a byte-copy of its own template.
66 // This is what makes `holo3_1_moe.jinja` REDUNDANT: Holo renders
67 // correctly off its own template + Rust behaviors. (The override
68 // file itself is still present for now only because
69 // `tokenizer/tests.rs::render_holo_template_*` reads it directly;
70 // it goes away together with those tests.)
71 //
72 // A `jinja-templates/{model_type}.jinja` override is OPT-IN by
73 // FILE PRESENCE: dropping the file in is the explicit signal that
74 // this model genuinely needs a template fix the Rust preprocessing
75 // can't express (MiniMax's `_args.items()`, Gemma-4's
76 // `strip_thinking`, etc.). We deliberately do NOT prefer the
77 // model's own template when such a file exists — that would
78 // silently undo those fixes. Instead, the operator opts OUT of all
79 // overrides with `--disable-template-overrides`, which forces
80 // every model onto its own template (relying purely on the Rust
81 // behaviors).
82 //
83 // Priority (high → low):
84 // 1. jinja-templates/{model_type}.jinja override
85 // (opt-in: file present AND overrides not disabled)
86 // 2. tokenizer_config.json / chat_template.jinja (the MODEL's own)
87 // 3. Default ChatML fallback
88 let override_tmpl = if disable_template_overrides {
89 None
90 } else {
91 super::jinja_helpers::load_override_template(model_type, repo_root)
92 };
93 let (chat_template, checkpoint_template) = if let Some(override_tmpl) = override_tmpl {
94 (override_tmpl, false)
95 } else if let Some(config_tmpl) = super::jinja_helpers::load_config_template(model_dir)? {
96 (config_tmpl, true)
97 } else {
98 tracing::warn!("No chat template found — using default ChatML");
99 (
100 super::jinja_helpers::default_chatml_template(supports_thinking),
101 false,
102 )
103 };
104
105 let jinja_env = super::jinja_helpers::build_jinja_env(&chat_template)?;
106
107 // Load OpenAI-variant template if it exists (jinja-templates/openai/{model_type}.jinja).
108 // This variant gates historical <think> wrappers on enable_thinking, preventing
109 // spontaneous thinking during tool-use when thinking is disabled.
110 let openai_jinja_env = super::jinja_helpers::load_openai_template(model_type, repo_root)
111 .and_then(|tmpl| {
112 tracing::info!("Loaded OpenAI-variant Jinja template for {model_type}");
113 super::jinja_helpers::build_jinja_env(&tmpl).ok()
114 });
115 let chat_encoding = if model_type == "deepseek_v4" {
116 tracing::info!("Using checkpoint-native DeepSeek-V4 message encoding");
117 ChatEncoding::DeepseekV4
118 } else {
119 ChatEncoding::Jinja
120 };
121
122 let native_qwen_tool_template = checkpoint_owns_qwen_tool_prompt(
123 model_type,
124 checkpoint_template,
125 openai_jinja_env.is_some(),
126 ) && jinja_env
127 .get_template("chat")?
128 .undeclared_variables(false)
129 .contains("tools");
130 tracing::info!("Loaded tokenizer from {}", tokenizer_path.display());
131 Ok(Self {
132 tokenizer,
133 eos_token_id,
134 supports_thinking,
135 chat_encoding,
136 native_qwen_tool_template,
137 chat_template,
138 jinja_env,
139 openai_jinja_env,
140 })
141 }
142
143 pub(crate) fn uses_native_qwen_tool_template(&self) -> bool {
144 self.native_qwen_tool_template
145 }
146
147 /// Returns a borrowed reference to the underlying HF tokenizer (for
148 /// callers that need to drive low-level encode/decode directly).
149 pub fn inner(&self) -> &tokenizers::Tokenizer {
150 &self.tokenizer
151 }
152
153 pub fn encode(&self, text: &str) -> Result<Vec<u32>> {
154 let encoding = self
155 .tokenizer
156 .encode(text, false)
157 .map_err(|e| anyhow::anyhow!("Tokenizer encode error: {e}"))?;
158 Ok(encoding.get_ids().to_vec())
159 }
160
161 pub fn decode(&self, ids: &[u32]) -> Result<String> {
162 self.tokenizer
163 .decode(ids, true)
164 .map_err(|e| anyhow::anyhow!("Tokenizer decode error: {e}"))
165 }
166
167 /// Decode without stripping special tokens. Use when tool calling is active —
168 /// some tokenizers register `<tool_call>` as a special token, and skip_special
169 /// would strip it, breaking tool call detection.
170 pub fn decode_with_special(&self, ids: &[u32]) -> Result<String> {
171 self.tokenizer
172 .decode(ids, false)
173 .map_err(|e| anyhow::anyhow!("Tokenizer decode error: {e}"))
174 }
175
176 /// Incremental detokenizer (vLLM `detokenize_incrementally` scheme).
177 /// Returns the newly-STABLE decoded bytes of `toks` since the last call and
178 /// advances the offsets. Only the suffix window `toks[prefix_offset..]` is
179 /// decoded each call (a handful of tokens since the last stable boundary),
180 /// so streaming a full response is O(n) rather than re-decoding the whole
181 /// history every token (O(n²)).
182 ///
183 /// Byte-identical to `decode(&all_toks)` + `trim_end_matches('\u{FFFD}')`
184 /// for byte-level BPE and SentencePiece tokenizers: a token's decoded bytes
185 /// do not depend on tokens before it, so `decode(toks[prefix_offset..])` is
186 /// exactly the corresponding suffix of `decode(toks)`. A token whose window
187 /// decode ends in U+FFFD (incomplete multibyte) is held back — the offsets
188 /// stay put, so the window naturally extends until a later token completes
189 /// the codepoint (same deferral the old `trim_end_matches` did). Uses the
190 /// skip-special-tokens `decode`, matching the full-decode it replaces.
191 pub fn incremental_decode(
192 &self,
193 toks: &[u32],
194 prefix_offset: &mut usize,
195 read_offset: &mut usize,
196 ) -> String {
197 // Guard against stale offsets after an `all_toks` reset.
198 if *read_offset > toks.len() || *prefix_offset > *read_offset {
199 *prefix_offset = 0;
200 *read_offset = 0;
201 }
202 let prefix_text = self
203 .decode(&toks[*prefix_offset..*read_offset])
204 .unwrap_or_default();
205 let new_text = self.decode(&toks[*prefix_offset..]).unwrap_or_default();
206 if new_text.len() > prefix_text.len()
207 && !new_text.ends_with('\u{FFFD}')
208 && let Some(delta) = new_text.get(prefix_text.len()..)
209 {
210 let delta = delta.to_string();
211 *prefix_offset = *read_offset;
212 *read_offset = toks.len();
213 return delta;
214 }
215 // Incomplete multibyte at the tail (or a non-boundary split): hold this
216 // token; the offsets stay put so the next call retries with more context.
217 String::new()
218 }
219
220 /// Create a stateful streaming decoder wrapper. Each `step(token_id)` returns
221 /// `Ok(Some(chunk))` when enough bytes have accumulated for valid UTF-8,
222 /// or `Ok(None)` for incomplete multi-byte sequences.
223 pub fn streaming_decoder(&self, skip_special_tokens: bool) -> StreamingDecoder<'_> {
224 StreamingDecoder {
225 inner: self.tokenizer.decode_stream(skip_special_tokens),
226 }
227 }
228
229 /// Apply the Jinja chat template and encode to token IDs.
230 ///
231 /// `messages`: Vec of serde_json::Value objects with `role`, `content`,
232 /// and optionally `tool_calls`, `reasoning_content`.
233 /// `tools`: Optional tool definitions (passed to Jinja context).
234 /// `enable_thinking`: Controls `<think>` generation prompt behavior.
235 pub fn apply_chat_template_jinja(
236 &self,
237 messages: &[serde_json::Value],
238 tools: Option<&[serde_json::Value]>,
239 enable_thinking: bool,
240 disable_tool_steering: bool,
241 ) -> Result<Vec<u32>> {
242 self.apply_chat_template_jinja_with_effort(
243 messages,
244 tools,
245 enable_thinking,
246 disable_tool_steering,
247 None,
248 None,
249 )
250 }
251
252 pub fn apply_chat_template_jinja_with_effort(
253 &self,
254 messages: &[serde_json::Value],
255 tools: Option<&[serde_json::Value]>,
256 enable_thinking: bool,
257 disable_tool_steering: bool,
258 reasoning_effort: Option<&str>,
259 preserve_thinking: Option<bool>,
260 ) -> Result<Vec<u32>> {
261 if self.chat_encoding == ChatEncoding::DeepseekV4 {
262 let rendered = super::deepseek_v4::encode_messages(
263 messages,
264 tools,
265 enable_thinking,
266 reasoning_effort,
267 )?;
268 return self.encode(&rendered);
269 }
270
271 let rendered = super::chat_render::render_chat(
272 &self.jinja_env,
273 messages,
274 tools,
275 super::chat_render::RenderFlags {
276 enable_thinking,
277 disable_tool_steering,
278 reasoning_effort,
279 preserve_thinking,
280 allow_continue_final: true,
281 },
282 )?;
283
284 // Debug: log the tail of the rendered template for the first few requests.
285 // Use floor_char_boundary to avoid panicking on multi-byte UTF-8 (e.g. Swedish å ä ö).
286 if rendered.len() < 2000 {
287 let tail_start = rendered.floor_char_boundary(rendered.len().saturating_sub(200));
288 tracing::info!(
289 "Jinja rendered ({} chars): {:?}",
290 rendered.len(),
291 &rendered[tail_start..]
292 );
293 }
294
295 self.encode(&rendered)
296 }
297
298 /// Apply the OpenAI-variant template (if available), falling back to the default.
299 /// The OpenAI variant gates historical `<think>` wrappers on enable_thinking,
300 /// preventing the model from learning a "always think" pattern during tool use.
301 pub fn apply_chat_template_openai(
302 &self,
303 messages: &[serde_json::Value],
304 tools: Option<&[serde_json::Value]>,
305 enable_thinking: bool,
306 disable_tool_steering: bool,
307 ) -> Result<Vec<u32>> {
308 self.apply_chat_template_openai_with_effort(
309 messages,
310 tools,
311 enable_thinking,
312 disable_tool_steering,
313 None,
314 None,
315 )
316 }
317
318 pub fn apply_chat_template_openai_with_effort(
319 &self,
320 messages: &[serde_json::Value],
321 tools: Option<&[serde_json::Value]>,
322 enable_thinking: bool,
323 disable_tool_steering: bool,
324 reasoning_effort: Option<&str>,
325 preserve_thinking: Option<bool>,
326 ) -> Result<Vec<u32>> {
327 if self.chat_encoding == ChatEncoding::DeepseekV4 {
328 return self.apply_chat_template_jinja_with_effort(
329 messages,
330 tools,
331 enable_thinking,
332 disable_tool_steering,
333 reasoning_effort,
334 preserve_thinking,
335 );
336 }
337 if let Some(ref env) = self.openai_jinja_env {
338 // Same render core as apply_chat_template_jinja, minus the
339 // continue-final diagnostic (this path always adds the
340 // generation prompt, preserving historical behavior).
341 let rendered = super::chat_render::render_chat(
342 env,
343 messages,
344 tools,
345 super::chat_render::RenderFlags {
346 enable_thinking,
347 disable_tool_steering,
348 reasoning_effort,
349 preserve_thinking,
350 allow_continue_final: false,
351 },
352 )
353 .map_err(|e| anyhow::anyhow!("Failed to render OpenAI Jinja template: {e}"))?;
354 self.encode(&rendered)
355 } else {
356 self.apply_chat_template_jinja_with_effort(
357 messages,
358 tools,
359 enable_thinking,
360 disable_tool_steering,
361 reasoning_effort,
362 preserve_thinking,
363 )
364 }
365 }
366
367 /// Legacy apply_chat_template for callers that pass (role, content) tuples.
368 /// Converts to JSON messages and delegates to apply_chat_template_jinja.
369 pub fn apply_chat_template(
370 &self,
371 messages: &[(String, String)],
372 enable_thinking: bool,
373 _image_pad_counts: &[usize],
374 ) -> Result<Vec<u32>> {
375 let json_messages: Vec<serde_json::Value> = messages
376 .iter()
377 .map(|(role, content)| {
378 serde_json::json!({
379 "role": role,
380 "content": content,
381 })
382 })
383 .collect();
384
385 self.apply_chat_template_jinja(&json_messages, None, enable_thinking, false)
386 }
387
388 pub fn eos_token_id(&self) -> u32 {
389 self.eos_token_id
390 }
391
392 pub fn think_end_token_id(&self) -> Option<u32> {
393 if !self.supports_thinking {
394 return None;
395 }
396 match self.encode("</think>") {
397 Ok(ids) if ids.len() == 1 => Some(ids[0]),
398 _ => None,
399 }
400 }
401
402 pub fn supports_thinking(&self) -> bool {
403 self.supports_thinking
404 }
405
406 pub fn uses_deepseek_v4_encoding(&self) -> bool {
407 self.chat_encoding == ChatEncoding::DeepseekV4
408 }
409
410 /// Encode the `<|image_pad|>` placeholder token and return its ID.
411 /// Returns `None` when the tokenizer doesn't have this token (text-only
412 /// models). Cheap to call repeatedly — the underlying tokenizer caches
413 /// single-token encodes.
414 pub fn image_pad_token_id(&self) -> Option<u32> {
415 self.encode("<|image_pad|>")
416 .ok()
417 .and_then(|ids| if ids.len() == 1 { Some(ids[0]) } else { None })
418 }
419
420 /// `<|video_pad|>`, the temporal sibling. `None` on a tokenizer without
421 /// it — every text-only model, and any VL model that predates video.
422 pub fn video_pad_token_id(&self) -> Option<u32> {
423 self.encode("<|video_pad|>")
424 .ok()
425 .and_then(|ids| if ids.len() == 1 { Some(ids[0]) } else { None })
426 }
427
428 /// Post-process a rendered token sequence to expand `<|image_pad|>`
429 /// placeholders. The Qwen3-VL / Qwen3.6 chat template emits exactly one
430 /// `<|image_pad|>` per image, but the vision encoder produces
431 /// `grid_h * grid_w` patches per image. At embed-injection time the
432 /// server expects one pad token per patch so each patch's embedding
433 /// lands at the right hidden-state position — this helper does the
434 /// fan-out.
435 ///
436 /// `pad_counts[i]` is the number of patches the i-th image produces.
437 /// Extra or missing `<|image_pad|>` occurrences (vs `pad_counts.len()`)
438 /// pass through unchanged, matching counts are replicated in place.
439 pub fn expand_vision_pads(&self, tokens: Vec<u32>, pad_counts: &[usize]) -> Vec<u32> {
440 if pad_counts.is_empty() || pad_counts.iter().all(|&c| c <= 1) {
441 return tokens;
442 }
443 let image_pad = self.image_pad_token_id();
444 let video_pad = self.video_pad_token_id();
445 if image_pad.is_none() && video_pad.is_none() {
446 return tokens;
447 }
448 let extra: usize = pad_counts.iter().map(|c| c.saturating_sub(1)).sum();
449 let mut out = Vec::with_capacity(tokens.len() + extra);
450 let mut img_idx = 0usize;
451 for t in tokens {
452 let pad_id = t;
453 if Some(t) == image_pad || Some(t) == video_pad {
454 let count = pad_counts.get(img_idx).copied().unwrap_or(1).max(1);
455 for _ in 0..count {
456 out.push(pad_id);
457 }
458 img_idx += 1;
459 } else {
460 out.push(t);
461 }
462 }
463 out
464 }
465}
466
467/// Only the known Qwen checkpoint templates own XML tool instructions.
468/// Custom overrides and ChatML fallback retain parser-provided instructions.
469fn checkpoint_owns_qwen_tool_prompt(
470 model_type: &str,
471 checkpoint: bool,
472 openai_override: bool,
473) -> bool {
474 checkpoint
475 && !openai_override
476 && matches!(
477 model_type,
478 "qwen3_5" | "qwen3_5_moe" | "qwen3_6" | "qwen3_6_moe"
479 )
480}