[Rust Frontend] Deduplicate request preprocessing for `/tokenize` (#50448)
Co-authored-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: Sage Ahrac <sagiahrak@gmail.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Sage committed
Aug 5, 2026 at 11:27 UTC
5bf32605a950ee50ed05f230243d54e0baf7d12d
5 files changed
+170
-50
rust/src/chat/src/lib.rs
+25
-33
@@ -155,17 +155,9 @@ impl ChatRequestProcessor {
155
Ok(Some(features))
156
}
157
158
- /// Prepare one chat request without submitting it to an engine.
159
- pub async fn prepare(
160
- &self,
161
- mut request: ChatRequest,
162
- options: NewChatOutputProcessorOptions<'_>,
163
- ) -> Result<(TextRequest, DynChatOutputProcessor)> {
164
- request.validate()?;
165
-
158
+ async fn prepare_text_request(&self, request: ChatRequest) -> Result<TextRequest> {
159
// Stamp before rendering so render and tokenize count toward TTFT/e2e.
160
let arrival_time = vllm_llm::current_unix_timestamp_secs();
168
- let output_processor = self.backend.new_chat_output_processor(&mut request, options)?;
161
let rendered = self.backend.chat_renderer().render(&request)?;
162
let reasoning_parser_kwargs =
163
request
@@ -176,7 +168,7 @@ impl ChatRequestProcessor {
168
chat_template_kwargs: rendered.effective_template_kwargs.clone(),
169
});
170
let (prompt, mm_features) = self.finalize_rendered_prompt(&request, rendered).await?;
179
- let text_request = TextRequest {
171
+ Ok(TextRequest {
172
request_id: request.request_id,
173
prompt,
174
mm_features,
@@ -191,7 +183,24 @@ impl ChatRequestProcessor {
183
reasoning_parser_kwargs,
184
lora_request: request.lora_request,
185
arrival_time: Some(arrival_time),
194
- };
186
+ })
187
+ }
188
+
189
+ /// Prepare one chat request for tokenization without constructing an output processor.
190
+ pub async fn prepare_for_tokenization(&self, request: ChatRequest) -> Result<TextRequest> {
191
+ request.validate()?;
192
+ self.prepare_text_request(request).await
193
+ }
194
+
195
+ /// Prepare one chat request without submitting it to an engine.
196
+ pub async fn prepare(
197
+ &self,
198
+ mut request: ChatRequest,
199
+ options: NewChatOutputProcessorOptions<'_>,
200
+ ) -> Result<(TextRequest, DynChatOutputProcessor)> {
201
+ request.validate()?;
202
+ let output_processor = self.backend.new_chat_output_processor(&mut request, options)?;
203
+ let text_request = self.prepare_text_request(request).await?;
204
Ok((text_request, output_processor))
205
}
206
}
@@ -265,6 +274,11 @@ impl ChatLlm {
274
&self.text
275
}
276
277
+ /// Return the chat request processor.
278
+ pub fn request_processor(&self) -> &ChatRequestProcessor {
279
+ &self.processor
280
+ }
281
+
282
/// Return the model ID reported by the underlying text backend.
283
pub fn model_id(&self) -> &str {
284
self.text.model_id()
@@ -332,28 +346,6 @@ impl ChatLlm {
346
Ok(ChatEventStream::new(request_id, structured_stream))
347
}
348
335
- /// Render through the chat template and tokenize, without submitting to the engine.
336
- ///
337
- /// Same render → [`multimodal::finalize_rendered_prompt`] → encode pipeline as
338
- /// [`Self::chat`], but stops after token IDs so `/tokenize` counts match what
339
- /// generation would see. Used by `POST /tokenize` (chat form).
340
- pub async fn tokenize_chat(&self, request: ChatRequest) -> Result<Vec<u32>> {
341
- request.validate()?;
342
-
343
- let rendered = self.processor.backend.chat_renderer().render(&request)?;
344
- let (prompt, _mm_features) =
345
- self.processor.finalize_rendered_prompt(&request, rendered).await?;
346
-
347
- let tokenizer = self.text.tokenizer();
348
- let token_ids = match prompt {
349
- // Rendered string from the template (usual chat path).
350
- Prompt::Text(text) => tokenizer.encode(&text, request.add_special_tokens)?,
351
- // Already tokenized (e.g. multimodal path); pass through unchanged.
352
- Prompt::TokenIds(ids) => ids,
353
- };
354
- Ok(token_ids)
355
- }
356
-
349
/// Abort in-flight requests by their external (user-supplied) request ids.
350
pub async fn abort(&self, external_ids: &[String]) -> Result<()> {
351
self.text.abort(external_ids).await?;
rust/src/server/src/routes/tests.rs
+69
@@ -6069,6 +6069,75 @@ async fn tokenize_chat_includes_generation_prompt_in_token_count() {
6069
assert!(with_len > without_len);
6070
}
6071
6072
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6073
+#[serial]
6074
+async fn tokenize_chat_with_tools_does_not_require_output_parser() {
6075
+ let mut app = test_app().await;
6076
+
6077
+ let (status, json) = post_json(
6078
+ &mut app,
6079
+ "/tokenize",
6080
+ json!({
6081
+ "model": "Qwen/Qwen1.5-0.5B-Chat",
6082
+ "messages": [{"role": "user", "content": "hi"}],
6083
+ "tools": [{
6084
+ "type": "function",
6085
+ "function": {
6086
+ "name": "get_weather",
6087
+ "description": "Get weather",
6088
+ "parameters": {
6089
+ "type": "object",
6090
+ "properties": {"city": {"type": "string"}},
6091
+ },
6092
+ },
6093
+ }],
6094
+ }),
6095
+ )
6096
+ .await;
6097
+
6098
+ assert_eq!(status, StatusCode::OK);
6099
+ assert!(!json["tokens"].as_array().expect("tokens").is_empty());
6100
+}
6101
+
6102
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6103
+#[serial]
6104
+async fn tokenize_allows_prompts_at_or_above_max_model_len() {
6105
+ let ready = vllm_engine_core_client::protocol::handshake::EngineCoreReadyResponse {
6106
+ max_model_len: 4,
6107
+ ..default_ready_response()
6108
+ };
6109
+ let (mut app, _engine_task) = test_dev_mode_app_with_ready(ready).await;
6110
+
6111
+ let (completion_status, completion_json) = post_json(
6112
+ &mut app,
6113
+ "/tokenize",
6114
+ json!({
6115
+ "model": "Qwen/Qwen1.5-0.5B-Chat",
6116
+ "prompt": "hello",
6117
+ "add_special_tokens": false,
6118
+ }),
6119
+ )
6120
+ .await;
6121
+ assert_eq!(completion_status, StatusCode::OK);
6122
+ assert_eq!(completion_json["count"], 5);
6123
+ assert_eq!(completion_json["max_model_len"], 4);
6124
+
6125
+ let (chat_status, chat_json) = post_json(
6126
+ &mut app,
6127
+ "/tokenize",
6128
+ json!({
6129
+ "model": "Qwen/Qwen1.5-0.5B-Chat",
6130
+ "messages": [{"role": "user", "content": "hello"}],
6131
+ "add_generation_prompt": false,
6132
+ "add_special_tokens": false,
6133
+ }),
6134
+ )
6135
+ .await;
6136
+ assert_eq!(chat_status, StatusCode::OK);
6137
+ assert!(chat_json["count"].as_u64().expect("count") > 4);
6138
+ assert_eq!(chat_json["max_model_len"], 4);
6139
+}
6140
+
6141
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6142
#[serial]
6143
async fn tokenize_chat_conflicting_generation_flags_returns_400() {
rust/src/server/src/routes/tokenize.rs
+20
-11
@@ -60,9 +60,7 @@ pub async fn tokenize(
60
let max_model_len = state.chat.engine_core_client().max_model_len();
61
62
let result = match body {
63
- // Completion form: encode the raw `prompt` string (no chat template).
64
- TokenizeRequest::Completion(req) => tokenize_completion(&state, &tokenizer, req),
65
- // Chat form: render `messages` through the template, then encode (see `tokenize_chat`).
63
+ TokenizeRequest::Completion(req) => tokenize_completion(&state, &request_id, req),
64
TokenizeRequest::Chat(req) => tokenize_chat(&state, &request_id, req).await,
65
};
66
@@ -83,20 +81,24 @@ pub async fn tokenize(
81
82
fn tokenize_completion(
83
state: &AppState,
86
- tokenizer: &vllm_text::tokenizer::DynTokenizer,
84
+ request_id: &str,
85
req: TokenizeCompletionRequest,
86
) -> Result<(Vec<u32>, bool), ApiError> {
87
check_model(state, req.model.as_deref())?;
90
- let tokens = tokenizer
91
- .encode(&req.prompt, req.add_special_tokens)
88
+ let return_token_strs = req.return_token_strs;
89
+ let tokens = state
90
+ .chat
91
+ .text()
92
+ .request_processor()
93
+ .tokenize(req.into_text_request(request_id.to_string()))
94
.map_err(|e| server_error!("tokenize failed: {}", e.to_report_string()))?;
93
- Ok((tokens, req.return_token_strs))
95
+ Ok((tokens, return_token_strs))
96
}
97
98
/// HTTP adapter for the chat-shaped `/tokenize` body.
99
///
98
-/// Not [`vllm_chat::ChatLlm::tokenize_chat`]: this checks the model name and maps
99
-/// errors to [`ApiError`]; the chat-crate method does render → finalize → encode.
100
+/// Checks the model name, prepares without engine submission, and maps errors
101
+/// to [`ApiError`].
102
async fn tokenize_chat(
103
state: &AppState,
104
request_id: &str,
@@ -106,11 +108,18 @@ async fn tokenize_chat(
108
let return_token_strs = req.return_token_strs;
109
// `continue_final_message` / `add_generation_prompt` mutual exclusion is
110
// enforced in `normalize_generation_prompt_mode` inside `into_chat_request`.
109
- let tokens = state
111
+ let text_request = state
112
.chat
111
- .tokenize_chat(req.into_chat_request(request_id.to_string())?)
113
+ .request_processor()
114
+ .prepare_for_tokenization(req.into_chat_request(request_id.to_string())?)
115
.await
116
.map_err(|e| server_error!("tokenize failed: {}", e.to_report_string()))?;
117
+ let tokens = state
118
+ .chat
119
+ .text()
120
+ .request_processor()
121
+ .tokenize(text_request)
122
+ .map_err(|e| server_error!("tokenize failed: {}", e.to_report_string()))?;
123
Ok((tokens, return_token_strs))
124
}
125
rust/src/server/src/routes/tokenize/types.rs
+23
@@ -9,6 +9,7 @@ use serde_json::Value;
9
use validator::{Validate, ValidationErrors};
10
use vllm_chat::{ChatOptions, ChatRequest, ChatToolChoice, SamplingParams};
11
use vllm_text::output::TextDecodeOptions;
12
+use vllm_text::{Prompt, TextRequest};
13
14
use crate::error::ApiError;
15
use crate::routes::openai::chat_completions::convert::{
@@ -37,6 +38,28 @@ pub struct TokenizeCompletionRequest {
38
pub return_token_strs: bool,
39
}
40
41
+impl TokenizeCompletionRequest {
42
+ /// Lower this tokenize body into a [`TextRequest`] for prompt tokenization.
43
+ pub fn into_text_request(self, request_id: String) -> TextRequest {
44
+ TextRequest {
45
+ request_id,
46
+ prompt: Prompt::Text(self.prompt),
47
+ mm_features: None,
48
+ sampling_params: SamplingParams::default(),
49
+ decode_options: TextDecodeOptions::default(),
50
+ intermediate: false,
51
+ priority: 0,
52
+ cache_salt: None,
53
+ add_special_tokens: self.add_special_tokens,
54
+ data_parallel_rank: None,
55
+ session_id: None,
56
+ reasoning_parser_kwargs: None,
57
+ lora_request: None,
58
+ arrival_time: None,
59
+ }
60
+ }
61
+}
62
+
63
#[derive(Debug, Clone, Deserialize, Validate)]
64
pub struct TokenizeChatRequest {
65
pub model: Option<String>,
rust/src/text/src/lib.rs
+33
-6
@@ -78,6 +78,29 @@ impl TextRequestProcessor {
78
self.max_model_len
79
}
80
81
+ fn tokenize_prompt(
82
+ tokenizer: &DynTokenizer,
83
+ prompt: Prompt,
84
+ add_special_tokens: bool,
85
+ ) -> Result<Vec<u32>> {
86
+ match prompt {
87
+ Prompt::Text(text) => tokenizer.encode(&text, add_special_tokens).map_err(Into::into),
88
+ // Pre-tokenized prompts are the main completions-side escape hatch that lets benchmark
89
+ // and infra workloads bypass chat rendering and tokenizer overhead entirely.
90
+ Prompt::TokenIds(token_ids) => Ok(token_ids),
91
+ }
92
+ }
93
+
94
+ /// Tokenize one request without generation-specific lowering.
95
+ pub fn tokenize(&self, request: TextRequest) -> Result<Vec<u32>> {
96
+ request.validate()?;
97
+ Self::tokenize_prompt(
98
+ &self.backend.tokenizer(),
99
+ request.prompt,
100
+ request.add_special_tokens,
101
+ )
102
+ }
103
+
104
/// Tokenize and lower one request without submitting it to an engine.
105
pub fn prepare(&self, mut request: TextRequest) -> Result<PreparedTextRequest> {
106
request.validate()?;
@@ -87,12 +110,11 @@ impl TextRequestProcessor {
110
}
111
112
let tokenizer = self.backend.tokenizer();
90
- let prompt_token_ids = match take(&mut request.prompt) {
91
- Prompt::Text(text) => tokenizer.encode(&text, request.add_special_tokens)?,
92
- // Pre-tokenized prompts are the main completions-side escape hatch that lets benchmark
93
- // and infra workloads bypass chat rendering and tokenizer overhead entirely.
94
- Prompt::TokenIds(token_ids) => token_ids,
95
- };
113
+ let prompt_token_ids = Self::tokenize_prompt(
114
+ &tokenizer,
115
+ take(&mut request.prompt),
116
+ request.add_special_tokens,
117
+ )?;
118
let sampling_hints = self.backend.sampling_hints()?;
119
let sampling_limits = SamplingLimits {
120
max_model_len: self.max_model_len,
@@ -153,6 +175,11 @@ impl TextLlm {
175
self.llm.engine_core_client()
176
}
177
178
+ /// Return the text request processor.
179
+ pub fn request_processor(&self) -> &TextRequestProcessor {
180
+ &self.processor
181
+ }
182
+
183
/// Return the tokenizer used by this text backend.
184
pub fn tokenizer(&self) -> DynTokenizer {
185
self.processor.tokenizer()