#![cfg(feature = "vllm")]
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct SamplingParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub min_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repetition_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_token_ids: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ignore_eos: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_special_tokens: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub spaces_between_special_tokens: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_stop_str_in_output: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncate_prompt_tokens: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncation_side: Option<TruncationSide>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_logprobs: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logprob_token_ids: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_token_ids: Option<Vec<u32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bad_words: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub length_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_beam_search: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub watermarking: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct ChatParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub echo: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub add_generation_prompt: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub continue_final_message: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub add_special_tokens: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_template: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_template_kwargs: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub documents: Option<Vec<std::collections::HashMap<String, String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mm_processor_kwargs: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_io_kwargs: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub structured_outputs: Option<StructuredOutputsParams>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_interval: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kv_transfer_params: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ec_transfer_params: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub return_tokens_as_token_ids: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub return_token_ids: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub return_token_offsets: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub return_prompt_text: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repetition_detection: Option<RepetitionDetectionParams>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vllm_xargs: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub routed_experts_prompt_start: Option<u32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct StructuredOutputsParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub json: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub regex: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub choice: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grammar: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub json_object: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub structural_tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_any_whitespace: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_additional_properties: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub whitespace_pattern: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct RepetitionDetectionParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_pattern_size: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_pattern_size: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_count: Option<u32>,
}
crate::wire_string_enum! {
pub enum TruncationSide {
Left => "left",
Right => "right",
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Logprob {
pub logprob: f64,
pub rank: Option<u32>,
pub decoded_token: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(untagged)]
pub enum StopReason {
Text(String),
TokenId(u32),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::create::request::{Message, RequestBody};
#[test]
fn sampling_params_flatten_to_top_level() {
let request = RequestBody {
messages: vec![Message::user("Hello")],
model: "Qwen/Qwen3-8B".to_string(),
vllm_sampling: Some(SamplingParams {
min_p: Some(0.1),
repetition_penalty: Some(1.05),
stop_token_ids: Some(vec![151645, 151643]),
ignore_eos: Some(false),
min_tokens: Some(16),
truncate_prompt_tokens: Some(-1),
truncation_side: Some(TruncationSide::Left),
prompt_logprobs: Some(3),
allowed_token_ids: Some(vec![1, 2, 3]),
bad_words: Some(vec!["<|im_start|>".to_string()]),
..Default::default()
}),
..Default::default()
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["min_p"], serde_json::json!(0.1f32));
assert_eq!(json["repetition_penalty"], serde_json::json!(1.05f32));
assert_eq!(json["stop_token_ids"], serde_json::json!([151645, 151643]));
assert_eq!(json["ignore_eos"], false);
assert_eq!(json["min_tokens"], 16);
assert_eq!(json["truncate_prompt_tokens"], -1);
assert_eq!(json["truncation_side"], "left");
assert_eq!(json["prompt_logprobs"], 3);
assert_eq!(json["bad_words"], serde_json::json!(["<|im_start|>"]));
assert!(json.get("skip_special_tokens").is_none());
assert!(json.get("watermarking").is_none());
}
#[test]
fn chat_params_flatten_to_top_level() {
let request = RequestBody {
messages: vec![Message::user("Hello")],
model: "Qwen/Qwen3-8B".to_string(),
vllm_chat: Some(ChatParams {
chat_template_kwargs: Some(
serde_json::from_value(serde_json::json!({"enable_thinking": false})).unwrap(),
),
structured_outputs: Some(StructuredOutputsParams {
choice: Some(vec!["positive".to_string(), "negative".to_string()]),
disable_any_whitespace: Some(true),
..Default::default()
}),
priority: Some(10),
kv_transfer_params: Some(
serde_json::from_value(serde_json::json!({"do_remote_decode": true})).unwrap(),
),
..Default::default()
}),
..Default::default()
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["chat_template_kwargs"]["enable_thinking"], false);
assert_eq!(
json["structured_outputs"],
serde_json::json!({"choice": ["positive", "negative"], "disable_any_whitespace": true})
);
assert_eq!(json["priority"], 10);
assert_eq!(json["kv_transfer_params"]["do_remote_decode"], true);
assert!(json.get("echo").is_none());
}
#[test]
fn structured_outputs_json_accepts_object_and_string() {
let inline = StructuredOutputsParams {
json: Some(serde_json::json!({"type": "object"})),
..Default::default()
};
assert_eq!(
serde_json::to_value(&inline).unwrap()["json"],
serde_json::json!({"type": "object"})
);
let as_string = StructuredOutputsParams {
json: Some(serde_json::json!(r#"{"type":"object"}"#)),
..Default::default()
};
assert_eq!(
serde_json::to_value(&as_string).unwrap()["json"],
serde_json::json!(r#"{"type":"object"}"#)
);
}
#[test]
fn vllm_body_round_trips_without_duplicating_keys() {
let json = r#"{
"model": "Qwen/Qwen3-8B",
"messages": [{"role": "user", "content": "Hello"}],
"min_p": 0.1,
"prompt_logprobs": 2,
"chat_template_kwargs": {"enable_thinking": true},
"structured_outputs": {"regex": "[a-z]+"},
"some_future_vllm_field": 42
}"#;
let parsed: RequestBody = serde_json::from_str(json).unwrap();
let sampling = parsed.vllm_sampling.as_ref().expect("vllm_sampling");
assert_eq!(sampling.min_p, Some(0.1));
assert_eq!(sampling.prompt_logprobs, Some(2));
let chat = parsed.vllm_chat.as_ref().expect("vllm_chat");
assert_eq!(
chat.chat_template_kwargs
.as_ref()
.expect("chat_template_kwargs")["enable_thinking"],
serde_json::json!(true)
);
assert_eq!(
chat.structured_outputs
.as_ref()
.expect("structured_outputs")
.regex
.as_deref(),
Some("[a-z]+")
);
let extra = parsed.extra_body_map.as_ref().expect("extra_body_map");
assert_eq!(extra.len(), 1, "extra_body_map: {extra:?}");
assert_eq!(extra["some_future_vllm_field"], 42);
let reserialized = serde_json::to_string(&parsed).unwrap();
let reparsed: serde_json::Value = serde_json::from_str(&reserialized).unwrap();
assert_eq!(reparsed["min_p"], 0.1);
assert_eq!(reparsed["prompt_logprobs"], 2);
assert_eq!(reparsed["structured_outputs"]["regex"], "[a-z]+");
assert_eq!(reparsed["some_future_vllm_field"], 42);
assert_eq!(
reserialized.matches(r#""min_p""#).count(),
1,
"duplicated key in {reserialized}"
);
assert_eq!(
reserialized.matches(r#""structured_outputs""#).count(),
1,
"duplicated key in {reserialized}"
);
}
#[test]
fn unset_vllm_params_add_no_keys() {
let request = RequestBody {
messages: vec![Message::user("Hello")],
model: "gpt-4.1".to_string(),
vllm_sampling: Some(SamplingParams::default()),
vllm_chat: Some(ChatParams::default()),
..Default::default()
};
let json = serde_json::to_string(&request).unwrap();
assert_eq!(
json,
r#"{"messages":[{"role":"user","content":"Hello"}],"model":"gpt-4.1"}"#
);
}
#[test]
fn completions_body_takes_sampling_params_only() {
let request = crate::completions::request::CompletionRequest {
model: "Qwen/Qwen3-8B".to_string(),
prompt: crate::completions::request::Prompt::PromptString("Hello".to_string()),
vllm_sampling: Some(SamplingParams {
min_p: Some(0.1),
skip_special_tokens: Some(false),
..Default::default()
}),
..Default::default()
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["min_p"], serde_json::json!(0.1f32));
assert_eq!(json["skip_special_tokens"], false);
}
#[test]
fn non_streaming_response_extras_parse() {
use crate::chat::ChatCompletion;
let json = r#"{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"created": 1735113344,
"model": "Qwen/Qwen3-8B",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hi"},
"logprobs": null,
"finish_reason": "stop",
"stop_reason": "<|im_end|>",
"token_ids": [9707, 151645]
}],
"usage": {"prompt_tokens": 4, "completion_tokens": 2, "total_tokens": 6},
"prompt_logprobs": [
null,
{"9707": {"logprob": -0.001, "rank": 1, "decoded_token": "Hello"}}
],
"prompt_token_ids": [151644, 8948, 198, 9707],
"prompt_text": "<|im_start|>user\nHello<|im_end|>\n",
"kv_transfer_params": {"do_remote_decode": true}
}"#;
let parsed: ChatCompletion = json.parse().expect("vLLM response must parse");
assert_eq!(
parsed.prompt_token_ids.unwrap(),
vec![151644, 8948, 198, 9707]
);
assert!(parsed.prompt_text.unwrap().starts_with("<|im_start|>"));
assert_eq!(
parsed.kv_transfer_params.unwrap()["do_remote_decode"],
serde_json::json!(true)
);
let prompt_logprobs = parsed.prompt_logprobs.expect("prompt_logprobs");
assert_eq!(prompt_logprobs.len(), 2);
assert!(prompt_logprobs[0].is_none());
let entry = &prompt_logprobs[1].as_ref().expect("position 1")[&9707];
assert_eq!(entry.rank, Some(1));
assert_eq!(entry.decoded_token.as_deref(), Some("Hello"));
assert!((entry.logprob - -0.001).abs() < 1e-9);
let choice = &parsed.choices[0];
assert_eq!(choice.token_ids.clone().unwrap(), vec![9707, 151645]);
assert_eq!(
choice.stop_reason,
Some(StopReason::Text("<|im_end|>".to_string()))
);
assert_eq!(choice.finish_reason.as_str(), "stop");
}
#[test]
fn stop_reason_accepts_token_id_and_string() {
use crate::chat::create::response::streaming::ChatCompletionChunk;
let by_id: ChatCompletionChunk = r#"{
"id": "chatcmpl-abc123", "object": "chat.completion.chunk",
"created": 1735113344, "model": "Qwen/Qwen3-8B",
"prompt_token_ids": [151644, 9707],
"choices": [{
"index": 0, "delta": {"role": "assistant", "content": "Hi"},
"logprobs": null, "finish_reason": "stop",
"stop_reason": 151645, "token_ids": [9707]
}]
}"#
.parse()
.expect("chunk with an integer stop_reason must parse");
assert_eq!(
by_id.choices[0].stop_reason,
Some(StopReason::TokenId(151645))
);
assert_eq!(by_id.choices[0].token_ids.clone().unwrap(), vec![9707]);
assert_eq!(by_id.prompt_token_ids.clone().unwrap(), vec![151644, 9707]);
assert!(by_id.prompt_text.is_none());
let by_text: ChatCompletionChunk = r#"{
"id": "chatcmpl-abc123", "object": "chat.completion.chunk",
"created": 1735113344, "model": "Qwen/Qwen3-8B",
"choices": [{
"index": 0, "delta": {"content": "Hi"},
"finish_reason": "stop", "stop_reason": "<|im_end|>"
}]
}"#
.parse()
.expect("chunk with a string stop_reason must parse");
assert_eq!(
by_text.choices[0].stop_reason,
Some(StopReason::Text("<|im_end|>".to_string()))
);
assert!(by_text.prompt_token_ids.is_none());
}
#[test]
fn plain_openai_response_leaves_vllm_fields_none() {
use crate::chat::ChatCompletion;
let parsed: ChatCompletion = r#"{
"id": "chatcmpl-1", "object": "chat.completion", "created": 1,
"model": "gpt-4.1",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hi"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
}"#
.parse()
.expect("plain OpenAI response must parse with the vllm feature on");
assert!(parsed.prompt_logprobs.is_none());
assert!(parsed.prompt_token_ids.is_none());
assert!(parsed.prompt_text.is_none());
assert!(parsed.kv_transfer_params.is_none());
assert!(parsed.ec_transfer_params.is_none());
assert!(parsed.choices[0].stop_reason.is_none());
assert!(parsed.choices[0].token_ids.is_none());
assert!(parsed.choices[0].routed_experts.is_none());
}
#[test]
fn reasoning_streams_under_vllm_key_and_accumulates() {
use crate::chat::create::accumulator::ChatCompletionAccumulator;
use crate::chat::create::response::streaming::ChatCompletionChunk;
let chunks = [
r#"{"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","reasoning":"Let me "},"finish_reason":null}]}"#,
r#"{"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"reasoning":"think."},"finish_reason":null}]}"#,
r#"{"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":"stop"}]}"#,
];
let mut accumulator = ChatCompletionAccumulator::new();
for chunk in chunks {
let parsed: ChatCompletionChunk = chunk.parse().expect("chunk must parse");
accumulator.push(&parsed);
}
assert_eq!(accumulator.reasoning(), "Let me think.");
assert_eq!(accumulator.reasoning_content(), "");
assert_eq!(accumulator.content(), "Hi");
let message = accumulator.into_message();
assert_eq!(message.reasoning.as_deref(), Some("Let me think."));
assert_eq!(message.reasoning_content, None);
assert_eq!(message.content.as_deref(), Some("Hi"));
}
#[test]
fn usage_extras_parse() {
use crate::chat::ChatCompletion;
let parsed: ChatCompletion = r#"{
"id": "chatcmpl-1", "object": "chat.completion", "created": 1,
"model": "Qwen/Qwen3-8B",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hi"},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120,
"prompt_tokens_details": {"cached_tokens": 64},
"completion_tokens_details": {
"reasoning_tokens": null,
"num_speculative_tokens": 8
}
}
}"#
.parse()
.expect("vLLM usage must parse");
let usage = parsed.usage.expect("usage");
assert_eq!(
usage
.completion_tokens_details
.as_ref()
.expect("completion_tokens_details")
.num_speculative_tokens,
Some(8)
);
assert_eq!(
usage
.prompt_tokens_details
.as_ref()
.expect("prompt_tokens_details")
.cached_tokens,
Some(64)
);
}
#[test]
fn model_card_extras_parse() {
let models: crate::models::list::response::ListModelsResponse = r#"{
"object": "list",
"data": [{
"id": "Qwen/Qwen3-8B",
"object": "model",
"created": 1735113344,
"owned_by": "vllm",
"root": "Qwen/Qwen3-8B",
"parent": null,
"max_model_len": 131072
}]
}"#
.parse()
.expect("vLLM model list must parse");
let model = &models.data[0];
assert_eq!(model.root.as_deref(), Some("Qwen/Qwen3-8B"));
assert_eq!(model.parent, None);
assert_eq!(model.max_model_len, Some(131072));
assert_eq!(model.owned_by, "vllm");
}
}