[Rust Frontend] Add support for `truncate_prompt_tokens` and `truncation_side` (#48584)

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

Pranav Thakur committed Aug 29, 2026 at 05:46 UTC 085e9bb07eb3a1ede9689f4110b6ab35eda3e08f
17 files changed +424 -35
rust/src/chat/src/lib.rs
+1
@@ -195,6 +195,7 @@ impl ChatRequestProcessor {
195 sampling_params: request.sampling_params,
196 decode_options: request.decode_options,
197 intermediate: request.intermediate,
198 + prompt_truncation: request.prompt_truncation,
199 priority: request.priority,
200 cache_salt: request.cache_salt,
201 add_special_tokens: request.add_special_tokens,
rust/src/chat/src/request.rs
+9 -1
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
8 use serde_json::Value;
9 use vllm_engine_core_client::protocol::lora::LoraRequest;
10 pub use vllm_parser::tool::Tool as ChatTool;
11 -pub use vllm_text::SamplingParams;
11 use vllm_text::TextDecodeOptions;
12 +pub use vllm_text::{PromptTruncation, SamplingParams};
13
14 use crate::AssistantMessageExt;
15 use crate::error::{Error, Result};
@@ -570,6 +570,8 @@ pub struct ChatRequest {
570 /// output. If `true`, callers may receive zero or more incremental
571 /// content events before the final terminal one.
572 pub intermediate: bool,
573 + /// Prompt-truncation policy resolved by the caller.
574 + pub prompt_truncation: Option<PromptTruncation>,
575 /// Request scheduling priority (lower means earlier handling; default 0).
576 pub priority: i32,
577 /// Documents for RAG (retrieval-augmented generation), passed to the chat
@@ -601,6 +603,7 @@ impl ChatRequest {
603 tool_context: ResolvedToolContext::default(),
604 decode_options: TextDecodeOptions::default(),
605 intermediate: true,
606 + prompt_truncation: None,
607 priority: 0,
608 documents: None,
609 cache_salt: None,
@@ -627,6 +630,11 @@ impl ChatRequest {
630 (GenerationPromptMode::NoGenerationPrompt, _)
631 | (GenerationPromptMode::StartNewAssistant, _) => {}
632 }
633 + if self.has_multimodal() && self.prompt_truncation.is_some() {
634 + return Err(Error::Text(
635 + vllm_text::Error::TruncateUnsupportedWithMultimodal,
636 + ));
637 + }
638 Ok(())
639 }
640
rust/src/server/src/error.rs
+25
@@ -224,6 +224,31 @@ mod tests {
224 assert!(response.error.message.contains("allowed_token_ids"));
225 }
226
227 + #[test]
228 + fn truncate_exceeds_budget_maps_to_invalid_request() {
229 + let error = vllm_text::Error::TruncatePromptTokensExceedsBudget {
230 + value: 8000,
231 + budget: 4000,
232 + };
233 + let api_error = text_submit_error("failed to submit completion request", error);
234 + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
235 + let response = api_error.to_error_response();
236 + assert_eq!(response.error.error_type, "invalid_request_error");
237 + assert!(response.error.message.contains("truncate_prompt_tokens=8000"));
238 + assert!(response.error.message.contains("max_tokens = 4000"));
239 + }
240 +
241 + #[test]
242 + fn invalid_truncate_prompt_tokens_maps_to_invalid_request() {
243 + let error = vllm_text::Error::InvalidTruncatePromptTokens { value: -2 };
244 + let api_error = text_submit_error("failed to submit completion request", error);
245 + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST);
246 + let response = api_error.to_error_response();
247 + assert_eq!(response.error.error_type, "invalid_request_error");
248 + assert!(response.error.message.contains("-2"));
249 + assert!(response.error.message.contains("must be >= -1"));
250 + }
251 +
252 #[test]
253 fn other_submit_errors_stay_internal() {
254 let error = vllm_text::Error::Tokenizer("backend exploded".to_string());
rust/src/server/src/grpc/convert.rs
+9 -7
@@ -11,8 +11,8 @@ use vllm_chat::MediaContentPart;
11 use vllm_engine_core_client::protocol::output::StopReason;
12 use vllm_engine_core_client::protocol::structured_outputs::StructuredOutputsParams;
13 use vllm_text::{
14 - DecodedLogprobs, DecodedPromptLogprobs, FinishReason, Finished, Prompt, SamplingParams,
15 - TextDecodeOptions, TextRequest,
14 + DecodedLogprobs, DecodedPromptLogprobs, FinishReason, Finished, Prompt, PromptTruncation,
15 + PromptTruncationLimit, SamplingParams, TextDecodeOptions, TextRequest, TruncationSide,
16 };
17
18 use super::pb;
@@ -110,11 +110,12 @@ pub fn to_text_request(
110 )));
111 }
112
113 - if req.truncate_prompt_tokens != 0 {
114 - return Err(Status::invalid_argument(
115 - "truncate_prompt_tokens is not supported",
116 - ));
117 - }
113 + // Proto3 uses zero as unset; positive values select fixed left truncation.
114 + // The -1 input-budget sentinel is outside the uint32 field domain.
115 + let prompt_truncation = (req.truncate_prompt_tokens != 0).then_some(PromptTruncation {
116 + limit: PromptTruncationLimit::Fixed(u64::from(req.truncate_prompt_tokens)),
117 + side: TruncationSide::Left,
118 + });
119
120 let prompt = match req.prompt {
121 Some(pb::generate_request::Prompt::Text(text)) => Prompt::Text(text),
@@ -173,6 +174,7 @@ pub fn to_text_request(
174 sampling_params,
175 decode_options,
176 intermediate: stream,
177 + prompt_truncation,
178 priority: req.priority,
179 cache_salt: kv.map(|k| &k.cache_salt).filter(|s| !s.is_empty()).cloned(),
180 add_special_tokens: true,
rust/src/server/src/routes/inference/generate/convert.rs
+1
@@ -73,6 +73,7 @@ pub(super) fn prepare_generate_request(
73 sampling_params,
74 decode_options: TextDecodeOptions::default(),
75 intermediate: false,
76 + prompt_truncation: None,
77 priority: request.priority,
78 cache_salt: request.cache_salt,
79 add_special_tokens: false,
rust/src/server/src/routes/openai/chat_completions/convert.rs
+33 -2
@@ -10,8 +10,9 @@ use vllm_chat::{
10
11 use super::types::ChatCompletionRequest;
12 use super::validate;
13 -use crate::error::{ApiError, bail_invalid_request, chat_submit_error};
13 +use crate::error::{ApiError, bail_invalid_request, chat_submit_error, text_submit_error};
14 use crate::lora::LoraModelResolution;
15 +use crate::routes::openai::utils::resolve_generation_prompt_truncation;
16 use crate::routes::openai::utils::structured_outputs::convert_from_response_format;
17 use crate::routes::openai::utils::types::{
18 ChatMessage, ContentPart, MessageContent, Tool, ToolChoice, ToolChoiceValue, ToolReference,
@@ -71,6 +72,12 @@ pub(super) fn prepare_chat_request(
72 ) -> Result<PreparedRequest, ApiError> {
73 validate::validate_request_compat(&request, &lora_resolution.model_names)?;
74
75 + let prompt_truncation = resolve_generation_prompt_truncation(
76 + request.truncate_prompt_tokens,
77 + request.truncation_side,
78 + )
79 + .map_err(|error| text_submit_error("invalid prompt truncation", error))?;
80 +
81 let request_id = format!("chatcmpl-{}", ctx.request_id);
82 let response_model = lora_resolution
83 .lora_request
@@ -184,6 +191,7 @@ pub(super) fn prepare_chat_request(
191 min_tokens: request.min_tokens.unwrap_or(0),
192 },
193 intermediate: request.stream,
194 + prompt_truncation,
195 priority: ctx.priority.or(request.priority).unwrap_or(0),
196 documents: request.documents,
197 cache_salt: request.cache_salt,
@@ -439,7 +447,9 @@ mod tests {
447 ChatRenderer, ChatTool as VllmChatTool, ChatToolChoice, GenerationPromptMode,
448 KimiK3ChatRenderer, SamplingParams as VllmSamplingParams,
449 };
442 - use vllm_text::{Prompt, output::TextDecodeOptions};
450 + use vllm_text::{
451 + Prompt, PromptTruncation, PromptTruncationLimit, TruncationSide, output::TextDecodeOptions,
452 + };
453 use vllm_tokenizer::{Tokenizer, test_utils::TestTokenizer};
454
455 use super::prepare_chat_request;
@@ -530,6 +540,27 @@ mod tests {
540 }
541 }
542
543 + #[test]
544 + fn prepare_chat_request_preserves_explicit_truncation_side() {
545 + let mut request = base_request();
546 + request.truncate_prompt_tokens = Some(2);
547 + request.truncation_side = Some(TruncationSide::Right);
548 + let prepared = prepare_chat_request(
549 + request,
550 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
551 + ResolvedRequestContext::default(),
552 + )
553 + .expect("prepare truncation");
554 +
555 + assert_eq!(
556 + prepared.chat_request.prompt_truncation,
557 + Some(PromptTruncation {
558 + limit: PromptTruncationLimit::Fixed(2),
559 + side: TruncationSide::Right,
560 + })
561 + );
562 + }
563 +
564 #[test]
565 fn prepare_chat_request_accepts_zero_min_tokens() {
566 let request: ChatCompletionRequest = serde_json::from_value(json!({
rust/src/server/src/routes/openai/chat_completions/types.rs
+5
@@ -11,6 +11,7 @@ use serde_with::SerializeDisplay;
11 use validator::Validate;
12 use vllm_chat::ReasoningEffort;
13 use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams;
14 +use vllm_text::TruncationSide;
15
16 use crate::routes::openai::utils::structured_outputs::ResponseFormat;
17 use crate::routes::openai::utils::types::{
@@ -159,6 +160,9 @@ pub struct ChatCompletionRequest {
160 /// Truncate prompt tokens to this length
161 pub truncate_prompt_tokens: Option<i64>,
162
163 + /// Which side to truncate from when truncate_prompt_tokens is active
164 + pub truncation_side: Option<TruncationSide>,
165 +
166 /// Number of prompt logprobs to return
167 pub prompt_logprobs: Option<i32>,
168
@@ -290,6 +294,7 @@ impl Default for ChatCompletionRequest {
294 skip_special_tokens: true,
295 spaces_between_special_tokens: true,
296 truncate_prompt_tokens: None,
297 + truncation_side: None,
298 prompt_logprobs: None,
299 allowed_token_ids: None,
300 bad_words: None,
rust/src/server/src/routes/openai/chat_completions/validate.rs
+2 -5
@@ -4,6 +4,7 @@
4 use super::types::ChatCompletionRequest;
5 use crate::error::{ApiError, bail_invalid_request};
6 use crate::routes::openai::utils::types::{ChatMessage, Tool};
7 +use crate::routes::openai::utils::validate_generation_prompt_truncation;
8
9 /// Enforce the minimal compatibility contract for the Rust OpenAI server.
10 pub(super) fn validate_request_compat(
@@ -84,11 +85,7 @@ pub(super) fn validate_request_compat(
85 "spaces_between_special_tokens is not supported."
86 );
87 }
87 - reject_non_default(
88 - request.truncate_prompt_tokens.as_ref(),
89 - "truncate_prompt_tokens",
90 - "truncate_prompt_tokens is not supported.",
91 - )?;
88 + validate_generation_prompt_truncation(request.truncate_prompt_tokens, request.echo)?;
89 reject_non_default(
90 request.media_io_kwargs.as_ref(),
91 "media_io_kwargs",
rust/src/server/src/routes/openai/completions/convert.rs
+35 -2
@@ -6,9 +6,10 @@ use vllm_text::tokenizer::Tokenizer;
6 use vllm_text::{Prompt, SamplingParams, TextDecodeOptions, TextRequest};
7
8 use super::types::CompletionRequest;
9 -use crate::error::ApiError;
9 +use crate::error::{ApiError, text_submit_error};
10 use crate::lora::LoraModelResolution;
11 use crate::routes::openai::completions::validate;
12 +use crate::routes::openai::utils::resolve_generation_prompt_truncation;
13 use crate::routes::openai::utils::structured_outputs::convert_from_response_format_value;
14 use crate::utils::{
15 ResolvedRequestContext, convert_logit_bias, merge_ec_transfer_params, merge_kv_transfer_params,
@@ -62,6 +63,12 @@ pub(super) fn prepare_completion_request(
63 ) -> Result<PreparedRequest, ApiError> {
64 validate::validate_request_compat(&request, &lora_resolution.model_names)?;
65
66 + let prompt_truncation = resolve_generation_prompt_truncation(
67 + request.truncate_prompt_tokens,
68 + request.truncation_side,
69 + )
70 + .map_err(|error| text_submit_error("invalid prompt truncation", error))?;
71 +
72 let request_id = format!("cmpl-{}", ctx.request_id);
73 let response_model = lora_resolution
74 .lora_request
@@ -141,6 +148,7 @@ pub(super) fn prepare_completion_request(
148 min_tokens: request.min_tokens.unwrap_or(0),
149 },
150 intermediate: request.stream,
151 + prompt_truncation,
152 priority: ctx.priority.or(request.priority).unwrap_or(0),
153 cache_salt: request.cache_salt,
154 add_special_tokens: request.add_special_tokens,
@@ -200,7 +208,7 @@ mod tests {
208 use axum::http::HeaderMap;
209 use serde_json::json;
210 use validator::Validate;
203 - use vllm_text::Prompt;
211 + use vllm_text::{Prompt, PromptTruncation, PromptTruncationLimit, TruncationSide};
212 use vllm_tokenizer::test_utils::TestTokenizer;
213
214 use super::prepare_completion_request;
@@ -323,6 +331,31 @@ mod tests {
331 }
332 }
333
334 + #[test]
335 + fn prepare_completion_request_uses_left_when_side_is_omitted() {
336 + let mut value = base_request_json();
337 + value
338 + .as_object_mut()
339 + .expect("request object")
340 + .insert("truncate_prompt_tokens".to_string(), json!(-1));
341 + let request = serde_json::from_value(value).expect("parse truncation");
342 + let prepared = prepare_completion_request(
343 + request,
344 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
345 + ResolvedRequestContext::default(),
346 + &test_tokenizer(),
347 + )
348 + .expect("prepare truncation");
349 +
350 + assert_eq!(
351 + prepared.text_request.prompt_truncation,
352 + Some(PromptTruncation {
353 + limit: PromptTruncationLimit::InputBudget,
354 + side: TruncationSide::Left,
355 + })
356 + );
357 + }
358 +
359 #[test]
360 fn normalize_coerces_null_max_tokens_to_default() {
361 // An absent `max_tokens` already gets the serde default.
rust/src/server/src/routes/openai/completions/types.rs
+4 -1
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
8 use serde_json::{Map, Value};
9 use validator::Validate;
10 use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams;
11 -use vllm_text::Prompt;
11 +use vllm_text::{Prompt, TruncationSide};
12
13 use crate::routes::openai::utils::types::{
14 LogProbs, Normalizable, PromptLogprobs, StreamOptions, StreamResponseEnvelope, StringOrArray,
@@ -138,6 +138,9 @@ pub struct CompletionRequest {
138 /// Truncate prompt tokens to this length
139 pub truncate_prompt_tokens: Option<i64>,
140
141 + /// Which side to truncate from when truncate_prompt_tokens is active
142 + pub truncation_side: Option<TruncationSide>,
143 +
144 /// Restrict output to these token IDs only
145 pub allowed_token_ids: Option<Vec<u32>>,
146
rust/src/server/src/routes/openai/completions/validate.rs
+2 -6
@@ -3,6 +3,7 @@
3
4 use super::types::CompletionRequest;
5 use crate::error::{ApiError, bail_invalid_request};
6 +use crate::routes::openai::utils::validate_generation_prompt_truncation;
7
8 /// Enforce the minimal compatibility contract for the Rust OpenAI server.
9 pub(super) fn validate_request_compat(
@@ -85,12 +86,7 @@ pub(super) fn validate_request_compat(
86 "spaces_between_special_tokens is not supported."
87 );
88 }
88 - if request.truncate_prompt_tokens.is_some() {
89 - bail_invalid_request!(
90 - param = "truncate_prompt_tokens",
91 - "truncate_prompt_tokens is not supported."
92 - );
93 - }
89 + validate_generation_prompt_truncation(request.truncate_prompt_tokens, request.echo)?;
90
91 Ok(())
92 }
rust/src/server/src/routes/openai/utils/mod.rs
+34
@@ -6,3 +6,37 @@ pub mod structured_outputs;
6 pub mod types;
7 pub mod usage;
8 pub mod validated_json;
9 +
10 +use vllm_text::{PromptTruncation, TruncationSide};
11 +
12 +use crate::error::{ApiError, bail_invalid_request};
13 +
14 +pub(crate) fn validate_generation_prompt_truncation(
15 + limit: Option<i64>,
16 + echo: bool,
17 +) -> Result<(), ApiError> {
18 + if let Some(limit) = limit
19 + && limit < -1
20 + {
21 + bail_invalid_request!(
22 + param = "truncate_prompt_tokens",
23 + "truncate_prompt_tokens must be >= -1."
24 + );
25 + }
26 + if echo && limit.is_some() {
27 + bail_invalid_request!(
28 + param = "echo",
29 + "`echo=true` is not supported with `truncate_prompt_tokens`."
30 + );
31 + }
32 + Ok(())
33 +}
34 +
35 +pub(crate) fn resolve_generation_prompt_truncation(
36 + limit: Option<i64>,
37 + side: Option<TruncationSide>,
38 +) -> vllm_text::Result<Option<PromptTruncation>> {
39 + limit
40 + .map(|limit| PromptTruncation::from_wire(limit, side.unwrap_or(TruncationSide::Left)))
41 + .transpose()
42 +}
rust/src/server/src/routes/tokenize/types.rs
+2
@@ -50,6 +50,7 @@ impl TokenizeCompletionRequest {
50 intermediate: false,
51 priority: 0,
52 cache_salt: None,
53 + prompt_truncation: None,
54 add_special_tokens: self.add_special_tokens,
55 data_parallel_rank: None,
56 session_id: None,
@@ -116,6 +117,7 @@ impl TokenizeChatRequest {
117 tool_context,
118 decode_options: TextDecodeOptions::default(),
119 intermediate: false,
120 + prompt_truncation: None,
121 priority: 0,
122 documents: None,
123 cache_salt: None,
rust/src/text/src/error.rs
+12
@@ -35,6 +35,15 @@ pub enum Error {
35 MinTokensExceedsMaxTokens { min_tokens: u32, max_tokens: u32 },
36 #[error("`thinking_token_budget` must be a non-negative integer or -1 for unlimited.")]
37 InvalidThinkingTokenBudget,
38 + #[error(
39 + "truncate_prompt_tokens={value} cannot be greater than max_model_len - \
40 + max_tokens = {budget}. Please request a smaller truncation size."
41 + )]
42 + TruncatePromptTokensExceedsBudget { value: u64, budget: u32 },
43 + #[error("invalid truncate_prompt_tokens={value}; must be >= -1")]
44 + InvalidTruncatePromptTokens { value: i64 },
45 + #[error("truncate_prompt_tokens is not supported for multimodal requests")]
46 + TruncateUnsupportedWithMultimodal,
47 #[error("invalid repetition detection params: {message}")]
48 InvalidRepetitionDetection { message: String },
49 #[error("text request stream `{request_id}` closed before terminal output")]
@@ -59,6 +68,9 @@ impl Error {
68 | Self::SamplingParams(_)
69 | Self::MinTokensExceedsMaxTokens { .. }
70 | Self::InvalidThinkingTokenBudget
71 + | Self::TruncatePromptTokensExceedsBudget { .. }
72 + | Self::InvalidTruncatePromptTokens { .. }
73 + | Self::TruncateUnsupportedWithMultimodal
74 | Self::InvalidRepetitionDetection { .. }
75 // An empty tokenized prompt detected later, at request prepare
76 // time, surfaces through the transparent Llm wrapper.
rust/src/text/src/lib.rs
+64 -11
@@ -33,6 +33,8 @@ mod error;
33 mod lower;
34 pub mod output;
35 mod request;
36 +mod truncation;
37 +pub use truncation::{PromptTruncation, PromptTruncationLimit, TruncationSide};
38 pub use vllm_tokenizer as tokenizer;
39
40 trait_set! {
@@ -80,26 +82,35 @@ impl TextRequestProcessor {
82 self.max_model_len
83 }
84
83 - fn tokenize_prompt(
84 - tokenizer: &DynTokenizer,
85 + fn prepare_prompt_tokens(
86 + &self,
87 prompt: Prompt,
88 add_special_tokens: bool,
89 + prompt_truncation: Option<PromptTruncation>,
90 + max_output_tokens: Option<u32>,
91 ) -> Result<Vec<u32>> {
88 - match prompt {
89 - Prompt::Text(text) => tokenizer.encode(&text, add_special_tokens).map_err(Into::into),
92 + let tokenizer = self.backend.tokenizer();
93 + let mut prompt_token_ids = match prompt {
94 + Prompt::Text(text) => tokenizer.encode(&text, add_special_tokens)?,
95 // Pre-tokenized prompts are the main completions-side escape hatch that lets benchmark
96 // and infra workloads bypass chat rendering and tokenizer overhead entirely.
92 - Prompt::TokenIds(token_ids) => Ok(token_ids),
97 + Prompt::TokenIds(token_ids) => token_ids,
98 + };
99 + if let Some(prompt_truncation) = prompt_truncation {
100 + let input_budget = self.max_model_len.saturating_sub(max_output_tokens.unwrap_or(0));
101 + prompt_truncation.apply(&mut prompt_token_ids, input_budget)?;
102 }
103 + Ok(prompt_token_ids)
104 }
105
96 - /// Tokenize one request without generation-specific lowering.
106 + /// Prepare one request's prompt tokens without generation-specific lowering.
107 pub fn tokenize(&self, request: TextRequest) -> Result<Vec<u32>> {
108 request.validate()?;
99 - Self::tokenize_prompt(
100 - &self.backend.tokenizer(),
109 + self.prepare_prompt_tokens(
110 request.prompt,
111 request.add_special_tokens,
112 + request.prompt_truncation,
113 + request.sampling_params.max_tokens,
114 )
115 }
116
@@ -111,12 +122,13 @@ impl TextRequestProcessor {
122 request.arrival_time = Some(vllm_llm::current_unix_timestamp_secs());
123 }
124
114 - let tokenizer = self.backend.tokenizer();
115 - let prompt_token_ids = Self::tokenize_prompt(
116 - &tokenizer,
125 + let prompt_token_ids = self.prepare_prompt_tokens(
126 take(&mut request.prompt),
127 request.add_special_tokens,
128 + request.prompt_truncation,
129 + request.sampling_params.max_tokens,
130 )?;
131 + let tokenizer = self.backend.tokenizer();
132 let sampling_hints = self.backend.sampling_hints()?;
133 let sampling_limits = SamplingLimits {
134 max_model_len: self.max_model_len,
@@ -247,3 +259,44 @@ impl TextLlm {
259 Ok(())
260 }
261 }
262 +
263 +#[cfg(test)]
264 +mod tests {
265 + use std::sync::Arc;
266 +
267 + use vllm_tokenizer::test_utils::TestTokenizer;
268 +
269 + use super::*;
270 +
271 + #[derive(Debug)]
272 + struct TestTextBackend;
273 +
274 + impl TextBackend for TestTextBackend {
275 + fn tokenizer(&self) -> DynTokenizer {
276 + Arc::new(TestTokenizer::new())
277 + }
278 +
279 + fn model_id(&self) -> &str {
280 + "test-model"
281 + }
282 + }
283 +
284 + #[test]
285 + fn tokenize_and_prepare_apply_the_same_prompt_truncation() {
286 + let processor = TextRequestProcessor::new(Arc::new(TestTextBackend), 10);
287 + let request = TextRequest {
288 + prompt: Prompt::TokenIds(vec![1, 2, 3, 4, 5]),
289 + prompt_truncation: Some(PromptTruncation {
290 + limit: PromptTruncationLimit::Fixed(3),
291 + side: TruncationSide::Left,
292 + }),
293 + ..TextRequest::for_test()
294 + };
295 +
296 + assert_eq!(processor.tokenize(request.clone()).unwrap(), vec![3, 4, 5]);
297 + assert_eq!(
298 + processor.prepare(request).unwrap().generate_request.prompt_token_ids,
299 + vec![3, 4, 5]
300 + );
301 + }
302 +}
rust/src/text/src/request.rs
+8
@@ -14,6 +14,7 @@ use vllm_engine_core_client::protocol::structured_outputs::StructuredOutputsPara
14
15 use crate::error::{Error, Result};
16 use crate::output::TextDecodeOptions;
17 +use crate::truncation::PromptTruncation;
18
19 /// Normalize vLLM's signed `top_k` domain into [`SamplingParams::top_k`].
20 ///
@@ -196,6 +197,9 @@ pub struct TextRequest {
197 pub priority: i32,
198 /// Salt for prefix cache isolation in multi-user environments.
199 pub cache_salt: Option<String>,
200 + /// Prompt-truncation policy resolved by the caller.
201 + #[serde(default)]
202 + pub prompt_truncation: Option<PromptTruncation>,
203 /// Whether to add special tokens (e.g. BOS) during prompt tokenization.
204 pub add_special_tokens: bool,
205 /// Override data parallel rank.
@@ -231,6 +235,7 @@ impl TextRequest {
235 intermediate: true,
236 priority: 0,
237 cache_salt: None,
238 + prompt_truncation: None,
239 add_special_tokens: false,
240 data_parallel_rank: None,
241 session_id: None,
@@ -257,6 +262,9 @@ impl TextRequest {
262 request_id: self.request_id.clone(),
263 });
264 }
265 + if self.mm_features.is_some() && self.prompt_truncation.is_some() {
266 + return Err(Error::TruncateUnsupportedWithMultimodal);
267 + }
268 Ok(())
269 }
270 }
rust/src/text/src/truncation.rs new
+178
@@ -0,0 +1,178 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use serde::{Deserialize, Serialize};
5 +
6 +use crate::error::{Error, Result};
7 +
8 +/// Which side of the prompt to discard when truncation is active.
9 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10 +#[serde(rename_all = "lowercase")]
11 +pub enum TruncationSide {
12 + /// Keep the last N tokens, discarding the prompt prefix.
13 + Left,
14 + /// Keep the first N tokens, discarding the prompt suffix.
15 + Right,
16 +}
17 +
18 +/// Maximum number of prompt tokens to retain.
19 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20 +pub enum PromptTruncationLimit {
21 + /// Use the input budget remaining after reserving output tokens.
22 + InputBudget,
23 + /// Retain at most the given number of prompt tokens.
24 + Fixed(u64),
25 +}
26 +
27 +/// Typed prompt-truncation policy used inside the text frontend.
28 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29 +pub struct PromptTruncation {
30 + /// Maximum number of prompt tokens to retain.
31 + pub limit: PromptTruncationLimit,
32 + /// Side from which excess prompt tokens are discarded.
33 + pub side: TruncationSide,
34 +}
35 +
36 +impl PromptTruncation {
37 + /// Convert the OpenAI-compatible integer representation into a typed policy.
38 + pub fn from_wire(limit: i64, side: TruncationSide) -> Result<Self> {
39 + let limit = match limit {
40 + -1 => PromptTruncationLimit::InputBudget,
41 + 0.. => PromptTruncationLimit::Fixed(limit as u64),
42 + _ => return Err(Error::InvalidTruncatePromptTokens { value: limit }),
43 + };
44 + Ok(Self { limit, side })
45 + }
46 +
47 + pub(crate) fn apply(self, prompt_token_ids: &mut Vec<u32>, input_budget: u32) -> Result<()> {
48 + let max_input_tokens = match self.limit {
49 + PromptTruncationLimit::InputBudget => input_budget,
50 + PromptTruncationLimit::Fixed(limit) => {
51 + if limit > u64::from(input_budget) {
52 + return Err(Error::TruncatePromptTokensExceedsBudget {
53 + value: limit,
54 + budget: input_budget,
55 + });
56 + }
57 + limit as u32
58 + }
59 + } as usize;
60 +
61 + if prompt_token_ids.len() <= max_input_tokens {
62 + return Ok(());
63 + }
64 +
65 + match self.side {
66 + TruncationSide::Left => {
67 + let start = prompt_token_ids.len() - max_input_tokens;
68 + prompt_token_ids.drain(0..start);
69 + }
70 + TruncationSide::Right => prompt_token_ids.truncate(max_input_tokens),
71 + }
72 + Ok(())
73 + }
74 +}
75 +
76 +#[cfg(test)]
77 +mod tests {
78 + use super::*;
79 +
80 + #[test]
81 + fn parses_wire_limits() {
82 + assert_eq!(
83 + PromptTruncation::from_wire(-1, TruncationSide::Left).unwrap(),
84 + PromptTruncation {
85 + limit: PromptTruncationLimit::InputBudget,
86 + side: TruncationSide::Left,
87 + }
88 + );
89 + assert_eq!(
90 + PromptTruncation::from_wire(0, TruncationSide::Right).unwrap(),
91 + PromptTruncation {
92 + limit: PromptTruncationLimit::Fixed(0),
93 + side: TruncationSide::Right,
94 + }
95 + );
96 + assert!(matches!(
97 + PromptTruncation::from_wire(-2, TruncationSide::Left),
98 + Err(Error::InvalidTruncatePromptTokens { value: -2 })
99 + ));
100 + }
101 +
102 + #[test]
103 + fn applies_fixed_limit_from_either_side() {
104 + let mut left = vec![1, 2, 3, 4, 5];
105 + PromptTruncation {
106 + limit: PromptTruncationLimit::Fixed(3),
107 + side: TruncationSide::Left,
108 + }
109 + .apply(&mut left, 90)
110 + .unwrap();
111 + assert_eq!(left, vec![3, 4, 5]);
112 +
113 + let mut right = vec![1, 2, 3, 4, 5];
114 + PromptTruncation {
115 + limit: PromptTruncationLimit::Fixed(3),
116 + side: TruncationSide::Right,
117 + }
118 + .apply(&mut right, 90)
119 + .unwrap();
120 + assert_eq!(right, vec![1, 2, 3]);
121 + }
122 +
123 + #[test]
124 + fn input_budget_reserves_output_tokens() {
125 + let mut prompt = vec![0; 100];
126 + PromptTruncation {
127 + limit: PromptTruncationLimit::InputBudget,
128 + side: TruncationSide::Left,
129 + }
130 + .apply(&mut prompt, 70)
131 + .unwrap();
132 + assert_eq!(prompt.len(), 70);
133 + }
134 +
135 + #[test]
136 + fn fixed_limit_cannot_exceed_input_budget() {
137 + let mut prompt = vec![1, 2, 3];
138 + let error = PromptTruncation {
139 + limit: PromptTruncationLimit::Fixed(80),
140 + side: TruncationSide::Left,
141 + }
142 + .apply(&mut prompt, 70)
143 + .unwrap_err();
144 + assert!(matches!(
145 + error,
146 + Error::TruncatePromptTokensExceedsBudget {
147 + value: 80,
148 + budget: 70
149 + }
150 + ));
151 + }
152 +
153 + #[test]
154 + fn zero_limit_clears_prompt() {
155 + let mut prompt = vec![1, 2, 3];
156 + PromptTruncation {
157 + limit: PromptTruncationLimit::Fixed(0),
158 + side: TruncationSide::Left,
159 + }
160 + .apply(&mut prompt, 90)
161 + .unwrap();
162 + assert!(prompt.is_empty());
163 + }
164 +
165 + #[test]
166 + fn limit_at_or_above_prompt_length_is_a_noop() {
167 + for limit in [3, 10] {
168 + let mut prompt = vec![1, 2, 3];
169 + PromptTruncation {
170 + limit: PromptTruncationLimit::Fixed(limit),
171 + side: TruncationSide::Left,
172 + }
173 + .apply(&mut prompt, 90)
174 + .unwrap();
175 + assert_eq!(prompt, vec![1, 2, 3]);
176 + }
177 + }
178 +}