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