[Rust Frontend] Token-aware marker parsing for Kimi K3 (#58358)
Signed-off-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bugen Zhao committed
Oct 1, 2026 at 12:21 UTC
36229e1337ae0e44e60075a11ebfde210c11d706
8 files changed
+1135
-133
rust/Cargo.lock
+2
@@ -5964,8 +5964,10 @@ dependencies = [
5964
name = "vllm-parser"
5965
version = "0.1.0"
5966
dependencies = [
5967
+ "auto_impl",
5968
"criterion",
5969
"easy-ext",
5970
+ "educe",
5971
"expect-test",
5972
"futures",
5973
"openai-protocol",
rust/Cargo.toml
+1
@@ -31,6 +31,7 @@ async-openai = { version = "0.33.1", default-features = false, features = ["nati
31
async-trait = "0.1.89"
32
asynk-strim-attr = "0.1.0"
33
auto_enums = { version = "0.8.9", features = ["tokio1"] }
34
+auto_impl = "1.3.0"
35
axum = "0.8.8"
36
base64 = "0.22.1"
37
bytemuck = { version = "1.25.0", features = ["extern_crate_alloc"] }
rust/src/chat/src/parser/unified.rs
+2
-1
@@ -141,7 +141,8 @@ mod tests {
141
let tokenizer = TestTokenizer::new()
142
.with_regular_token("<|open|>", 1001)
143
.with_regular_token("<|close|>", 1002)
144
- .with_regular_token("<|sep|>", 1003);
144
+ .with_regular_token("<|sep|>", 1003)
145
+ .with_regular_token("<|end_of_msg|>", 1004);
146
147
assert!(factory.contains(names::KIMI_K3));
148
assert_eq!(
rust/src/parser/Cargo.toml
+2
@@ -8,7 +8,9 @@ license.workspace = true
8
test-util = []
9
10
[dependencies]
11
+auto_impl.workspace = true
12
easy-ext.workspace = true
13
+educe.workspace = true
14
serde.workspace = true
15
serde_json.workspace = true
16
serde_with.workspace = true
rust/src/parser/src/unified/kimi_k3.rs
+340
-54
@@ -26,6 +26,14 @@
26
//! starts *inside* that channel without re-emitting the open tag;
27
//! [`UnifiedParser::initialize`] detects this from the prompt token IDs.
28
//!
29
+//! Channel markers are structural only when their `<|open|>` / `<|close|>` /
30
+//! `<|sep|>` bytes come from the dedicated special tokens. The model can spell
31
+//! the same text with ordinary BPE tokens (for example while quoting a
32
+//! configuration snippet in its reasoning); such text stays content in the
33
+//! current channel. The parser therefore expects token-attributed input from
34
+//! the incremental detokenizer: fed unattributed text, it treats every marker
35
+//! as content. Channel names such as `think` are ordinary text either way.
36
+//!
37
//! Argument decoding mirrors the renderer's type tagging (inverse encoding):
38
//! `type="string"` values pass the raw text through, other types are
39
//! JSON-decoded, and a raw `json` block is passed through unmodified. Attribute
@@ -34,29 +42,33 @@
42
//! Known limitation (shared with the Python parser): string argument and
43
//! response bodies are emitted raw, so a value that literally contains
44
//! `<|close|>argument<|sep|>` or `<|close|>response<|sep|>` is
37
-//! indistinguishable from a real closing marker.
45
+//! indistinguishable from a real closing marker. With token attribution this
46
+//! now only applies to `argument` and `json` blocks inside a call body, which
47
+//! are still parsed from text.
48
49
mod structural_tag;
50
51
pub use structural_tag::KimiK3StructuralTagBuilder;
52
53
use serde_json::{Map, Value};
44
-use vllm_tokenizer::{DecodedText, DynTokenizer};
54
+use vllm_tokenizer::{DecodedText, DynTokenizer, Tokenizer};
55
use winnow::ascii::{multispace0 as ws0, multispace1 as ws1};
56
use winnow::combinator::{alt, delimited, eof, preceded, repeat, seq, terminated};
57
use winnow::error::{ContextError, ErrMode, ModalResult, StrContext};
58
use winnow::prelude::*;
49
-use winnow::stream::Partial;
59
use winnow::token::{literal, rest, take_till, take_until, take_while};
60
61
use self::structural_tag::KIMI_K3_STRUCTURAL_TAG_BUILDER;
53
-use super::{Result, UnifiedParser, UnifiedParserOutput, token_id};
62
+use super::{Result, UnifiedParser, UnifiedParserOutput, special_token};
63
use crate::output_grammar::{
64
self, BuiltOutputGrammar, OutputGrammarContext, visible_format_from_builder,
65
};
66
use crate::tool::{Tool, ToolCallDelta};
67
use crate::unified::parsing_failed;
59
-use crate::utils::{MarkerScanState, parse_buffered_event, safe_text_len_mul, take_until_marker};
68
+use crate::utils::{
69
+ Attributed, AttributedExt as _, Marker, MarkerScanState, SpecialToken,
70
+ parse_buffered_event_attributed, safe_text_len_mul, take_until_marker,
71
+};
72
73
const OPEN: &str = "<|open|>";
74
const CLOSE: &str = "<|close|>";
@@ -70,31 +82,82 @@ const RESPONSE_CLOSE: &str = "<|close|>response<|sep|>";
82
const TOOLS_OPEN: &str = "<|open|>tools<|sep|>";
83
const TOOLS_CLOSE: &str = "<|close|>tools<|sep|>";
84
const MESSAGE_CLOSE: &str = "<|close|>message<|sep|>";
73
-const CALL_OPEN: &str = "<|open|>call";
85
const CALL_CLOSE: &str = "<|close|>call<|sep|>";
86
const ARG_OPEN: &str = "<|open|>argument";
87
const ARG_CLOSE: &str = "<|close|>argument<|sep|>";
88
const JSON_OPEN: &str = "<|open|>json";
89
const JSON_CLOSE: &str = "<|close|>json<|sep|>";
90
80
-const IDLE_MARKERS: &[&str] = &[
81
- THINK_OPEN,
82
- RESPONSE_OPEN,
83
- TOOLS_OPEN,
84
- MESSAGE_CLOSE,
85
- END_OF_MSG,
86
-];
87
-const REASONING_MARKERS: &[&str] = &[THINK_CLOSE, END_OF_MSG];
88
-const RESPONSE_MARKERS: &[&str] = &[RESPONSE_CLOSE, TOOLS_OPEN, MESSAGE_CLOSE, END_OF_MSG];
89
-const EPILOGUE_MARKERS: &[&str] = &[TOOLS_OPEN, MESSAGE_CLOSE, END_OF_MSG];
90
-const TOOLS_MARKERS: &[&str] = &[CALL_OPEN, TOOLS_CLOSE, MESSAGE_CLOSE, END_OF_MSG];
91
-
91
/// Channel tags are a couple of text tokens; longer `<|open|>…<|sep|>` spans in
92
/// the prompt tail (attribute-bearing message opens, message bodies) never name
93
/// a generation channel.
94
const MAX_PREFILL_TAG_TOKENS: usize = 8;
95
97
-type KimiK3Input<'i> = Partial<&'i str>;
96
+type KimiK3Input<'i> = Attributed<'i, Markers>;
97
+
98
+/// The K3 structural special tokens, resolved once from the active tokenizer.
99
+struct SpecialTokens {
100
+ open: SpecialToken,
101
+ close: SpecialToken,
102
+ sep: SpecialToken,
103
+ end_of_msg: SpecialToken,
104
+}
105
+
106
+impl SpecialTokens {
107
+ fn new(tokenizer: &dyn Tokenizer) -> Result<Self> {
108
+ Ok(Self {
109
+ open: special_token(tokenizer, OPEN)?,
110
+ close: special_token(tokenizer, CLOSE)?,
111
+ sep: special_token(tokenizer, SEP)?,
112
+ end_of_msg: special_token(tokenizer, END_OF_MSG)?,
113
+ })
114
+ }
115
+}
116
+
117
+/// The K3 channel markers, with their special-token segments guarded by the
118
+/// active tokenizer's IDs.
119
+///
120
+/// One definition serves both the `alt` branches that consume a marker and the
121
+/// safe-text scans that stop in front of one, so the two can never disagree on
122
+/// whether a spelling is structural.
123
+#[derive(Debug)]
124
+struct Markers {
125
+ think_open: Marker,
126
+ think_close: Marker,
127
+ response_open: Marker,
128
+ response_close: Marker,
129
+ tools_open: Marker,
130
+ tools_close: Marker,
131
+ message_close: Marker,
132
+ end_of_msg: Marker,
133
+ /// `<|open|>call`; the tag attributes and the terminating `<|sep|>` follow.
134
+ call_open: Marker,
135
+ call_close: Marker,
136
+ sep: Marker,
137
+}
138
+
139
+impl Markers {
140
+ fn new(tokens: &SpecialTokens) -> Self {
141
+ let open =
142
+ |tag: &str| Marker::special(&tokens.open).then_text(tag).then_special(&tokens.sep);
143
+ let close =
144
+ |tag: &str| Marker::special(&tokens.close).then_text(tag).then_special(&tokens.sep);
145
+
146
+ Self {
147
+ think_open: open("think"),
148
+ think_close: close("think"),
149
+ response_open: open("response"),
150
+ response_close: close("response"),
151
+ tools_open: open("tools"),
152
+ tools_close: close("tools"),
153
+ message_close: close("message"),
154
+ end_of_msg: Marker::special(&tokens.end_of_msg),
155
+ call_open: Marker::special(&tokens.open).then_text("call"),
156
+ call_close: close("call"),
157
+ sep: Marker::special(&tokens.sep),
158
+ }
159
+ }
160
+}
161
162
#[derive(Debug, Clone, PartialEq, Eq)]
163
enum KimiK3Event {
@@ -148,26 +211,25 @@ enum KimiK3Mode {
211
pub struct KimiK3UnifiedParser {
212
buffer: DecodedText,
213
mode: KimiK3Mode,
214
+ markers: Markers,
215
/// Number of calls emitted in the current response.
216
emitted_call_count: usize,
217
tokenizer: DynTokenizer,
154
- open_token_id: u32,
155
- sep_token_id: u32,
218
+ special_tokens: SpecialTokens,
219
}
220
221
impl KimiK3UnifiedParser {
159
- /// Create a Kimi K3 parser.
222
+ /// Create a Kimi K3 parser expecting token-attributed input.
223
pub fn new(_tools: &[Tool], tokenizer: DynTokenizer) -> Result<Self> {
161
- let open_token_id = token_id(tokenizer.as_ref(), OPEN)?;
162
- let sep_token_id = token_id(tokenizer.as_ref(), SEP)?;
224
+ let special_tokens = SpecialTokens::new(tokenizer.as_ref())?;
225
226
Ok(Self {
227
buffer: DecodedText::default(),
228
mode: KimiK3Mode::default(),
229
+ markers: Markers::new(&special_tokens),
230
emitted_call_count: 0,
231
tokenizer,
169
- open_token_id,
170
- sep_token_id,
232
+ special_tokens,
233
})
234
}
235
@@ -180,11 +242,14 @@ impl KimiK3UnifiedParser {
242
fn initialize_mode(&mut self, prompt_token_ids: &[u32]) {
243
self.mode = KimiK3Mode::Idle;
244
183
- let Some(sep_pos) = prompt_token_ids.iter().rposition(|&id| id == self.sep_token_id) else {
245
+ let Some(sep_pos) =
246
+ prompt_token_ids.iter().rposition(|&id| id == self.special_tokens.sep.id)
247
+ else {
248
return;
249
};
186
- let Some(open_pos) =
187
- prompt_token_ids[..sep_pos].iter().rposition(|&id| id == self.open_token_id)
250
+ let Some(open_pos) = prompt_token_ids[..sep_pos]
251
+ .iter()
252
+ .rposition(|&id| id == self.special_tokens.open.id)
253
else {
254
return;
255
};
@@ -291,9 +356,11 @@ impl UnifiedParser for KimiK3UnifiedParser {
356
fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()> {
357
self.buffer.append(delta);
358
294
- while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer.text, |input| {
295
- parse_next_kimi_k3_event(input, &mut self.mode)
296
- })? {
359
+ while let Some((event, consumed_len)) =
360
+ parse_buffered_event_attributed(&self.buffer, &self.markers, |input| {
361
+ parse_next_kimi_k3_event(input, &mut self.mode)
362
+ })?
363
+ {
364
let piece = self.buffer.drain_prefix(consumed_len);
365
self.apply_event(event, piece, output)?;
366
}
@@ -347,10 +414,11 @@ fn parse_next_kimi_k3_event(
414
415
/// Parse an event while waiting for the next channel open.
416
fn parse_idle_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
417
+ let markers = input.markers();
418
alt((
351
- literal(THINK_OPEN).value(KimiK3Event::ThinkOpen),
352
- literal(RESPONSE_OPEN).value(KimiK3Event::ResponseOpen),
353
- literal(TOOLS_OPEN).value(KimiK3Event::ToolsOpen),
419
+ markers.think_open.value(KimiK3Event::ThinkOpen),
420
+ markers.response_open.value(KimiK3Event::ResponseOpen),
421
+ markers.tools_open.value(KimiK3Event::ToolsOpen),
422
message_end_event,
423
safe_idle_text_event,
424
))
@@ -359,11 +427,12 @@ fn parse_idle_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
427
428
/// Parse an event inside the `think` channel.
429
fn parse_reasoning_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
430
+ let markers = input.markers();
431
alt((
363
- literal(THINK_CLOSE).value(KimiK3Event::ThinkClose),
432
+ markers.think_close.value(KimiK3Event::ThinkClose),
433
// `<|end_of_msg|>` can reach the parser under `ignore_eos` or
434
// `include_stop_str_in_output`; never leak it into reasoning.
366
- literal(END_OF_MSG).value(KimiK3Event::MessageEnd),
435
+ markers.end_of_msg.value(KimiK3Event::MessageEnd),
436
safe_reasoning_event,
437
))
438
.parse_next(input)
@@ -371,10 +440,11 @@ fn parse_reasoning_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event
440
441
/// Parse an event inside the `response` channel.
442
fn parse_response_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
443
+ let markers = input.markers();
444
alt((
375
- literal(RESPONSE_CLOSE).value(KimiK3Event::ResponseClose),
445
+ markers.response_close.value(KimiK3Event::ResponseClose),
446
// The response body also implicitly ends at a `tools` channel.
377
- literal(TOOLS_OPEN).value(KimiK3Event::ToolsOpen),
447
+ markers.tools_open.value(KimiK3Event::ToolsOpen),
448
message_end_event,
449
safe_response_text_event,
450
))
@@ -383,8 +453,9 @@ fn parse_response_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event>
453
454
/// Parse an event after the response channel closed.
455
fn parse_epilogue_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
456
+ let markers = input.markers();
457
alt((
387
- literal(TOOLS_OPEN).value(KimiK3Event::ToolsOpen),
458
+ markers.tools_open.value(KimiK3Event::ToolsOpen),
459
message_end_event,
460
skip_epilogue_noise_event,
461
))
@@ -393,9 +464,10 @@ fn parse_epilogue_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event>
464
465
/// Parse an event inside the `tools` channel, between `call` blocks.
466
fn parse_tools_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
467
+ let markers = input.markers();
468
alt((
469
call_open_event,
398
- literal(TOOLS_CLOSE).value(KimiK3Event::ToolsClose),
470
+ markers.tools_close.value(KimiK3Event::ToolsClose),
471
// Defensive: an unterminated tools channel still ends with the message.
472
message_end_event,
473
skip_tools_noise_event,
@@ -405,7 +477,8 @@ fn parse_tools_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
477
478
/// Parse a message close or end-of-message marker.
479
fn message_end_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
408
- alt((literal(MESSAGE_CLOSE), literal(END_OF_MSG)))
480
+ let markers = input.markers();
481
+ alt((&markers.message_close, &markers.end_of_msg))
482
.value(KimiK3Event::MessageEnd)
483
.parse_next(input)
484
}
@@ -417,35 +490,82 @@ fn parse_done_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
490
491
/// Parse safe text while waiting for the next channel marker.
492
fn safe_idle_text_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
420
- safe_text_len_mul(input, IDLE_MARKERS).map(|_| KimiK3Event::Text)
493
+ let markers = input.markers();
494
+ safe_text_len_mul(
495
+ input,
496
+ &[
497
+ &markers.think_open,
498
+ &markers.response_open,
499
+ &markers.tools_open,
500
+ &markers.message_close,
501
+ &markers.end_of_msg,
502
+ ],
503
+ )
504
+ .map(|_| KimiK3Event::Text)
505
}
506
507
/// Parse safe reasoning before the think close marker.
508
fn safe_reasoning_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
425
- safe_text_len_mul(input, REASONING_MARKERS).map(|_| KimiK3Event::Reasoning)
509
+ let markers = input.markers();
510
+ safe_text_len_mul(input, &[&markers.think_close, &markers.end_of_msg])
511
+ .map(|_| KimiK3Event::Reasoning)
512
}
513
514
/// Parse safe response text before the next channel marker.
515
fn safe_response_text_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
430
- safe_text_len_mul(input, RESPONSE_MARKERS).map(|_| KimiK3Event::Text)
516
+ let markers = input.markers();
517
+ safe_text_len_mul(
518
+ input,
519
+ &[
520
+ &markers.response_close,
521
+ &markers.tools_open,
522
+ &markers.message_close,
523
+ &markers.end_of_msg,
524
+ ],
525
+ )
526
+ .map(|_| KimiK3Event::Text)
527
}
528
529
/// Skip non-content noise after the response channel closed.
530
fn skip_epilogue_noise_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
435
- safe_text_len_mul(input, EPILOGUE_MARKERS).map(|_| KimiK3Event::Skip)
531
+ let markers = input.markers();
532
+ safe_text_len_mul(
533
+ input,
534
+ &[
535
+ &markers.tools_open,
536
+ &markers.message_close,
537
+ &markers.end_of_msg,
538
+ ],
539
+ )
540
+ .map(|_| KimiK3Event::Skip)
541
}
542
543
/// Skip non-content noise between `call` blocks.
544
fn skip_tools_noise_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
440
- safe_text_len_mul(input, TOOLS_MARKERS).map(|_| KimiK3Event::Skip)
545
+ let markers = input.markers();
546
+ safe_text_len_mul(
547
+ input,
548
+ &[
549
+ &markers.call_open,
550
+ &markers.tools_close,
551
+ &markers.message_close,
552
+ &markers.end_of_msg,
553
+ ],
554
+ )
555
+ .map(|_| KimiK3Event::Skip)
556
}
557
558
/// Parse a `call` open tag into its tool name and one-based index.
559
+///
560
+/// The attributes end at the structural `<|sep|>`; an ordinary spelling of
561
+/// `<|sep|>` inside an attribute value stays attribute text.
562
fn call_open_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
563
+ let markers = input.markers();
564
+ let mut scan = MarkerScanState::default();
565
let (attrs,) = seq!(
446
- _: literal(CALL_OPEN),
447
- take_until(0.., SEP),
448
- _: literal(SEP),
566
+ _: &markers.call_open,
567
+ take_until_marker(&markers.sep, &mut scan),
568
+ _: &markers.sep,
569
)
570
.parse_next(input)?;
571
let attrs = parse_tag_attrs(attrs)?;
@@ -464,9 +584,10 @@ fn call_body_event(
584
input: &mut KimiK3Input<'_>,
585
scan: &mut MarkerScanState,
586
) -> ModalResult<KimiK3Event> {
587
+ let markers = input.markers();
588
let (body,) = seq!(
468
- take_until_marker(CALL_CLOSE, scan),
469
- _: literal(CALL_CLOSE),
589
+ take_until_marker(&markers.call_close, scan),
590
+ _: &markers.call_close,
591
)
592
.parse_next(input)?;
593
let arguments = parse_call_arguments(body)?;
@@ -614,9 +735,11 @@ mod tests {
735
}
736
737
impl UnifiedParserTestExt for KimiK3UnifiedParser {
738
+ /// Parse `chunk` as the detokenizer would deliver it: markers spelled in
739
+ /// full are structural special tokens, everything else ordinary text.
740
fn parse_chunk(&mut self, chunk: &str) -> super::Result<UnifiedParserOutput> {
741
let mut output = UnifiedParserOutput::default();
619
- self.parse_into(DecodedText::unattributed(chunk), &mut output)?;
742
+ self.parse_into(decode_ids(&structural(chunk)), &mut output)?;
743
Ok(output)
744
}
745
@@ -665,14 +788,41 @@ mod tests {
788
}
789
}
790
791
+ /// The parser as constructed in production: markers must be spelled by
792
+ /// their special tokens.
793
fn test_parser() -> KimiK3UnifiedParser {
794
KimiK3UnifiedParser::new(&[], Arc::new(tokenizer())).unwrap()
795
}
796
797
+ /// Run token IDs through the real incremental decoder to obtain attributed
798
+ /// input, exactly as the frontend does.
799
+ fn decode_ids(ids: &[u32]) -> DecodedText {
800
+ let tokenizer = tokenizer();
801
+ let mut decoder = tokenizer.create_decode_stream(&[], false, 0);
802
+ for &id in ids {
803
+ decoder.push_token(id).unwrap();
804
+ }
805
+ decoder.flush(None).unwrap().1
806
+ }
807
+
808
+ /// Encode with the special tokens, so markers are structural.
809
+ fn structural(text: &str) -> Vec<u32> {
810
+ tokenizer().encode(text, false).unwrap()
811
+ }
812
+
813
+ /// Encode as ordinary bytes only, so marker spellings are content.
814
+ fn ordinary(text: &str) -> Vec<u32> {
815
+ tokenizer().encode_ordinary(text).unwrap()
816
+ }
817
+
818
+ /// Stream the structural encoding of the concatenated `chunks`, split at the
819
+ /// chunk boundaries, so a marker split across chunks keeps its token anchor.
820
fn collect_stream(parser: &mut KimiK3UnifiedParser, chunks: &[&str]) -> UnifiedParserOutput {
821
+ let mut rest = decode_ids(&structural(&chunks.concat()));
822
let mut output = UnifiedParserOutput::default();
823
for chunk in chunks {
675
- output.append(parser.parse_chunk(chunk).unwrap());
824
+ let piece = rest.drain_prefix(chunk.len());
825
+ parser.parse_into(piece, &mut output).unwrap();
826
}
827
output.append(parser.finish().unwrap());
828
output
@@ -1136,4 +1286,140 @@ mod tests {
1286
1287
assert_eq!(raw, "<|close|>resp");
1288
}
1289
+
1290
+ // --- Ordinary marker spellings ------------------------------------------
1291
+
1292
+ fn start_in_reasoning(parser: &mut KimiK3UnifiedParser) {
1293
+ parser.initialize(&structural(THINK_OPEN)).unwrap();
1294
+ }
1295
+
1296
+ fn parse_decoded(
1297
+ parser: &mut KimiK3UnifiedParser,
1298
+ decoded: DecodedText,
1299
+ ) -> UnifiedParserOutput {
1300
+ let mut output = UnifiedParserOutput::default();
1301
+ parser.parse_into(decoded, &mut output).unwrap();
1302
+ output.append(parser.finish().unwrap());
1303
+ output
1304
+ }
1305
+
1306
+ #[test]
1307
+ fn kimi_k3_markers_spell_the_documented_strings() {
1308
+ let special_tokens = super::SpecialTokens::new(&tokenizer()).unwrap();
1309
+ let markers = super::Markers::new(&special_tokens);
1310
+
1311
+ assert_eq!(markers.think_open.as_str(), THINK_OPEN);
1312
+ assert_eq!(markers.think_close.as_str(), THINK_CLOSE);
1313
+ assert_eq!(markers.response_open.as_str(), RESPONSE_OPEN);
1314
+ assert_eq!(markers.response_close.as_str(), RESPONSE_CLOSE);
1315
+ assert_eq!(markers.tools_open.as_str(), TOOLS_OPEN);
1316
+ assert_eq!(markers.tools_close.as_str(), TOOLS_CLOSE);
1317
+ assert_eq!(markers.message_close.as_str(), super::MESSAGE_CLOSE);
1318
+ assert_eq!(markers.call_close.as_str(), super::CALL_CLOSE);
1319
+ assert_eq!(markers.end_of_msg.as_str(), END_OF_MSG);
1320
+ }
1321
+
1322
+ #[test]
1323
+ fn kimi_k3_ordinary_close_marker_text_stays_in_reasoning() {
1324
+ // The production incident: the model spells `<|close|>think<|sep|>` with
1325
+ // ordinary tokens while reasoning, then really closes the channel later.
1326
+ let mut ids = ordinary(THINK_CLOSE);
1327
+ ids.extend(structural(&format!(
1328
+ " after{THINK_CLOSE}{RESPONSE_OPEN}answer{RESPONSE_CLOSE}<|close|>message{SEP}"
1329
+ )));
1330
+ let decoded = decode_ids(&ids);
1331
+ assert!(
1332
+ decoded.text.starts_with(THINK_CLOSE),
1333
+ "same bytes as the structural marker"
1334
+ );
1335
+
1336
+ let mut parser = test_parser();
1337
+ start_in_reasoning(&mut parser);
1338
+ let output = parse_decoded(&mut parser, decoded);
1339
+
1340
+ assert_eq!(output.reasoning_text(), format!("{THINK_CLOSE} after"));
1341
+ assert_eq!(output.normal_text(), "answer");
1342
+ }
1343
+
1344
+ #[test]
1345
+ fn kimi_k3_ordinary_open_marker_text_stays_in_reasoning() {
1346
+ let mut ids = structural("config: ");
1347
+ ids.extend(ordinary(THINK_OPEN));
1348
+ ids.extend(structural(&format!(
1349
+ "{THINK_CLOSE}{RESPONSE_OPEN}ok{RESPONSE_CLOSE}"
1350
+ )));
1351
+
1352
+ let mut parser = test_parser();
1353
+ start_in_reasoning(&mut parser);
1354
+ let output = parse_decoded(&mut parser, decode_ids(&ids));
1355
+
1356
+ assert_eq!(output.reasoning_text(), format!("config: {THINK_OPEN}"));
1357
+ assert_eq!(output.normal_text(), "ok");
1358
+ }
1359
+
1360
+ #[test]
1361
+ fn kimi_k3_attributed_streaming_splits_at_every_char_boundary() {
1362
+ // A lookalike close inside reasoning, then genuine markers and a call.
1363
+ let mut ids = structural(THINK_OPEN);
1364
+ ids.extend(ordinary(THINK_CLOSE));
1365
+ ids.extend(structural(&format!(
1366
+ " step{THINK_CLOSE}{RESPONSE_OPEN}the answer{RESPONSE_CLOSE}{TOOLS_OPEN}{}{TOOLS_CLOSE}<|close|>message{SEP}",
1367
+ call("tool=\"calc\" index=\"1\"", &arg("x", "number", "42")),
1368
+ )));
1369
+ let full = decode_ids(&ids);
1370
+
1371
+ for split in (0..=full.text.len()).filter(|&index| full.text.is_char_boundary(index)) {
1372
+ let mut rest = full.clone();
1373
+ let head = rest.drain_prefix(split);
1374
+ let mut parser = test_parser();
1375
+ let mut output = UnifiedParserOutput::default();
1376
+ parser.parse_into(head, &mut output).unwrap();
1377
+ parser.parse_into(rest, &mut output).unwrap();
1378
+ output.append(parser.finish().unwrap());
1379
+
1380
+ assert_eq!(
1381
+ output.reasoning_text(),
1382
+ format!("{THINK_CLOSE} step"),
1383
+ "split {split}"
1384
+ );
1385
+ assert_eq!(output.normal_text(), "the answer", "split {split}");
1386
+ assert_eq!(
1387
+ first_call(&output).arguments,
1388
+ r#"{"x":42}"#,
1389
+ "split {split}"
1390
+ );
1391
+ }
1392
+ }
1393
+
1394
+ #[test]
1395
+ fn kimi_k3_incomplete_utf8_before_structural_close_still_closes() {
1396
+ // A stray incomplete byte right before the close marker: the decoder
1397
+ // anchors the special token at its own spelling, so the boundary holds.
1398
+ let mut ids = vec![0xe4];
1399
+ ids.extend(structural(&format!(
1400
+ "{THINK_CLOSE}{RESPONSE_OPEN}ok{RESPONSE_CLOSE}"
1401
+ )));
1402
+
1403
+ let mut parser = test_parser();
1404
+ start_in_reasoning(&mut parser);
1405
+ let output = parse_decoded(&mut parser, decode_ids(&ids));
1406
+
1407
+ assert_eq!(output.reasoning_text(), "\u{FFFD}");
1408
+ assert_eq!(output.normal_text(), "ok");
1409
+ }
1410
+
1411
+ #[test]
1412
+ fn kimi_k3_ordinary_sep_text_inside_call_attributes_stays_attribute_text() {
1413
+ let mut ids = structural(&format!("{TOOLS_OPEN}{OPEN}call tool=\"a"));
1414
+ ids.extend(ordinary(SEP));
1415
+ ids.extend(structural(&format!(
1416
+ "b\" index=\"1\"{SEP}{}<|close|>call{SEP}{TOOLS_CLOSE}",
1417
+ arg("x", "number", "1")
1418
+ )));
1419
+
1420
+ let output = parse_decoded(&mut test_parser(), decode_ids(&ids));
1421
+
1422
+ assert_eq!(first_call(&output).name, Some(format!("a{SEP}b")));
1423
+ assert_eq!(first_call(&output).arguments, r#"{"x":1}"#);
1424
+ }
1425
}
rust/src/parser/src/unified/mod.rs
+9
@@ -21,6 +21,7 @@ use vllm_tokenizer::{DecodedText, DynTokenizer};
21
use crate::output_grammar::{self, BuiltOutputGrammar, OutputGrammarContext};
22
use crate::reasoning::ReasoningError;
23
use crate::tool::{Tool, ToolCallDelta, ToolParserError, ToolParserEvent, ToolParserOutput};
24
+use crate::utils::SpecialToken;
25
26
/// Result alias for unified parser operations.
27
pub type Result<T> = std::result::Result<T, UnifiedParserError>;
@@ -288,3 +289,11 @@ fn token_id(tokenizer: &dyn vllm_tokenizer::Tokenizer, token: &str) -> Result<u3
289
token: token.to_string(),
290
})
291
}
292
+
293
+/// Resolves `token` to a [`SpecialToken`], or an error if it's not found.
294
+fn special_token(tokenizer: &dyn vllm_tokenizer::Tokenizer, token: &str) -> Result<SpecialToken> {
295
+ Ok(SpecialToken {
296
+ id: token_id(tokenizer, token)?,
297
+ text: token.to_string(),
298
+ })
299
+}
rust/src/parser/src/utils.rs
+119
-78
@@ -3,14 +3,23 @@
3
4
//! Shared helpers for streaming parsers.
5
6
+use std::fmt::Debug;
7
+
8
+use vllm_tokenizer::DecodedText;
9
use winnow::Parser;
10
use winnow::error::{ContextError, ErrMode, ModalResult, Needed, StrContext, StrContextValue};
8
-use winnow::stream::{FindSlice, Offset, Partial, Stream};
11
+use winnow::stream::{Partial, Stream};
12
13
use crate::tool::{Result, ToolParserError};
14
15
+pub(crate) mod marker;
16
pub(crate) mod recursion;
17
18
+pub use marker::{
19
+ Attributed, AttributedExt, Marker, MarkerLike, MarkerStream, SpecialToken,
20
+ attributed_with_markers,
21
+};
22
+
23
/// Return the byte length of the longest proper prefix of `token` that is also
24
/// a suffix of `buffer`.
25
///
@@ -48,48 +57,81 @@ pub fn partial_prefix_len(buffer: &str, token: &str) -> usize {
57
0
58
}
59
60
+/// Where the next marker may be, in the coordinate system of
61
+/// [`MarkerStream::offset`].
62
+enum Scan {
63
+ /// A complete marker starts at this offset.
64
+ Found(usize),
65
+ /// A marker may start at this offset but needs more input to decide; text
66
+ /// from here on must be held back.
67
+ Pending(usize),
68
+ /// Every candidate before this offset (the end of the input) was rejected.
69
+ Clear(usize),
70
+}
71
+
72
+/// Scan for the earliest of `markers` at or after `from`.
73
+///
74
+/// Candidates come from the stream ([`MarkerStream::next_candidate`]): text
75
+/// occurrences for plain markers, visible anchors for markers guarded by a
76
+/// special token. Each candidate is decided by probing the marker's own parser
77
+/// on a cloned cursor, so the scanner and the consuming `alt` branch can never
78
+/// disagree; a rejected candidate (an ordinary lookalike) is skipped and the
79
+/// scan continues.
80
+fn scan_markers<'i, I: MarkerStream<'i>, M: MarkerLike>(
81
+ input: &I,
82
+ markers: &[M],
83
+ from: usize,
84
+) -> ModalResult<Scan> {
85
+ let start = input.offset();
86
+ let mut from = from.max(start);
87
+ while let Some(at) = input.next_candidate(markers, from) {
88
+ for marker in markers {
89
+ let mut probe = input.clone();
90
+ probe.next_slice(at - start);
91
+ match marker.as_marker().parse_next(&mut probe) {
92
+ Ok(_) => return Ok(Scan::Found(at)),
93
+ Err(ErrMode::Backtrack(_)) => {}
94
+ Err(ErrMode::Incomplete(_)) => return Ok(Scan::Pending(at)),
95
+ Err(error @ ErrMode::Cut(_)) => return Err(error),
96
+ }
97
+ }
98
+ from = at + 1;
99
+ }
100
+ Ok(Scan::Clear(start + input.eof_offset()))
101
+}
102
+
103
/// Parse a safe text run before the next marker.
104
/// This is the single-marker variant of [`safe_text_len_mul`].
105
///
106
/// Returns the text length in bytes, and advances the input.
55
-pub fn safe_text_len(input: &mut Partial<&str>, marker: &str) -> ModalResult<usize> {
56
- let text = **input;
57
- if text.is_empty() {
58
- return incomplete();
59
- }
60
-
61
- if let Some(start_idx) = text.find(marker) {
62
- input.next_slice(start_idx);
63
- return Ok(start_idx);
64
- }
65
-
66
- let keep_len = partial_prefix_len(text, marker);
67
- let emit_len = text.len().saturating_sub(keep_len);
68
- if emit_len == 0 {
69
- return incomplete();
70
- }
71
-
72
- input.next_slice(emit_len);
73
- Ok(emit_len)
107
+pub fn safe_text_len<'i, I: MarkerStream<'i>, M: MarkerLike>(
108
+ input: &mut I,
109
+ marker: M,
110
+) -> ModalResult<usize> {
111
+ safe_text_len_mul(input, &[marker])
112
}
113
114
/// Parse a safe text run before the earliest next marker.
115
/// This is the multi-marker variant of [`safe_text_len`].
116
///
117
+/// Text that may still grow into a marker is held back: a partial spelling at
118
+/// the end of the input for plain markers, and only a genuine special token's
119
+/// incomplete marker for guarded ones.
120
+///
121
/// Returns the text length in bytes, and advances the input.
80
-pub fn safe_text_len_mul(input: &mut Partial<&str>, markers: &[&str]) -> ModalResult<usize> {
81
- let text = **input;
82
- if text.is_empty() {
122
+pub fn safe_text_len_mul<'i, I: MarkerStream<'i>, M: MarkerLike>(
123
+ input: &mut I,
124
+ markers: &[M],
125
+) -> ModalResult<usize> {
126
+ if input.eof_offset() == 0 {
127
return incomplete();
128
}
129
86
- if let Some(start_idx) = find_slice_mul(text, markers) {
87
- input.next_slice(start_idx);
88
- return Ok(start_idx);
89
- }
90
-
91
- let keep_len = markers.iter().map(|marker| partial_prefix_len(text, marker)).max().unwrap_or(0);
92
- let emit_len = text.len().saturating_sub(keep_len);
130
+ let start = input.offset();
131
+ let stop = match scan_markers(input, markers, start)? {
132
+ Scan::Found(at) | Scan::Pending(at) | Scan::Clear(at) => at,
133
+ };
134
+ let emit_len = stop - start;
135
if emit_len == 0 {
136
return incomplete();
137
}
@@ -98,23 +140,11 @@ pub fn safe_text_len_mul(input: &mut Partial<&str>, markers: &[&str]) -> ModalRe
140
Ok(emit_len)
141
}
142
101
-#[inline(always)]
102
-fn find_slice_mul(text: &str, markers: &[&str]) -> Option<usize> {
103
- let range = match markers {
104
- // Use the fast specialized `winnow::stream::FindSlice` impl for 1-3 markers.
105
- [first] => text.find_slice(*first),
106
- [first, second] => text.find_slice((*first, *second)),
107
- [first, second, third] => text.find_slice((*first, *second, *third)),
108
- // Fall back to a linear scan for 4+ markers.
109
- _ => return markers.iter().filter_map(|marker| text.find(marker)).min(),
110
- };
111
- range.map(|range| range.start)
112
-}
113
-
143
/// Streaming scan state for a buffered marker search [`take_until_marker`],
144
/// so that we don't have to rescan the whole buffered prefix when resuming.
145
#[derive(Debug, Clone, Default, PartialEq, Eq)]
146
pub struct MarkerScanState {
147
+ /// Offset relative to the cursor at call time from which the scan resumes.
148
scan_start: usize,
149
}
150
@@ -128,55 +158,47 @@ impl MarkerScanState {
158
///
159
/// This is the streaming-buffered variant of `winnow::token::take_until(0..,
160
/// marker)`: it returns the slice before `marker` and leaves `marker` for the
131
-/// caller to consume. On incomplete input, it stores the earliest byte offset
132
-/// that can still match `marker` and returns `Incomplete` without consuming
133
-/// input, so the next parse can avoid rescanning the whole buffered prefix.
161
+/// caller to consume. On incomplete input, it stores the earliest offset that
162
+/// can still start `marker` and returns `Incomplete` without consuming input,
163
+/// so the next parse can avoid rescanning the whole buffered prefix.
164
///
165
/// Use this for outer parser states that keep the full buffered input across
166
/// chunks while waiting for a closing marker. Plain `take_until` is still a
167
/// better fit for one-shot parsers over a complete body, and for `1..` cases
168
/// where an empty slice before the marker should be rejected.
139
-pub fn take_until_marker<'i, 'a>(
140
- marker: &'a str,
169
+pub fn take_until_marker<'i, 'a, I: MarkerStream<'i>, M: MarkerLike + 'a>(
170
+ marker: M,
171
state: &'a mut MarkerScanState,
142
-) -> impl Parser<Partial<&'i str>, &'i str, ErrMode<ContextError>> + 'a {
143
- move |input: &mut Partial<&'i str>| take_until_marker_(input, marker, state)
172
+) -> impl Parser<I, &'i str, ErrMode<ContextError>> + 'a {
173
+ move |input: &mut I| take_until_marker_(input, &marker, state)
174
}
175
146
-fn take_until_marker_<'i>(
147
- input: &mut Partial<&'i str>,
148
- marker: &str,
176
+fn take_until_marker_<'i, I: MarkerStream<'i>, M: MarkerLike>(
177
+ input: &mut I,
178
+ marker: &M,
179
state: &mut MarkerScanState,
180
) -> ModalResult<&'i str> {
151
- debug_assert!(!marker.is_empty());
181
+ debug_assert!(!marker.as_marker().as_str().is_empty());
182
153
- let text = **input;
154
- if text.is_empty() {
183
+ if input.eof_offset() == 0 {
184
return incomplete();
185
}
186
158
- // Normal updates store a char boundary; this keeps stale or misused state from panicking.
159
- let scan_start = floor_char_boundary(text, state.scan_start);
160
-
161
- if let Some(offset) = text[scan_start..].find(marker) {
162
- let marker_start = scan_start + offset;
163
- let body = &text[..marker_start];
164
- input.next_slice(marker_start);
165
- state.reset();
166
- return Ok(body);
167
- }
168
-
169
- let keep_len = partial_prefix_len(text, marker);
170
- state.scan_start = text.len() - keep_len;
171
- incomplete()
172
-}
173
-
174
-fn floor_char_boundary(text: &str, index: usize) -> usize {
175
- let mut index = index.min(text.len());
176
- while !text.is_char_boundary(index) {
177
- index -= 1;
187
+ let start = input.offset();
188
+ match scan_markers(
189
+ input,
190
+ std::slice::from_ref(marker),
191
+ start + state.scan_start,
192
+ )? {
193
+ Scan::Found(at) => {
194
+ state.reset();
195
+ Ok(input.next_slice(at - start))
196
+ }
197
+ Scan::Pending(at) | Scan::Clear(at) => {
198
+ state.scan_start = at - start;
199
+ incomplete()
200
+ }
201
}
179
- index
202
}
203
204
/// Streaming lexical state for a top-level JSON object.
@@ -394,7 +416,26 @@ pub fn parse_buffered_event<E>(
416
buffer: &str,
417
parse: impl FnOnce(&mut Partial<&str>) -> ModalResult<E>,
418
) -> Result<Option<(E, usize)>> {
397
- let mut input = Partial::new(buffer);
419
+ parse_buffered_stream_event(Partial::new(buffer), parse)
420
+}
421
+
422
+/// Parse one event from a buffered [`DecodedText`], for token-aware parsers.
423
+///
424
+/// Same contract as [`parse_buffered_event`]; the input also carries the
425
+/// buffer's token anchors and the parser's `markers` ([`Attributed`]).
426
+pub fn parse_buffered_event_attributed<M: Debug, E>(
427
+ buffer: &DecodedText,
428
+ markers: &M,
429
+ parse: impl FnOnce(&mut Attributed<'_, M>) -> ModalResult<E>,
430
+) -> Result<Option<(E, usize)>> {
431
+ parse_buffered_stream_event(attributed_with_markers(buffer, markers), parse)
432
+}
433
+
434
+fn parse_buffered_stream_event<'i, I: MarkerStream<'i>, E>(
435
+ mut input: I,
436
+ parse: impl FnOnce(&mut I) -> ModalResult<E>,
437
+) -> Result<Option<(E, usize)>> {
438
+ let buffer = input.remaining();
439
let checkpoint = input.checkpoint();
440
let event = match parse(&mut input) {
441
Ok(event) => event,
rust/src/parser/src/utils/marker.rs
new
+660
@@ -0,0 +1,660 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+//! Token-aware marker matching for streaming parsers.
5
+//!
6
+//! Model protocols delimit channels with dedicated special tokens such as
7
+//! `<|close|>` and `<|sep|>`. Ordinary BPE tokens can decode to the very same
8
+//! spelling, so matching a marker by text alone mistakes model-written content
9
+//! for structure. The helpers here keep text as the parse axis and consult the
10
+//! generated-token attribution carried by [`DecodedText`] only where a marker's
11
+//! bytes must have come from a special token.
12
+//!
13
+//! - [`Attributed`] is a winnow input over one `DecodedText` buffer: the text
14
+//! plus its buffer-local token anchors, composed from winnow's own stream
15
+//! wrappers so every existing combinator keeps working.
16
+//! - [`Marker`] is a fixed marker string whose special-token segments are
17
+//! *guarded*: they match only when the expected special token produced them.
18
+//! A marker without guards matches by spelling alone, which is exactly what a
19
+//! plain `&str` marker is; [`MarkerRef`] is the borrowed view shared by both.
20
+//! One marker definition is both the parser that consumes the marker and the
21
+//! definition the safe-text scanner in the parent module stops in front of,
22
+//! so the two never disagree.
23
+//! - [`MarkerStream`] abstracts over `Partial<&str>` (text-only parsers) and
24
+//! [`Attributed`], letting one generic scanner serve both.
25
+
26
+use std::fmt::Debug;
27
+use std::ops::Range;
28
+
29
+use auto_impl::auto_impl;
30
+use educe::Educe;
31
+use vllm_tokenizer::{DecodedText, TokenAnchor, TokenAttribution};
32
+use winnow::Parser;
33
+use winnow::error::{ContextError, ErrMode, ModalResult};
34
+use winnow::stream::{
35
+ Compare, FindSlice, LocatingSlice, Location, Partial, Stateful, Stream, StreamIsPartial,
36
+};
37
+use winnow::token::literal;
38
+
39
+use super::partial_prefix_len;
40
+
41
+/// Streaming winnow input over one [`DecodedText`] buffer.
42
+///
43
+/// `LocatingSlice` reports the cursor's byte offset from the buffer start, which
44
+/// is the coordinate system of the anchors; `Partial` keeps the incomplete-input
45
+/// semantics streaming parsers rely on; `Stateful` carries the immutable
46
+/// [`AttributedState`]. winnow forwards `Compare`, `FindSlice`, `Location`, and
47
+/// `UpdateSlice` through all three, so `literal`, `alt`, `seq!`, `take_until`,
48
+/// and `rest` work unchanged.
49
+pub type Attributed<'i, M = ()> = Stateful<Partial<LocatingSlice<&'i str>>, AttributedState<'i, M>>;
50
+
51
+/// Immutable state of an [`Attributed`] input.
52
+#[derive(Debug, Educe)]
53
+#[educe(Clone, Copy)]
54
+pub struct AttributedState<'i, M = ()> {
55
+ /// Token anchors of the buffer, sorted by byte offset.
56
+ anchors: &'i [TokenAttribution],
57
+ /// The parser's markers; see [`AttributedExt::markers`].
58
+ markers: &'i M,
59
+}
60
+
61
+#[easy_ext::ext(AttributedExt)]
62
+pub impl<'i, M> Attributed<'i, M> {
63
+ /// The parser's markers, reachable from every grammar function through the
64
+ /// input instead of an extra parameter.
65
+ fn markers(&self) -> &'i M {
66
+ self.state.markers
67
+ }
68
+}
69
+
70
+/// Create an [`Attributed`] input over `buffer`, carrying `markers`.
71
+pub fn attributed_with_markers<'i, M>(
72
+ buffer: &'i DecodedText,
73
+ markers: &'i M,
74
+) -> Attributed<'i, M> {
75
+ Stateful {
76
+ input: Partial::new(LocatingSlice::new(buffer.text.as_str())),
77
+ state: AttributedState {
78
+ anchors: &buffer.attributions,
79
+ markers,
80
+ },
81
+ }
82
+}
83
+
84
+/// A `&str`-sliced streaming input that can say where markers may begin.
85
+///
86
+/// Implemented for `Partial<&str>` (text-only parsers, no anchors) and
87
+/// [`Attributed`].
88
+pub trait MarkerStream<'i>:
89
+ Stream<Slice = &'i str> + StreamIsPartial + Clone + for<'m> Compare<&'m str>
90
+{
91
+ /// Remaining input from the cursor.
92
+ fn remaining(&self) -> &'i str;
93
+
94
+ /// Byte offset of the cursor in the coordinate system of
95
+ /// [`Self::anchors`] and of resumable scan state.
96
+ ///
97
+ /// `Partial<&str>` tracks no origin and reports 0, so its offsets are
98
+ /// relative to the cursor at the time of the call; [`Attributed`] reports
99
+ /// the offset from the buffer start.
100
+ fn offset(&self) -> usize;
101
+
102
+ /// Token anchors of the buffer, sorted by byte offset; empty for text-only
103
+ /// input.
104
+ fn anchors(&self) -> &'i [TokenAttribution];
105
+
106
+ /// Earliest offset at or after `from` where one of `markers` may begin.
107
+ ///
108
+ /// A guarded marker can only begin at a visible anchor of its first special
109
+ /// token, so text-only input never yields one. Any other marker begins at a
110
+ /// complete occurrence of its spelling, or at a proper prefix of it that
111
+ /// ends the input.
112
+ fn next_candidate<M: MarkerLike>(&self, markers: &[M], from: usize) -> Option<usize> {
113
+ let start = self.offset();
114
+ let from = from.max(start);
115
+ let markers = || markers.iter().map(MarkerLike::as_marker);
116
+
117
+ let anchored = markers().any(|marker| marker.first_token_id().is_some()).then(|| {
118
+ visible_in(self.anchors(), from..usize::MAX).find_map(|(at, token_id)| {
119
+ markers().any(|marker| marker.first_token_id() == Some(token_id)).then_some(at)
120
+ })
121
+ });
122
+ let by_text = text_candidate(
123
+ self.remaining(),
124
+ markers()
125
+ .filter(|marker| marker.first_token_id().is_none())
126
+ .map(MarkerRef::as_str),
127
+ from - start,
128
+ )
129
+ .map(|at| start + at);
130
+
131
+ match (anchored.flatten(), by_text) {
132
+ (Some(anchored), Some(by_text)) => Some(anchored.min(by_text)),
133
+ (anchored, by_text) => anchored.or(by_text),
134
+ }
135
+ }
136
+}
137
+
138
+impl<'i> MarkerStream<'i> for Partial<&'i str> {
139
+ fn remaining(&self) -> &'i str {
140
+ **self
141
+ }
142
+
143
+ fn offset(&self) -> usize {
144
+ 0
145
+ }
146
+
147
+ fn anchors(&self) -> &'i [TokenAttribution] {
148
+ &[]
149
+ }
150
+}
151
+
152
+impl<'i, M: Debug> MarkerStream<'i> for Attributed<'i, M> {
153
+ fn remaining(&self) -> &'i str {
154
+ ****self
155
+ }
156
+
157
+ fn offset(&self) -> usize {
158
+ self.current_token_start()
159
+ }
160
+
161
+ fn anchors(&self) -> &'i [TokenAttribution] {
162
+ self.state.anchors
163
+ }
164
+}
165
+
166
+fn anchor_offset(anchor: TokenAnchor) -> usize {
167
+ match anchor {
168
+ TokenAnchor::Visible { byte_offset } | TokenAnchor::ZeroWidth { byte_offset } => {
169
+ byte_offset as usize
170
+ }
171
+ }
172
+}
173
+
174
+/// Visible anchors within `range`, as `(byte_offset, token_id)`, in generation
175
+/// order.
176
+fn visible_in(
177
+ anchors: &[TokenAttribution],
178
+ range: Range<usize>,
179
+) -> impl Iterator<Item = (usize, u32)> + '_ {
180
+ let first =
181
+ anchors.partition_point(|attribution| anchor_offset(attribution.anchor) < range.start);
182
+ anchors[first..]
183
+ .iter()
184
+ .take_while(move |attribution| anchor_offset(attribution.anchor) < range.end)
185
+ .filter_map(|attribution| match attribution.anchor {
186
+ TokenAnchor::Visible { byte_offset } => {
187
+ Some((byte_offset as usize, attribution.token_id))
188
+ }
189
+ TokenAnchor::ZeroWidth { .. } => None,
190
+ })
191
+}
192
+
193
+/// Return whether `range` is exactly the visible span of the single token
194
+/// `token_id`: one visible anchor at `range.start` with that ID and no other
195
+/// visible anchor inside. Zero-width records are ignored.
196
+///
197
+/// Special tokens never merge with neighbouring text, so this is the identity
198
+/// test for a special token's spelling. Anchors beyond the available input do
199
+/// not exist yet, so a partially arrived genuine spelling passes and is left to
200
+/// `literal` to report as incomplete.
201
+fn is_single_token(anchors: &[TokenAttribution], range: Range<usize>, token_id: u32) -> bool {
202
+ let mut visible = visible_in(anchors, range.clone());
203
+ visible.next() == Some((range.start, token_id)) && visible.next().is_none()
204
+}
205
+
206
+/// Earliest text position at or after `from` where one of `spellings` occurs
207
+/// in full, or where a proper prefix of one of them ends the input.
208
+fn text_candidate<'m>(
209
+ text: &str,
210
+ spellings: impl Iterator<Item = &'m str> + Clone,
211
+ from: usize,
212
+) -> Option<usize> {
213
+ // Round up: a marker starts on a char boundary at or after `from`, and a
214
+ // rejected candidate inside a multi-byte char must not be found again.
215
+ let from = text.ceil_char_boundary(from);
216
+ let tail = &text[from..];
217
+ if tail.is_empty() {
218
+ return None;
219
+ }
220
+ if let Some(at) = find_earliest(tail, spellings.clone()) {
221
+ return Some(from + at);
222
+ }
223
+ let keep_len = spellings.map(|spelling| partial_prefix_len(tail, spelling)).max().unwrap_or(0);
224
+ (keep_len > 0).then(|| text.len() - keep_len)
225
+}
226
+
227
+/// Earliest occurrence of any spelling in `text`.
228
+#[inline(always)]
229
+fn find_earliest<'m>(text: &str, mut spellings: impl Iterator<Item = &'m str>) -> Option<usize> {
230
+ let first = spellings.next()?;
231
+ let second = spellings.next();
232
+ let third = second.and_then(|_| spellings.next());
233
+ let fourth = third.and_then(|_| spellings.next());
234
+ // Use the fast specialized `FindSlice` impls for 1-3 spellings, and fall
235
+ // back to a linear scan for 4+.
236
+ let range = match (second, third, fourth) {
237
+ (None, _, _) => text.find_slice(first),
238
+ (Some(second), None, _) => text.find_slice((first, second)),
239
+ (Some(second), Some(third), None) => text.find_slice((first, second, third)),
240
+ (Some(second), Some(third), Some(fourth)) => {
241
+ return [first, second, third, fourth]
242
+ .into_iter()
243
+ .chain(spellings)
244
+ .filter_map(|spelling| text.find(spelling))
245
+ .min();
246
+ }
247
+ };
248
+ range.map(|range| range.start)
249
+}
250
+
251
+/// A special token's spelling and its ID in the active tokenizer, resolved
252
+/// together so a marker cannot pair one token's spelling with another's ID.
253
+#[derive(Clone, Debug, PartialEq, Eq)]
254
+pub struct SpecialToken {
255
+ pub text: String,
256
+ pub id: u32,
257
+}
258
+
259
+/// A fixed marker string whose special-token segments are guarded by token
260
+/// identity.
261
+///
262
+/// Build it once from tokenizer IDs, then use it both as the parser that
263
+/// consumes the marker (it implements [`Parser`] by reference) and as the
264
+/// definition the safe-text scanner stops in front of.
265
+///
266
+/// ```ignore
267
+/// let think_close = Marker::special(&close).then_text("think").then_special(&sep);
268
+/// ```
269
+#[derive(Clone, Debug, PartialEq, Eq)]
270
+pub struct Marker {
271
+ text: String,
272
+ guards: Vec<Guard>,
273
+}
274
+
275
+/// One special-token segment of a marker.
276
+#[derive(Clone, Debug, PartialEq, Eq)]
277
+struct Guard {
278
+ /// Byte range of the spelling within the marker text.
279
+ range: Range<usize>,
280
+ token_id: u32,
281
+}
282
+
283
+impl Marker {
284
+ /// A marker matched by spelling alone.
285
+ pub fn text(text: impl Into<String>) -> Self {
286
+ Self {
287
+ text: text.into(),
288
+ guards: Vec::new(),
289
+ }
290
+ }
291
+
292
+ /// A marker beginning with the special token `token`.
293
+ pub fn special(token: &SpecialToken) -> Self {
294
+ Self::text(String::new()).then_special(token)
295
+ }
296
+
297
+ /// Append ordinary text, matched by spelling.
298
+ #[must_use]
299
+ pub fn then_text(mut self, text: &str) -> Self {
300
+ self.text.push_str(text);
301
+ self
302
+ }
303
+
304
+ /// Append the special token `token`.
305
+ #[must_use]
306
+ pub fn then_special(mut self, token: &SpecialToken) -> Self {
307
+ let start = self.text.len();
308
+ self.text.push_str(&token.text);
309
+ self.guards.push(Guard {
310
+ range: start..self.text.len(),
311
+ token_id: token.id,
312
+ });
313
+ self
314
+ }
315
+
316
+ /// Drop the token-identity guards, so the marker matches by spelling alone,
317
+ /// for callers whose input carries no token attribution.
318
+ #[must_use]
319
+ pub fn without_guards(mut self) -> Self {
320
+ self.guards.clear();
321
+ self
322
+ }
323
+
324
+ /// The full spelling of the marker.
325
+ pub fn as_str(&self) -> &str {
326
+ &self.text
327
+ }
328
+}
329
+
330
+/// Anything the scanning helpers accept as a marker, viewed through
331
+/// [`MarkerRef`].
332
+///
333
+/// A plain `str` is a marker without guards, so text-only parsers keep passing
334
+/// `&[&str]` (or `&String`); token-aware parsers pass `&Marker`.
335
+#[auto_impl(&)]
336
+pub trait MarkerLike {
337
+ /// Borrow the marker's spelling and guards.
338
+ fn as_marker(&self) -> MarkerRef<'_>;
339
+}
340
+
341
+impl MarkerLike for str {
342
+ fn as_marker(&self) -> MarkerRef<'_> {
343
+ MarkerRef {
344
+ text: self,
345
+ guards: &[],
346
+ }
347
+ }
348
+}
349
+
350
+impl MarkerLike for String {
351
+ fn as_marker(&self) -> MarkerRef<'_> {
352
+ self.as_str().as_marker()
353
+ }
354
+}
355
+
356
+impl MarkerLike for Marker {
357
+ fn as_marker(&self) -> MarkerRef<'_> {
358
+ MarkerRef {
359
+ text: &self.text,
360
+ guards: &self.guards,
361
+ }
362
+ }
363
+}
364
+
365
+/// Borrowed view of a marker: its spelling and guarded segments.
366
+///
367
+/// A plain `&str` is a marker without guards; see [`MarkerLike`].
368
+#[derive(Clone, Copy, Debug)]
369
+pub struct MarkerRef<'m> {
370
+ text: &'m str,
371
+ guards: &'m [Guard],
372
+}
373
+
374
+impl<'m> MarkerRef<'m> {
375
+ /// The full spelling of the marker.
376
+ pub fn as_str(self) -> &'m str {
377
+ self.text
378
+ }
379
+
380
+ /// The special token the marker begins with, if it begins with one.
381
+ pub fn first_token_id(self) -> Option<u32> {
382
+ self.guards
383
+ .first()
384
+ .filter(|guard| guard.range.start == 0)
385
+ .map(|guard| guard.token_id)
386
+ }
387
+
388
+ /// Parse the marker at the cursor, returning its spelling.
389
+ ///
390
+ /// Every guard whose first byte is available is checked before the spelling:
391
+ /// when those bytes do not come from the expected special token the parser
392
+ /// backtracks at once instead of asking for more input, so an ordinary
393
+ /// partial lookalike streams as content immediately. A genuine special token
394
+ /// split by output holdback still reports incomplete, because its anchor
395
+ /// arrived with its first byte. Nothing is consumed on failure.
396
+ pub fn parse_next<'i, I: MarkerStream<'i>>(self, input: &mut I) -> ModalResult<&'i str> {
397
+ let start = input.offset();
398
+ let available = input.eof_offset();
399
+ let anchors = input.anchors();
400
+ for guard in self.guards.iter().take_while(|guard| guard.range.start < available) {
401
+ let range = start + guard.range.start..start + guard.range.end;
402
+ if !is_single_token(anchors, range, guard.token_id) {
403
+ return Err(ErrMode::Backtrack(ContextError::new()));
404
+ }
405
+ }
406
+ literal(self.text).parse_next(input)
407
+ }
408
+}
409
+
410
+impl<'i, I: MarkerStream<'i>> Parser<I, &'i str, ErrMode<ContextError>> for &Marker {
411
+ fn parse_next(&mut self, input: &mut I) -> ModalResult<&'i str> {
412
+ self.as_marker().parse_next(input)
413
+ }
414
+}
415
+
416
+#[cfg(test)]
417
+mod tests {
418
+ use vllm_tokenizer::{DecodedText, TokenAnchor, TokenAttribution};
419
+ use winnow::Parser;
420
+ use winnow::error::ErrMode;
421
+ use winnow::stream::{Partial, Stream};
422
+
423
+ use super::{
424
+ Attributed, Marker, MarkerLike, MarkerStream, SpecialToken, attributed_with_markers,
425
+ };
426
+ use crate::utils::safe_text_len_mul;
427
+
428
+ const CLOSE_ID: u32 = 256;
429
+ const SEP_ID: u32 = 258;
430
+
431
+ /// Build attributed text from `(piece, token_id)` pairs, one visible anchor
432
+ /// at the first byte of each piece.
433
+ fn pieces(pieces: &[(&str, u32)]) -> DecodedText {
434
+ let mut decoded = DecodedText::default();
435
+ for &(piece, token_id) in pieces {
436
+ decoded.attributions.push(TokenAttribution {
437
+ token_id,
438
+ anchor: TokenAnchor::Visible {
439
+ byte_offset: decoded.text.len() as u32,
440
+ },
441
+ });
442
+ decoded.text.push_str(piece);
443
+ }
444
+ decoded
445
+ }
446
+
447
+ fn token(text: &str, id: u32) -> SpecialToken {
448
+ SpecialToken {
449
+ text: text.to_string(),
450
+ id,
451
+ }
452
+ }
453
+
454
+ /// An input over `buffer` without parser markers.
455
+ fn attributed(buffer: &DecodedText) -> Attributed<'_> {
456
+ attributed_with_markers(buffer, &())
457
+ }
458
+
459
+ fn close() -> Marker {
460
+ Marker::special(&token("<|close|>", CLOSE_ID))
461
+ }
462
+
463
+ fn think_close() -> Marker {
464
+ close().then_text("think").then_special(&token("<|sep|>", SEP_ID))
465
+ }
466
+
467
+ /// `<|close|>` spelled by ordinary tokens.
468
+ fn ordinary_close() -> Vec<(&'static str, u32)> {
469
+ vec![("<", 60), ("|", 124), ("close", 900), ("|", 124), (">", 62)]
470
+ }
471
+
472
+ #[test]
473
+ fn special_marker_accepts_single_token_with_expected_id() {
474
+ let buffer = pieces(&[("<|close|>", CLOSE_ID), ("x", 1)]);
475
+ let mut input = attributed(&buffer);
476
+
477
+ let matched = (&close()).parse_next(&mut input).unwrap();
478
+
479
+ assert_eq!(matched, "<|close|>");
480
+ assert_eq!(input.remaining(), "x");
481
+ }
482
+
483
+ #[test]
484
+ fn special_marker_rejects_ordinary_tokens_with_same_spelling() {
485
+ let mut fixture = ordinary_close();
486
+ fixture.push(("x", 1));
487
+ let buffer = pieces(&fixture);
488
+ let mut input = attributed(&buffer);
489
+
490
+ let error = (&close()).parse_next(&mut input).unwrap_err();
491
+
492
+ assert!(matches!(error, ErrMode::Backtrack(_)));
493
+ assert_eq!(input.remaining(), "<|close|>x", "nothing consumed");
494
+ }
495
+
496
+ #[test]
497
+ fn special_marker_rejects_single_ordinary_token_with_wrong_id() {
498
+ let buffer = pieces(&[("<|close|>", 900)]);
499
+ let mut input = attributed(&buffer);
500
+
501
+ let error = (&close()).parse_next(&mut input).unwrap_err();
502
+
503
+ assert!(matches!(error, ErrMode::Backtrack(_)));
504
+ }
505
+
506
+ #[test]
507
+ fn special_marker_reports_incomplete_for_split_genuine_spelling() {
508
+ // Output holdback delivered only the first bytes; the anchor came with them.
509
+ let buffer = pieces(&[("<|clo", CLOSE_ID)]);
510
+ let mut input = attributed(&buffer);
511
+
512
+ let error = (&close()).parse_next(&mut input).unwrap_err();
513
+
514
+ assert!(matches!(error, ErrMode::Incomplete(_)));
515
+ }
516
+
517
+ #[test]
518
+ fn special_marker_rejects_partial_lookalike_without_waiting() {
519
+ let buffer = pieces(&[("<", 60), ("|", 124), ("clo", 901)]);
520
+ let mut input = attributed(&buffer);
521
+
522
+ let error = (&close()).parse_next(&mut input).unwrap_err();
523
+
524
+ assert!(matches!(error, ErrMode::Backtrack(_)));
525
+ }
526
+
527
+ #[test]
528
+ fn special_marker_ignores_zero_width_records_inside_span() {
529
+ let mut buffer = pieces(&[("<|close|>", CLOSE_ID)]);
530
+ buffer.attributions.push(TokenAttribution {
531
+ token_id: 7,
532
+ anchor: TokenAnchor::ZeroWidth { byte_offset: 3 },
533
+ });
534
+ let mut input = attributed(&buffer);
535
+
536
+ (&close()).parse_next(&mut input).unwrap();
537
+ }
538
+
539
+ #[test]
540
+ fn marker_without_guards_matches_spelling_only() {
541
+ let buffer = pieces(&ordinary_close());
542
+ let marker = close().without_guards();
543
+
544
+ (&marker).parse_next(&mut attributed(&buffer)).unwrap();
545
+ (&marker).parse_next(&mut Partial::new("<|close|>")).unwrap();
546
+ }
547
+
548
+ #[test]
549
+ fn guarded_marker_never_matches_text_only_input() {
550
+ let error = (&close()).parse_next(&mut Partial::new("<|close|>")).unwrap_err();
551
+
552
+ assert!(matches!(error, ErrMode::Backtrack(_)));
553
+ }
554
+
555
+ #[test]
556
+ fn marker_requires_every_special_segment() {
557
+ let marker = think_close();
558
+ let genuine = pieces(&[("<|close|>", CLOSE_ID), ("think", 5), ("<|sep|>", SEP_ID)]);
559
+ assert_eq!(
560
+ (&marker).parse_next(&mut attributed(&genuine)).unwrap(),
561
+ "<|close|>think<|sep|>"
562
+ );
563
+
564
+ // Real close, ordinary separator: the strict rule keeps it as content.
565
+ let mixed = pieces(&[("<|close|>", CLOSE_ID), ("think", 5), ("<|sep|>", 902)]);
566
+ let mut input = attributed(&mixed);
567
+ assert!(matches!(
568
+ (&marker).parse_next(&mut input).unwrap_err(),
569
+ ErrMode::Backtrack(_)
570
+ ));
571
+ assert_eq!(
572
+ input.remaining(),
573
+ "<|close|>think<|sep|>",
574
+ "nothing consumed"
575
+ );
576
+ }
577
+
578
+ #[test]
579
+ fn marker_first_token_id_requires_leading_special() {
580
+ assert_eq!(think_close().as_marker().first_token_id(), Some(CLOSE_ID));
581
+ assert_eq!(Marker::text("</think>").as_marker().first_token_id(), None);
582
+ assert_eq!(
583
+ Marker::text("x")
584
+ .then_special(&token("<|sep|>", SEP_ID))
585
+ .as_marker()
586
+ .first_token_id(),
587
+ None
588
+ );
589
+ assert_eq!("</think>".as_marker().first_token_id(), None);
590
+ }
591
+
592
+ #[test]
593
+ fn attributed_next_candidate_uses_anchors_for_guarded_markers() {
594
+ let mut fixture = vec![("say ", 1)];
595
+ fixture.extend(ordinary_close()); // lookalike at 4
596
+ fixture.extend([("think", 5), ("<|sep|>", 903), (" then ", 2)]);
597
+ let genuine_at = fixture.iter().map(|(piece, _)| piece.len()).sum::<usize>();
598
+ fixture.extend([("<|close|>", CLOSE_ID), ("think", 5), ("<|sep|>", SEP_ID)]);
599
+ let buffer = pieces(&fixture);
600
+ let input = attributed(&buffer);
601
+
602
+ let marker = think_close();
603
+ assert_eq!(input.next_candidate(&[&marker], 0), Some(genuine_at));
604
+ assert_eq!(input.next_candidate(&[&marker], genuine_at + 1), None);
605
+ }
606
+
607
+ #[test]
608
+ fn attributed_next_candidate_falls_back_to_text_for_unguarded_markers() {
609
+ let buffer = pieces(&[("a", 1), ("</think>", 2), ("b", 3)]);
610
+ let input = attributed(&buffer);
611
+
612
+ assert_eq!(input.next_candidate(&["</think>"], 0), Some(1));
613
+ assert_eq!(input.next_candidate(&["</thinking>"], 0), None);
614
+ // Partial spelling at the end of the input is a candidate too.
615
+ let buffer = pieces(&[("a", 1), ("</thi", 2)]);
616
+ let input = attributed(&buffer);
617
+ assert_eq!(input.next_candidate(&["</think>"], 0), Some(1));
618
+ }
619
+
620
+ #[test]
621
+ fn text_next_candidate_matches_previous_scan_semantics() {
622
+ let input = Partial::new("hello<not_marker><|tool");
623
+
624
+ assert_eq!(
625
+ input.next_candidate(&["<|tool_call>", "<|channel>thought\n"], 0),
626
+ Some("hello<not_marker>".len())
627
+ );
628
+ assert_eq!(input.next_candidate(&["<|tool_call>"], 18), None);
629
+ assert_eq!(
630
+ Partial::new("hello<channel|><|tool_call>")
631
+ .next_candidate(&["<|tool_call>", "<channel|>"], 0),
632
+ Some(5)
633
+ );
634
+ }
635
+
636
+ #[test]
637
+ fn scan_skips_rejected_text_candidate_starting_with_multibyte_char() {
638
+ // A marker whose leading text is found by spelling but whose guard then
639
+ // fails: the scan must move past the whole char, not retry inside it.
640
+ let marker = Marker::text("é").then_special(&token("<|sep|>", SEP_ID));
641
+ let buffer = pieces(&[("aé", 1), ("<", 60), ("|sep|>", 904), ("b", 2)]);
642
+ let mut input = attributed(&buffer);
643
+
644
+ let len = safe_text_len_mul(&mut input, &[&marker]).unwrap();
645
+
646
+ assert_eq!(len, buffer.text.len());
647
+ }
648
+
649
+ #[test]
650
+ fn attributed_offsets_follow_the_cursor() {
651
+ let buffer = pieces(&[("ab", 1), ("<|close|>", CLOSE_ID)]);
652
+ let mut input = attributed(&buffer);
653
+ input.next_slice(2);
654
+
655
+ assert_eq!(input.offset(), 2);
656
+ assert_eq!(input.remaining(), "<|close|>");
657
+ (&close()).parse_next(&mut input).unwrap();
658
+ assert_eq!(input.offset(), 11);
659
+ }
660
+}