[Rust Frontend] Extend renderer/parser roundtrip tests to support token ids (#47110)

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

Bugen Zhao committed Jun 30, 2026 at 21:27 UTC bdbd7278b6b169c2b188bc1e3f9541468a9e6f4b
2 files changed +160 -79
rust/src/chat/src/output/harmony/tests.rs
-14
@@ -1,14 +1,8 @@
1 -//! Harmony output tests share the upstream `openai-harmony` tiktoken cache.
2 -//!
3 -//! Use a file lock for tests that load the encoding so `cargo nextest` cannot
4 -//! start multiple processes that concurrently populate the same cache file.
5 -
1 use std::sync::Arc;
2
3 use futures::executor::block_on;
4 use futures::{TryStreamExt as _, stream};
5 use openai_harmony::chat::{Message, Role};
11 -use serial_test::file_serial;
6 use vllm_text::output::{DecodedLogprobs, DecodedPositionLogprobs, DecodedTextEvent, Finished};
7
8 use super::*;
@@ -91,7 +85,6 @@ fn request_with_tools() -> ChatRequest {
85 }
86
87 #[test]
94 -#[file_serial(harmony_tiktoken_cache)]
88 fn interrupted_final_message_is_preserved() {
89 let tokens = completion_tokens(&[text_message("final", "hello")]);
90 let events = block_on(collect_events(
@@ -127,7 +120,6 @@ fn interrupted_final_message_is_preserved() {
120 }
121
122 #[test]
130 -#[file_serial(harmony_tiktoken_cache)]
123 fn eos_flush_preserves_trailing_replacement_text() {
124 let mut tokens = completion_tokens(&[text_message("final", "Hi")]);
125 tokens.pop();
@@ -153,7 +145,6 @@ fn eos_flush_preserves_trailing_replacement_text() {
145 }
146
147 #[test]
156 -#[file_serial(harmony_tiktoken_cache)]
148 fn interrupted_analysis_message_is_preserved() {
149 let tokens = completion_tokens(&[text_message("analysis", "think")]);
150 let events = block_on(collect_events(
@@ -189,7 +180,6 @@ fn interrupted_analysis_message_is_preserved() {
180 }
181
182 #[test]
192 -#[file_serial(harmony_tiktoken_cache)]
183 fn commentary_preamble_is_visible_but_commentary_tool_payload_is_not() {
184 let tokens = completion_tokens(&[
185 text_message("commentary", "Let me check."),
@@ -217,7 +207,6 @@ fn commentary_preamble_is_visible_but_commentary_tool_payload_is_not() {
207 }
208
209 #[test]
220 -#[file_serial(harmony_tiktoken_cache)]
210 fn multiple_messages_get_newline_separators() {
211 let tokens = completion_tokens(&[
212 text_message("analysis", "first think"),
@@ -249,7 +238,6 @@ fn multiple_messages_get_newline_separators() {
238 }
239
240 #[test]
252 -#[file_serial(harmony_tiktoken_cache)]
241 fn tool_calls_stream_arguments_and_finish_with_local_id_shape() {
242 let tokens = completion_tokens(&[tool_message(
243 "get_weather",
@@ -302,7 +290,6 @@ fn tool_calls_stream_arguments_and_finish_with_local_id_shape() {
290 }
291
292 #[test]
305 -#[file_serial(harmony_tiktoken_cache)]
293 fn semantic_events_precede_same_update_logprobs() {
294 let tokens = completion_tokens(&[text_message("final", "hello")]);
295 let events = block_on(collect_events(
@@ -353,7 +340,6 @@ fn rejects_generic_parser_overrides() {
340 }
341
342 #[test]
356 -#[file_serial(harmony_tiktoken_cache)]
343 fn allows_auto_auto_only() {
344 validate_harmony_parser_overrides(&ParserSelection::Auto, &ParserSelection::Auto).unwrap();
345 let _ = HarmonyChatOutputProcessor::new(&ChatRequest::for_test()).unwrap();
rust/src/chat/tests/roundtrip.rs
+160 -65
@@ -1,8 +1,8 @@
1 -//! Text-level roundtrip tests for the real chat-template and output-processor pairing.
1 +//! Roundtrip tests for the real chat-template and output-processor pairing.
2 //!
3 //! The invariant under test is that a structured assistant message rendered as history can be
4 //! parsed from the generated assistant completion and then rendered back to the exact same
5 -//! assistant-completion text.
5 +//! assistant completion.
6
7 use std::pin::Pin;
8 use std::sync::Arc;
@@ -18,6 +18,10 @@ use vllm_chat::{
18 RendererSelection, load_model_backends,
19 };
20 use vllm_text::{DecodedTextEvent, Finished, Prompt};
21 +use vllm_tokenizer::Tokenizer;
22 +
23 +const TEXT_COMPLETION_CHUNK_CHARS: usize = 7;
24 +const TOKEN_COMPLETION_CHUNK_TOKENS: usize = 1;
25
26 /// One model/parser configuration used to run the fixed roundtrip fixtures.
27 #[derive(Clone)]
@@ -191,14 +195,28 @@ impl RoundtripCase {
195 sort_json_keys: false,
196 }
197 }
198 +
199 + /// GPT-OSS Harmony token-id renderer and native Harmony output processor.
200 + fn gpt_oss() -> Self {
201 + Self {
202 + model_id: "openai/gpt-oss-20b",
203 + assistant_stop_suffix: "", // not applicable for token-id cases
204 + tool_call_parser: ParserSelection::Auto,
205 + reasoning_parser: ParserSelection::Auto,
206 + thinking_behavior: ThinkingBehavior::Always { value: true },
207 + json_fmt: compact_json_fmt(),
208 + sort_json_keys: false,
209 + }
210 + }
211 }
212
213 macro_rules! roundtrip_tests {
197 - ($($case:ident => [$($fixture:ident),* $(,)?]),+ $(,)?) => {
214 + ($($case:ident => [$($(#[$fixture_attr:meta])* $fixture:ident),* $(,)?]),+ $(,)?) => {
215 paste::paste! {
216 $(
217 $(
218 #[tokio::test]
219 + $(#[$fixture_attr])*
220 #[file_serial([<hf_ $case>])]
221 async fn [<roundtrip_ $case _ $fixture>]() -> Result<()> {
222 [<run_roundtrip_ $fixture>](RoundtripCase::$case()).await
@@ -217,9 +235,9 @@ roundtrip_tests! {
235 glm47 => [reasoning_and_content, tool_call_mix],
236 seed_oss => [reasoning_and_content],
237 step3p5 => [reasoning_and_content],
220 -
238 gemma4 => [tool_call_mix], // Gemma4 strips reasoning in history if there's no tool call
239 kimi_k25 => [tool_call_mix], // Kimi K2.5 strips reasoning in history
240 + gpt_oss => [tool_call_mix], // Harmony strips reasoning in history if there's no tool call
241 }
242
243 /// Run the fixed reasoning+content fixture for one model/parser case.
@@ -421,10 +439,10 @@ struct RoundtripResult {
439 parsed_message: AssistantMessage,
440 /// Assistant-completion suffix cut from rendering the expected assistant as
441 /// history.
424 - closed_completion: String,
442 + closed_completion: Prompt,
443 /// Assistant-completion suffix cut after rendering the parsed assistant
444 /// back as history.
427 - rerendered_closed_completion: String,
445 + rerendered_closed_completion: Prompt,
446 }
447
448 /// Render, parse, and rerender one assistant turn through the production
@@ -436,60 +454,59 @@ async fn run_roundtrip(
454 assistant: AssistantMessage,
455 ) -> Result<RoundtripResult> {
456 let renderer = backends.chat_backend.chat_renderer();
439 - let (prompt, closed_completion_text) =
440 - render_closed_completion(renderer.as_ref(), request, &assistant)?;
441 - let completion_body = closed_completion_text
442 - .strip_suffix(case.assistant_stop_suffix)
443 - .with_context(|| {
444 - format!(
445 - "closed assistant completion did not end with {:?}: {:?}",
446 - case.assistant_stop_suffix, closed_completion_text
447 - )
448 - })?;
449 -
450 - let parsed_message =
451 - parse_completion(case, backends, request, &prompt, completion_body).await?;
452 - let (_, rerendered_closed_completion) =
453 - render_closed_completion(renderer.as_ref(), request, &parsed_message)?;
457 + let rendered = render_closed_completion(renderer.as_ref(), request, &assistant)?;
458 +
459 + let parsed_message = parse_completion(case, backends, request, &rendered).await?;
460 + let rerendered = render_closed_completion(renderer.as_ref(), request, &parsed_message)?;
461
462 Ok(RoundtripResult {
463 parsed_message,
457 - closed_completion: closed_completion_text,
458 - rerendered_closed_completion,
464 + closed_completion: rendered.completion,
465 + rerendered_closed_completion: rerendered.completion,
466 })
467 }
468
469 +/// Rendered prompt/completion artifacts at the renderer boundary.
470 +struct RenderedTurn {
471 + prompt: Prompt,
472 + completion: Prompt,
473 +}
474 +
475 /// Render `history` as a production prompt and `history + assistant` as closed
476 /// history, then return the production prompt and assistant-completion suffix.
477 fn render_closed_completion(
478 renderer: &dyn vllm_chat::ChatRenderer,
479 base_request: &ChatRequest,
480 assistant: &AssistantMessage,
468 -) -> Result<(String, String)> {
481 +) -> Result<RenderedTurn> {
482 let mut prompt_request = base_request.clone();
483 prompt_request.chat_options.generation_prompt_mode = GenerationPromptMode::StartNewAssistant;
471 - let prompt = render_text(renderer, &prompt_request).context("failed to render prompt")?;
484 + let prompt = renderer.render(&prompt_request).context("failed to render prompt")?.prompt;
485
486 let mut full_request = base_request.clone();
487 full_request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
488 full_request.messages.push(ChatMessage::from(assistant.clone()));
476 - let full = render_text(renderer, &full_request).context("failed to render full prompt")?;
477 -
478 - ensure!(
479 - full.starts_with(&prompt),
480 - "full prompt must extend production prompt\nprompt: {prompt:?}\nfull: {full:?}"
481 - );
482 - let completion = full[prompt.len()..].to_string();
483 -
484 - Ok((prompt, completion))
485 -}
489 + let full = renderer.render(&full_request).context("failed to render full prompt")?.prompt;
490 +
491 + let completion = match (&prompt, full) {
492 + (Prompt::Text(prompt), Prompt::Text(full)) => {
493 + ensure!(
494 + full.starts_with(prompt),
495 + "full prompt must extend production prompt\nprompt: {prompt:?}\nfull: {full:?}"
496 + );
497 + Prompt::Text(full[prompt.len()..].to_string())
498 + }
499 + (Prompt::TokenIds(prompt), Prompt::TokenIds(full)) => {
500 + ensure!(
501 + full.starts_with(prompt),
502 + "full prompt must extend production prompt\nprompt: {prompt:?}\nfull: {full:?}"
503 + );
504 + Prompt::TokenIds(full[prompt.len()..].to_vec())
505 + }
506 + (prompt, full) => bail!("prompt kind changed between renders: {prompt:?} vs {full:?}"),
507 + };
508
487 -/// Render one chat request and require a text prompt.
488 -fn render_text(renderer: &dyn vllm_chat::ChatRenderer, request: &ChatRequest) -> Result<String> {
489 - match renderer.render(request)?.prompt {
490 - Prompt::Text(text) => Ok(text),
491 - other => bail!("roundtrip tests expect text prompts, got {other:?}"),
492 - }
509 + Ok(RenderedTurn { prompt, completion })
510 }
511
512 /// Feed one rendered assistant completion body into the real output processor
@@ -498,13 +515,15 @@ async fn parse_completion(
515 case: &RoundtripCase,
516 backends: &vllm_chat::LoadedModelBackends,
517 base_request: &ChatRequest,
501 - prompt: &str,
502 - completion_body: &str,
518 + rendered: &RenderedTurn,
519 ) -> Result<AssistantMessage> {
520 let tokenizer = backends.text_backend.tokenizer();
505 - let prompt_token_ids = tokenizer
506 - .encode(prompt, base_request.add_special_tokens)
507 - .context("failed to encode rendered prompt")?;
521 + let prompt_token_ids = match &rendered.prompt {
522 + Prompt::Text(prompt) => tokenizer
523 + .encode(prompt, base_request.add_special_tokens)
524 + .context("failed to encode rendered prompt")?,
525 + Prompt::TokenIds(token_ids) => token_ids.clone(),
526 + };
527
528 let mut request = base_request.clone();
529 let processor = backends.chat_backend.new_chat_output_processor(
@@ -515,7 +534,12 @@ async fn parse_completion(
534 },
535 )?;
536
518 - let decoded = decoded_completion_stream(prompt_token_ids, completion_body);
537 + let decoded = decoded_completion_stream(
538 + tokenizer.as_ref(),
539 + prompt_token_ids,
540 + &rendered.completion,
541 + case.assistant_stop_suffix,
542 + )?;
543 let mut events = processor.process(decoded)?;
544
545 while let Some(event) = events.next().await {
@@ -538,16 +562,46 @@ async fn parse_completion(
562 /// split into small chunks to exercise streaming parser state across marker
563 /// and JSON boundaries.
564 fn decoded_completion_stream(
565 + tokenizer: &dyn Tokenizer,
566 prompt_token_ids: Vec<u32>,
542 - completion_body: &str,
543 -) -> Pin<Box<dyn Stream<Item = vllm_chat::Result<DecodedTextEvent>> + Send>> {
544 - let prompt_token_count = prompt_token_ids.len();
567 + completion: &Prompt,
568 + assistant_stop_suffix: &str,
569 +) -> Result<Pin<Box<dyn Stream<Item = vllm_chat::Result<DecodedTextEvent>> + Send>>> {
570 let mut events = vec![DecodedTextEvent::Start {
546 - prompt_token_ids: Arc::from(prompt_token_ids.into_boxed_slice()),
571 + prompt_token_ids: Arc::from(prompt_token_ids.clone().into_boxed_slice()),
572 prompt_logprobs: None,
573 }];
574
550 - let chunks = split_by_chars(completion_body, 7);
575 + let chunks = match completion {
576 + Prompt::Text(text) => {
577 + let body = text.strip_suffix(assistant_stop_suffix).with_context(|| {
578 + format!(
579 + "closed assistant completion did not end with {:?}: {:?}",
580 + assistant_stop_suffix, text
581 + )
582 + })?;
583 + split_by_chars(body, TEXT_COMPLETION_CHUNK_CHARS)
584 + .into_iter()
585 + .map(|delta| DecodedCompletionChunk {
586 + delta,
587 + token_ids: Vec::new(), // unused for text-level roundtrip cases
588 + })
589 + .collect()
590 + }
591 + Prompt::TokenIds(token_ids) => {
592 + ensure!(
593 + assistant_stop_suffix.is_empty(),
594 + "token-id roundtrip cases do not support text stop suffixes"
595 + );
596 + incremental_decode_chunks(
597 + tokenizer,
598 + &prompt_token_ids,
599 + token_ids,
600 + TOKEN_COMPLETION_CHUNK_TOKENS,
601 + )?
602 + }
603 + };
604 +
605 if chunks.is_empty() {
606 events.push({
607 DecodedTextEvent::TextDelta {
@@ -555,11 +609,7 @@ fn decoded_completion_stream(
609 token_ids: Vec::new(),
610 logprobs: None,
611 finished: Some(Finished {
558 - usage: vllm_llm::TokenUsage {
559 - prompt_token_count: 0,
560 - output_token_count: 0,
561 - cached_token_count: 0,
562 - },
612 + usage: Default::default(),
613 finish_reason: FinishReason::stop_eos(),
614 kv_transfer_params: None,
615 }),
@@ -569,24 +619,26 @@ fn decoded_completion_stream(
619 let last_index = chunks.len() - 1;
620 for (index, chunk) in chunks.into_iter().enumerate() {
621 let finished = (index == last_index).then(|| Finished {
572 - usage: vllm_llm::TokenUsage {
573 - prompt_token_count,
574 - output_token_count: completion_body.chars().count(),
575 - cached_token_count: 0,
576 - },
622 + usage: Default::default(),
623 finish_reason: FinishReason::stop_eos(),
624 kv_transfer_params: None,
625 });
626 events.push(DecodedTextEvent::TextDelta {
581 - delta: chunk,
582 - token_ids: Vec::new(),
627 + delta: chunk.delta,
628 + token_ids: chunk.token_ids,
629 logprobs: None,
630 finished,
631 });
632 }
633 }
634
589 - stream::iter(events).map(Ok).boxed()
635 + Ok(stream::iter(events).map(Ok).boxed())
636 +}
637 +
638 +/// One decoded completion chunk fed into the output processor.
639 +struct DecodedCompletionChunk {
640 + delta: String,
641 + token_ids: Vec<u32>,
642 }
643
644 /// Split text into chunks containing at most `chunk_chars` Unicode scalar
@@ -612,6 +664,49 @@ fn split_by_chars(text: &str, chunk_chars: usize) -> Vec<String> {
664 chunks
665 }
666
667 +/// Split token ids into chunks containing at most `chunk_size` ids.
668 +fn split_by_count(token_ids: &[u32], chunk_size: usize) -> Vec<Vec<u32>> {
669 + token_ids.chunks(chunk_size).map(<[u32]>::to_vec).collect()
670 +}
671 +
672 +/// Decode token ids incrementally using the production tokenizer stream.
673 +fn incremental_decode_chunks(
674 + tokenizer: &dyn Tokenizer,
675 + prompt_token_ids: &[u32],
676 + token_ids: &[u32],
677 + chunk_size: usize,
678 +) -> Result<Vec<DecodedCompletionChunk>> {
679 + let mut decoder = tokenizer.create_decode_stream(prompt_token_ids, false, 0);
680 + let mut chunks = Vec::new();
681 + for chunk_token_ids in split_by_count(token_ids, chunk_size) {
682 + let mut delta = String::new();
683 + for token_id in chunk_token_ids.iter().copied() {
684 + decoder.push_token(token_id)?;
685 + while let Some(chunk) = decoder.next_chunk() {
686 + delta.push_str(&chunk);
687 + }
688 + }
689 + chunks.push(DecodedCompletionChunk {
690 + delta,
691 + token_ids: chunk_token_ids,
692 + });
693 + }
694 +
695 + let (last_chunk, _) = decoder.flush(None)?;
696 + if let Some(last_chunk) = last_chunk {
697 + if let Some(delta) = chunks.last_mut() {
698 + delta.delta.push_str(&last_chunk);
699 + } else {
700 + chunks.push(DecodedCompletionChunk {
701 + delta: last_chunk,
702 + token_ids: Vec::new(),
703 + });
704 + }
705 + }
706 +
707 + Ok(chunks)
708 +}
709 +
710 /// Build a chat request fixture with parser-enabling tool-choice semantics.
711 fn roundtrip_request(
712 request_id: impl Into<String>,