[Rust Frontend] Align OpenAI request and response edge cases (#53218)

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

Bugen Zhao committed Aug 28, 2026 at 05:23 UTC 9db222c6bea2623e18ce09a8f270ac9cb7d86981
16 files changed +419 -69
rust/src/server/src/routes/openai/chat_completions.rs
+2 -2
@@ -63,7 +63,8 @@ pub async fn chat_completions(
63 ) -> Response {
64 let stream = body.stream;
65 let request_context = resolve_request_context(&headers, body.request_id.as_deref());
66 - let lora_resolution = state.resolve_model_with_loras(Some(&body.model)).await;
66 + let requested_model = body.model.as_deref().filter(|model| !model.is_empty());
67 + let lora_resolution = state.resolve_model_with_loras(requested_model).await;
68
69 let prepared = match prepare_chat_request(body, &lora_resolution, request_context) {
70 Ok(prepared) => prepared,
@@ -196,7 +197,6 @@ async fn collect_chat_completion(
197 Some(prompt_logprobs_to_maps(
198 prompt_logprobs.as_ref(),
199 &prompt_token_ids,
199 - return_tokens_as_token_ids,
200 )?)
201 } else {
202 None
rust/src/server/src/routes/openai/chat_completions/convert.rs
+118 -3
@@ -433,6 +433,7 @@ mod tests {
433 use expect_test::expect;
434 use llm_multimodal::ImageDetail;
435 use serde_json::json;
436 + use validator::Validate;
437 use vllm_chat::{
438 AssistantContentBlock, AssistantToolCall, ChatContentPart, ChatMessage as VllmChatMessage,
439 ChatRenderer, ChatTool as VllmChatTool, ChatToolChoice, GenerationPromptMode,
@@ -466,7 +467,7 @@ mod tests {
467
468 fn base_request() -> ChatCompletionRequest {
469 ChatCompletionRequest {
469 - model: "Qwen/Qwen1.5-0.5B-Chat".to_string(),
470 + model: Some("Qwen/Qwen1.5-0.5B-Chat".to_string()),
471 messages: vec![ChatMessage::User {
472 content: MessageContent::Text("hello".to_string()),
473 name: None,
@@ -476,6 +477,120 @@ mod tests {
477 }
478 }
479
480 + #[test]
481 + fn chat_http_request_defaults_missing_or_null_model() {
482 + for model in [None, Some(serde_json::Value::Null)] {
483 + let mut value = json!({
484 + "messages": [{"role": "user", "content": "hello"}],
485 + "stream": true,
486 + });
487 + if let Some(model) = model {
488 + value
489 + .as_object_mut()
490 + .expect("request object")
491 + .insert("model".to_string(), model);
492 + }
493 +
494 + let request: ChatCompletionRequest =
495 + serde_json::from_value(value).expect("parse request without model");
496 + assert!(request.model.is_none());
497 + }
498 +
499 + let request: ChatCompletionRequest = serde_json::from_value(json!({
500 + "messages": [{"role": "user", "content": "hello"}],
501 + "model": "",
502 + }))
503 + .expect("parse empty model");
504 + assert_eq!(request.model.as_deref(), Some(""));
505 + }
506 +
507 + #[test]
508 + fn prepare_chat_request_normalizes_top_k_sentinels() {
509 + for (top_k, expected) in [
510 + (serde_json::Value::Null, None),
511 + (json!(-1), Some(0)),
512 + (json!(0), Some(0)),
513 + (json!(20), Some(20)),
514 + ] {
515 + let request: ChatCompletionRequest = serde_json::from_value(json!({
516 + "model": "Qwen/Qwen1.5-0.5B-Chat",
517 + "messages": [{"role": "user", "content": "hello"}],
518 + "stream": true,
519 + "top_k": top_k,
520 + }))
521 + .expect("parse top_k");
522 + let prepared = prepare_chat_request(
523 + request,
524 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
525 + ResolvedRequestContext::default(),
526 + )
527 + .expect("prepare top_k");
528 +
529 + assert_eq!(prepared.chat_request.sampling_params.top_k, expected);
530 + }
531 + }
532 +
533 + #[test]
534 + fn prepare_chat_request_accepts_zero_min_tokens() {
535 + let request: ChatCompletionRequest = serde_json::from_value(json!({
536 + "model": "Qwen/Qwen1.5-0.5B-Chat",
537 + "messages": [{"role": "user", "content": "hello"}],
538 + "stream": true,
539 + "min_tokens": 0,
540 + }))
541 + .expect("parse zero min_tokens");
542 + request.validate().expect("validate zero min_tokens");
543 +
544 + let prepared = prepare_chat_request(
545 + request,
546 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
547 + ResolvedRequestContext::default(),
548 + )
549 + .expect("prepare zero min_tokens");
550 +
551 + assert_eq!(prepared.chat_request.sampling_params.min_tokens, Some(0));
552 + assert_eq!(prepared.chat_request.decode_options.min_tokens, 0);
553 + }
554 +
555 + #[test]
556 + fn prepare_chat_request_defaults_function_tool_fields() {
557 + let request: ChatCompletionRequest = serde_json::from_value(json!({
558 + "model": "Qwen/Qwen1.5-0.5B-Chat",
559 + "messages": [{"role": "user", "content": "hello"}],
560 + "tools": [{"function": {"name": "lookup"}}],
561 + }))
562 + .expect("parse tool defaults");
563 +
564 + let prepared = prepare_chat_request(
565 + request,
566 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
567 + ResolvedRequestContext::default(),
568 + )
569 + .expect("prepare tool defaults");
570 +
571 + assert_eq!(
572 + prepared.chat_request.tools(),
573 + &[VllmChatTool {
574 + name: "lookup".to_string(),
575 + description: None,
576 + parameters: serde_json::Value::Null,
577 + strict: None,
578 + }]
579 + );
580 + }
581 +
582 + #[test]
583 + fn chat_http_request_rejects_empty_cache_salt() {
584 + let request: ChatCompletionRequest = serde_json::from_value(json!({
585 + "model": "Qwen/Qwen1.5-0.5B-Chat",
586 + "messages": [{"role": "user", "content": "hello"}],
587 + "cache_salt": "",
588 + }))
589 + .expect("parse cache_salt");
590 +
591 + assert!(request.validate().is_err());
592 + }
593 +
594 #[test]
595 fn prepare_chat_request_maps_parallel_tool_calls() {
596 let mut request = base_request();
@@ -506,7 +621,7 @@ mod tests {
621 #[test]
622 fn prepare_chat_request_passes_response_format_to_kimi_k3_renderer() {
623 let mut request = base_request();
509 - request.model = "moonshotai/Kimi-K3".to_string();
624 + request.model = Some("moonshotai/Kimi-K3".to_string());
625 let response_format = ResponseFormat::JsonSchema {
626 json_schema: JsonSchemaFormat {
627 name: "answer".to_string(),
@@ -913,7 +1028,7 @@ mod tests {
1028 #[test]
1029 fn prepare_chat_request_accepts_audio_content_parts() {
1030 let request = ChatCompletionRequest {
916 - model: "Qwen/Qwen3-ASR-1.7B".to_string(),
1031 + model: Some("Qwen/Qwen3-ASR-1.7B".to_string()),
1032 messages: vec![ChatMessage::User {
1033 content: MessageContent::Parts(vec![
1034 ContentPart::InputAudio {
rust/src/server/src/routes/openai/chat_completions/types.rs
+8 -12
@@ -13,9 +13,9 @@ use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams;
13
14 use crate::routes::openai::utils::structured_outputs::ResponseFormat;
15 use crate::routes::openai::utils::types::{
16 - ChatLogProbs, ChatMessage, Normalizable, StreamOptions, StringOrArray, Tool, ToolCall,
17 - ToolCallDelta, ToolChoice, UNKNOWN_MODEL_ID, Usage, default_true, validate_messages,
18 - validate_stop, validate_top_p_value,
16 + ChatLogProbs, ChatMessage, Normalizable, PromptLogprobs, StreamOptions, StringOrArray, Tool,
17 + ToolCall, ToolCallDelta, ToolChoice, Usage, default_true, deserialize_request_top_k,
18 + validate_messages, validate_stop, validate_top_p_value,
19 };
20
21 /// vLLM-compatible request type for the Chat Completions API.
@@ -33,8 +33,7 @@ pub struct ChatCompletionRequest {
33 pub messages: Vec<ChatMessage>,
34
35 /// ID of the model to use
36 - #[serde(default = "default_model")]
37 - pub model: String,
36 + pub model: Option<String>,
37
38 /// Number between -2.0 and 2.0. Positive values penalize new tokens based
39 /// on their existing frequency in the text so far
@@ -120,6 +119,7 @@ pub struct ChatCompletionRequest {
119 pub use_beam_search: bool,
120
121 /// Top-k sampling parameter
122 + #[serde(default, deserialize_with = "deserialize_request_top_k")]
123 pub top_k: Option<u32>,
124
125 /// Min-p nucleus sampling parameter
@@ -145,7 +145,6 @@ pub struct ChatCompletionRequest {
145 pub ignore_eos: bool,
146
147 /// Minimum number of tokens to generate
148 - #[validate(range(min = 1))]
148 pub min_tokens: Option<u32>,
149
150 /// Skip special tokens during detokenization
@@ -233,6 +232,7 @@ pub struct ChatCompletionRequest {
232 pub return_token_ids: Option<bool>,
233
234 /// Salt for prefix cache isolation in multi-user environments
235 + #[validate(length(min = 1))]
236 pub cache_salt: Option<String>,
237
238 /// KV transfer parameters for disaggregated serving
@@ -254,7 +254,7 @@ impl Default for ChatCompletionRequest {
254 fn default() -> Self {
255 Self {
256 messages: Vec::new(),
257 - model: default_model(),
257 + model: None,
258 frequency_penalty: None,
259 logit_bias: None,
260 logprobs: false,
@@ -341,7 +341,7 @@ pub(super) struct ChatCompletionResponse {
341 pub choices: Vec<ChatCompletionChoice>,
342 pub usage: Option<Usage>,
343 pub system_fingerprint: Option<String>,
344 - pub prompt_logprobs: Option<Vec<Option<HashMap<String, f32>>>>,
344 + pub prompt_logprobs: Option<PromptLogprobs>,
345 pub prompt_token_ids: Option<Vec<u32>>,
346 pub kv_transfer_params: Option<Value>,
347 pub ec_transfer_params: Option<Value>,
@@ -429,10 +429,6 @@ pub(super) struct ChatMessageDelta {
429 pub reasoning: Option<String>,
430 }
431
432 -fn default_model() -> String {
433 - UNKNOWN_MODEL_ID.to_string()
434 -}
435 -
432 /// Schema-level validation for cross-field dependencies
433 fn validate_chat_cross_parameters(
434 req: &ChatCompletionRequest,
rust/src/server/src/routes/openai/chat_completions/validate.rs
+17 -3
@@ -10,8 +10,10 @@ pub(super) fn validate_request_compat(
10 request: &ChatCompletionRequest,
11 served_model_names: &[String],
12 ) -> Result<(), ApiError> {
13 - if !served_model_names.iter().any(|n| n == &request.model) {
14 - return Err(ApiError::model_not_found(request.model.clone()));
13 + if let Some(model) = request.model.as_ref().filter(|model| !model.is_empty())
14 + && !served_model_names.iter().any(|name| name == model)
15 + {
16 + return Err(ApiError::model_not_found(model.clone()));
17 }
18
19 if request.stream_options.is_some() && !request.stream {
@@ -142,7 +144,7 @@ mod tests {
144
145 fn base_request() -> ChatCompletionRequest {
146 ChatCompletionRequest {
145 - model: "Qwen/Qwen1.5-0.5B-Chat".to_string(),
147 + model: Some("Qwen/Qwen1.5-0.5B-Chat".to_string()),
148 messages: vec![ChatMessage::User {
149 content: MessageContent::Text("hello".to_string()),
150 name: None,
@@ -163,6 +165,18 @@ mod tests {
165 .expect("stop strings should be accepted");
166 }
167
168 + #[test]
169 + fn validate_request_compat_accepts_default_model_inputs() {
170 + for model in [None, Some(String::new())] {
171 + let request = ChatCompletionRequest {
172 + model,
173 + ..base_request()
174 + };
175 + validate_request_compat(&request, &served(&["served-model"]))
176 + .expect("default model input should select the primary served model");
177 + }
178 + }
179 +
180 #[test]
181 fn validate_request_compat_accepts_non_zero_penalties_and_function_tools() {
182 let request = ChatCompletionRequest {
rust/src/server/src/routes/openai/completions.rs
+2 -2
@@ -66,7 +66,8 @@ pub async fn completions(
66 ) -> Response {
67 let stream = body.stream;
68 let request_context = resolve_request_context(&headers, body.request_id.as_deref());
69 - let lora_resolution = state.resolve_model_with_loras(Some(&body.model)).await;
69 + let requested_model = body.model.as_deref().filter(|model| !model.is_empty());
70 + let lora_resolution = state.resolve_model_with_loras(requested_model).await;
71
72 let tokenizer = state.chat.text().tokenizer();
73 let prepared = match prepare_completion_request(
@@ -186,7 +187,6 @@ async fn collect_completion(
187 Some(prompt_logprobs_to_maps(
188 collected.prompt_logprobs.as_ref(),
189 collected.prompt_token_ids.as_ref(),
189 - return_tokens_as_token_ids,
190 )?)
191 } else {
192 None
rust/src/server/src/routes/openai/completions/convert.rs
+92 -11
@@ -40,7 +40,7 @@ pub(super) struct ResponseOptions {
40 /// Prompt text that should be echoed back northbound when `echo=true`.
41 pub echo: Option<String>,
42 /// Whether the caller requested output logprobs on completion choices.
43 - pub requested_logprobs: Option<u32>,
43 + pub requested_logprobs: Option<i32>,
44 /// Whether the caller requested choice-level prompt logprobs.
45 pub include_prompt_logprobs: bool,
46 /// Whether to include token IDs alongside generated text.
@@ -69,15 +69,7 @@ pub(super) fn prepare_completion_request(
69 .map(|request| request.lora_name.clone())
70 .unwrap_or_else(|| lora_resolution.model_names.first().cloned().unwrap_or_default());
71
72 - let logprobs = match request.logprobs {
73 - Some(logprobs) => Some(i32::try_from(logprobs).map_err(|_| {
74 - ApiError::invalid_request(
75 - "`logprobs` must fit within a signed 32-bit integer.".to_string(),
76 - Some("logprobs"),
77 - )
78 - })?),
79 - None => None,
80 - };
72 + let logprobs = request.logprobs;
73 let prompt_only = request.echo && request.max_tokens == Some(0);
74 let prompt_logprobs =
75 request.prompt_logprobs.or(if request.echo && (!request.stream || prompt_only) {
@@ -207,6 +199,7 @@ fn completion_echo_text(
199 mod tests {
200 use axum::http::HeaderMap;
201 use serde_json::json;
202 + use validator::Validate;
203 use vllm_text::Prompt;
204 use vllm_tokenizer::test_utils::TestTokenizer;
205
@@ -245,7 +238,35 @@ mod tests {
238 serde_json::from_value(base_request_json()).expect("parse request");
239
240 assert_eq!(request.prompt, Prompt::Text("hello".to_string()));
248 - assert_eq!(request.model, "Qwen/Qwen1.5-0.5B-Chat");
241 + assert_eq!(request.model.as_deref(), Some("Qwen/Qwen1.5-0.5B-Chat"));
242 + }
243 +
244 + #[test]
245 + fn completion_http_request_defaults_missing_or_null_model() {
246 + for model in [None, Some(serde_json::Value::Null)] {
247 + let mut value = base_request_json();
248 + let object = value.as_object_mut().expect("request object");
249 + match model {
250 + Some(model) => {
251 + object.insert("model".to_string(), model);
252 + }
253 + None => {
254 + object.remove("model");
255 + }
256 + }
257 +
258 + let request: CompletionRequest =
259 + serde_json::from_value(value).expect("parse request without model");
260 + assert!(request.model.is_none());
261 + }
262 +
263 + let mut value = base_request_json();
264 + value
265 + .as_object_mut()
266 + .expect("request object")
267 + .insert("model".to_string(), json!(""));
268 + let request: CompletionRequest = serde_json::from_value(value).expect("parse empty model");
269 + assert_eq!(request.model.as_deref(), Some(""));
270 }
271
272 #[test]
@@ -264,6 +285,44 @@ mod tests {
285 assert!(request.ignore_eos);
286 }
287
288 + #[test]
289 + fn completion_http_request_rejects_empty_cache_salt() {
290 + let mut value = base_request_json();
291 + value
292 + .as_object_mut()
293 + .expect("request object")
294 + .insert("cache_salt".to_string(), json!(""));
295 + let request: CompletionRequest = serde_json::from_value(value).expect("parse cache_salt");
296 +
297 + assert!(request.validate().is_err());
298 + }
299 +
300 + #[test]
301 + fn prepare_completion_request_normalizes_top_k_sentinels() {
302 + for (top_k, expected) in [
303 + (serde_json::Value::Null, None),
304 + (json!(-1), Some(0)),
305 + (json!(0), Some(0)),
306 + (json!(20), Some(20)),
307 + ] {
308 + let mut value = base_request_json();
309 + value
310 + .as_object_mut()
311 + .expect("request object")
312 + .insert("top_k".to_string(), top_k);
313 + let request: CompletionRequest = serde_json::from_value(value).expect("parse top_k");
314 + let prepared = prepare_completion_request(
315 + request,
316 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
317 + ResolvedRequestContext::default(),
318 + &test_tokenizer(),
319 + )
320 + .expect("prepare top_k");
321 +
322 + assert_eq!(prepared.text_request.sampling_params.top_k, expected);
323 + }
324 + }
325 +
326 #[test]
327 fn normalize_coerces_null_max_tokens_to_default() {
328 // An absent `max_tokens` already gets the serde default.
@@ -572,6 +631,28 @@ mod tests {
631 );
632 }
633
634 + #[test]
635 + fn prepare_completion_request_accepts_full_vocab_logprobs() {
636 + let request: CompletionRequest = serde_json::from_value(json!({
637 + "model": "Qwen/Qwen1.5-0.5B-Chat",
638 + "prompt": "hello",
639 + "stream": false,
640 + "logprobs": -1
641 + }))
642 + .expect("parse request");
643 +
644 + let prepared = prepare_completion_request(
645 + request,
646 + &served(&["Qwen/Qwen1.5-0.5B-Chat"]),
647 + ResolvedRequestContext::default(),
648 + &test_tokenizer(),
649 + )
650 + .expect("prepare full-vocabulary logprobs");
651 +
652 + assert_eq!(prepared.text_request.sampling_params.logprobs, Some(-1));
653 + assert_eq!(prepared.options.requested_logprobs, Some(-1));
654 + }
655 +
656 #[test]
657 fn prepare_completion_request_threads_data_parallel_rank() {
658 let request: CompletionRequest = serde_json::from_value(json!({
rust/src/server/src/routes/openai/completions/types.rs
+7 -4
@@ -10,7 +10,8 @@ use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams;
10 use vllm_text::Prompt;
11
12 use crate::routes::openai::utils::types::{
13 - LogProbs, Normalizable, StreamOptions, StringOrArray, Usage, default_true, validate_stop,
13 + LogProbs, Normalizable, PromptLogprobs, StreamOptions, StringOrArray, Usage, default_true,
14 + deserialize_request_top_k, validate_stop,
15 };
16
17 /// Serde default for `CompletionRequest::max_tokens`, matching the Python vLLM
@@ -30,7 +31,7 @@ fn default_completion_max_tokens() -> Option<u32> {
31 pub struct CompletionRequest {
32 // -------- Standard OpenAI API Parameters --------
33 /// ID of the model to use
33 - pub model: String,
34 + pub model: Option<String>,
35
36 /// The prompt(s) to generate completions for.
37 ///
@@ -49,7 +50,7 @@ pub struct CompletionRequest {
50 pub logit_bias: Option<HashMap<String, f32>>,
51
52 /// Include the log probabilities on the logprobs most likely tokens
52 - pub logprobs: Option<u32>,
53 + pub logprobs: Option<i32>,
54
55 /// The maximum number of tokens to generate (defaults to 16 when absent,
56 /// matching the Python vLLM / OpenAI API convention)
@@ -96,6 +97,7 @@ pub struct CompletionRequest {
97 pub use_beam_search: bool,
98
99 /// Top-k sampling parameter
100 + #[serde(default, deserialize_with = "deserialize_request_top_k")]
101 pub top_k: Option<u32>,
102
103 /// Min-p nucleus sampling parameter
@@ -175,6 +177,7 @@ pub struct CompletionRequest {
177 pub return_token_ids: Option<bool>,
178
179 /// Salt for prefix cache isolation in multi-user environments
180 + #[validate(length(min = 1))]
181 pub cache_salt: Option<String>,
182
183 /// KV transfer parameters for disaggregated serving
@@ -229,7 +232,7 @@ pub(super) struct CompletionChoice {
232 pub logprobs: Option<LogProbs>,
233 pub finish_reason: Option<String>,
234 pub stop_reason: Option<Value>,
232 - pub prompt_logprobs: Option<Vec<Option<HashMap<String, f32>>>>,
235 + pub prompt_logprobs: Option<PromptLogprobs>,
236 pub token_ids: Option<Vec<u32>>,
237 pub prompt_token_ids: Option<Vec<u32>>,
238 }
rust/src/server/src/routes/openai/completions/validate.rs
+40 -4
@@ -12,8 +12,10 @@ pub(super) fn validate_request_compat(
12 // This path is intentionally scoped to the minimum surface needed by
13 // `vllm-bench` random workload compatibility, so unsupported legacy
14 // completions features fail early here.
15 - if !served_model_names.iter().any(|n| n == &request.model) {
16 - return Err(ApiError::model_not_found(request.model.clone()));
15 + if let Some(model) = request.model.as_ref().filter(|model| !model.is_empty())
16 + && !served_model_names.iter().any(|name| name == model)
17 + {
18 + return Err(ApiError::model_not_found(model.clone()));
19 }
20
21 if request.stream_options.is_some() && !request.stream {
@@ -39,11 +41,12 @@ pub(super) fn validate_request_compat(
41 }
42
43 if let Some(logprobs) = request.logprobs
42 - && logprobs > i32::MAX as u32
44 + && logprobs < 0
45 + && logprobs != -1
46 {
47 bail_invalid_request!(
48 param = "logprobs",
46 - "`logprobs` must fit within a signed 32-bit integer."
49 + "`logprobs` must be a non-negative value or -1."
50 );
51 }
52
@@ -123,6 +126,28 @@ mod tests {
126 );
127 }
128
129 + #[test]
130 + fn validate_request_compat_accepts_full_vocab_logprobs() {
131 + let request = CompletionRequest {
132 + logprobs: Some(-1),
133 + ..base_request()
134 + };
135 + assert!(
136 + validate_request_compat(&request, &served_names(&["Qwen/Qwen1.5-0.5B-Chat"])).is_ok()
137 + );
138 + }
139 +
140 + #[test]
141 + fn validate_request_compat_rejects_other_negative_logprobs() {
142 + let request = CompletionRequest {
143 + logprobs: Some(-2),
144 + ..base_request()
145 + };
146 + assert!(
147 + validate_request_compat(&request, &served_names(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err()
148 + );
149 + }
150 +
151 #[test]
152 fn validate_request_compat_accepts_any_served_name() {
153 let request = base_request();
@@ -135,6 +160,17 @@ mod tests {
160 );
161 }
162
163 + #[test]
164 + fn validate_request_compat_accepts_default_model_inputs() {
165 + for model in [None, Some(String::new())] {
166 + let request = CompletionRequest {
167 + model,
168 + ..base_request()
169 + };
170 + assert!(validate_request_compat(&request, &served_names(&["served-model"])).is_ok());
171 + }
172 + }
173 +
174 #[test]
175 fn validate_request_compat_rejects_unknown_model() {
176 let request = base_request();
rust/src/server/src/routes/openai/utils/logprobs.rs
+43 -8
@@ -9,7 +9,9 @@ use vllm_text::{
9 DecodedTokenLogprob,
10 };
11
12 -use super::types::{ChatLogProbs, ChatLogProbsContent, LogProbs, TopLogProb};
12 +use super::types::{
13 + ChatLogProbs, ChatLogProbsContent, LogProbs, PromptLogprob, PromptLogprobs, TopLogProb,
14 +};
15 use crate::error::{ApiError, server_error};
16
17 /// Convert decoded token-position logprobs into the OpenAI completions
@@ -105,15 +107,26 @@ pub fn decoded_prompt_logprobs_to_openai(
107 pub fn prompt_logprobs_to_maps(
108 prompt_logprobs: Option<&DecodedPromptLogprobs>,
109 prompt_token_ids: &[u32],
108 - return_tokens_as_token_ids: bool,
109 -) -> Result<Vec<Option<HashMap<String, f32>>>, ApiError> {
110 +) -> Result<PromptLogprobs, ApiError> {
111 if let Some(prompt_logprobs) = prompt_logprobs {
112 return Ok(std::iter::once(None)
113 .chain(prompt_logprobs.scored_positions.iter().map(|position| {
113 - Some(position_top_logprobs_map(
114 - position,
115 - return_tokens_as_token_ids,
116 - ))
114 + Some(
115 + position
116 + .entries
117 + .iter()
118 + .map(|entry| {
119 + (
120 + entry.token_id,
121 + PromptLogprob {
122 + logprob: clamp_logprob(entry.logprob),
123 + rank: entry.rank,
124 + decoded_token: entry.token.clone(),
125 + },
126 + )
127 + })
128 + .collect(),
129 + )
130 }))
131 .collect());
132 }
@@ -290,10 +303,32 @@ mod tests {
303
304 #[test]
305 fn prompt_logprobs_maps_reject_missing_multi_token_payload() {
293 - prompt_logprobs_to_maps(None, &[9707, 11], false)
306 + prompt_logprobs_to_maps(None, &[9707, 11])
307 .expect_err("multi-token prompt without payload is an engine failure");
308 }
309
310 + #[test]
311 + fn prompt_logprobs_maps_preserve_token_ids_and_metadata() {
312 + let prompt_logprobs = vllm_text::DecodedPromptLogprobs {
313 + first_token_id: 10,
314 + first_token: "first".to_string(),
315 + scored_positions: sample_logprobs().positions,
316 + };
317 +
318 + let maps = prompt_logprobs_to_maps(Some(&prompt_logprobs), &[10, 1]).expect("maps");
319 + assert_eq!(
320 + serde_json::to_value(maps).expect("serialize prompt logprobs"),
321 + serde_json::json!([
322 + null,
323 + {
324 + "1": {"logprob": -0.10000000149011612_f64, "rank": 1, "decoded_token": "A"},
325 + "2": {"logprob": -1.0, "rank": 2, "decoded_token": "B"},
326 + "3": {"logprob": -2.0, "rank": 3, "decoded_token": "C"},
327 + }
328 + ])
329 + );
330 + }
331 +
332 fn sample_logprobs() -> DecodedLogprobs {
333 DecodedLogprobs {
334 positions: vec![DecodedPositionLogprobs {
rust/src/server/src/routes/openai/utils/types.rs
+34 -4
@@ -5,7 +5,7 @@ use std::collections::HashMap;
5 use std::slice;
6
7 use llm_multimodal::ImageDetail;
8 -use serde::{Deserialize, Serialize};
8 +use serde::{Deserialize, Deserializer, Serialize};
9 use serde_json::Value;
10 use vllm_llm::TokenUsage;
11
@@ -13,9 +13,6 @@ use vllm_llm::TokenUsage;
13 // Constants
14 // ============================================================================
15
16 -/// Default model identifier used when no model is specified.
17 -pub const UNKNOWN_MODEL_ID: &str = "unknown";
18 -
16 // ============================================================================
17 // Default value helpers
18 // ============================================================================
@@ -25,6 +22,23 @@ pub fn default_true() -> bool {
22 true
23 }
24
25 +/// Deserialize an OpenAI request `top_k` while preserving explicit disable.
26 +///
27 +/// Null remains `None` so model generation defaults apply. Explicit `-1` and
28 +/// `0` become `Some(0)` so the request overrides those defaults and disables
29 +/// top-k sampling. Positive limits are preserved.
30 +pub fn deserialize_request_top_k<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
31 +where
32 + D: Deserializer<'de>,
33 +{
34 + match Option::<i64>::deserialize(deserializer)? {
35 + None => Ok(None),
36 + Some(value) => vllm_text::normalize_top_k(value)
37 + .map(|value| Some(value.unwrap_or(0)))
38 + .map_err(serde::de::Error::custom),
39 + }
40 +}
41 +
42 // ============================================================================
43 // String/Array Utilities
44 // ============================================================================
@@ -201,15 +215,21 @@ pub struct StreamOptions {
215 #[derive(Debug, Clone, Deserialize, Serialize)]
216 pub struct Tool {
217 #[serde(rename = "type")]
218 + #[serde(default = "default_function_tool_type")]
219 pub tool_type: String,
220 pub function: Function,
221 }
222
223 +fn default_function_tool_type() -> String {
224 + "function".to_string()
225 +}
226 +
227 #[serde_with::skip_serializing_none]
228 #[derive(Debug, Clone, Deserialize, Serialize)]
229 pub struct Function {
230 pub name: String,
231 pub description: Option<String>,
232 + #[serde(default)]
233 pub parameters: Value,
234 /// Whether to enable strict schema adherence (OpenAI structured outputs).
235 pub strict: Option<bool>,
@@ -480,6 +500,16 @@ pub struct LogProbs {
500 pub text_offset: Vec<u32>,
501 }
502
503 +/// vLLM prompt-logprob metadata keyed by vocabulary token ID.
504 +#[derive(Debug, Clone, PartialEq, Serialize)]
505 +pub struct PromptLogprob {
506 + pub logprob: f32,
507 + pub rank: u32,
508 + pub decoded_token: String,
509 +}
510 +
511 +pub type PromptLogprobs = Vec<Option<HashMap<u32, PromptLogprob>>>;
512 +
513 /// Mirrors the Python vLLM `ChatCompletionLogProbs` class.
514 #[derive(Debug, Clone, Serialize)]
515 pub struct ChatLogProbs {
rust/src/server/src/routes/render.rs
+10 -2
@@ -68,6 +68,14 @@ fn model_resolution(state: &RenderState) -> LoraModelResolution {
68 }
69 }
70
71 +fn response_model(state: &RenderState, requested_model: Option<&str>) -> String {
72 + requested_model
73 + .filter(|model| !model.is_empty())
74 + .or_else(|| state.served_model_names.first().map(String::as_str))
75 + .unwrap_or_default()
76 + .to_string()
77 +}
78 +
79 fn lower_render_request(
80 state: &RenderState,
81 text_request: TextRequest,
@@ -108,7 +116,7 @@ async fn render_chat(
116 headers: HeaderMap,
117 ValidatedJson(body): ValidatedJson<ChatCompletionRequest>,
118 ) -> Result<Json<GenerateRequest>, ApiError> {
111 - let model = body.model.clone();
119 + let model = response_model(&state, body.model.as_deref());
120 let stream = body.stream;
121 let stream_options = body.stream_options.clone();
122 let request_context = resolve_request_context(&headers, body.request_id.as_deref());
@@ -132,7 +140,7 @@ async fn render_completion(
140 headers: HeaderMap,
141 ValidatedJson(body): ValidatedJson<CompletionRequest>,
142 ) -> Result<Json<Vec<GenerateRequest>>, ApiError> {
135 - let model = body.model.clone();
143 + let model = response_model(&state, body.model.as_deref());
144 let stream = body.stream;
145 let stream_options = body.stream_options.clone();
146 let request_context = resolve_request_context(&headers, body.request_id.as_deref());
rust/src/server/src/routes/tests.rs
+4 -1
@@ -3654,7 +3654,10 @@ async fn non_stream_completions_include_prompt_logprobs() {
3654 );
3655 assert_eq!(
3656 json["choices"][0]["prompt_logprobs"][1],
3657 - json!({"a": -0.5, "e": -0.3})
3657 + json!({
3658 + "97": {"logprob": -0.5, "rank": 1, "decoded_token": "a"},
3659 + "101": {"logprob": -0.3, "rank": 1, "decoded_token": "e"},
3660 + })
3661 );
3662 }
3663
rust/src/text/src/backend/hf/config.rs
+9 -12
@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
9 use thiserror_ext::AsReport as _;
10
11 use crate::error::{Error, Result};
12 +use crate::normalize_top_k;
13
14 /// Minimal subset of `tokenizer_config.json` needed by chat/EOS handling.
15 #[derive(Debug, Default, Deserialize)]
@@ -118,23 +119,19 @@ pub(super) struct GenerationConfig {
119 pub max_new_tokens: Option<u32>,
120 }
121
121 -/// Deserialize vLLM-compatible `top_k` values from generation configs.
122 +/// Deserialize a generation-config `top_k` into the text sampling model.
123 ///
123 -/// Both `-1` and `0` disable top-k sampling and become `None`; positive values
124 -/// are preserved.
124 +/// Null, `-1`, and `0` all disable top-k sampling; positive limits are
125 +/// preserved.
126 fn deserialize_top_k<'de, D>(deserializer: D) -> std::result::Result<Option<u32>, D::Error>
127 where
128 D: serde::Deserializer<'de>,
129 {
129 - match Option::<i64>::deserialize(deserializer)? {
130 - None | Some(-1) | Some(0) => Ok(None),
131 - Some(value) if value > 0 => {
132 - u32::try_from(value).map(Some).map_err(serde::de::Error::custom)
133 - }
134 - Some(value) => Err(serde::de::Error::custom(format!(
135 - "top_k must be -1, 0, or a positive integer, got {value}"
136 - ))),
137 - }
130 + Option::<i64>::deserialize(deserializer)?
131 + .map(normalize_top_k)
132 + .transpose()
133 + .map(Option::flatten)
134 + .map_err(serde::de::Error::custom)
135 }
136
137 /// HF generation configs allow either one EOS id or a list of EOS ids.
rust/src/text/src/lib.rs
+1 -1
@@ -21,7 +21,7 @@ pub use output::{
21 CollectedTextOutput, DecodedLogprobs, DecodedPositionLogprobs, DecodedPromptLogprobs,
22 DecodedTextEvent, DecodedTokenLogprob, Finished, TextDecodeOptions, TextOutputStreamExt,
23 };
24 -pub use request::{Prompt, SamplingParams, TextRequest};
24 +pub use request::{Prompt, SamplingParams, TextRequest, normalize_top_k};
25 use trait_set::trait_set;
26 use vllm_engine_core_client::EngineCoreClient;
27 pub use vllm_llm::FinishReason;
rust/src/text/src/lower.rs
+14
@@ -434,6 +434,20 @@ mod tests {
434 ));
435 }
436
437 + #[test]
438 + fn lower_sampling_params_preserves_zero_min_tokens() {
439 + let params = lower_sampling_params_with_limits(
440 + SamplingParams {
441 + min_tokens: Some(0),
442 + ..SamplingParams::default()
443 + },
444 + sample_sampling_limits(),
445 + )
446 + .expect("lower zero min_tokens");
447 +
448 + assert_eq!(params.min_tokens, 0);
449 + }
450 +
451 #[test]
452 fn lower_sampling_params_validates_repetition_detection() {
453 let lower = |repetition_detection| {
rust/src/text/src/request.rs
+18
@@ -15,6 +15,24 @@ use vllm_engine_core_client::protocol::structured_outputs::StructuredOutputsPara
15 use crate::error::{Error, Result};
16 use crate::output::TextDecodeOptions;
17
18 +/// Normalize vLLM's signed `top_k` domain into [`SamplingParams::top_k`].
19 +///
20 +/// vLLM uses both `-1` and `0` to disable top-k sampling. The text sampling
21 +/// model represents that state as `None` and preserves positive limits as
22 +/// `Some(k)`. Values below `-1` and values above `u32::MAX` are rejected.
23 +///
24 +/// Callers that need to distinguish an omitted value from an explicit disable
25 +/// should preserve that request-level distinction around this normalization.
26 +pub fn normalize_top_k(value: i64) -> std::result::Result<Option<u32>, String> {
27 + match value {
28 + -1 | 0 => Ok(None),
29 + value if value > 0 => u32::try_from(value).map(Some).map_err(|error| error.to_string()),
30 + value => Err(format!(
31 + "top_k must be -1, 0, or a positive integer, got {value}"
32 + )),
33 + }
34 +}
35 +
36 /// One raw text-generation prompt.
37 ///
38 /// This supports either ordinary text that still needs tokenization or