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}