Skip to main content

llama_cpp_4/token/
detokenizer.rs

1//! Incremental, byte-exact detokenization.
2//!
3//! Byte-fallback tokenizers split a single UTF-8 codepoint across multiple
4//! tokens (common for emoji, CJK, and accented text). Converting each token to
5//! a [`String`] in isolation therefore yields invalid UTF-8 mid-sequence, and
6//! the attribute-filtering conversions ([`LlamaModel::token_to_bytes`]) drop
7//! control/byte pieces entirely.
8//!
9//! [`StreamDetokenizer`] solves this for token-by-token generation loops: it
10//! accumulates the raw piece bytes from [`LlamaModel::token_to_raw_bytes`] and
11//! only emits complete UTF-8, retaining any trailing partial sequence until a
12//! later token completes it.
13//!
14//! ```no_run
15//! use llama_cpp_4::model::{LlamaModel, Special};
16//! use llama_cpp_4::token::detokenizer::StreamDetokenizer;
17//! # fn demo(model: &LlamaModel, tokens: &[llama_cpp_4::token::LlamaToken]) {
18//! let mut detok = StreamDetokenizer::new(model, Special::Plaintext);
19//! let mut text = String::new();
20//! for &token in tokens {
21//!     text.push_str(&detok.push(token).unwrap());
22//! }
23//! text.push_str(&detok.finish().unwrap());
24//! # }
25//! ```
26
27use std::str::Utf8Error;
28
29use crate::model::{LlamaModel, Special};
30use crate::token::LlamaToken;
31use crate::TokenToStringError;
32
33/// An error produced while streaming detokenization.
34#[derive(Debug, thiserror::Error)]
35#[non_exhaustive]
36pub enum DetokenizeError {
37    /// A token could not be converted to its raw piece bytes.
38    #[error("failed to convert token to raw bytes: {0}")]
39    TokenToBytes(#[from] TokenToStringError),
40    /// The accumulated bytes contained a genuinely invalid UTF-8 sequence, as
41    /// opposed to a merely incomplete trailing sequence (which is buffered).
42    #[error("invalid utf-8 in detokenized output: {0}")]
43    InvalidUtf8(Utf8Error),
44    /// [`StreamDetokenizer::finish`] was called while an incomplete trailing
45    /// UTF-8 sequence was still buffered. Carries the leftover bytes.
46    #[error("stream ended with {} incomplete utf-8 byte(s)", .0.len())]
47    IncompleteUtf8(Vec<u8>),
48}
49
50/// A stateful, incremental detokenizer built on
51/// [`LlamaModel::token_to_raw_bytes`].
52///
53/// Feed tokens one at a time with [`push`](StreamDetokenizer::push); each call
54/// returns the text that has become complete, buffering any partial multi-byte
55/// character until it can be finished. Call
56/// [`finish`](StreamDetokenizer::finish) at the end of a generation to flush and
57/// validate any remaining bytes.
58#[derive(Debug)]
59pub struct StreamDetokenizer<'a> {
60    model: &'a LlamaModel,
61    special: Special,
62    buffer: Vec<u8>,
63}
64
65impl<'a> StreamDetokenizer<'a> {
66    /// Create a new streaming detokenizer over `model`.
67    ///
68    /// `special` is forwarded to [`LlamaModel::token_to_raw_bytes`] and controls
69    /// whether special/control tokens are rendered ([`Special::Tokenize`]) or
70    /// treated as plaintext ([`Special::Plaintext`]).
71    #[must_use]
72    pub fn new(model: &'a LlamaModel, special: Special) -> Self {
73        Self {
74            model,
75            special,
76            buffer: Vec::new(),
77        }
78    }
79
80    /// Feed a single token, returning any text that is now complete.
81    ///
82    /// A trailing incomplete UTF-8 sequence is retained internally and emitted
83    /// once a subsequent token completes it.
84    ///
85    /// # Errors
86    ///
87    /// - [`DetokenizeError::TokenToBytes`] if the token cannot be converted.
88    /// - [`DetokenizeError::InvalidUtf8`] if the accumulated bytes contain a
89    ///   genuinely malformed (not merely incomplete) UTF-8 sequence.
90    pub fn push(&mut self, token: LlamaToken) -> Result<String, DetokenizeError> {
91        let raw = self.model.token_to_raw_bytes(token, self.special)?;
92        self.buffer.extend_from_slice(&raw);
93        self.drain_complete()
94    }
95
96    /// Feed several tokens, returning the concatenation of all completed text.
97    ///
98    /// # Errors
99    ///
100    /// See [`push`](StreamDetokenizer::push).
101    pub fn push_all(
102        &mut self,
103        tokens: impl IntoIterator<Item = LlamaToken>,
104    ) -> Result<String, DetokenizeError> {
105        let mut out = String::new();
106        for token in tokens {
107            out.push_str(&self.push(token)?);
108        }
109        Ok(out)
110    }
111
112    /// The bytes currently buffered awaiting completion of a multi-byte
113    /// character. Empty when the stream is on a UTF-8 boundary.
114    #[must_use]
115    pub fn pending(&self) -> &[u8] {
116        &self.buffer
117    }
118
119    /// Finish the stream, returning any remaining complete text.
120    ///
121    /// # Errors
122    ///
123    /// [`DetokenizeError::IncompleteUtf8`] if bytes remain that do not form a
124    /// complete UTF-8 sequence (carrying the leftover bytes), or
125    /// [`DetokenizeError::InvalidUtf8`] if the buffered bytes are malformed.
126    pub fn finish(mut self) -> Result<String, DetokenizeError> {
127        let text = self.drain_complete()?;
128        if self.buffer.is_empty() {
129            Ok(text)
130        } else {
131            Err(DetokenizeError::IncompleteUtf8(self.buffer))
132        }
133    }
134
135    /// Move the longest valid UTF-8 prefix out of the buffer and return it,
136    /// leaving any incomplete trailing sequence buffered.
137    fn drain_complete(&mut self) -> Result<String, DetokenizeError> {
138        take_valid_utf8(&mut self.buffer).map_err(DetokenizeError::InvalidUtf8)
139    }
140}
141
142/// Drain the longest valid UTF-8 prefix from `buffer`, returning it as a
143/// [`String`] and leaving any incomplete trailing sequence in place.
144///
145/// Returns [`Utf8Error`] only for genuinely malformed bytes; a merely
146/// unfinished trailing multi-byte sequence is retained in `buffer` and reported
147/// as an empty (or partial) success so the caller can wait for more input.
148fn take_valid_utf8(buffer: &mut Vec<u8>) -> Result<String, Utf8Error> {
149    let valid = match std::str::from_utf8(buffer) {
150        Ok(_) => buffer.len(),
151        // `error_len().is_some()` means the invalid bytes are not simply an
152        // unfinished tail — they are genuinely malformed, so surface them.
153        Err(e) if e.error_len().is_some() => return Err(e),
154        Err(e) => e.valid_up_to(),
155    };
156    let complete: Vec<u8> = buffer.drain(..valid).collect();
157    Ok(String::from_utf8(complete).expect("prefix up to valid_up_to is valid utf-8"))
158}
159
160#[cfg(test)]
161mod tests {
162    use super::{take_valid_utf8, DetokenizeError};
163
164    #[test]
165    fn complete_utf8_is_fully_drained() {
166        let mut buf = "hello".as_bytes().to_vec();
167        assert_eq!(take_valid_utf8(&mut buf).unwrap(), "hello");
168        assert!(buf.is_empty());
169    }
170
171    #[test]
172    fn incomplete_multibyte_is_retained() {
173        // "€" is 0xE2 0x82 0xAC; feed only the first two bytes.
174        let mut buf = vec![b'a', 0xE2, 0x82];
175        assert_eq!(take_valid_utf8(&mut buf).unwrap(), "a");
176        // The unfinished sequence stays buffered for the next chunk.
177        assert_eq!(buf, vec![0xE2, 0x82]);
178
179        // Completing it emits the character and empties the buffer.
180        buf.push(0xAC);
181        assert_eq!(take_valid_utf8(&mut buf).unwrap(), "€");
182        assert!(buf.is_empty());
183    }
184
185    #[test]
186    fn malformed_bytes_are_surfaced() {
187        // 0xFF is never valid in UTF-8 and is not an unfinished tail.
188        let mut buf = vec![b'a', 0xFF, b'b'];
189        assert!(take_valid_utf8(&mut buf).is_err());
190    }
191
192    #[test]
193    fn incomplete_utf8_reports_leftover_bytes() {
194        let err = DetokenizeError::IncompleteUtf8(vec![0xE2, 0x82]);
195        assert_eq!(
196            err.to_string(),
197            "stream ended with 2 incomplete utf-8 byte(s)"
198        );
199    }
200}