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 = if let Some(override_tmpl) = override_tmpl {
94 override_tmpl
95 } else if let Some(config_tmpl) = super::jinja_helpers::load_config_template(model_dir)? {
96 config_tmpl
97 } else {
98 tracing::warn!("No chat template found — using default ChatML");
99 super::jinja_helpers::default_chatml_template(supports_thinking)
100 };
101
102 let jinja_env = super::jinja_helpers::build_jinja_env(&chat_template)?;
103
104 // Load OpenAI-variant template if it exists (jinja-templates/openai/{model_type}.jinja).
105 // This variant gates historical <think> wrappers on enable_thinking, preventing
106 // spontaneous thinking during tool-use when thinking is disabled.
107 let openai_jinja_env = super::jinja_helpers::load_openai_template(model_type, repo_root)
108 .and_then(|tmpl| {
109 tracing::info!("Loaded OpenAI-variant Jinja template for {model_type}");
110 super::jinja_helpers::build_jinja_env(&tmpl).ok()
111 });
112 let chat_encoding = if model_type == "deepseek_v4" {
113 tracing::info!("Using checkpoint-native DeepSeek-V4 message encoding");
114 ChatEncoding::DeepseekV4
115 } else {
116 ChatEncoding::Jinja
117 };
118
119 tracing::info!("Loaded tokenizer from {}", tokenizer_path.display());
120 Ok(Self {
121 tokenizer,
122 eos_token_id,
123 supports_thinking,
124 chat_encoding,
125 chat_template,
126 jinja_env,
127 openai_jinja_env,
128 })
129 }
130
131 /// Returns a borrowed reference to the underlying HF tokenizer (for
132 /// callers that need to drive low-level encode/decode directly).
133 pub fn inner(&self) -> &tokenizers::Tokenizer {
134 &self.tokenizer
135 }
136
137 pub fn encode(&self, text: &str) -> Result<Vec<u32>> {
138 let encoding = self
139 .tokenizer
140 .encode(text, false)
141 .map_err(|e| anyhow::anyhow!("Tokenizer encode error: {e}"))?;
142 Ok(encoding.get_ids().to_vec())
143 }
144
145 pub fn decode(&self, ids: &[u32]) -> Result<String> {
146 self.tokenizer
147 .decode(ids, true)
148 .map_err(|e| anyhow::anyhow!("Tokenizer decode error: {e}"))
149 }
150
151 /// Decode without stripping special tokens. Use when tool calling is active —
152 /// some tokenizers register `<tool_call>` as a special token, and skip_special
153 /// would strip it, breaking tool call detection.
154 pub fn decode_with_special(&self, ids: &[u32]) -> Result<String> {
155 self.tokenizer
156 .decode(ids, false)
157 .map_err(|e| anyhow::anyhow!("Tokenizer decode error: {e}"))
158 }
159
160 /// Incremental detokenizer (vLLM `detokenize_incrementally` scheme).
161 /// Returns the newly-STABLE decoded bytes of `toks` since the last call and
162 /// advances the offsets. Only the suffix window `toks[prefix_offset..]` is
163 /// decoded each call (a handful of tokens since the last stable boundary),
164 /// so streaming a full response is O(n) rather than re-decoding the whole
165 /// history every token (O(n²)).
166 ///
167 /// Byte-identical to `decode(&all_toks)` + `trim_end_matches('\u{FFFD}')`
168 /// for byte-level BPE and SentencePiece tokenizers: a token's decoded bytes
169 /// do not depend on tokens before it, so `decode(toks[prefix_offset..])` is
170 /// exactly the corresponding suffix of `decode(toks)`. A token whose window
171 /// decode ends in U+FFFD (incomplete multibyte) is held back — the offsets
172 /// stay put, so the window naturally extends until a later token completes
173 /// the codepoint (same deferral the old `trim_end_matches` did). Uses the
174 /// skip-special-tokens `decode`, matching the full-decode it replaces.
175 pub fn incremental_decode(
176 &self,
177 toks: &[u32],
178 prefix_offset: &mut usize,
179 read_offset: &mut usize,
180 ) -> String {
181 // Guard against stale offsets after an `all_toks` reset.
182 if *read_offset > toks.len() || *prefix_offset > *read_offset {
183 *prefix_offset = 0;
184 *read_offset = 0;
185 }
186 let prefix_text = self
187 .decode(&toks[*prefix_offset..*read_offset])
188 .unwrap_or_default();
189 let new_text = self.decode(&toks[*prefix_offset..]).unwrap_or_default();
190 if new_text.len() > prefix_text.len()
191 && !new_text.ends_with('\u{FFFD}')
192 && let Some(delta) = new_text.get(prefix_text.len()..)
193 {
194 let delta = delta.to_string();
195 *prefix_offset = *read_offset;
196 *read_offset = toks.len();
197 return delta;
198 }
199 // Incomplete multibyte at the tail (or a non-boundary split): hold this
200 // token; the offsets stay put so the next call retries with more context.
201 String::new()
202 }
203
204 /// Create a stateful streaming decoder wrapper. Each `step(token_id)` returns
205 /// `Ok(Some(chunk))` when enough bytes have accumulated for valid UTF-8,
206 /// or `Ok(None)` for incomplete multi-byte sequences.
207 pub fn streaming_decoder(&self, skip_special_tokens: bool) -> StreamingDecoder<'_> {
208 StreamingDecoder {
209 inner: self.tokenizer.decode_stream(skip_special_tokens),
210 }
211 }
212
213 /// Apply the Jinja chat template and encode to token IDs.
214 ///
215 /// `messages`: Vec of serde_json::Value objects with `role`, `content`,
216 /// and optionally `tool_calls`, `reasoning_content`.
217 /// `tools`: Optional tool definitions (passed to Jinja context).
218 /// `enable_thinking`: Controls `<think>` generation prompt behavior.
219 pub fn apply_chat_template_jinja(
220 &self,
221 messages: &[serde_json::Value],
222 tools: Option<&[serde_json::Value]>,
223 enable_thinking: bool,
224 disable_tool_steering: bool,
225 ) -> Result<Vec<u32>> {
226 self.apply_chat_template_jinja_with_effort(
227 messages,
228 tools,
229 enable_thinking,
230 disable_tool_steering,
231 None,
232 None,
233 )
234 }
235
236 pub fn apply_chat_template_jinja_with_effort(
237 &self,
238 messages: &[serde_json::Value],
239 tools: Option<&[serde_json::Value]>,
240 enable_thinking: bool,
241 disable_tool_steering: bool,
242 reasoning_effort: Option<&str>,
243 preserve_thinking: Option<bool>,
244 ) -> Result<Vec<u32>> {
245 if self.chat_encoding == ChatEncoding::DeepseekV4 {
246 let rendered = super::deepseek_v4::encode_messages(
247 messages,
248 tools,
249 enable_thinking,
250 reasoning_effort,
251 )?;
252 return self.encode(&rendered);
253 }
254
255 let rendered = super::chat_render::render_chat(
256 &self.jinja_env,
257 messages,
258 tools,
259 super::chat_render::RenderFlags {
260 enable_thinking,
261 disable_tool_steering,
262 reasoning_effort,
263 preserve_thinking,
264 allow_continue_final: true,
265 },
266 )?;
267
268 // Debug: log the tail of the rendered template for the first few requests.
269 // Use floor_char_boundary to avoid panicking on multi-byte UTF-8 (e.g. Swedish å ä ö).
270 if rendered.len() < 2000 {
271 let tail_start = rendered.floor_char_boundary(rendered.len().saturating_sub(200));
272 tracing::info!(
273 "Jinja rendered ({} chars): {:?}",
274 rendered.len(),
275 &rendered[tail_start..]
276 );
277 }
278
279 self.encode(&rendered)
280 }
281
282 /// Apply the OpenAI-variant template (if available), falling back to the default.
283 /// The OpenAI variant gates historical `<think>` wrappers on enable_thinking,
284 /// preventing the model from learning a "always think" pattern during tool use.
285 pub fn apply_chat_template_openai(
286 &self,
287 messages: &[serde_json::Value],
288 tools: Option<&[serde_json::Value]>,
289 enable_thinking: bool,
290 disable_tool_steering: bool,
291 ) -> Result<Vec<u32>> {
292 self.apply_chat_template_openai_with_effort(
293 messages,
294 tools,
295 enable_thinking,
296 disable_tool_steering,
297 None,
298 None,
299 )
300 }
301
302 pub fn apply_chat_template_openai_with_effort(
303 &self,
304 messages: &[serde_json::Value],
305 tools: Option<&[serde_json::Value]>,
306 enable_thinking: bool,
307 disable_tool_steering: bool,
308 reasoning_effort: Option<&str>,
309 preserve_thinking: Option<bool>,
310 ) -> Result<Vec<u32>> {
311 if self.chat_encoding == ChatEncoding::DeepseekV4 {
312 return self.apply_chat_template_jinja_with_effort(
313 messages,
314 tools,
315 enable_thinking,
316 disable_tool_steering,
317 reasoning_effort,
318 preserve_thinking,
319 );
320 }
321 if let Some(ref env) = self.openai_jinja_env {
322 // Same render core as apply_chat_template_jinja, minus the
323 // continue-final diagnostic (this path always adds the
324 // generation prompt, preserving historical behavior).
325 let rendered = super::chat_render::render_chat(
326 env,
327 messages,
328 tools,
329 super::chat_render::RenderFlags {
330 enable_thinking,
331 disable_tool_steering,
332 reasoning_effort,
333 preserve_thinking,
334 allow_continue_final: false,
335 },
336 )
337 .map_err(|e| anyhow::anyhow!("Failed to render OpenAI Jinja template: {e}"))?;
338 self.encode(&rendered)
339 } else {
340 self.apply_chat_template_jinja_with_effort(
341 messages,
342 tools,
343 enable_thinking,
344 disable_tool_steering,
345 reasoning_effort,
346 preserve_thinking,
347 )
348 }
349 }
350
351 /// Legacy apply_chat_template for callers that pass (role, content) tuples.
352 /// Converts to JSON messages and delegates to apply_chat_template_jinja.
353 pub fn apply_chat_template(
354 &self,
355 messages: &[(String, String)],
356 enable_thinking: bool,
357 _image_pad_counts: &[usize],
358 ) -> Result<Vec<u32>> {
359 let json_messages: Vec<serde_json::Value> = messages
360 .iter()
361 .map(|(role, content)| {
362 serde_json::json!({
363 "role": role,
364 "content": content,
365 })
366 })
367 .collect();
368
369 self.apply_chat_template_jinja(&json_messages, None, enable_thinking, false)
370 }
371
372 pub fn eos_token_id(&self) -> u32 {
373 self.eos_token_id
374 }
375
376 pub fn think_end_token_id(&self) -> Option<u32> {
377 if !self.supports_thinking {
378 return None;
379 }
380 match self.encode("</think>") {
381 Ok(ids) if ids.len() == 1 => Some(ids[0]),
382 _ => None,
383 }
384 }
385
386 pub fn supports_thinking(&self) -> bool {
387 self.supports_thinking
388 }
389
390 pub fn uses_deepseek_v4_encoding(&self) -> bool {
391 self.chat_encoding == ChatEncoding::DeepseekV4
392 }
393
394 /// Encode the `<|image_pad|>` placeholder token and return its ID.
395 /// Returns `None` when the tokenizer doesn't have this token (text-only
396 /// models). Cheap to call repeatedly — the underlying tokenizer caches
397 /// single-token encodes.
398 pub fn image_pad_token_id(&self) -> Option<u32> {
399 self.encode("<|image_pad|>")
400 .ok()
401 .and_then(|ids| if ids.len() == 1 { Some(ids[0]) } else { None })
402 }
403
404 /// `<|video_pad|>`, the temporal sibling. `None` on a tokenizer without
405 /// it — every text-only model, and any VL model that predates video.
406 pub fn video_pad_token_id(&self) -> Option<u32> {
407 self.encode("<|video_pad|>")
408 .ok()
409 .and_then(|ids| if ids.len() == 1 { Some(ids[0]) } else { None })
410 }
411
412 /// Post-process a rendered token sequence to expand `<|image_pad|>`
413 /// placeholders. The Qwen3-VL / Qwen3.6 chat template emits exactly one
414 /// `<|image_pad|>` per image, but the vision encoder produces
415 /// `grid_h * grid_w` patches per image. At embed-injection time the
416 /// server expects one pad token per patch so each patch's embedding
417 /// lands at the right hidden-state position — this helper does the
418 /// fan-out.
419 ///
420 /// `pad_counts[i]` is the number of patches the i-th image produces.
421 /// Extra or missing `<|image_pad|>` occurrences (vs `pad_counts.len()`)
422 /// pass through unchanged, matching counts are replicated in place.
423 pub fn expand_vision_pads(&self, tokens: Vec<u32>, pad_counts: &[usize]) -> Vec<u32> {
424 if pad_counts.is_empty() || pad_counts.iter().all(|&c| c <= 1) {
425 return tokens;
426 }
427 let image_pad = self.image_pad_token_id();
428 let video_pad = self.video_pad_token_id();
429 if image_pad.is_none() && video_pad.is_none() {
430 return tokens;
431 }
432 let extra: usize = pad_counts.iter().map(|c| c.saturating_sub(1)).sum();
433 let mut out = Vec::with_capacity(tokens.len() + extra);
434 let mut img_idx = 0usize;
435 for t in tokens {
436 let pad_id = t;
437 if Some(t) == image_pad || Some(t) == video_pad {
438 let count = pad_counts.get(img_idx).copied().unwrap_or(1).max(1);
439 for _ in 0..count {
440 out.push(pad_id);
441 }
442 img_idx += 1;
443 } else {
444 out.push(t);
445 }
446 }
447 out
448 }
449}