[Rust Frontend] add repetition_detection support to sampling params (#46684)

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

yangyang-cs95 committed Jul 3, 2026 at 14:06 UTC 6429d5f527f57d4a88b79b7fda2fcdd32f4a0e13
14 files changed +177 -20
rust/src/engine-core-client/src/protocol/sampling.rs
+29
@@ -21,6 +21,30 @@ fn default_max_tokens() -> u32 {
21 16
22 }
23
24 +///
25 +/// Parameters for detecting repetitive N-gram patterns in output tokens.
26 +///
27 +/// Mirrors Python's `RepetitionDetectionParams`:
28 +/// <https://github.com/vllm-project/vllm/blob/f22d6e026798a74e6542a52ef776c054f2de572a/vllm/sampling_params.py#L109-L144>
29 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30 +pub struct RepetitionDetectionParams {
31 + /// Maximum N-gram size to check. 0 disables detection.
32 + pub max_pattern_size: u32,
33 + /// Minimum N-gram size to check. Defaults to 1 when zero.
34 + #[serde(default)]
35 + pub min_pattern_size: u32,
36 + /// Minimum number of repetitions to trigger detection (must be >= 2).
37 + pub min_count: u32,
38 +}
39 +
40 +impl RepetitionDetectionParams {
41 + /// Return `true` when the params are effectively disabled (max_pattern_size
42 + /// is 0).
43 + pub fn is_disabled(&self) -> bool {
44 + self.max_pattern_size == 0
45 + }
46 +}
47 +
48 /// Engine-core-facing sampling parameters for text generation.
49 ///
50 /// This is the normalized southbound subset used by the Rust frontend when it
@@ -76,6 +100,9 @@ pub struct EngineCoreSamplingParams {
100 /// Repetition penalty applied by the sampler.
101 #[serde(default = "default_repetition_penalty")]
102 pub repetition_penalty: f32,
103 + /// Parameters for detecting repetitive N-gram patterns. `None` disables
104 + /// detection.
105 + pub repetition_detection: Option<RepetitionDetectionParams>,
106 /// Token IDs that stop generation.
107 pub stop_token_ids: Vec<u32>,
108 /// Primary EOS token ID used by engine-core's dedicated EOS stop path.
@@ -134,6 +161,7 @@ impl EngineCoreSamplingParams {
161 frequency_penalty: 0.0,
162 presence_penalty: 0.0,
163 repetition_penalty: 1.0,
164 + repetition_detection: None,
165 stop_token_ids: Vec::new(),
166 eos_token_id: None,
167 all_stop_token_ids: BTreeSet::new(),
@@ -203,6 +231,7 @@ mod tests {
231 assert_eq!(sampling.frequency_penalty, 0.0);
232 assert_eq!(sampling.presence_penalty, 0.0);
233 assert_eq!(sampling.repetition_penalty, 1.0);
234 + assert_eq!(sampling.repetition_detection, None);
235 assert_eq!(sampling.logprobs, None);
236 assert_eq!(sampling.prompt_logprobs, None);
237 assert_eq!(sampling.eos_token_id, None);
rust/src/engine-core-client/src/tests/client.rs
+1
@@ -2462,6 +2462,7 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2462 frequency_penalty: 0.0,
2463 presence_penalty: 0.0,
2464 repetition_penalty: 1.0,
2465 + repetition_detection: None,
2466 stop_token_ids: Vec::new(),
2467 eos_token_id: None,
2468 all_stop_token_ids: BTreeSet::new(),
rust/src/llm/src/output.rs
+5 -3
@@ -69,7 +69,7 @@ pub enum FinishReason {
69 /// A retryable request-level internal error occurred.
70 Error,
71 /// A repetitive token pattern was detected.
72 - Repetition,
72 + Repetition(Option<StopReason>),
73 }
74
75 impl FinishReason {
@@ -87,7 +87,7 @@ impl FinishReason {
87 Self::Length => "length",
88 Self::Abort => "abort",
89 Self::Error => "error",
90 - Self::Repetition => "repetition",
90 + Self::Repetition(_) => "repetition",
91 }
92 }
93
@@ -96,6 +96,7 @@ impl FinishReason {
96 pub fn as_stop_reason(&self) -> Option<&StopReason> {
97 match self {
98 Self::Stop(stop_reason) => stop_reason.as_ref(),
99 + Self::Repetition(stop_reason) => stop_reason.as_ref(),
100 _ => None,
101 }
102 }
@@ -105,6 +106,7 @@ impl FinishReason {
106 pub fn into_stop_reason(self) -> Option<StopReason> {
107 match self {
108 Self::Stop(stop_reason) => stop_reason,
109 + Self::Repetition(stop_reason) => stop_reason,
110 _ => None,
111 }
112 }
@@ -119,7 +121,7 @@ fn finish_reason_from_engine(
121 EngineCoreFinishReason::Length => FinishReason::Length,
122 EngineCoreFinishReason::Abort => FinishReason::Abort,
123 EngineCoreFinishReason::Error => FinishReason::Error,
122 - EngineCoreFinishReason::Repetition => FinishReason::Repetition,
124 + EngineCoreFinishReason::Repetition => FinishReason::Repetition(stop_reason),
125 })
126 }
127
rust/src/server/src/grpc/convert.rs
+1 -1
@@ -332,7 +332,7 @@ fn to_finish_info(finished: &Finished, token_ids: &[u32]) -> pb::FinishInfo {
332 (PbFinishReason::Stop as i32, sr)
333 }
334 FinishReason::Length => (PbFinishReason::Length as i32, None),
335 - FinishReason::Abort | FinishReason::Error | FinishReason::Repetition => {
335 + FinishReason::Abort | FinishReason::Error | FinishReason::Repetition(_) => {
336 (PbFinishReason::Aborted as i32, None)
337 }
338 };
rust/src/server/src/routes/openai/chat_completions.rs
+1 -1
@@ -805,7 +805,7 @@ fn chat_finish_reason_to_openai(
805 FinishReason::Stop(_) => Ok("stop"),
806 FinishReason::Length => Ok("length"),
807 FinishReason::Abort => Ok("abort"),
808 - FinishReason::Repetition => Ok("stop"),
808 + FinishReason::Repetition(_) => Ok("repetition"),
809 FinishReason::Error => {
810 bail_server_error!("Internal server error");
811 }
rust/src/server/src/routes/openai/chat_completions/convert.rs
+1
@@ -122,6 +122,7 @@ pub(super) fn prepare_chat_request(
122 frequency_penalty: request.frequency_penalty,
123 presence_penalty: request.presence_penalty,
124 repetition_penalty: request.repetition_penalty,
125 + repetition_detection: request.repetition_detection,
126 stop_token_ids: request.stop_token_ids,
127 ignore_eos: request.ignore_eos,
128 logit_bias: convert_logit_bias(request.logit_bias)?,
rust/src/server/src/routes/openai/chat_completions/types.rs
+2 -1
@@ -6,6 +6,7 @@ use serde_json::Value;
6 use serde_with::SerializeDisplay;
7 use validator::Validate;
8 use vllm_chat::ReasoningEffort;
9 +use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams;
10
11 use crate::routes::openai::utils::structured_outputs::ResponseFormat;
12 use crate::routes::openai::utils::types::{
@@ -236,7 +237,7 @@ pub struct ChatCompletionRequest {
237 pub vllm_xargs: Option<HashMap<String, Value>>,
238
239 /// Parameters for detecting repetitive N-gram patterns in output tokens
239 - pub repetition_detection: Option<Value>,
240 + pub repetition_detection: Option<RepetitionDetectionParams>,
241 }
242
243 impl Default for ChatCompletionRequest {
rust/src/server/src/routes/openai/chat_completions/validate.rs
-5
@@ -94,11 +94,6 @@ pub(super) fn validate_request_compat(
94 "mm_processor_kwargs",
95 "mm_processor_kwargs is not supported.",
96 )?;
97 - reject_non_default(
98 - request.repetition_detection.as_ref(),
99 - "repetition_detection",
100 - "repetition_detection is not supported.",
101 - )?;
97
98 Ok(())
99 }
rust/src/server/src/routes/openai/completions.rs
+32 -8
@@ -14,9 +14,11 @@ use axum::http::HeaderMap;
14 use axum::response::sse::{Event, Sse};
15 use axum::response::{IntoResponse, Response};
16 use futures::{Stream, StreamExt as _, pin_mut};
17 +use serde_json::Value;
18 use thiserror_ext::AsReport as _;
19 use tracing::{debug, error, info, trace};
20 use tracing_futures::Instrument as _;
21 +use vllm_engine_core_client::protocol::output::StopReason;
22 use vllm_text::{
23 DecodedPromptLogprobs, DecodedTextEvent, FinishReason, TextOutputStream,
24 TextOutputStreamExt as _,
@@ -142,9 +144,7 @@ async fn collect_completion(
144 .await
145 .map_err(|error| server_error!("completion stream failed: {}", error.to_report_string()))?;
146 let finish_reason = collected.finish_reason.clone();
145 - let stop_reason = finish_reason
146 - .as_stop_reason()
147 - .map(|sr| serde_json::to_value(sr).expect("StopReason must serialize to JSON"));
147 + let stop_reason = finish_reason.as_stop_reason().map(stop_reason_to_json);
148
149 let prompt_char_count = echo.as_ref().map(|prompt| text_len(prompt)).unwrap_or_default();
150 let logprobs = if requested_logprobs.is_some() && prompt_only {
@@ -181,7 +181,7 @@ async fn collect_completion(
181 Some(prompt) if prompt_only => prompt.clone(),
182 Some(prompt) => format!("{prompt}{}", collected.text),
183 };
184 - let finish_reason = completion_finish_reason_to_openai(finish_reason)?.to_string();
184 + let finish_reason = completion_finish_reason_to_openai(&finish_reason)?.to_string();
185 let usage = Usage::from_token_usage(collected.usage, enable_prompt_tokens_details);
186
187 if enable_log_requests {
@@ -441,21 +441,24 @@ fn final_chunk(
441 created: u64,
442 finish_reason: FinishReason,
443 ) -> Result<CompletionStreamResponse, ApiError> {
444 - let finish_reason = completion_finish_reason_to_openai(finish_reason)?;
444 + let stop_reason = finish_reason.as_stop_reason().map(stop_reason_to_json);
445 + let finish_reason = completion_finish_reason_to_openai(&finish_reason)?;
446
447 let mut chunk = CompletionStreamResponse::new(request_id, response_model, created);
448 chunk.choices.push(CompletionStreamChoice {
449 finish_reason: Some(finish_reason.to_string()),
450 + stop_reason,
451 ..Default::default()
452 });
453 Ok(chunk)
454 }
455
456 fn completion_finish_reason_to_openai(
455 - finish_reason: FinishReason,
457 + finish_reason: &FinishReason,
458 ) -> Result<&'static str, ApiError> {
459 match finish_reason {
458 - FinishReason::Stop(_) | FinishReason::Repetition => Ok("stop"),
460 + FinishReason::Stop(_) => Ok("stop"),
461 + FinishReason::Repetition(_) => Ok("repetition"),
462 FinishReason::Length => Ok("length"),
463 FinishReason::Abort => Ok("abort"),
464 FinishReason::Error => {
@@ -464,6 +467,10 @@ fn completion_finish_reason_to_openai(
467 }
468 }
469
470 +fn stop_reason_to_json(stop_reason: &StopReason) -> Value {
471 + serde_json::to_value(stop_reason).expect("StopReason must serialize to JSON")
472 +}
473 +
474 fn prompt_only_logprobs_to_openai(
475 prompt_logprobs: Option<&DecodedPromptLogprobs>,
476 prompt: &str,
@@ -577,6 +584,7 @@ fn done_sse_event() -> Event {
584 mod tests {
585 use futures::{StreamExt as _, stream};
586 use itertools::Itertools as _;
587 + use vllm_engine_core_client::protocol::output::StopReason;
588 use vllm_text::{
589 DecodedLogprobs, DecodedPositionLogprobs, DecodedPromptLogprobs, DecodedTextEvent,
590 DecodedTokenLogprob, FinishReason, Finished,
@@ -670,7 +678,9 @@ mod tests {
678 output_token_count: 2,
679 cached_token_count: 3,
680 },
673 - finish_reason: FinishReason::stop_eos(),
681 + finish_reason: FinishReason::Repetition(Some(StopReason::Text(
682 + "repetition_detected".to_string(),
683 + ))),
684 kv_transfer_params: None,
685 }),
686 }),
@@ -726,6 +736,20 @@ mod tests {
736 CompletionSseChunk::Usage(_) => panic!("expected regular chunk"),
737 }
738
739 + match &chunks[2] {
740 + CompletionSseChunk::Chunk(chunk) => {
741 + assert_eq!(
742 + chunk.choices[0].finish_reason.as_deref(),
743 + Some("repetition")
744 + );
745 + assert_eq!(
746 + chunk.choices[0].stop_reason,
747 + Some(serde_json::json!("repetition_detected"))
748 + );
749 + }
750 + CompletionSseChunk::Usage(_) => panic!("expected regular chunk"),
751 + }
752 +
753 match &chunks[3] {
754 CompletionSseChunk::Usage(chunk) => {
755 assert_eq!(
rust/src/server/src/routes/openai/completions/convert.rs
+1
@@ -117,6 +117,7 @@ pub(super) fn prepare_completion_request(
117 frequency_penalty: request.frequency_penalty,
118 presence_penalty: request.presence_penalty,
119 repetition_penalty: request.repetition_penalty,
120 + repetition_detection: request.repetition_detection,
121 stop_token_ids: request.stop_token_ids,
122 ignore_eos: request.ignore_eos,
123 logit_bias: convert_logit_bias(request.logit_bias)?,
rust/src/server/src/routes/openai/completions/types.rs
+4
@@ -3,6 +3,7 @@ use std::collections::HashMap;
3 use serde::{Deserialize, Serialize};
4 use serde_json::{Map, Value};
5 use validator::Validate;
6 +use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams;
7 use vllm_text::Prompt;
8
9 use crate::routes::openai::utils::types::{
@@ -100,6 +101,9 @@ pub struct CompletionRequest {
101 /// Repetition penalty for reducing repetitive text
102 pub repetition_penalty: Option<f32>,
103
104 + /// Parameters for detecting repetitive N-gram patterns in output tokens
105 + pub repetition_detection: Option<RepetitionDetectionParams>,
106 +
107 /// Length penalty for beam search
108 pub length_penalty: Option<f32>,
109
rust/src/text/src/error.rs
+3
@@ -27,6 +27,8 @@ pub enum Error {
27 MinTokensExceedsMaxTokens { min_tokens: u32, max_tokens: u32 },
28 #[error("`thinking_token_budget` must be a non-negative integer or -1 for unlimited.")]
29 InvalidThinkingTokenBudget,
30 + #[error("invalid repetition detection params: {message}")]
31 + InvalidRepetitionDetection { message: String },
32 #[error("text request stream `{request_id}` closed before terminal output")]
33 StreamClosedBeforeTerminalOutput { request_id: String },
34 #[error(transparent)]
@@ -47,6 +49,7 @@ impl Error {
49 | Self::TokenIds(_)
50 | Self::MinTokensExceedsMaxTokens { .. }
51 | Self::InvalidThinkingTokenBudget
52 + | Self::InvalidRepetitionDetection { .. }
53 // An empty tokenized prompt detected later, at request prepare
54 // time, surfaces through the transparent Llm wrapper.
55 | Self::Llm(LlmError::EmptyPromptTokenIds { .. }) => true,
rust/src/text/src/lower.rs
+92 -1
@@ -5,7 +5,9 @@ pub(crate) mod token_ids;
5
6 use logprobs::validate_logprobs;
7 use token_ids::{validate_prompt_token_ids, validate_vocab_range};
8 -use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams;
8 +use vllm_engine_core_client::protocol::sampling::{
9 + EngineCoreSamplingParams, RepetitionDetectionParams,
10 +};
11 use vllm_llm::GenerateRequest;
12 use vllm_tokenizer::Tokenizer;
13
@@ -93,6 +95,7 @@ pub fn lower_sampling_params(
95 frequency_penalty,
96 presence_penalty,
97 repetition_penalty,
98 + repetition_detection,
99 stop_token_ids,
100 ignore_eos,
101 logit_bias,
@@ -110,6 +113,7 @@ pub fn lower_sampling_params(
113 logprob_token_ids.as_deref(),
114 sampling_limits,
115 )?;
116 + validate_repetition_detection(repetition_detection.as_ref())?;
117
118 // Mirrors the model-generation-config inheritance used by vLLM's OpenAI chat
119 // path: https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/entrypoints/openai/chat_completion/protocol.py#L424-L450
@@ -163,6 +167,7 @@ pub fn lower_sampling_params(
167 frequency_penalty,
168 presence_penalty,
169 repetition_penalty,
170 + repetition_detection: repetition_detection.filter(|p| !p.is_disabled()),
171 stop_token_ids,
172 eos_token_id: (!ignore_eos).then_some(primary_eos_token_id).flatten(),
173 all_stop_token_ids,
@@ -194,6 +199,33 @@ fn normalize_thinking_token_budget(value: Option<i64>) -> Result<Option<u64>> {
199 }
200 }
201
202 +fn validate_repetition_detection(params: Option<&RepetitionDetectionParams>) -> Result<()> {
203 + let Some(params) = params else {
204 + return Ok(());
205 + };
206 +
207 + if params.min_pattern_size > params.max_pattern_size {
208 + return Err(Error::InvalidRepetitionDetection {
209 + message: format!(
210 + "`min_pattern_size` must be less than or equal to \
211 + `max_pattern_size`, got min_pattern_size={}, \
212 + max_pattern_size={}",
213 + params.min_pattern_size, params.max_pattern_size
214 + ),
215 + });
216 + }
217 + if params.max_pattern_size > 0 && params.min_count < 2 {
218 + return Err(Error::InvalidRepetitionDetection {
219 + message: format!(
220 + "`min_count` must be at least 2, got min_count={}",
221 + params.min_count
222 + ),
223 + });
224 + }
225 +
226 + Ok(())
227 +}
228 +
229 /// Convert bad-word strings into token-ID sequences, following the Python vLLM
230 /// logic in `SamplingParams.update_from_tokenizer()`.
231 ///
@@ -390,6 +422,59 @@ mod tests {
422 ));
423 }
424
425 + #[test]
426 + fn lower_sampling_params_validates_repetition_detection() {
427 + let lower = |repetition_detection| {
428 + lower_sampling_params_with_limits(
429 + SamplingParams {
430 + repetition_detection,
431 + ..SamplingParams::default()
432 + },
433 + sample_sampling_limits(),
434 + )
435 + };
436 +
437 + let enabled = RepetitionDetectionParams {
438 + max_pattern_size: 4,
439 + min_pattern_size: 2,
440 + min_count: 2,
441 + };
442 + assert_eq!(
443 + lower(Some(enabled.clone())).unwrap().repetition_detection,
444 + Some(enabled)
445 + );
446 +
447 + let disabled = RepetitionDetectionParams {
448 + max_pattern_size: 0,
449 + min_pattern_size: 0,
450 + min_count: 0,
451 + };
452 + assert_eq!(lower(Some(disabled)).unwrap().repetition_detection, None);
453 +
454 + let error = lower(Some(RepetitionDetectionParams {
455 + max_pattern_size: 1,
456 + min_pattern_size: 2,
457 + min_count: 2,
458 + }))
459 + .unwrap_err();
460 + let Error::InvalidRepetitionDetection { message } = error else {
461 + panic!("expected repetition_detection validation error");
462 + };
463 + assert!(message.contains("min_pattern_size=2"));
464 + assert!(message.contains("max_pattern_size=1"));
465 +
466 + let error = lower(Some(RepetitionDetectionParams {
467 + max_pattern_size: 1,
468 + min_pattern_size: 1,
469 + min_count: 1,
470 + }))
471 + .unwrap_err();
472 + let Error::InvalidRepetitionDetection { message } = error else {
473 + panic!("expected repetition_detection validation error");
474 + };
475 + assert!(message.contains("min_count=1"));
476 + }
477 +
478 #[test]
479 fn lower_text_request_applies_python_style_eos_hints() {
480 let prepared = lower_text_request(
@@ -417,6 +502,7 @@ mod tests {
502 frequency_penalty: 0.0,
503 presence_penalty: 0.0,
504 repetition_penalty: 1.0,
505 + repetition_detection: None,
506 stop_token_ids: [
507 77,
508 ],
@@ -469,6 +555,7 @@ mod tests {
555 frequency_penalty: 0.0,
556 presence_penalty: 0.0,
557 repetition_penalty: 1.0,
558 + repetition_detection: None,
559 stop_token_ids: [],
560 eos_token_id: None,
561 all_stop_token_ids: {
@@ -600,6 +687,7 @@ mod tests {
687 frequency_penalty: 0.0,
688 presence_penalty: 0.0,
689 repetition_penalty: 1.0,
690 + repetition_detection: None,
691 stop_token_ids: [
692 151643,
693 ],
@@ -662,6 +750,7 @@ mod tests {
750 frequency_penalty: 0.0,
751 presence_penalty: 0.0,
752 repetition_penalty: 1.0,
753 + repetition_detection: None,
754 stop_token_ids: [
755 11,
756 77,
@@ -732,6 +821,7 @@ mod tests {
821 frequency_penalty: 0.0,
822 presence_penalty: 0.0,
823 repetition_penalty: 1.2,
824 + repetition_detection: None,
825 stop_token_ids: [],
826 eos_token_id: None,
827 all_stop_token_ids: {},
@@ -980,6 +1070,7 @@ mod tests {
1070 frequency_penalty: 0.0,
1071 presence_penalty: 0.0,
1072 repetition_penalty: 1.2,
1073 + repetition_detection: None,
1074 stop_token_ids: [],
1075 eos_token_id: None,
1076 all_stop_token_ids: {},
rust/src/text/src/request.rs
+5
@@ -6,6 +6,7 @@ use serde_json::Value;
6 use vllm_engine_core_client::protocol::lora::LoraRequest;
7 use vllm_engine_core_client::protocol::multimodal::MmFeatures;
8 use vllm_engine_core_client::protocol::request::ReasoningParserKwargs;
9 +use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams;
10 use vllm_engine_core_client::protocol::structured_outputs::StructuredOutputsParams;
11
12 use crate::error::{Error, Result};
@@ -83,6 +84,9 @@ pub struct SamplingParams {
84 /// Repetition penalty applied by the sampler. `None` means no explicit user
85 /// override.
86 pub repetition_penalty: Option<f32>,
87 + /// Parameters for detecting repetitive N-gram patterns. `None` means no
88 + /// explicit user override.
89 + pub repetition_detection: Option<RepetitionDetectionParams>,
90 /// Explicit stop token IDs provided by the caller. `None` means no explicit
91 /// user override.
92 pub stop_token_ids: Option<Vec<u32>>,
@@ -130,6 +134,7 @@ impl Default for SamplingParams {
134 frequency_penalty: None,
135 presence_penalty: None,
136 repetition_penalty: None,
137 + repetition_detection: None,
138 stop_token_ids: None,
139 ignore_eos: false,
140 logit_bias: None,