[Rust Frontend] Attribute decoded text to tokens (#52910)

Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Sep 2, 2026 at 08:25 UTC b911fe85c42ae984f27d183a0b1de309adab9bf2
17 files changed +1182 -320
rust/Cargo.lock
+1
@@ -6043,6 +6043,7 @@ dependencies = [
6043 "rustc-hash 1.1.0",
6044 "serde",
6045 "serde_json",
6046 + "smallvec",
6047 "tekken",
6048 "tempfile",
6049 "thiserror 2.0.18",
rust/Cargo.toml
+1
@@ -99,6 +99,7 @@ serde_tuple = "1.1.3"
99 serde_with = "3.18.0"
100 serial_test = { version = "3.2.0", features = ["file_locks"] }
101 sha2 = "0.10.9"
102 +smallvec = "1.15.1"
103 socket2 = "0.6.3"
104 strum = { version = "0.27.2", features = ["derive"] }
105 subenum = "1.1.3"
rust/src/chat/src/output/default/unified.rs
+14 -13
@@ -13,7 +13,7 @@ use futures::{StreamExt as _, pin_mut};
13 use thiserror_ext::AsReport;
14 use tracing::warn;
15 use vllm_parser::unified::{UnifiedParser, UnifiedParserEvent, UnifiedParserOutput};
16 -use vllm_text::output::DecodedTextEvent;
16 +use vllm_text::output::{DecodedTextEvent, SampledDelta};
17
18 use crate::Result;
19 use crate::error::Error;
@@ -237,12 +237,15 @@ pub(crate) async fn unified_event_stream(
237 .await;
238 }
239 DecodedTextEvent::TextDelta {
240 - delta,
241 - token_ids,
242 - logprobs,
240 + decoded,
241 + sampled:
242 + SampledDelta {
243 + token_ids,
244 + logprobs,
245 + },
246 finished,
247 } => {
245 - for next in state.process_delta(delta)? {
248 + for next in state.process_delta(decoded.text)? {
249 y.yield_ok(next).await;
250 }
251 if logprobs.is_some() || !token_ids.is_empty() {
@@ -375,24 +378,22 @@ mod tests {
378
379 fn decoded_delta(delta: &str) -> vllm_text::output::DecodedTextEvent {
380 vllm_text::output::DecodedTextEvent::TextDelta {
378 - delta: delta.to_string(),
379 - token_ids: Vec::new(),
380 - logprobs: None,
381 + decoded: vllm_text::DecodedText::unattributed(delta),
382 + sampled: vllm_text::SampledDelta::default(),
383 finished: None,
384 }
385 }
386
387 fn finished_delta(delta: &str) -> vllm_text::output::DecodedTextEvent {
388 vllm_text::output::DecodedTextEvent::TextDelta {
387 - delta: delta.to_string(),
388 - token_ids: Vec::new(),
389 - logprobs: None,
390 - finished: Some(vllm_text::output::Finished {
389 + decoded: vllm_text::DecodedText::unattributed(delta),
390 + sampled: vllm_text::SampledDelta::default(),
391 + finished: Some(Box::new(vllm_text::output::Finished {
392 usage: vllm_llm::TokenUsage::default(),
393 finish_reason: crate::FinishReason::Stop(None),
394 kv_transfer_params: None,
395 ec_transfer_params: None,
395 - }),
396 + })),
397 }
398 }
399
rust/src/chat/src/output/harmony/mod.rs
+7 -4
@@ -11,7 +11,7 @@ use asynk_strim_attr::{TryYielder, try_stream};
11 use futures::StreamExt as _;
12 use openai_harmony::chat::{Content as HarmonyContent, Message as HarmonyMessage, Role};
13 use openai_harmony::{HarmonyEncoding, StreamableParser};
14 -use vllm_text::output::DecodedTextEvent;
14 +use vllm_text::output::{DecodedTextEvent, SampledDelta};
15
16 use crate::Result as ChatResult;
17 use crate::error::{Error, Result};
@@ -345,9 +345,12 @@ async fn harmony_assistant_event_stream(
345 .await;
346 }
347 DecodedTextEvent::TextDelta {
348 - delta: _, // harmony takes raw token IDs as input, so we ignore text deltas here
349 - token_ids,
350 - logprobs,
348 + decoded: _, // harmony takes raw token IDs as input, so we ignore decoded text here
349 + sampled:
350 + SampledDelta {
351 + token_ids,
352 + logprobs,
353 + },
354 finished,
355 } => {
356 for event in state.process_token_ids(&token_ids)? {
rust/src/chat/src/output/harmony/tests.rs
+30 -49
@@ -6,7 +6,9 @@ use std::sync::Arc;
6 use futures::executor::block_on;
7 use futures::{TryStreamExt as _, stream};
8 use openai_harmony::chat::{Message, Role};
9 -use vllm_text::output::{DecodedLogprobs, DecodedPositionLogprobs, DecodedTextEvent, Finished};
9 +use vllm_text::output::{
10 + DecodedLogprobs, DecodedPositionLogprobs, DecodedText, DecodedTextEvent, Finished, SampledDelta,
11 +};
12
13 use super::*;
14 use crate::output::ChatOutputProcessor;
@@ -59,6 +61,21 @@ fn finished() -> Finished {
61 }
62 }
63
64 +fn decoded_tokens(
65 + token_ids: Vec<u32>,
66 + logprobs: Option<DecodedLogprobs>,
67 + finished: Option<Finished>,
68 +) -> DecodedTextEvent {
69 + DecodedTextEvent::TextDelta {
70 + decoded: DecodedText::default(),
71 + sampled: SampledDelta {
72 + token_ids,
73 + logprobs,
74 + },
75 + finished: finished.map(Box::new),
76 + }
77 +}
78 +
79 async fn collect_events(
80 processor: HarmonyChatOutputProcessor,
81 events: Vec<DecodedTextEvent>,
@@ -96,12 +113,7 @@ fn interrupted_final_message_is_preserved() {
113 HarmonyChatOutputProcessor::new(&ChatRequest::for_test()).unwrap(),
114 vec![
115 decoded_start(),
99 - DecodedTextEvent::TextDelta {
100 - delta: String::new(),
101 - token_ids: tokens[..tokens.len() - 1].to_vec(),
102 - logprobs: None,
103 - finished: Some(finished()),
104 - },
116 + decoded_tokens(tokens[..tokens.len() - 1].to_vec(), None, Some(finished())),
117 ],
118 ));
119
@@ -135,12 +147,7 @@ fn eos_flush_preserves_trailing_replacement_text() {
147 HarmonyChatOutputProcessor::new(&ChatRequest::for_test()).unwrap(),
148 vec![
149 decoded_start(),
138 - DecodedTextEvent::TextDelta {
139 - delta: String::new(),
140 - token_ids: tokens,
141 - logprobs: None,
142 - finished: Some(finished()),
143 - },
150 + decoded_tokens(tokens, None, Some(finished())),
151 ],
152 ));
153
@@ -157,12 +164,7 @@ fn interrupted_analysis_message_is_preserved() {
164 HarmonyChatOutputProcessor::new(&ChatRequest::for_test()).unwrap(),
165 vec![
166 decoded_start(),
160 - DecodedTextEvent::TextDelta {
161 - delta: String::new(),
162 - token_ids: tokens[..tokens.len() - 1].to_vec(),
163 - logprobs: None,
164 - finished: Some(finished()),
165 - },
167 + decoded_tokens(tokens[..tokens.len() - 1].to_vec(), None, Some(finished())),
168 ],
169 ));
170
@@ -196,12 +198,7 @@ fn commentary_preamble_is_visible_but_commentary_tool_payload_is_not() {
198 HarmonyChatOutputProcessor::new(&request_with_tools()).unwrap(),
199 vec![
200 decoded_start(),
199 - DecodedTextEvent::TextDelta {
200 - delta: String::new(),
201 - token_ids: tokens,
202 - logprobs: None,
203 - finished: Some(finished()),
204 - },
201 + decoded_tokens(tokens, None, Some(finished())),
202 ],
203 ));
204
@@ -225,12 +222,7 @@ fn multiple_messages_get_newline_separators() {
222 HarmonyChatOutputProcessor::new(&ChatRequest::for_test()).unwrap(),
223 vec![
224 decoded_start(),
228 - DecodedTextEvent::TextDelta {
229 - delta: String::new(),
230 - token_ids: tokens,
231 - logprobs: None,
232 - finished: Some(finished()),
233 - },
225 + decoded_tokens(tokens, None, Some(finished())),
226 ],
227 ));
228
@@ -256,18 +248,8 @@ fn tool_calls_stream_arguments_and_finish_with_local_id_shape() {
248 HarmonyChatOutputProcessor::new(&request_with_tools()).unwrap(),
249 vec![
250 decoded_start(),
259 - DecodedTextEvent::TextDelta {
260 - delta: String::new(),
261 - token_ids: tokens[..midpoint].to_vec(),
262 - logprobs: None,
263 - finished: None,
264 - },
265 - DecodedTextEvent::TextDelta {
266 - delta: String::new(),
267 - token_ids: tokens[midpoint..].to_vec(),
268 - logprobs: None,
269 - finished: Some(finished()),
270 - },
251 + decoded_tokens(tokens[..midpoint].to_vec(), None, None),
252 + decoded_tokens(tokens[midpoint..].to_vec(), None, Some(finished())),
253 ],
254 ));
255
@@ -303,14 +285,13 @@ fn semantic_events_precede_same_update_logprobs() {
285 HarmonyChatOutputProcessor::new(&ChatRequest::for_test()).unwrap(),
286 vec![
287 decoded_start(),
306 - DecodedTextEvent::TextDelta {
307 - delta: String::new(),
308 - token_ids: tokens,
309 - logprobs: Some(DecodedLogprobs {
288 + decoded_tokens(
289 + tokens,
290 + Some(DecodedLogprobs {
291 positions: vec![DecodedPositionLogprobs { entries: vec![] }],
292 }),
312 - finished: Some(finished()),
313 - },
293 + Some(finished()),
294 + ),
295 ],
296 ));
297
rust/src/chat/tests/roundtrip.rs
+13 -12
@@ -784,15 +784,14 @@ fn decoded_completion_stream(
784 if chunks.is_empty() {
785 events.push({
786 DecodedTextEvent::TextDelta {
787 - delta: String::new(),
788 - token_ids: Vec::new(),
789 - logprobs: None,
790 - finished: Some(Finished {
787 + decoded: vllm_text::DecodedText::default(),
788 + sampled: vllm_text::SampledDelta::default(),
789 + finished: Some(Box::new(Finished {
790 usage: Default::default(),
791 finish_reason: FinishReason::stop_eos(),
792 kv_transfer_params: None,
793 ec_transfer_params: None,
795 - }),
794 + })),
795 }
796 });
797 } else {
@@ -805,10 +804,12 @@ fn decoded_completion_stream(
804 ec_transfer_params: None,
805 });
806 events.push(DecodedTextEvent::TextDelta {
808 - delta: chunk.delta,
809 - token_ids: chunk.token_ids,
810 - logprobs: None,
811 - finished,
807 + decoded: vllm_text::DecodedText::unattributed(chunk.delta),
808 + sampled: vllm_text::SampledDelta {
809 + token_ids: chunk.token_ids,
810 + logprobs: None,
811 + },
812 + finished: finished.map(Box::new),
813 });
814 }
815 }
@@ -864,7 +865,7 @@ fn incremental_decode_chunks(
865 for token_id in chunk_token_ids.iter().copied() {
866 decoder.push_token(token_id)?;
867 while let Some(chunk) = decoder.next_chunk() {
867 - delta.push_str(&chunk);
868 + delta.push_str(&chunk.text);
869 }
870 }
871 chunks.push(DecodedCompletionChunk {
@@ -876,10 +877,10 @@ fn incremental_decode_chunks(
877 let (last_chunk, _) = decoder.flush(None)?;
878 if let Some(last_chunk) = last_chunk {
879 if let Some(delta) = chunks.last_mut() {
879 - delta.delta.push_str(&last_chunk);
880 + delta.delta.push_str(&last_chunk.text);
881 } else {
882 chunks.push(DecodedCompletionChunk {
882 - delta: last_chunk,
883 + delta: last_chunk.text,
884 token_ids: Vec::new(),
885 });
886 }
rust/src/server/src/grpc/inference.rs
+9 -6
@@ -14,7 +14,7 @@ use tracing::{Span, info, info_span, warn};
14 use tracing_futures::Instrument as _;
15 use uuid::Uuid;
16 use vllm_llm::current_unix_timestamp_secs;
17 -use vllm_text::{DecodedTextEvent, Prompt, TextOutputStreamExt as _, TextRequest};
17 +use vllm_text::{DecodedTextEvent, Prompt, SampledDelta, TextOutputStreamExt as _, TextRequest};
18
19 use super::convert::{self, ResponseOpts};
20 use super::{InferenceServer, pb};
@@ -264,17 +264,20 @@ impl pb::inference_server::Inference for InferenceServiceImpl {
264 })
265 }
266 Ok(DecodedTextEvent::TextDelta {
267 - delta,
268 - token_ids,
269 - logprobs,
267 + decoded,
268 + sampled:
269 + SampledDelta {
270 + token_ids,
271 + logprobs,
272 + },
273 finished,
274 }) => Ok(pb::GenerateResponse {
275 prompt_info: None,
276 outputs: Some(convert::to_sequence_output(
274 - &delta,
277 + &decoded.text,
278 &token_ids,
279 logprobs.as_ref(),
277 - finished.as_ref(),
280 + finished.as_deref(),
281 &response_opts,
282 )),
283 }),
rust/src/server/src/routes/openai/completions.rs
+68 -48
@@ -23,7 +23,7 @@ use tracing_futures::Instrument as _;
23 use vllm_engine_core_client::protocol::output::StopReason;
24 use vllm_text::tokenizer::Tokenizer;
25 use vllm_text::{
26 - DecodedPromptLogprobs, DecodedTextEvent, FinishReason, TextOutputStream,
26 + DecodedPromptLogprobs, DecodedTextEvent, FinishReason, SampledDelta, TextOutputStream,
27 TextOutputStreamExt as _, TextRequest,
28 };
29
@@ -317,11 +317,15 @@ async fn completion_chunk_stream(
317 }
318 }
319 Ok(DecodedTextEvent::TextDelta {
320 - delta,
321 - token_ids,
322 - logprobs,
320 + decoded,
321 + sampled:
322 + SampledDelta {
323 + token_ids,
324 + logprobs,
325 + },
326 finished,
327 }) => {
328 + let delta = decoded.text;
329 // Prompt-only streaming already emitted the echoed prompt in the Start chunk.
330 // The one generated token is only used to drive the engine to a finished event,
331 // so hide its delta and forward only the terminal finish/usage metadata.
@@ -561,8 +565,8 @@ mod tests {
565 use itertools::Itertools as _;
566 use vllm_engine_core_client::protocol::output::StopReason;
567 use vllm_text::{
564 - DecodedLogprobs, DecodedPositionLogprobs, DecodedPromptLogprobs, DecodedTextEvent,
565 - DecodedTokenLogprob, FinishReason, Finished,
568 + DecodedLogprobs, DecodedPositionLogprobs, DecodedPromptLogprobs, DecodedText,
569 + DecodedTextEvent, DecodedTokenLogprob, FinishReason, Finished, SampledDelta,
570 };
571
572 use super::{
@@ -570,6 +574,22 @@ mod tests {
574 StreamResponseEnvelope, completion_chunk_stream, final_chunk,
575 };
576
577 + fn decoded_delta(
578 + text: impl Into<String>,
579 + token_ids: Vec<u32>,
580 + logprobs: Option<DecodedLogprobs>,
581 + finished: Option<Finished>,
582 + ) -> DecodedTextEvent {
583 + DecodedTextEvent::TextDelta {
584 + decoded: DecodedText::unattributed(text),
585 + sampled: SampledDelta {
586 + token_ids,
587 + logprobs,
588 + },
589 + finished: finished.map(Box::new),
590 + }
591 + }
592 +
593 fn stream_envelope() -> Arc<StreamResponseEnvelope> {
594 Arc::new(StreamResponseEnvelope::new(
595 "cmpl-1".to_string(),
@@ -619,10 +639,10 @@ mod tests {
639 prompt_token_ids: vec![1, 2, 3, 4, 5].into(),
640 prompt_logprobs: None,
641 }),
622 - Ok(DecodedTextEvent::TextDelta {
623 - delta: "h".to_string(),
624 - token_ids: vec![b'h' as u32],
625 - logprobs: Some(DecodedLogprobs {
642 + Ok(decoded_delta(
643 + "h",
644 + vec![b'h' as u32],
645 + Some(DecodedLogprobs {
646 positions: vec![DecodedPositionLogprobs {
647 entries: vec![
648 DecodedTokenLogprob {
@@ -640,12 +660,12 @@ mod tests {
660 ],
661 }],
662 }),
643 - finished: None,
644 - }),
645 - Ok(DecodedTextEvent::TextDelta {
646 - delta: String::new(),
647 - token_ids: vec![b'!' as u32],
648 - logprobs: Some(DecodedLogprobs {
663 + None,
664 + )),
665 + Ok(decoded_delta(
666 + "",
667 + vec![b'!' as u32],
668 + Some(DecodedLogprobs {
669 positions: vec![DecodedPositionLogprobs {
670 entries: vec![
671 DecodedTokenLogprob {
@@ -663,7 +683,7 @@ mod tests {
683 ],
684 }],
685 }),
666 - finished: Some(Finished {
686 + Some(Finished {
687 usage: vllm_llm::TokenUsage {
688 prompt_token_count: 5,
689 output_token_count: 2,
@@ -675,7 +695,7 @@ mod tests {
695 kv_transfer_params: None,
696 ec_transfer_params: None,
697 }),
678 - }),
698 + )),
699 ]);
700
701 let chunks = completion_chunk_stream(
@@ -773,11 +793,11 @@ mod tests {
793 prompt_token_ids: vec![1, 2].into(),
794 prompt_logprobs: None,
795 }),
776 - Ok(DecodedTextEvent::TextDelta {
777 - delta: " leaked".to_string(),
778 - token_ids: vec![3],
779 - logprobs: None,
780 - finished: Some(Finished {
796 + Ok(decoded_delta(
797 + " leaked",
798 + vec![3],
799 + None,
800 + Some(Finished {
801 usage: vllm_llm::TokenUsage {
802 prompt_token_count: 2,
803 output_token_count: 1,
@@ -787,7 +807,7 @@ mod tests {
807 kv_transfer_params: None,
808 ec_transfer_params: None,
809 }),
790 - }),
810 + )),
811 ]);
812
813 let response = super::collect_completion(
@@ -825,11 +845,11 @@ mod tests {
845 prompt_token_ids: vec![9707].into(),
846 prompt_logprobs: None,
847 }),
828 - Ok(DecodedTextEvent::TextDelta {
829 - delta: " leaked".to_string(),
830 - token_ids: vec![3],
831 - logprobs: None,
832 - finished: Some(Finished {
848 + Ok(decoded_delta(
849 + " leaked",
850 + vec![3],
851 + None,
852 + Some(Finished {
853 usage: vllm_llm::TokenUsage {
854 prompt_token_count: 1,
855 output_token_count: 1,
@@ -839,7 +859,7 @@ mod tests {
859 kv_transfer_params: None,
860 ec_transfer_params: None,
861 }),
842 - }),
862 + )),
863 ]);
864
865 let response = super::collect_completion(
@@ -880,11 +900,11 @@ mod tests {
900 prompt_token_ids: vec![1, 2].into(),
901 prompt_logprobs: None,
902 }),
883 - Ok(DecodedTextEvent::TextDelta {
884 - delta: " leaked".to_string(),
885 - token_ids: vec![3],
886 - logprobs: None,
887 - finished: Some(Finished {
903 + Ok(decoded_delta(
904 + " leaked",
905 + vec![3],
906 + None,
907 + Some(Finished {
908 usage: vllm_llm::TokenUsage {
909 prompt_token_count: 2,
910 output_token_count: 1,
@@ -894,7 +914,7 @@ mod tests {
914 kv_transfer_params: None,
915 ec_transfer_params: None,
916 }),
897 - }),
917 + )),
918 ]);
919
920 let chunks = completion_chunk_stream(
@@ -952,11 +972,11 @@ mod tests {
972 prompt_token_ids: vec![9707].into(),
973 prompt_logprobs: None,
974 }),
955 - Ok(DecodedTextEvent::TextDelta {
956 - delta: " leaked".to_string(),
957 - token_ids: vec![3],
958 - logprobs: None,
959 - finished: Some(Finished {
975 + Ok(decoded_delta(
976 + " leaked",
977 + vec![3],
978 + None,
979 + Some(Finished {
980 usage: vllm_llm::TokenUsage {
981 prompt_token_count: 1,
982 output_token_count: 1,
@@ -966,7 +986,7 @@ mod tests {
986 kv_transfer_params: None,
987 ec_transfer_params: None,
988 }),
969 - }),
989 + )),
990 ]);
991
992 let chunks = completion_chunk_stream(
@@ -1026,11 +1046,11 @@ mod tests {
1046 }],
1047 }),
1048 }),
1029 - Ok(DecodedTextEvent::TextDelta {
1030 - delta: " leaked".to_string(),
1031 - token_ids: vec![3],
1032 - logprobs: None,
1033 - finished: Some(Finished {
1049 + Ok(decoded_delta(
1050 + " leaked",
1051 + vec![3],
1052 + None,
1053 + Some(Finished {
1054 usage: vllm_llm::TokenUsage {
1055 prompt_token_count: 2,
1056 output_token_count: 1,
@@ -1040,7 +1060,7 @@ mod tests {
1060 kv_transfer_params: None,
1061 ec_transfer_params: None,
1062 }),
1043 - }),
1063 + )),
1064 ]);
1065
1066 let chunks = completion_chunk_stream(
rust/src/text/src/lib.rs
+2 -1
@@ -19,7 +19,8 @@ pub use lower::{
19 };
20 pub use output::{
21 CollectedTextOutput, DecodedLogprobs, DecodedPositionLogprobs, DecodedPromptLogprobs,
22 - DecodedTextEvent, DecodedTokenLogprob, Finished, TextDecodeOptions, TextOutputStreamExt,
22 + DecodedText, DecodedTextEvent, DecodedTokenLogprob, Finished, SampledDelta, TextDecodeOptions,
23 + TextOutputStreamExt, TokenAnchor, TokenAttribution,
24 };
25 pub use request::{Prompt, SamplingParams, TextRequest, normalize_top_k};
26 use trait_set::trait_set;
rust/src/text/src/output/decoded.rs
+182 -48
@@ -10,7 +10,7 @@ use tracing::{Level, debug, trace};
10 use vllm_engine_core_client::AbortCause;
11 use vllm_engine_core_client::protocol::output::StopReason;
12 use vllm_llm::{FinishReason, GenerateOutput, TokenUsage};
13 -use vllm_tokenizer::{DynTokenizer, IncrementalDecoder};
13 +use vllm_tokenizer::{DecodedText, DynTokenizer, IncrementalDecoder};
14
15 use super::logprobs::{
16 DecodedLogprobs, DecodedPromptLogprobs, decode_logprobs, decode_prompt_logprobs,
@@ -52,6 +52,19 @@ pub struct Finished {
52 pub ec_transfer_params: Option<serde_json::Value>,
53 }
54
55 +/// Sample metadata emitted by one engine output update.
56 +///
57 +/// Token IDs and logprobs share the engine-update clock and align by generated
58 +/// token position. They may arrive before the corresponding decoded text is
59 +/// visible or alongside decoded text attributed to earlier updates.
60 +#[derive(Debug, Clone, Default, PartialEq)]
61 +pub struct SampledDelta {
62 + /// Token IDs produced by this engine update, after stop-string truncation.
63 + pub token_ids: Vec<u32>,
64 + /// Per-position logprobs aligned with `token_ids`, when requested.
65 + pub logprobs: Option<DecodedLogprobs>,
66 +}
67 +
68 /// Internal decoded-text event emitted before higher-level assistant
69 /// adaptation.
70 #[derive(Debug, Clone, PartialEq)]
@@ -68,25 +81,25 @@ pub enum DecodedTextEvent {
81 /// remaining prompt positions.
82 prompt_logprobs: Option<DecodedPromptLogprobs>,
83 },
71 - /// A delta of text has been decoded, optionally alongside token-position
72 - /// logprobs.
84 + /// Decoded text and sampled metadata observed during one output update.
85 ///
74 - /// `delta` is the newly visible decoded text fragment for this update.
86 + /// `decoded` follows the decoder clock. It may be empty while tokens remain
87 + /// undecodable, and may later contain token attributions from earlier
88 + /// engine updates. Tokens suppressed before decoding are absent.
89 ///
76 - /// `logprobs` covers the newly generated token positions from the same
77 - /// update, but is not guaranteed to align with `delta` by character
78 - /// span. One update may carry token logprobs but no newly visible text
79 - /// yet, and one visible text fragment may reflect multiple token
80 - /// positions becoming decodable together.
90 + /// `sampled` follows the engine-update clock. Its token IDs and logprobs
91 + /// align with each other. `decoded.attributions` has an independent decoder
92 + /// clock and positional sequence.
93 ///
82 - /// Upper-level may further parse `delta` as reasoning or tool calls.
94 + /// Upper-level may further parse `decoded.text` as reasoning or tool calls.
95 ///
96 /// When `finished` is `Some`, this is the terminal event for the request.
97 TextDelta {
86 - delta: String,
87 - token_ids: Vec<u32>,
88 - logprobs: Option<DecodedLogprobs>,
89 - finished: Option<Finished>,
98 + decoded: DecodedText,
99 + sampled: SampledDelta,
100 + /// Heap-backed because terminal metadata is large and appears once per
101 + /// request, while every streaming event carries this enum variant.
102 + finished: Option<Box<Finished>>,
103 },
104 }
105
@@ -104,9 +117,9 @@ pub async fn decoded_text_event_stream(
117 let mut decoder: Option<Box<dyn IncrementalDecoder>> = None;
118 let mut prompt_token_count = 0_usize;
119 let mut cached_token_count = 0_usize;
107 - let mut token_ids = Vec::new();
120 + let mut sampled_token_ids = Vec::new();
121 let mut output_token_count: usize = 0;
109 - let mut logprobs: Option<DecodedLogprobs> = None;
122 + let mut sampled_logprobs: Option<DecodedLogprobs> = None;
123
124 while let Some(next) = raw_stream.next().await {
125 let output = next?;
@@ -168,7 +181,7 @@ pub async fn decoded_text_event_stream(
181 &output.token_ids
182 };
183
171 - let mut delta: Option<String> = None;
184 + let mut decoded = DecodedText::default();
185 let mut truncate_output_to = None;
186 let mut truncate_tokens_to = None;
187 for (tok_idx, &token_id) in decodable_token_ids.iter().enumerate() {
@@ -189,12 +202,9 @@ pub async fn decoded_text_event_stream(
202 break;
203 }
204
205 + // TODO: avoid generating a chunk every time a token is pushed
206 if intermediate && let Some(chunk) = decoder.next_chunk() {
193 - if let Some(delta_str) = delta.as_mut() {
194 - delta_str.push_str(&chunk);
195 - } else {
196 - delta = Some(chunk);
197 - }
207 + decoded.append(chunk);
208 }
209 }
210
@@ -223,9 +233,9 @@ pub async fn decoded_text_event_stream(
233 .transpose()?;
234
235 if !intermediate {
226 - token_ids.extend(&new_token_ids);
236 + sampled_token_ids.extend(&new_token_ids);
237 if let Some(dlp) = decoded_logprobs.as_ref() {
228 - logprobs
238 + sampled_logprobs
239 .get_or_insert_with(|| DecodedLogprobs { positions: vec![] })
240 .positions
241 .extend_from_slice(&dlp.positions);
@@ -234,22 +244,19 @@ pub async fn decoded_text_event_stream(
244
245 if let Some(reason) = finish_reason {
246 // Flush any remaining buffered text.
237 - let (last_chunk, mut text) = decoder.flush(truncate_output_to)?;
238 - let text_len = text.len();
239 - let full_text = tracing::enabled!(Level::TRACE).then(|| text.clone());
240 -
241 - if intermediate {
247 + let (last_chunk, full_decoded) = decoder.flush(truncate_output_to)?;
248 + let text_len = full_decoded.text.len();
249 + let full_text = tracing::enabled!(Level::TRACE).then(|| full_decoded.text.clone());
250 + let decoded = if intermediate {
251 if let Some(chunk) = last_chunk {
243 - if let Some(delta_str) = delta.as_mut() {
244 - delta_str.push_str(&chunk);
245 - } else {
246 - delta = Some(chunk);
247 - }
252 + decoded.append(chunk);
253 }
249 - token_ids = new_token_ids;
250 - logprobs = decoded_logprobs;
251 - text = delta.unwrap_or_default();
252 - }
254 + sampled_token_ids = new_token_ids;
255 + sampled_logprobs = decoded_logprobs;
256 + decoded
257 + } else {
258 + full_decoded
259 + };
260
261 debug!(
262 finish_reason = ?reason,
@@ -269,10 +276,12 @@ pub async fn decoded_text_event_stream(
276 }
277
278 y.yield_ok(DecodedTextEvent::TextDelta {
272 - delta: text,
273 - token_ids,
274 - logprobs,
275 - finished: Some(Finished {
279 + decoded,
280 + sampled: SampledDelta {
281 + token_ids: sampled_token_ids,
282 + logprobs: sampled_logprobs,
283 + },
284 + finished: Some(Box::new(Finished {
285 usage: TokenUsage {
286 prompt_token_count,
287 output_token_count,
@@ -281,7 +290,7 @@ pub async fn decoded_text_event_stream(
290 finish_reason: reason,
291 kv_transfer_params,
292 ec_transfer_params,
284 - }),
293 + })),
294 })
295 .await;
296 return Ok(());
@@ -289,9 +298,11 @@ pub async fn decoded_text_event_stream(
298
299 if intermediate {
300 y.yield_ok(DecodedTextEvent::TextDelta {
292 - delta: delta.unwrap_or_default(),
293 - token_ids: new_token_ids,
294 - logprobs: decoded_logprobs,
301 + decoded,
302 + sampled: SampledDelta {
303 + token_ids: new_token_ids,
304 + logprobs: decoded_logprobs,
305 + },
306 finished: None,
307 })
308 .await;
@@ -338,10 +349,10 @@ mod tests {
349 use std::sync::{Arc, Mutex};
350 use std::task::{Context, Poll};
351
341 - use futures::{Stream, stream};
352 + use futures::{Stream, StreamExt as _, stream};
353 use vllm_engine_core_client::AbortCause;
354 use vllm_llm::GenerateOutput;
344 - use vllm_tokenizer::test_utils::TestTokenizer;
355 + use vllm_tokenizer::{TokenAnchor, TokenAttribution, test_utils::TestTokenizer};
356
357 use super::*;
358 use crate::output::TextOutputStreamExt as _;
@@ -378,6 +389,129 @@ mod tests {
389 }
390 }
391
392 + fn visible(token_id: u32, byte_offset: u32) -> TokenAttribution {
393 + TokenAttribution {
394 + token_id,
395 + anchor: TokenAnchor::Visible { byte_offset },
396 + }
397 + }
398 +
399 + fn text_delta(
400 + text: impl Into<String>,
401 + attributions: impl IntoIterator<Item = TokenAttribution>,
402 + sampled_token_ids: Vec<u32>,
403 + finished: Option<Finished>,
404 + ) -> DecodedTextEvent {
405 + DecodedTextEvent::TextDelta {
406 + decoded: DecodedText {
407 + text: text.into(),
408 + attributions: attributions.into_iter().collect(),
409 + },
410 + sampled: SampledDelta {
411 + token_ids: sampled_token_ids,
412 + logprobs: None,
413 + },
414 + finished: finished.map(Box::new),
415 + }
416 + }
417 +
418 + async fn run_intermediate(
419 + outputs: Vec<GenerateOutput>,
420 + decode_options: TextDecodeOptions,
421 + ) -> Vec<DecodedTextEvent> {
422 + let tokenizer: DynTokenizer = Arc::new(TestTokenizer::new());
423 + decoded_text_event_stream(
424 + "test".into(),
425 + tokenizer,
426 + stream::iter(outputs.into_iter().map(Ok)),
427 + decode_options,
428 + true,
429 + )
430 + .collect::<Vec<_>>()
431 + .await
432 + .into_iter()
433 + .collect::<crate::Result<Vec<_>>>()
434 + .unwrap()
435 + }
436 +
437 + #[tokio::test]
438 + async fn decoded_and_sampled_clocks_are_independent() {
439 + struct Case {
440 + name: &'static str,
441 + outputs: Vec<GenerateOutput>,
442 + expected: Vec<DecodedTextEvent>,
443 + }
444 +
445 + let prompt: Arc<[u32]> = Arc::from([]);
446 + let cases = [
447 + Case {
448 + name: "utf8 byte fallback resolves across engine updates",
449 + outputs: vec![
450 + GenerateOutput::for_test(Some(Arc::clone(&prompt)), vec![0xe4], None),
451 + GenerateOutput::for_test(None, vec![0xbd, 0xa0], Some(FinishReason::Length)),
452 + ],
453 + expected: vec![
454 + DecodedTextEvent::Start {
455 + prompt_token_ids: Arc::clone(&prompt),
456 + prompt_logprobs: None,
457 + },
458 + text_delta("", [], vec![0xe4], None),
459 + text_delta(
460 + "你",
461 + [visible(0xe4, 0), visible(0xbd, 0), visible(0xa0, 0)],
462 + vec![0xbd, 0xa0],
463 + Some(Finished {
464 + usage: TokenUsage {
465 + prompt_token_count: 0,
466 + output_token_count: 3,
467 + cached_token_count: 0,
468 + },
469 + finish_reason: FinishReason::Length,
470 + kv_transfer_params: None,
471 + ec_transfer_params: None,
472 + }),
473 + ),
474 + ],
475 + },
476 + Case {
477 + name: "terminal stop token remains sampled and bypasses decoder",
478 + outputs: vec![GenerateOutput::for_test(
479 + Some(Arc::clone(&prompt)),
480 + ascii_tokens("a!"),
481 + Some(FinishReason::Stop(Some(StopReason::TokenId(b'!' as u32)))),
482 + )],
483 + expected: vec![
484 + DecodedTextEvent::Start {
485 + prompt_token_ids: Arc::clone(&prompt),
486 + prompt_logprobs: None,
487 + },
488 + text_delta(
489 + "a",
490 + [visible(b'a' as u32, 0)],
491 + ascii_tokens("a!"),
492 + Some(Finished {
493 + usage: TokenUsage {
494 + prompt_token_count: 0,
495 + output_token_count: 2,
496 + cached_token_count: 0,
497 + },
498 + finish_reason: FinishReason::Stop(Some(StopReason::TokenId(
499 + b'!' as u32,
500 + ))),
501 + kv_transfer_params: None,
502 + ec_transfer_params: None,
503 + }),
504 + ),
505 + ],
506 + },
507 + ];
508 +
509 + for case in cases {
510 + let actual = run_intermediate(case.outputs, TextDecodeOptions::default()).await;
511 + assert_eq!(actual, case.expected, "{}", case.name);
512 + }
513 + }
514 +
515 struct DropRecordingStream {
516 next: Option<vllm_llm::Result<GenerateOutput>>,
517 dropped_cause: Arc<Mutex<Option<AbortCause>>>,
rust/src/text/src/output/mod.rs
+96 -84
@@ -3,10 +3,13 @@
3
4 //! Output processing helpers shared by text and chat layers.
5
6 -pub use decoded::{DecodedTextEvent, Finished, TextDecodeOptions, decoded_text_event_stream};
6 +pub use decoded::{
7 + DecodedTextEvent, Finished, SampledDelta, TextDecodeOptions, decoded_text_event_stream,
8 +};
9 pub use logprobs::{
10 DecodedLogprobs, DecodedPositionLogprobs, DecodedPromptLogprobs, DecodedTokenLogprob,
11 };
12 +pub use vllm_tokenizer::{DecodedText, TokenAnchor, TokenAttribution};
13
14 mod decoded;
15 mod logprobs;
@@ -58,13 +61,16 @@ impl<T: TextOutputStream> T {
61 prompt_token_ids = start_prompt_token_ids;
62 }
63 DecodedTextEvent::TextDelta {
61 - delta,
62 - token_ids: delta_token_ids,
63 - logprobs: mut delta_logprobs,
64 + decoded,
65 + sampled:
66 + SampledDelta {
67 + token_ids: delta_token_ids,
68 + logprobs: mut delta_logprobs,
69 + },
70 finished,
71 } => {
72 if let Some(c) = collected.as_mut() {
67 - c.text.push_str(&delta);
73 + c.text.push_str(&decoded.text);
74 c.token_ids.extend(delta_token_ids);
75 if let Some(dlp) = delta_logprobs.as_mut() {
76 if let Some(lp) = c.logprobs.as_mut() {
@@ -75,7 +81,7 @@ impl<T: TextOutputStream> T {
81 }
82 } else {
83 collected = Some(CollectedTextOutput {
78 - text: delta,
84 + text: decoded.text,
85 prompt_token_ids: Arc::clone(&prompt_token_ids),
86 prompt_logprobs: prompt_logprobs.take(),
87 logprobs: delta_logprobs,
@@ -134,29 +140,31 @@ mod tests {
140 }),
141 }),
142 Ok(DecodedTextEvent::TextDelta {
137 - delta: "bc".to_string(),
138 - token_ids: vec![1, 2],
139 - logprobs: Some(DecodedLogprobs {
140 - positions: vec![
141 - DecodedPositionLogprobs {
142 - entries: vec![DecodedTokenLogprob {
143 - token_id: 0,
144 - token: "a".to_string(),
145 - logprob: -0.2,
146 - rank: 1,
147 - }],
148 - },
149 - DecodedPositionLogprobs {
150 - entries: vec![DecodedTokenLogprob {
151 - token_id: 0,
152 - token: "bc".to_string(),
153 - logprob: -0.3,
154 - rank: 1,
155 - }],
156 - },
157 - ],
158 - }),
159 - finished: Some(Finished {
143 + decoded: DecodedText::unattributed("bc"),
144 + sampled: SampledDelta {
145 + token_ids: vec![1, 2],
146 + logprobs: Some(DecodedLogprobs {
147 + positions: vec![
148 + DecodedPositionLogprobs {
149 + entries: vec![DecodedTokenLogprob {
150 + token_id: 0,
151 + token: "a".to_string(),
152 + logprob: -0.2,
153 + rank: 1,
154 + }],
155 + },
156 + DecodedPositionLogprobs {
157 + entries: vec![DecodedTokenLogprob {
158 + token_id: 0,
159 + token: "bc".to_string(),
160 + logprob: -0.3,
161 + rank: 1,
162 + }],
163 + },
164 + ],
165 + }),
166 + },
167 + finished: Some(Box::new(Finished {
168 usage: vllm_llm::TokenUsage {
169 prompt_token_count: 2,
170 output_token_count: 2,
@@ -165,7 +173,7 @@ mod tests {
173 finish_reason: FinishReason::stop_eos(),
174 kv_transfer_params: None,
175 ec_transfer_params: None,
168 - }),
176 + })),
177 }),
178 ]);
179
@@ -219,62 +227,66 @@ mod tests {
227 prompt_logprobs: None,
228 }),
229 Ok(DecodedTextEvent::TextDelta {
222 - delta: "he".to_string(),
223 - token_ids: vec![1, 2],
224 - logprobs: Some(DecodedLogprobs {
225 - positions: vec![
226 - DecodedPositionLogprobs {
227 - entries: vec![DecodedTokenLogprob {
228 - token_id: 0,
229 - token: "h".to_string(),
230 - logprob: -0.1,
231 - rank: 1,
232 - }],
233 - },
234 - DecodedPositionLogprobs {
235 - entries: vec![DecodedTokenLogprob {
236 - token_id: 0,
237 - token: "e".to_string(),
238 - logprob: -0.2,
239 - rank: 1,
240 - }],
241 - },
242 - ],
243 - }),
230 + decoded: DecodedText::unattributed("he"),
231 + sampled: SampledDelta {
232 + token_ids: vec![1, 2],
233 + logprobs: Some(DecodedLogprobs {
234 + positions: vec![
235 + DecodedPositionLogprobs {
236 + entries: vec![DecodedTokenLogprob {
237 + token_id: 0,
238 + token: "h".to_string(),
239 + logprob: -0.1,
240 + rank: 1,
241 + }],
242 + },
243 + DecodedPositionLogprobs {
244 + entries: vec![DecodedTokenLogprob {
245 + token_id: 0,
246 + token: "e".to_string(),
247 + logprob: -0.2,
248 + rank: 1,
249 + }],
250 + },
251 + ],
252 + }),
253 + },
254 finished: None,
255 }),
256 Ok(DecodedTextEvent::TextDelta {
247 - delta: "llo".to_string(),
248 - token_ids: vec![3, 4, 5],
249 - logprobs: Some(DecodedLogprobs {
250 - positions: vec![
251 - DecodedPositionLogprobs {
252 - entries: vec![DecodedTokenLogprob {
253 - token_id: 0,
254 - token: "l".to_string(),
255 - logprob: -0.3,
256 - rank: 1,
257 - }],
258 - },
259 - DecodedPositionLogprobs {
260 - entries: vec![DecodedTokenLogprob {
261 - token_id: 0,
262 - token: "l".to_string(),
263 - logprob: -0.4,
264 - rank: 1,
265 - }],
266 - },
267 - DecodedPositionLogprobs {
268 - entries: vec![DecodedTokenLogprob {
269 - token_id: 0,
270 - token: "o".to_string(),
271 - logprob: -0.5,
272 - rank: 1,
273 - }],
274 - },
275 - ],
276 - }),
277 - finished: Some(Finished {
257 + decoded: DecodedText::unattributed("llo"),
258 + sampled: SampledDelta {
259 + token_ids: vec![3, 4, 5],
260 + logprobs: Some(DecodedLogprobs {
261 + positions: vec![
262 + DecodedPositionLogprobs {
263 + entries: vec![DecodedTokenLogprob {
264 + token_id: 0,
265 + token: "l".to_string(),
266 + logprob: -0.3,
267 + rank: 1,
268 + }],
269 + },
270 + DecodedPositionLogprobs {
271 + entries: vec![DecodedTokenLogprob {
272 + token_id: 0,
273 + token: "l".to_string(),
274 + logprob: -0.4,
275 + rank: 1,
276 + }],
277 + },
278 + DecodedPositionLogprobs {
279 + entries: vec![DecodedTokenLogprob {
280 + token_id: 0,
281 + token: "o".to_string(),
282 + logprob: -0.5,
283 + rank: 1,
284 + }],
285 + },
286 + ],
287 + }),
288 + },
289 + finished: Some(Box::new(Finished {
290 usage: vllm_llm::TokenUsage {
291 prompt_token_count: 2,
292 output_token_count: 5,
@@ -283,7 +295,7 @@ mod tests {
295 finish_reason: FinishReason::stop_eos(),
296 kv_transfer_params: None,
297 ec_transfer_params: None,
286 - }),
298 + })),
299 }),
300 ]);
301
rust/src/tokenizer/Cargo.toml
+1
@@ -14,6 +14,7 @@ riptoken.workspace = true
14 rustc-hash.workspace = true
15 serde.workspace = true
16 serde_json.workspace = true
17 +smallvec.workspace = true
18 tekken.workspace = true
19 thiserror.workspace = true
20 thiserror-ext.workspace = true
rust/src/tokenizer/src/incremental.rs
+63 -46
@@ -1,10 +1,13 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use std::mem::take;
5 -
4 use crate::{Result, Tokenizer};
5
6 +mod attribution;
7 +
8 +use attribution::AttributedTextBuffer;
9 +pub use attribution::{DecodedText, TokenAnchor, TokenAttribution};
10 +
11 /// Stateful incremental decoder that emits text chunks one token at a time.
12 pub trait IncrementalDecoder: Send {
13 /// Push one generated token and return how many new string bytes were
@@ -12,13 +15,16 @@ pub trait IncrementalDecoder: Send {
15 fn push_token(&mut self, token_id: u32) -> Result<usize>;
16
17 /// Consume any text which is currently ready.
15 - fn next_chunk(&mut self) -> Option<String>;
18 + fn next_chunk(&mut self) -> Option<DecodedText>;
19
20 /// Flush any remaining buffered text that has not yet been emitted.
21 ///
22 /// Called after the final generated token to force out buffered/incomplete
23 /// fragments.
21 - fn flush(&mut self, truncate_output_to: Option<usize>) -> Result<(Option<String>, String)>;
24 + fn flush(
25 + &mut self,
26 + truncate_output_to: Option<usize>,
27 + ) -> Result<(Option<DecodedText>, DecodedText)>;
28
29 /// Return cumulative decoded text so far.
30 fn output(&self) -> &str;
@@ -36,8 +42,7 @@ pub(crate) struct DecodeStream<'a, T: Tokenizer + ?Sized> {
42 prefix: String,
43 prefix_index: usize,
44 prefix_seeded: bool,
39 - cumulative_output: String,
40 - output_index: usize,
45 + decoded: AttributedTextBuffer,
46 }
47
48 impl<'a, T: Tokenizer + ?Sized> DecodeStream<'a, T> {
@@ -55,8 +60,7 @@ impl<'a, T: Tokenizer + ?Sized> DecodeStream<'a, T> {
60 prefix: String::new(),
61 prefix_index: 0,
62 prefix_seeded: prompt_token_ids.is_empty(),
58 - cumulative_output: String::new(),
59 - output_index: 0,
63 + decoded: AttributedTextBuffer::default(),
64 }
65 }
66 }
@@ -68,6 +72,14 @@ const SAFE_SUFFIX_MIN: usize = 4;
72 const SAFE_SUFFIX_MAX: usize = 6;
73
74 impl<T: Tokenizer + ?Sized> DecodeStream<'_, T> {
75 + fn record_token(&mut self, token_id: u32) {
76 + if self.skip_special_tokens && self.tokenizer.is_special_id(token_id) {
77 + self.decoded.record_zero_width_token(token_id);
78 + } else {
79 + self.decoded.record_pending_token(token_id);
80 + }
81 + }
82 +
83 /// Decode prompt-only context for prefix seeding.
84 ///
85 /// Prompt ids may come from the model vocabulary rather than the local
@@ -132,6 +144,7 @@ impl<T: Tokenizer + ?Sized> IncrementalDecoder for DecodeStream<'_, T> {
144 }
145
146 self.ids.push(token_id);
147 + self.record_token(token_id);
148 let string = self.tokenizer.decode(&self.ids, self.skip_special_tokens)?;
149 let prefix_len = self.prefix.len();
150 if string.len() <= prefix_len || string.ends_with('\u{FFFD}') {
@@ -139,49 +152,48 @@ impl<T: Tokenizer + ?Sized> IncrementalDecoder for DecodeStream<'_, T> {
152 }
153 // Ensure we split at a utf-8 char boundary.
154 let new_chunk = &string[string.floor_char_boundary(prefix_len)..];
142 - self.cumulative_output.push_str(new_chunk);
155 + self.decoded.append_visible_text(new_chunk);
156 self.ids.drain(..self.prefix_index);
157 self.prefix = self.tokenizer.decode(&self.ids, self.skip_special_tokens)?;
158 self.prefix_index = self.ids.len();
159 Ok(new_chunk.len())
160 }
161
149 - fn next_chunk(&mut self) -> Option<String> {
150 - let cutoff = self.cumulative_output.len().saturating_sub(self.min_bytes_to_buffer);
162 + fn next_chunk(&mut self) -> Option<DecodedText> {
163 + let cutoff = self.decoded.len().saturating_sub(self.min_bytes_to_buffer);
164 // Ensure we split at a utf-8 char boundary.
152 - let cutoff = self.cumulative_output.floor_char_boundary(cutoff);
153 - (cutoff > self.output_index).then(|| {
154 - let chunk = self.cumulative_output[self.output_index..cutoff].to_string();
155 - self.output_index = cutoff;
156 - chunk
157 - })
165 + let cutoff = self.decoded.text().floor_char_boundary(cutoff);
166 + self.decoded.take_ready(cutoff)
167 }
168
160 - fn flush(&mut self, truncate_output_to: Option<usize>) -> Result<(Option<String>, String)> {
169 + fn flush(
170 + &mut self,
171 + truncate_output_to: Option<usize>,
172 + ) -> Result<(Option<DecodedText>, DecodedText)> {
173 // If the prefix was never seeded (no push_token was called), `ids`
174 // holds only prompt context — decoding it would re-emit prompt text.
175 if self.prefix_seeded && !self.ids.is_empty() {
176 let string = self.tokenizer.decode(&self.ids, self.skip_special_tokens)?;
177 let prefix_len = self.prefix.len();
178 // Ensure we split at a utf-8 char boundary.
167 - self.cumulative_output
168 - .push_str(&string[string.floor_char_boundary(prefix_len)..]);
179 + let new_chunk = &string[string.floor_char_boundary(prefix_len)..];
180 + if !new_chunk.is_empty() {
181 + self.decoded.append_visible_text(new_chunk);
182 + }
183 }
184 + self.decoded.resolve_pending_zero_width();
185 self.ids.clear();
186 self.prefix.clear();
187 self.prefix_index = 0;
188 self.prefix_seeded = true;
189 if let Some(truncate_output_to) = truncate_output_to {
175 - self.cumulative_output.truncate(truncate_output_to);
190 + self.decoded.truncate(truncate_output_to);
191 }
177 - let last_chunk = (self.output_index < self.cumulative_output.len())
178 - .then(|| self.cumulative_output[self.output_index..].to_string());
179 - self.output_index = 0;
180 - Ok((last_chunk, take(&mut self.cumulative_output)))
192 + Ok(self.decoded.finish())
193 }
194
195 fn output(&self) -> &str {
184 - &self.cumulative_output
196 + self.decoded.text()
197 }
198 }
199
@@ -245,13 +257,13 @@ mod tests {
257 let mut decoder = backend.create_decode_stream(&[], false, 0);
258
259 assert_eq!(decoder.push_token(b'o' as u32).unwrap(), 1);
248 - assert_eq!(decoder.next_chunk().as_deref(), Some("o"));
260 + assert_eq!(decoder.next_chunk().unwrap().text, "o");
261 assert_eq!(decoder.push_token(b'k' as u32).unwrap(), 1);
250 - assert_eq!(decoder.next_chunk().as_deref(), Some("k"));
262 + assert_eq!(decoder.next_chunk().unwrap().text, "k");
263 // All text already consumed via next_chunk
264 let (last_chunk, full_text) = decoder.flush(None).unwrap();
265 assert_eq!(last_chunk, None);
254 - assert_eq!(full_text, "ok");
266 + assert_eq!(full_text.text, "ok");
267 }
268
269 #[test]
@@ -288,6 +300,7 @@ mod tests {
300 0 if !skip_special_tokens => text.push_str("<special>"),
301 0 => {}
302 1 => text.push('a'),
303 + 2 => text.push('b'),
304 _ => {}
305 }
306 }
@@ -301,6 +314,10 @@ mod tests {
314 fn id_to_token(&self, _id: u32) -> Option<String> {
315 unreachable!()
316 }
317 +
318 + fn is_special_id(&self, token_id: u32) -> bool {
319 + token_id == 0
320 + }
321 }
322
323 #[test]
@@ -393,17 +410,17 @@ mod tests {
410 let mut decoder = backend.create_decode_stream(&[], false, 0);
411
412 let input = b"Hello, world!";
396 - let mut full = String::new();
413 + let mut out = String::new();
414 for &byte in input {
415 decoder.push_token(byte as u32).unwrap();
416 if let Some(chunk) = decoder.next_chunk() {
400 - full.push_str(&chunk);
417 + out.push_str(&chunk.text);
418 }
419 }
403 - let (last_chunk, full_text) = decoder.flush(None).unwrap();
420 + let (last_chunk, full) = decoder.flush(None).unwrap();
421 assert_eq!(last_chunk, None); // all consumed via next_chunk
405 - assert_eq!(full, "Hello, world!");
406 - assert_eq!(full_text, "Hello, world!");
422 + assert_eq!(out, "Hello, world!");
423 + assert_eq!(full.text, "Hello, world!");
424 }
425
426 /// Backend simulating non-monotonic decode where adding a token changes how
@@ -470,15 +487,15 @@ mod tests {
487 for &byte in input {
488 decoder.push_token(byte as u32).unwrap();
489 if let Some(chunk) = decoder.next_chunk() {
473 - chunks.push_str(&chunk);
490 + chunks.push_str(&chunk.text);
491 }
492 }
493 // With hold_back_bytes=3, last 3 bytes ("lo!") are held back
494 assert_eq!(chunks, "Hel");
495 // Flush returns the rest
479 - let (last_chunk, full_text) = decoder.flush(None).unwrap();
480 - assert_eq!(last_chunk.as_deref(), Some("lo!"));
481 - assert_eq!(full_text, "Hello!");
496 + let (last_chunk, full) = decoder.flush(None).unwrap();
497 + assert_eq!(last_chunk.unwrap().text, "lo!");
498 + assert_eq!(full.text, "Hello!");
499 }
500
501 #[test]
@@ -493,14 +510,14 @@ mod tests {
510 for byte in "你好A".bytes() {
511 decoder.push_token(u32::from(byte)).unwrap();
512 if let Some(chunk) = decoder.next_chunk() {
496 - out.push_str(&chunk);
513 + out.push_str(&chunk.text);
514 }
515 }
499 - let (last_chunk, full_text) = decoder.flush(None).unwrap();
516 + let (last_chunk, full) = decoder.flush(None).unwrap();
517 if let Some(chunk) = last_chunk {
501 - out.push_str(&chunk);
518 + out.push_str(&chunk.text);
519 }
503 - assert_eq!(full_text, "你好A");
520 + assert_eq!(full.text, "你好A");
521 assert_eq!(out, "你好A");
522 }
523
@@ -515,9 +532,9 @@ mod tests {
532 .collect();
533 let mut decoder = backend.create_decode_stream(&prompt, false, 0);
534
518 - let (last_chunk, full_text) = decoder.flush(None).unwrap();
535 + let (last_chunk, full) = decoder.flush(None).unwrap();
536 assert_eq!(last_chunk, None);
520 - assert_eq!(full_text, "");
537 + assert_eq!(full.text, "");
538 }
539
540 #[test]
@@ -526,8 +543,8 @@ mod tests {
543 let prompt = vec![0xe4, 0xbd];
544 let mut decoder = backend.create_decode_stream(&prompt, false, 0);
545
529 - let (last_chunk, full_text) = decoder.flush(None).unwrap();
546 + let (last_chunk, full) = decoder.flush(None).unwrap();
547 assert_eq!(last_chunk, None);
531 - assert_eq!(full_text, "");
548 + assert_eq!(full.text, "");
549 }
550 }
rust/src/tokenizer/src/incremental/attribution.rs new
+261
@@ -0,0 +1,261 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use std::mem::take;
5 +
6 +use smallvec::SmallVec;
7 +
8 +/// Position of one generated token in decoded text.
9 +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10 +pub enum TokenAnchor {
11 + /// The token contributed to visible decoded text.
12 + Visible { byte_offset: u32 },
13 + /// The token produced no visible decoded bytes.
14 + ZeroWidth { byte_offset: u32 },
15 +}
16 +
17 +impl TokenAnchor {
18 + fn byte_offset(self) -> u32 {
19 + match self {
20 + Self::Visible { byte_offset } | Self::ZeroWidth { byte_offset } => byte_offset,
21 + }
22 + }
23 +
24 + /// Return a new anchor offset by `delta` bytes.
25 + #[must_use]
26 + pub(super) fn offset_by(self, delta: i64) -> Self {
27 + let byte_offset = i64::from(self.byte_offset())
28 + .checked_add(delta)
29 + .and_then(|offset| u32::try_from(offset).ok())
30 + .expect("token anchor byte offset out of range");
31 + match self {
32 + Self::Visible { .. } => Self::Visible { byte_offset },
33 + Self::ZeroWidth { .. } => Self::ZeroWidth { byte_offset },
34 + }
35 + }
36 +}
37 +
38 +/// One generated token and its position in decoded text.
39 +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40 +pub struct TokenAttribution {
41 + /// Token ID passed to [`super::IncrementalDecoder::push_token`].
42 + pub token_id: u32,
43 + /// Position of the token in the decoded text.
44 + pub anchor: TokenAnchor,
45 +}
46 +
47 +/// Decoded text and the generated tokens attributed to it.
48 +#[derive(Clone, Debug, Default, PartialEq, Eq)]
49 +pub struct DecodedText {
50 + /// Decoded UTF-8 text.
51 + pub text: String,
52 + /// One entry per generated token, in generation order.
53 + ///
54 + /// Offsets are local to `text`. Visible offsets may repeat when multiple
55 + /// tokens jointly decode into one character. A zero-width offset may equal
56 + /// `text.len()`.
57 + /// Four inline records cover one complete UTF-8 byte-fallback sequence.
58 + pub attributions: SmallVec<[TokenAttribution; 4]>,
59 +}
60 +
61 +impl DecodedText {
62 + /// Create decoded text without token attribution records. Mostly for testing purposes.
63 + pub fn unattributed(text: impl Into<String>) -> Self {
64 + Self {
65 + text: text.into(),
66 + attributions: SmallVec::new(),
67 + }
68 + }
69 +
70 + /// Append another decoded fragment, rebasing its token anchors.
71 + pub fn append(&mut self, other: Self) {
72 + if self.text.is_empty() && self.attributions.is_empty() {
73 + *self = other;
74 + return;
75 + }
76 +
77 + let byte_offset = offset_as_u32(self.text.len());
78 + let other_len = offset_as_u32(other.text.len());
79 + let _combined_len = byte_offset.checked_add(other_len).expect("decoded text exceeds 4 GiB");
80 +
81 + self.text.push_str(&other.text);
82 + self.attributions.extend(other.attributions.into_iter().map(|attribution| {
83 + TokenAttribution {
84 + token_id: attribution.token_id,
85 + anchor: attribution.anchor.offset_by(i64::from(byte_offset)),
86 + }
87 + }));
88 + }
89 +}
90 +
91 +#[derive(Clone, Copy, Debug)]
92 +enum PendingAnchor {
93 + Unresolved,
94 + Resolved(TokenAnchor),
95 +}
96 +
97 +#[derive(Clone, Copy, Debug)]
98 +struct PendingAttribution {
99 + token_id: u32,
100 + anchor: PendingAnchor,
101 +}
102 +
103 +/// Cumulative decoded text plus the attribution state needed to emit local
104 +/// chunks.
105 +#[derive(Default)]
106 +pub(super) struct AttributedTextBuffer {
107 + text: String,
108 + emitted_byte_offset: usize,
109 + attributions: Vec<PendingAttribution>,
110 + emitted_attribution_count: usize,
111 + pending_start: Option<usize>,
112 +}
113 +
114 +impl AttributedTextBuffer {
115 + /// Return all decoded text accumulated so far.
116 + pub(super) fn text(&self) -> &str {
117 + &self.text
118 + }
119 +
120 + /// Return the decoded text length in bytes.
121 + pub(super) fn len(&self) -> usize {
122 + self.text.len()
123 + }
124 +
125 + /// Record a token awaiting visible decoded output.
126 + pub(super) fn record_pending_token(&mut self, token_id: u32) {
127 + self.pending_start.get_or_insert(self.attributions.len());
128 + self.attributions.push(PendingAttribution {
129 + token_id,
130 + anchor: PendingAnchor::Unresolved,
131 + });
132 + }
133 +
134 + /// Record a token that produced no visible decoded output.
135 + pub(super) fn record_zero_width_token(&mut self, token_id: u32) {
136 + self.attributions.push(PendingAttribution {
137 + token_id,
138 + anchor: PendingAnchor::Resolved(TokenAnchor::ZeroWidth {
139 + byte_offset: offset_as_u32(self.text.len()),
140 + }),
141 + });
142 + }
143 +
144 + /// Append visible text and anchor pending tokens at its start.
145 + pub(super) fn append_visible_text(&mut self, text: &str) {
146 + let byte_offset = offset_as_u32(self.text.len());
147 + if let Some(pending_start) = self.pending_start.take() {
148 + for attribution in &mut self.attributions[pending_start..] {
149 + if matches!(attribution.anchor, PendingAnchor::Unresolved) {
150 + attribution.anchor =
151 + PendingAnchor::Resolved(TokenAnchor::Visible { byte_offset });
152 + }
153 + }
154 + }
155 + self.text.push_str(text);
156 + }
157 +
158 + /// Resolve remaining pending tokens at the current text boundary.
159 + pub(super) fn resolve_pending_zero_width(&mut self) {
160 + let Some(pending_start) = self.pending_start.take() else {
161 + return;
162 + };
163 + let byte_offset = offset_as_u32(self.text.len());
164 + for attribution in &mut self.attributions[pending_start..] {
165 + if matches!(attribution.anchor, PendingAnchor::Unresolved) {
166 + attribution.anchor =
167 + PendingAnchor::Resolved(TokenAnchor::ZeroWidth { byte_offset });
168 + }
169 + }
170 + }
171 +
172 + /// Truncate text and collapse removed anchors to the new boundary.
173 + pub(super) fn truncate(&mut self, byte_offset: usize) {
174 + self.text.truncate(byte_offset);
175 + let byte_offset = offset_as_u32(byte_offset);
176 + for attribution in &mut self.attributions {
177 + let PendingAnchor::Resolved(anchor) = &mut attribution.anchor else {
178 + continue;
179 + };
180 + if anchor.byte_offset() >= byte_offset {
181 + *anchor = TokenAnchor::ZeroWidth { byte_offset };
182 + }
183 + }
184 + }
185 +
186 + /// Emit decoded text and attributions ready through `cutoff`.
187 + pub(super) fn take_ready(&mut self, cutoff: usize) -> Option<DecodedText> {
188 + let chunk_start = self.emitted_byte_offset;
189 + let cutoff_u32 = offset_as_u32(cutoff);
190 + let mut attribution_end = self.emitted_attribution_count;
191 +
192 + for attribution in &self.attributions[self.emitted_attribution_count..] {
193 + let PendingAnchor::Resolved(anchor) = attribution.anchor else {
194 + break;
195 + };
196 + let ready = match anchor {
197 + TokenAnchor::Visible { byte_offset } => byte_offset < cutoff_u32,
198 + TokenAnchor::ZeroWidth { byte_offset } => byte_offset <= cutoff_u32,
199 + };
200 + if !ready {
201 + break;
202 + }
203 + attribution_end += 1;
204 + }
205 +
206 + if cutoff == chunk_start && attribution_end == self.emitted_attribution_count {
207 + return None;
208 + }
209 +
210 + let chunk_start_u32 = offset_as_u32(chunk_start);
211 + let attributions = self.attributions[self.emitted_attribution_count..attribution_end]
212 + .iter()
213 + .map(|attribution| {
214 + let PendingAnchor::Resolved(anchor) = attribution.anchor else {
215 + unreachable!("ready attributions must be resolved")
216 + };
217 + TokenAttribution {
218 + token_id: attribution.token_id,
219 + anchor: anchor.offset_by(-i64::from(chunk_start_u32)),
220 + }
221 + })
222 + .collect();
223 + let text = self.text[chunk_start..cutoff].to_string();
224 +
225 + self.emitted_byte_offset = cutoff;
226 + self.emitted_attribution_count = attribution_end;
227 + Some(DecodedText { text, attributions })
228 + }
229 +
230 + /// Emit the final chunk and take the complete decoded output.
231 + pub(super) fn finish(&mut self) -> (Option<DecodedText>, DecodedText) {
232 + let last_chunk = self.take_ready(self.text.len());
233 + let attributions = take(&mut self.attributions)
234 + .into_iter()
235 + .map(|attribution| {
236 + let PendingAnchor::Resolved(anchor) = attribution.anchor else {
237 + unreachable!("finish requires every attribution to be resolved")
238 + };
239 + TokenAttribution {
240 + token_id: attribution.token_id,
241 + anchor,
242 + }
243 + })
244 + .collect();
245 + let full = DecodedText {
246 + text: take(&mut self.text),
247 + attributions,
248 + };
249 + self.emitted_byte_offset = 0;
250 + self.emitted_attribution_count = 0;
251 + self.pending_start = None;
252 + (last_chunk, full)
253 + }
254 +}
255 +
256 +fn offset_as_u32(offset: usize) -> u32 {
257 + u32::try_from(offset).expect("decoded text exceeds 4 GiB")
258 +}
259 +
260 +#[cfg(test)]
261 +mod tests;
rust/src/tokenizer/src/incremental/attribution/tests.rs new
+425
@@ -0,0 +1,425 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use super::*;
5 +use crate::{Result, Tokenizer, test_utils::TestTokenizer};
6 +
7 +const SPECIAL_TOKEN_ID: u32 = 0x100;
8 +
9 +/// Backend with tokens that decode to whole text pieces.
10 +#[derive(Debug)]
11 +struct PieceBackend;
12 +
13 +impl Tokenizer for PieceBackend {
14 + fn encode(&self, _text: &str, _add_special_tokens: bool) -> Result<Vec<u32>> {
15 + unreachable!()
16 + }
17 +
18 + fn encode_ordinary(&self, _text: &str) -> Result<Vec<u32>> {
19 + unreachable!()
20 + }
21 +
22 + fn decode(&self, token_ids: &[u32], _skip_special_tokens: bool) -> Result<String> {
23 + Ok(token_ids
24 + .iter()
25 + .map(|token_id| match token_id {
26 + 1 => "abcd",
27 + 2 => "ab",
28 + 3 => "<stop>",
29 + 4 => "",
30 + _ => unreachable!("unexpected token id: {token_id}"),
31 + })
32 + .collect())
33 + }
34 +
35 + fn token_to_id(&self, _token: &str) -> Option<u32> {
36 + unreachable!()
37 + }
38 +
39 + fn id_to_token(&self, id: u32) -> Option<String> {
40 + Some(id.to_string())
41 + }
42 +}
43 +
44 +/// Raw-byte backend with one filterable special token.
45 +#[derive(Debug)]
46 +struct Utf8SpecialBackend;
47 +
48 +impl Tokenizer for Utf8SpecialBackend {
49 + fn encode(&self, _text: &str, _add_special_tokens: bool) -> Result<Vec<u32>> {
50 + unreachable!()
51 + }
52 +
53 + fn encode_ordinary(&self, _text: &str) -> Result<Vec<u32>> {
54 + unreachable!()
55 + }
56 +
57 + fn decode(&self, token_ids: &[u32], skip_special_tokens: bool) -> Result<String> {
58 + let mut bytes = Vec::new();
59 + for &token_id in token_ids {
60 + if token_id == SPECIAL_TOKEN_ID {
61 + if !skip_special_tokens {
62 + bytes.extend_from_slice(b"<special>");
63 + }
64 + } else {
65 + bytes.push(token_id as u8);
66 + }
67 + }
68 + Ok(String::from_utf8_lossy(&bytes).into_owned())
69 + }
70 +
71 + fn token_to_id(&self, _token: &str) -> Option<u32> {
72 + unreachable!()
73 + }
74 +
75 + fn id_to_token(&self, id: u32) -> Option<String> {
76 + Some(id.to_string())
77 + }
78 +
79 + fn is_special_id(&self, token_id: u32) -> bool {
80 + token_id == SPECIAL_TOKEN_ID
81 + }
82 +}
83 +
84 +fn tokenizer_with_special_token() -> TestTokenizer {
85 + TestTokenizer::new().with_special_token("<special>", SPECIAL_TOKEN_ID)
86 +}
87 +
88 +fn visible(byte_offset: u32) -> TokenAnchor {
89 + TokenAnchor::Visible { byte_offset }
90 +}
91 +
92 +fn zero_width(byte_offset: u32) -> TokenAnchor {
93 + TokenAnchor::ZeroWidth { byte_offset }
94 +}
95 +
96 +fn decoded(text: &str, token_ids: &[u32], anchors: &[TokenAnchor]) -> DecodedText {
97 + assert_eq!(token_ids.len(), anchors.len());
98 + DecodedText {
99 + text: text.to_string(),
100 + attributions: token_ids
101 + .iter()
102 + .copied()
103 + .zip(anchors.iter().copied())
104 + .map(|(token_id, anchor)| TokenAttribution { token_id, anchor })
105 + .collect(),
106 + }
107 +}
108 +
109 +#[test]
110 +fn token_anchor_offset_by_preserves_anchor_kind() {
111 + for (anchor, delta, expected) in [
112 + (visible(3), -2, visible(1)),
113 + (zero_width(1), 4, zero_width(5)),
114 + ] {
115 + assert_eq!(anchor.offset_by(delta), expected);
116 + }
117 +}
118 +
119 +#[test]
120 +fn decoded_text_append_rebases_attributions() {
121 + let mut combined = decoded("a", &[1], &[visible(0)]);
122 + combined.append(decoded(
123 + "你",
124 + &[2, 3, 4, 5],
125 + &[visible(0), visible(0), visible(0), zero_width(3)],
126 + ));
127 +
128 + assert_eq!(
129 + combined,
130 + decoded(
131 + "a你",
132 + &[1, 2, 3, 4, 5],
133 + &[
134 + visible(0),
135 + visible(1),
136 + visible(1),
137 + visible(1),
138 + zero_width(4),
139 + ],
140 + )
141 + );
142 +}
143 +
144 +#[test]
145 +fn decoded_text_append_moves_allocations_into_empty_receiver() {
146 + let source = decoded(
147 + "hello",
148 + &[1, 2, 3, 4, 5],
149 + &[visible(0), visible(1), visible(2), visible(3), visible(4)],
150 + );
151 + let text_ptr = source.text.as_ptr();
152 + let attributions_ptr = source.attributions.as_ptr();
153 + let mut combined = DecodedText::default();
154 +
155 + combined.append(source);
156 +
157 + assert_eq!(combined.text.as_ptr(), text_ptr);
158 + assert_eq!(combined.attributions.as_ptr(), attributions_ptr);
159 +}
160 +
161 +#[test]
162 +fn decoded_text_append_preserves_zero_width_only_receiver() {
163 + let mut combined = decoded("", &[1], &[zero_width(0)]);
164 + combined.append(decoded("a", &[2], &[visible(0)]));
165 +
166 + assert_eq!(
167 + combined,
168 + decoded("a", &[1, 2], &[zero_width(0), visible(0)])
169 + );
170 +}
171 +
172 +struct AttributionCase<'a> {
173 + tokenizer: &'a dyn Tokenizer,
174 + prompt_token_ids: Vec<u32>,
175 + token_ids: Vec<u32>,
176 + skip_special_tokens: bool,
177 + min_bytes_to_buffer: usize,
178 + truncate_output_to: Option<usize>,
179 + expected_chunks: Vec<DecodedText>,
180 + expected_full: DecodedText,
181 +}
182 +
183 +fn run_attribution_case(case: AttributionCase<'_>) {
184 + let mut decoder = case.tokenizer.create_decode_stream(
185 + &case.prompt_token_ids,
186 + case.skip_special_tokens,
187 + case.min_bytes_to_buffer,
188 + );
189 + let mut chunks = Vec::new();
190 +
191 + for token_id in &case.token_ids {
192 + decoder.push_token(*token_id).unwrap();
193 + while let Some(chunk) = decoder.next_chunk() {
194 + chunks.push(chunk);
195 + }
196 + }
197 + let (remaining, full) = decoder.flush(case.truncate_output_to).unwrap();
198 + if let Some(remaining) = remaining {
199 + chunks.push(remaining);
200 + }
201 +
202 + assert_eq!(chunks, case.expected_chunks, "chunks");
203 + assert_eq!(full, case.expected_full, "full output");
204 + assert_eq!(
205 + full.attributions.len(),
206 + case.token_ids.len(),
207 + "one attribution per pushed token"
208 + );
209 + assert_eq!(
210 + full.attributions
211 + .iter()
212 + .map(|attribution| attribution.token_id)
213 + .collect::<Vec<_>>(),
214 + case.token_ids,
215 + "attribution preserves pushed token IDs"
216 + );
217 +
218 + let mut reconstructed_text = String::new();
219 + let mut reconstructed_attributions = Vec::new();
220 + for chunk in &chunks {
221 + let chunk_start = reconstructed_text.len() as u32;
222 + reconstructed_text.push_str(&chunk.text);
223 + reconstructed_attributions.extend(chunk.attributions.iter().map(|attribution| {
224 + TokenAttribution {
225 + token_id: attribution.token_id,
226 + anchor: attribution.anchor.offset_by(i64::from(chunk_start)),
227 + }
228 + }));
229 + }
230 + assert_eq!(
231 + reconstructed_text, full.text,
232 + "chunk text reconstructs full text"
233 + );
234 + assert_eq!(
235 + reconstructed_attributions.as_slice(),
236 + full.attributions.as_slice(),
237 + "chunk attributions reconstruct full attributions"
238 + );
239 +}
240 +
241 +#[test]
242 +fn attribution_ascii_tokens_have_one_visible_anchor_each() {
243 + let tokenizer = TestTokenizer::new();
244 + run_attribution_case(AttributionCase {
245 + tokenizer: &tokenizer,
246 + prompt_token_ids: vec![],
247 + token_ids: vec![b'o' as u32, b'k' as u32],
248 + skip_special_tokens: false,
249 + min_bytes_to_buffer: 0,
250 + truncate_output_to: None,
251 + expected_chunks: vec![
252 + decoded("o", &[b'o' as u32], &[visible(0)]),
253 + decoded("k", &[b'k' as u32], &[visible(0)]),
254 + ],
255 + expected_full: decoded("ok", &[b'o' as u32, b'k' as u32], &[visible(0), visible(1)]),
256 + });
257 +}
258 +
259 +#[test]
260 +fn attribution_byte_fallback_tokens_share_one_visible_anchor() {
261 + let tokenizer = TestTokenizer::new();
262 + run_attribution_case(AttributionCase {
263 + tokenizer: &tokenizer,
264 + prompt_token_ids: vec![],
265 + token_ids: vec![0xe4, 0xbd, 0xa0],
266 + skip_special_tokens: false,
267 + min_bytes_to_buffer: 0,
268 + truncate_output_to: None,
269 + expected_chunks: vec![decoded(
270 + "你",
271 + &[0xe4, 0xbd, 0xa0],
272 + &[visible(0), visible(0), visible(0)],
273 + )],
274 + expected_full: decoded(
275 + "你",
276 + &[0xe4, 0xbd, 0xa0],
277 + &[visible(0), visible(0), visible(0)],
278 + ),
279 + });
280 +}
281 +
282 +#[test]
283 +fn attribution_holdback_splits_text_without_repeating_token() {
284 + run_attribution_case(AttributionCase {
285 + tokenizer: &PieceBackend,
286 + prompt_token_ids: vec![],
287 + token_ids: vec![1],
288 + skip_special_tokens: false,
289 + min_bytes_to_buffer: 2,
290 + truncate_output_to: None,
291 + expected_chunks: vec![decoded("ab", &[1], &[visible(0)]), decoded("cd", &[], &[])],
292 + expected_full: decoded("abcd", &[1], &[visible(0)]),
293 + });
294 +}
295 +
296 +#[test]
297 +fn attribution_filtered_special_token_is_zero_width_at_its_byte_boundary() {
298 + let tokenizer = tokenizer_with_special_token();
299 + run_attribution_case(AttributionCase {
300 + tokenizer: &tokenizer,
301 + prompt_token_ids: vec![],
302 + token_ids: vec![b'a' as u32, SPECIAL_TOKEN_ID, b'b' as u32],
303 + skip_special_tokens: true,
304 + min_bytes_to_buffer: 0,
305 + truncate_output_to: None,
306 + expected_chunks: vec![
307 + decoded("a", &[b'a' as u32], &[visible(0)]),
308 + decoded("", &[SPECIAL_TOKEN_ID], &[zero_width(0)]),
309 + decoded("b", &[b'b' as u32], &[visible(0)]),
310 + ],
311 + expected_full: decoded(
312 + "ab",
313 + &[b'a' as u32, SPECIAL_TOKEN_ID, b'b' as u32],
314 + &[visible(0), zero_width(1), visible(1)],
315 + ),
316 + });
317 +}
318 +
319 +#[test]
320 +fn attribution_zero_width_preserves_order_inside_byte_fallback_group() {
321 + run_attribution_case(AttributionCase {
322 + tokenizer: &Utf8SpecialBackend,
323 + prompt_token_ids: vec![],
324 + token_ids: vec![0xe4, SPECIAL_TOKEN_ID, 0xbd, 0xa0],
325 + skip_special_tokens: true,
326 + min_bytes_to_buffer: 0,
327 + truncate_output_to: None,
328 + expected_chunks: vec![decoded(
329 + "你",
330 + &[0xe4, SPECIAL_TOKEN_ID, 0xbd, 0xa0],
331 + &[visible(0), zero_width(0), visible(0), visible(0)],
332 + )],
333 + expected_full: decoded(
334 + "你",
335 + &[0xe4, SPECIAL_TOKEN_ID, 0xbd, 0xa0],
336 + &[visible(0), zero_width(0), visible(0), visible(0)],
337 + ),
338 + });
339 +}
340 +
341 +#[test]
342 +fn attribution_retained_special_token_has_visible_anchor() {
343 + run_attribution_case(AttributionCase {
344 + tokenizer: &Utf8SpecialBackend,
345 + prompt_token_ids: vec![],
346 + token_ids: vec![SPECIAL_TOKEN_ID],
347 + skip_special_tokens: false,
348 + min_bytes_to_buffer: 0,
349 + truncate_output_to: None,
350 + expected_chunks: vec![decoded("<special>", &[SPECIAL_TOKEN_ID], &[visible(0)])],
351 + expected_full: decoded("<special>", &[SPECIAL_TOKEN_ID], &[visible(0)]),
352 + });
353 +}
354 +
355 +#[test]
356 +fn attribution_empty_decode_resolves_to_zero_width_on_flush() {
357 + run_attribution_case(AttributionCase {
358 + tokenizer: &PieceBackend,
359 + prompt_token_ids: vec![],
360 + token_ids: vec![4],
361 + skip_special_tokens: false,
362 + min_bytes_to_buffer: 0,
363 + truncate_output_to: None,
364 + expected_chunks: vec![decoded("", &[4], &[zero_width(0)])],
365 + expected_full: decoded("", &[4], &[zero_width(0)]),
366 + });
367 +}
368 +
369 +#[test]
370 +fn attribution_incomplete_utf8_tokens_share_replacement_anchor_on_flush() {
371 + let tokenizer = TestTokenizer::new();
372 + run_attribution_case(AttributionCase {
373 + tokenizer: &tokenizer,
374 + prompt_token_ids: vec![],
375 + token_ids: vec![0xe4, 0xbd],
376 + skip_special_tokens: false,
377 + min_bytes_to_buffer: 0,
378 + truncate_output_to: None,
379 + expected_chunks: vec![decoded("�", &[0xe4, 0xbd], &[visible(0), visible(0)])],
380 + expected_full: decoded("�", &[0xe4, 0xbd], &[visible(0), visible(0)]),
381 + });
382 +}
383 +
384 +#[test]
385 +fn attribution_truncation_converts_fully_removed_token_to_zero_width() {
386 + run_attribution_case(AttributionCase {
387 + tokenizer: &PieceBackend,
388 + prompt_token_ids: vec![],
389 + token_ids: vec![2, 3],
390 + skip_special_tokens: false,
391 + min_bytes_to_buffer: 32,
392 + truncate_output_to: Some(2),
393 + expected_chunks: vec![decoded("ab", &[2, 3], &[visible(0), zero_width(2)])],
394 + expected_full: decoded("ab", &[2, 3], &[visible(0), zero_width(2)]),
395 + });
396 +}
397 +
398 +#[test]
399 +fn attribution_truncation_inside_token_retains_first_byte_anchor() {
400 + run_attribution_case(AttributionCase {
401 + tokenizer: &PieceBackend,
402 + prompt_token_ids: vec![],
403 + token_ids: vec![1],
404 + skip_special_tokens: false,
405 + min_bytes_to_buffer: 32,
406 + truncate_output_to: Some(2),
407 + expected_chunks: vec![decoded("ab", &[1], &[visible(0)])],
408 + expected_full: decoded("ab", &[1], &[visible(0)]),
409 + });
410 +}
411 +
412 +#[test]
413 +fn attribution_excludes_prompt_context() {
414 + let tokenizer = TestTokenizer::new();
415 + run_attribution_case(AttributionCase {
416 + tokenizer: &tokenizer,
417 + prompt_token_ids: vec![b'H' as u32, b'i' as u32],
418 + token_ids: vec![b'!' as u32],
419 + skip_special_tokens: false,
420 + min_bytes_to_buffer: 0,
421 + truncate_output_to: None,
422 + expected_chunks: vec![decoded("!", &[b'!' as u32], &[visible(0)])],
423 + expected_full: decoded("!", &[b'!' as u32], &[visible(0)]),
424 + });
425 +}
rust/src/tokenizer/src/lib.rs
+1 -1
@@ -17,7 +17,7 @@ mod tiktoken;
17
18 pub use error::{Result, TokenizerError};
19 pub use hf::HuggingFaceTokenizer;
20 -pub use incremental::IncrementalDecoder;
20 +pub use incremental::{DecodedText, IncrementalDecoder, TokenAnchor, TokenAttribution};
21 pub use tekken::TekkenTokenizer;
22 pub use tiktoken::TiktokenTokenizer;
23
rust/src/tokenizer/src/tiktoken.rs
+8 -8
@@ -964,16 +964,16 @@ mod tests {
964 for &id in &ids {
965 decoder.push_token(id).unwrap();
966 if let Some(chunk) = decoder.next_chunk() {
967 - output.push_str(&chunk);
967 + output.push_str(&chunk.text);
968 }
969 }
970 - let (last_chunk, full_text) = decoder.flush(None).unwrap();
970 + let (last_chunk, full) = decoder.flush(None).unwrap();
971 if let Some(chunk) = last_chunk {
972 - output.push_str(&chunk);
972 + output.push_str(&chunk.text);
973 }
974
975 assert_eq!(output, text);
976 - assert_eq!(full_text, text);
976 + assert_eq!(full.text, text);
977 }
978 }
979
@@ -991,16 +991,16 @@ mod tests {
991 for &id in &ids {
992 decoder.push_token(id).unwrap();
993 if let Some(chunk) = decoder.next_chunk() {
994 - output.push_str(&chunk);
994 + output.push_str(&chunk.text);
995 }
996 }
997 - let (last_chunk, full_text) = decoder.flush(None).unwrap();
997 + let (last_chunk, full) = decoder.flush(None).unwrap();
998 if let Some(chunk) = last_chunk {
999 - output.push_str(&chunk);
999 + output.push_str(&chunk.text);
1000 }
1001
1002 assert_eq!(output, text);
1003 - assert_eq!(full_text, text);
1003 + assert_eq!(full.text, text);
1004 }
1005 }
1006