[Rust Frontend] Support echo for token-ID completion prompts (#46219)

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: reidliu41 <reid201711@gmail.com>

Reid committed Jun 23, 2026 at 16:04 UTC accaa434f36b37a35b3e68eede167415ecc83c51
4 files changed +275 -23
rust/src/server/src/routes/openai/completions.rs
+7 -1
@@ -51,7 +51,13 @@ pub async fn completions(
51 let request_context = resolve_request_context(&headers, body.request_id.as_deref());
52 let lora_resolution = state.resolve_model_with_loras(Some(&body.model)).await;
53
54 - let prepared = match prepare_completion_request(body, &lora_resolution, request_context) {
54 + let tokenizer = state.chat.text().tokenizer();
55 + let prepared = match prepare_completion_request(
56 + body,
57 + &lora_resolution,
58 + request_context,
59 + tokenizer.as_ref(),
60 + ) {
61 Ok(prepared) => prepared,
62 Err(error) => return error.into_response(),
63 };
rust/src/server/src/routes/openai/completions/convert.rs
+87 -13
@@ -1,4 +1,6 @@
1 -use vllm_text::{SamplingParams, TextDecodeOptions, TextRequest};
1 +use thiserror_ext::AsReport as _;
2 +use vllm_text::tokenizer::Tokenizer;
3 +use vllm_text::{Prompt, SamplingParams, TextDecodeOptions, TextRequest};
4
5 use super::types::CompletionRequest;
6 use crate::error::ApiError;
@@ -29,8 +31,7 @@ pub(super) struct ResponseOptions {
31 pub include_continuous_usage: bool,
32 /// Whether the caller requested prompt-only echo via `max_tokens=0`.
33 pub prompt_only: bool,
32 - /// Original text prompt that should be echoed back northbound when
33 - /// `echo=true`.
34 + /// Prompt text that should be echoed back northbound when `echo=true`.
35 pub echo: Option<String>,
36 /// Whether the caller requested output logprobs on completion choices.
37 pub requested_logprobs: Option<u32>,
@@ -51,6 +52,7 @@ pub(super) fn prepare_completion_request(
52 request: CompletionRequest,
53 lora_resolution: &LoraModelResolution,
54 ctx: ResolvedRequestContext,
55 + tokenizer: &dyn Tokenizer,
56 ) -> Result<PreparedRequest, ApiError> {
57 validate::validate_request_compat(&request, &lora_resolution.model_names)?;
58
@@ -92,7 +94,7 @@ pub(super) fn prepare_completion_request(
94 } else {
95 request.max_tokens
96 };
95 - let echo = request.echo.then(|| request.prompt.as_text().cloned()).flatten();
97 + let echo = completion_echo_text(&request, tokenizer)?;
98
99 let structured_outputs =
100 convert_from_response_format_value(&request.response_format, &request.structured_outputs)?;
@@ -159,11 +161,39 @@ pub(super) fn prepare_completion_request(
161 })
162 }
163
164 +fn completion_echo_text(
165 + request: &CompletionRequest,
166 + tokenizer: &dyn Tokenizer,
167 +) -> Result<Option<String>, ApiError> {
168 + if !request.echo {
169 + return Ok(None);
170 + }
171 +
172 + match &request.prompt {
173 + Prompt::Text(prompt) => Ok(Some(prompt.clone())),
174 + Prompt::TokenIds(token_ids) if request.return_token_ids.unwrap_or(false) => {
175 + Ok(Some(String::new()))
176 + }
177 + Prompt::TokenIds(token_ids) => {
178 + tokenizer.decode(token_ids, false).map(Some).map_err(|error| {
179 + ApiError::invalid_request(
180 + format!(
181 + "Failed to decode token-ID prompt for echo: {}",
182 + error.to_report_string()
183 + ),
184 + Some("prompt"),
185 + )
186 + })
187 + }
188 + }
189 +}
190 +
191 #[cfg(test)]
192 mod tests {
193 use axum::http::HeaderMap;
194 use serde_json::json;
195 use vllm_text::Prompt;
196 + use vllm_text::tokenizer::Tokenizer;
197
198 use super::prepare_completion_request;
199 use crate::lora::LoraModelResolution;
@@ -181,6 +211,34 @@ mod tests {
211 }
212 }
213
214 + #[derive(Debug)]
215 + struct TestTokenizer;
216 +
217 + impl Tokenizer for TestTokenizer {
218 + fn encode(
219 + &self,
220 + text: &str,
221 + _add_special_tokens: bool,
222 + ) -> vllm_text::tokenizer::Result<Vec<u32>> {
223 + Ok(text.bytes().map(u32::from).collect())
224 + }
225 +
226 + fn decode(
227 + &self,
228 + token_ids: &[u32],
229 + _skip_special_tokens: bool,
230 + ) -> vllm_text::tokenizer::Result<String> {
231 + Ok(
232 + String::from_utf8_lossy(&token_ids.iter().map(|id| *id as u8).collect::<Vec<_>>())
233 + .into_owned(),
234 + )
235 + }
236 +
237 + fn token_to_id(&self, _token: &str) -> Option<u32> {
238 + None
239 + }
240 + }
241 +
242 fn base_request_json() -> serde_json::Value {
243 json!({
244 "model": "Qwen/Qwen1.5-0.5B-Chat",
@@ -238,6 +296,7 @@ mod tests {
296 request,
297 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
298 ResolvedRequestContext::default(),
299 + &TestTokenizer,
300 )
301 .expect("prepare");
302
@@ -280,6 +339,7 @@ mod tests {
339 request,
340 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
341 ResolvedRequestContext::default(),
342 + &TestTokenizer,
343 )
344 .expect("prepare")
345 .text_request
@@ -313,6 +373,7 @@ mod tests {
373 request,
374 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
375 ResolvedRequestContext::default(),
376 + &TestTokenizer,
377 )
378 .expect("prepare");
379
@@ -337,6 +398,7 @@ mod tests {
398 request,
399 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
400 ResolvedRequestContext::default(),
401 + &TestTokenizer,
402 )
403 .expect("prepare");
404
@@ -359,6 +421,7 @@ mod tests {
421 request,
422 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
423 ResolvedRequestContext::default(),
424 + &TestTokenizer,
425 )
426 .expect("prepare");
427
@@ -382,6 +445,7 @@ mod tests {
445 request,
446 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
447 ResolvedRequestContext::default(),
448 + &TestTokenizer,
449 )
450 .expect("prepare");
451
@@ -406,6 +470,7 @@ mod tests {
470 request,
471 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
472 ResolvedRequestContext::default(),
473 + &TestTokenizer,
474 )
475 .expect("prepare");
476
@@ -432,6 +497,7 @@ mod tests {
497 request,
498 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
499 ResolvedRequestContext::default(),
500 + &TestTokenizer,
501 )
502 .expect("prepare");
503
@@ -443,22 +509,27 @@ mod tests {
509 }
510
511 #[test]
446 - fn prepare_completion_request_rejects_token_id_prompt_echo() {
512 + fn prepare_completion_request_decodes_token_id_prompt_echo() {
513 let request: CompletionRequest = serde_json::from_value(json!({
514 "model": "Qwen/Qwen1.5-0.5B-Chat",
449 - "prompt": [11, 22, 33],
515 + "prompt": [104, 101, 108, 108, 111],
516 "stream": true,
517 "echo": true
518 }))
519 .expect("parse request");
520
455 - assert!(
456 - prepare_completion_request(
457 - request,
458 - &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
459 - ResolvedRequestContext::default(),
460 - )
461 - .is_err()
521 + let prepared = prepare_completion_request(
522 + request,
523 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
524 + ResolvedRequestContext::default(),
525 + &TestTokenizer,
526 + )
527 + .expect("prepare");
528 +
529 + assert_eq!(prepared.options.echo, Some("hello".to_string()));
530 + assert_eq!(
531 + prepared.text_request.prompt,
532 + Prompt::TokenIds(vec![104, 101, 108, 108, 111])
533 );
534 }
535
@@ -477,6 +548,7 @@ mod tests {
548 request,
549 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
550 ResolvedRequestContext::default(),
551 + &TestTokenizer,
552 )
553 .expect("prepare");
554 assert_eq!(prepared.text_request.sampling_params.logprobs, Some(1));
@@ -501,6 +573,7 @@ mod tests {
573 request,
574 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
575 request_context(&headers, None),
576 + &TestTokenizer,
577 )
578 .expect("prepare");
579 assert_eq!(prepared.text_request.data_parallel_rank, Some(3));
@@ -519,6 +592,7 @@ mod tests {
592 request,
593 &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
594 ResolvedRequestContext::default(),
595 + &TestTokenizer,
596 )
597 .expect("prepare");
598 assert_eq!(prepared.text_request.data_parallel_rank, None);
rust/src/server/src/routes/openai/completions/validate.rs
+15 -9
@@ -1,5 +1,3 @@
1 -use vllm_text::Prompt;
2 -
1 use super::types::CompletionRequest;
2 use crate::error::{ApiError, bail_invalid_request};
3
@@ -33,13 +31,6 @@ pub(super) fn validate_request_compat(
31 );
32 }
33
36 - if request.echo && matches!(request.prompt, Prompt::TokenIds(_)) {
37 - bail_invalid_request!(
38 - param = "echo",
39 - "echo is not supported with token-ID prompts."
40 - );
41 - }
42 -
34 if request.suffix.is_some() {
35 bail_invalid_request!(param = "suffix", "suffix is not supported.");
36 }
@@ -183,6 +174,21 @@ mod tests {
174 );
175 }
176
177 + #[test]
178 + fn validate_request_compat_accepts_token_id_prompt_echo() {
179 + let request: CompletionRequest = serde_json::from_value(json!({
180 + "model": "Qwen/Qwen1.5-0.5B-Chat",
181 + "prompt": [104, 101, 108, 108, 111],
182 + "stream": true,
183 + "echo": true,
184 + }))
185 + .expect("parse request");
186 +
187 + assert!(
188 + validate_request_compat(&request, &served_names(&["Qwen/Qwen1.5-0.5B-Chat"])).is_ok()
189 + );
190 + }
191 +
192 #[test]
193 fn validate_request_compat_rejects_prompt_only_without_echo() {
194 let request = CompletionRequest {
rust/src/server/src/routes/tests.rs
+166
@@ -3052,6 +3052,107 @@ async fn non_stream_completions_echo_prepends_prompt_text() {
3052 assert_eq!(json["usage"]["completion_tokens"], 3);
3053 }
3054
3055 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3056 +#[serial]
3057 +async fn non_stream_completions_echo_decodes_token_id_prompt_text() {
3058 + let prompt_token_ids = bytes_to_token_ids(b"hello");
3059 + let expected_prompt_token_ids = prompt_token_ids.clone();
3060 + let (app, engine_task) = test_app_with_backend_and_engine_request_check(
3061 + Arc::new(FakeChatBackend::new()),
3062 + move |request| {
3063 + assert_eq!(
3064 + request.prompt_token_ids.as_deref(),
3065 + Some(expected_prompt_token_ids.as_slice())
3066 + );
3067 + },
3068 + )
3069 + .await;
3070 + let response = app
3071 + .clone()
3072 + .call(
3073 + Request::builder()
3074 + .method("POST")
3075 + .uri("/v1/completions")
3076 + .header("content-type", "application/json")
3077 + .body(Body::from(
3078 + json!({
3079 + "model": "Qwen/Qwen1.5-0.5B-Chat",
3080 + "prompt": prompt_token_ids,
3081 + "echo": true,
3082 + "stream": false
3083 + })
3084 + .to_string(),
3085 + ))
3086 + .expect("build request"),
3087 + )
3088 + .await
3089 + .expect("call app");
3090 +
3091 + assert_eq!(response.status(), StatusCode::OK);
3092 +
3093 + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
3094 + engine_task.await.expect("mock engine task");
3095 + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
3096 +
3097 + assert_eq!(json["choices"][0]["text"], "hellohi");
3098 + assert_eq!(json["usage"]["prompt_tokens"], 5);
3099 + assert_eq!(json["usage"]["completion_tokens"], 3);
3100 +}
3101 +
3102 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3103 +#[serial]
3104 +async fn non_stream_completions_token_id_echo_return_token_ids_keeps_prompt_ids_separate() {
3105 + let prompt_token_ids = bytes_to_token_ids(b"hello");
3106 + let expected_prompt_token_ids = prompt_token_ids.clone();
3107 + let (app, engine_task) = test_app_with_backend_and_engine_request_check(
3108 + Arc::new(FakeChatBackend::new()),
3109 + move |request| {
3110 + assert_eq!(
3111 + request.prompt_token_ids.as_deref(),
3112 + Some(expected_prompt_token_ids.as_slice())
3113 + );
3114 + },
3115 + )
3116 + .await;
3117 + let response = app
3118 + .clone()
3119 + .call(
3120 + Request::builder()
3121 + .method("POST")
3122 + .uri("/v1/completions")
3123 + .header("content-type", "application/json")
3124 + .body(Body::from(
3125 + json!({
3126 + "model": "Qwen/Qwen1.5-0.5B-Chat",
3127 + "prompt": prompt_token_ids,
3128 + "echo": true,
3129 + "return_token_ids": true,
3130 + "stream": false
3131 + })
3132 + .to_string(),
3133 + ))
3134 + .expect("build request"),
3135 + )
3136 + .await
3137 + .expect("call app");
3138 +
3139 + assert_eq!(response.status(), StatusCode::OK);
3140 +
3141 + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
3142 + engine_task.await.expect("mock engine task");
3143 + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
3144 +
3145 + assert_eq!(json["choices"][0]["text"], "hi");
3146 + assert_eq!(
3147 + json["choices"][0]["prompt_token_ids"],
3148 + json!(bytes_to_token_ids(b"hello"))
3149 + );
3150 + assert_eq!(
3151 + json["choices"][0]["token_ids"],
3152 + json!(bytes_to_token_ids(b"hi!"))
3153 + );
3154 +}
3155 +
3156 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3157 #[serial]
3158 async fn non_stream_completions_include_logprobs() {
@@ -4112,6 +4213,71 @@ async fn completions_echo_stream_emits_separate_prompt_chunk() {
4213 assert_eq!(usage_chunk["usage"]["completion_tokens"], 3);
4214 }
4215
4216 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4217 +#[serial]
4218 +async fn completions_echo_stream_decodes_token_id_prompt_chunk() {
4219 + let prompt_token_ids = bytes_to_token_ids(b"hello");
4220 + let expected_prompt_token_ids = prompt_token_ids.clone();
4221 + let (app, engine_task) = test_app_with_backend_and_engine_request_check(
4222 + Arc::new(FakeChatBackend::new()),
4223 + move |request| {
4224 + assert_eq!(
4225 + request.prompt_token_ids.as_deref(),
4226 + Some(expected_prompt_token_ids.as_slice())
4227 + );
4228 + },
4229 + )
4230 + .await;
4231 + let response = app
4232 + .clone()
4233 + .call(
4234 + Request::builder()
4235 + .method("POST")
4236 + .uri("/v1/completions")
4237 + .header("content-type", "application/json")
4238 + .body(Body::from(
4239 + json!({
4240 + "model": "Qwen/Qwen1.5-0.5B-Chat",
4241 + "prompt": prompt_token_ids,
4242 + "echo": true,
4243 + "stream": true,
4244 + "stream_options": {"include_usage": true}
4245 + })
4246 + .to_string(),
4247 + ))
4248 + .expect("build request"),
4249 + )
4250 + .await
4251 + .expect("call app");
4252 +
4253 + assert_eq!(response.status(), StatusCode::OK);
4254 +
4255 + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
4256 + engine_task.await.expect("mock engine task");
4257 + let text = String::from_utf8(body.to_vec()).expect("utf8 body");
4258 + let payloads = sse_data_payloads(&text);
4259 + let hello_index = payloads
4260 + .iter()
4261 + .position(|payload| payload.contains("\"text\":\"hello\""))
4262 + .expect("prompt echo chunk");
4263 + let h_index = payloads
4264 + .iter()
4265 + .position(|payload| payload.contains("\"text\":\"h\""))
4266 + .expect("first generation chunk");
4267 +
4268 + assert!(hello_index < h_index, "{text}");
4269 +
4270 + let usage_chunk: serde_json::Value = serde_json::from_str(
4271 + payloads
4272 + .iter()
4273 + .find(|payload| payload.contains("\"usage\":"))
4274 + .expect("usage chunk"),
4275 + )
4276 + .expect("usage chunk json");
4277 + assert_eq!(usage_chunk["usage"]["prompt_tokens"], 5);
4278 + assert_eq!(usage_chunk["usage"]["completion_tokens"], 3);
4279 +}
4280 +
4281 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4282 #[serial]
4283 async fn reasoning_blocks_are_mapped_to_reasoning_sse_chunks() {