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}