Compare commits

...
Author SHA1 Message Date
Bugen Zhao 97cbc212b4 [Rust Frontend] Extract shared chat types into vllm-chat-types
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-07-23 10:16:32 +00:00
27 changed files with 933 additions and 728 deletions
+12 -2
View File
@@ -5516,7 +5516,6 @@ dependencies = [
"asynk-strim-attr",
"bytes",
"clap",
"easy-ext",
"expect-test",
"futures",
"half",
@@ -5546,6 +5545,7 @@ dependencies = [
"tracing-subscriber",
"trait-set",
"uuid",
"vllm-chat-types",
"vllm-engine-core-client",
"vllm-llm",
"vllm-parser",
@@ -5555,6 +5555,16 @@ dependencies = [
"zeromq",
]
[[package]]
name = "vllm-chat-types"
version = "0.1.0"
dependencies = [
"easy-ext",
"serde",
"serde_json",
"serde_with",
]
[[package]]
name = "vllm-cmd"
version = "0.1.0"
@@ -5695,11 +5705,11 @@ dependencies = [
"expect-test",
"futures",
"openai-protocol",
"serde",
"serde_json",
"thiserror 2.0.18",
"thiserror-ext",
"tool-parser",
"vllm-chat-types",
"vllm-tokenizer",
"winnow",
"xgrammar-structural-tag",
+2
View File
@@ -2,6 +2,7 @@
members = [
"src/bench",
"src/chat",
"src/chat-types",
"src/cmd",
"src/engine-core-client",
"src/llm",
@@ -135,6 +136,7 @@ uuid = { version = "1.22.0", features = ["v4"] }
validator = { version = "0.20.0", features = ["derive"] }
vllm-bench = { path = "src/bench" }
vllm-chat = { path = "src/chat" }
vllm-chat-types = { path = "src/chat-types" }
vllm-engine-core-client = { path = "src/engine-core-client" }
vllm-llm = { path = "src/llm" }
vllm-managed-engine = { path = "src/managed-engine" }
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "vllm-chat-types"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
easy-ext.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_with.workspace = true
[lints]
workspace = true
+155
View File
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use std::ops::Deref;
use serde::{Deserialize, Serialize};
/// One finalized assistant tool call.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssistantToolCall {
/// Stable tool-call identifier.
pub id: String,
/// Function name selected by the assistant.
pub name: String,
/// Serialized function arguments.
pub arguments: String,
}
/// Semantic kind of one assistant output block.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssistantBlockKind {
/// Visible final-answer text.
Text,
/// Extracted reasoning content.
Reasoning,
/// One finalized tool call.
ToolCall,
}
/// One structured assistant output block.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssistantContentBlock {
/// Visible final-answer text.
Text {
/// Visible text.
text: String,
},
/// Extracted reasoning content.
Reasoning {
/// Reasoning text.
text: String,
},
/// One finalized tool call.
ToolCall(AssistantToolCall),
}
impl AssistantContentBlock {
/// Return the semantic kind of this block.
pub fn kind(&self) -> AssistantBlockKind {
match self {
Self::Text { .. } => AssistantBlockKind::Text,
Self::Reasoning { .. } => AssistantBlockKind::Reasoning,
Self::ToolCall(..) => AssistantBlockKind::ToolCall,
}
}
/// Return this block as one finalized tool call when applicable.
pub fn as_tool_call(&self) -> Option<&AssistantToolCall> {
match self {
Self::ToolCall(call) => Some(call),
_ => None,
}
}
/// Trim whitespace from text and tool arguments.
///
/// Returns `None` when trimming makes a text or reasoning block empty.
pub fn trim(mut self) -> Option<Self> {
match &mut self {
Self::Text { text } | Self::Reasoning { text } => {
let trimmed_text = text.trim();
if trimmed_text.is_empty() {
return None;
}
*text = trimmed_text.to_string();
}
Self::ToolCall(call) => {
call.arguments = call.arguments.trim().to_string();
}
}
Some(self)
}
}
#[easy_ext::ext(AssistantMessageExt)]
impl [AssistantContentBlock] {
/// Concatenate all visible final-answer text blocks.
pub fn text(&self) -> String {
self.iter()
.filter_map(|block| match block {
AssistantContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect()
}
/// Concatenate all extracted reasoning blocks.
pub fn reasoning(&self) -> Option<String> {
Some(
self.iter()
.filter_map(|block| match block {
AssistantContentBlock::Reasoning { text } => Some(text.as_str()),
_ => None,
})
.collect(),
)
.filter(|text: &String| !text.is_empty())
}
/// Return whether this assistant message contains reasoning text.
pub fn has_reasoning(&self) -> bool {
self.iter().any(|block| match block {
AssistantContentBlock::Reasoning { text } => !text.is_empty(),
_ => false,
})
}
/// Iterate over finalized assistant tool calls in encounter order.
pub fn tool_calls(&self) -> impl Iterator<Item = &AssistantToolCall> {
self.iter().filter_map(AssistantContentBlock::as_tool_call)
}
/// Return whether this assistant message contains any tool-call blocks.
pub fn has_tool_calls(&self) -> bool {
self.iter().any(|block| matches!(block, AssistantContentBlock::ToolCall(_)))
}
}
/// Final structured assistant message assembled from parsed output.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssistantMessage {
/// Assistant content blocks in emission order.
pub content: Vec<AssistantContentBlock>,
}
impl Deref for AssistantMessage {
type Target = [AssistantContentBlock];
fn deref(&self) -> &Self::Target {
&self.content
}
}
impl AssistantMessage {
/// Push one new block to the end of the message content.
pub fn push_block(&mut self, block: AssistantContentBlock) {
self.content.push(block);
}
/// Trim all blocks and remove text blocks that become empty.
pub fn trim(mut self) -> Self {
self.content = self.content.into_iter().filter_map(AssistantContentBlock::trim).collect();
self
}
}
+191
View File
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use serde::{Deserialize, Serialize};
/// Detail level requested for an OpenAI-style image input.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImageDetail {
/// Let the model-specific multimodal processor select the detail level.
#[default]
Auto,
/// Request low-detail image processing.
Low,
/// Request high-detail image processing.
High,
}
/// One chat content part in OpenAI-style block format.
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ChatContentPart {
/// One plain-text content block.
Text {
/// Plain-text content.
text: String,
},
/// One image URL or data URL content block.
ImageUrl {
/// Image URL or data URL.
image_url: String,
/// Requested image detail level.
detail: Option<ImageDetail>,
/// Optional caller-provided media identifier.
uuid: Option<String>,
},
/// One video URL or data URL content block.
VideoUrl {
/// Video URL or data URL.
video_url: String,
/// Optional caller-provided media identifier.
uuid: Option<String>,
},
/// One `input_audio` content block carrying base64-encoded audio bytes.
InputAudio {
/// Base64-encoded audio bytes.
data: String,
/// Optional audio format such as `wav` or `mp3`.
format: Option<String>,
/// Optional caller-provided media identifier.
uuid: Option<String>,
},
/// One audio URL or data URL content block.
AudioUrl {
/// Audio URL or data URL.
audio_url: String,
/// Optional caller-provided media identifier.
uuid: Option<String>,
},
}
impl ChatContentPart {
/// Construct one text content part with plain string content.
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
/// Construct one image URL content part with the given URL string.
pub fn image_url(image_url: impl Into<String>) -> Self {
Self::ImageUrl {
image_url: image_url.into(),
detail: None,
uuid: None,
}
}
/// Construct one video URL content part with the given URL string.
pub fn video_url(video_url: impl Into<String>) -> Self {
Self::VideoUrl {
video_url: video_url.into(),
uuid: None,
}
}
/// Construct one base64-encoded input-audio content part.
pub fn input_audio(data: impl Into<String>, format: Option<String>) -> Self {
Self::InputAudio {
data: data.into(),
format,
uuid: None,
}
}
/// Construct one audio URL content part with the given URL string.
pub fn audio_url(audio_url: impl Into<String>) -> Self {
Self::AudioUrl {
audio_url: audio_url.into(),
uuid: None,
}
}
/// Return the text content of this part.
///
/// Returns the static content-part type for multimodal content.
pub fn as_text(&self) -> Result<&str, &'static str> {
match self {
Self::Text { text } => Ok(text),
Self::ImageUrl { .. } => Err("image_url"),
Self::VideoUrl { .. } => Err("video_url"),
Self::InputAudio { .. } => Err("input_audio"),
Self::AudioUrl { .. } => Err("audio_url"),
}
}
/// Return whether this part is a text block with empty content.
fn is_empty_text(&self) -> bool {
matches!(self, Self::Text { text } if text.is_empty())
}
/// Return whether this part contains any multimodal content.
fn is_multimodal(&self) -> bool {
match self {
Self::Text { .. } => false,
Self::ImageUrl { .. }
| Self::VideoUrl { .. }
| Self::InputAudio { .. }
| Self::AudioUrl { .. } => true,
}
}
}
/// Chat content represented as a string or OpenAI-style content parts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ChatContent {
/// Simple text content.
Text(String),
/// OpenAI-style content parts.
Parts(Vec<ChatContentPart>),
}
impl ChatContent {
/// Flatten text parts into one string without adding separators.
///
/// Returns the static content-part type when the content is multimodal.
pub fn try_flatten_to_text(&self) -> Result<String, &'static str> {
Ok(match self {
Self::Text(text) => text.clone(),
Self::Parts(parts) => parts
.iter()
.map(ChatContentPart::as_text)
.collect::<Result<Vec<_>, _>>()?
.concat(),
})
}
/// Return whether the content has no text or only empty text blocks.
pub fn is_empty(&self) -> bool {
match self {
Self::Text(text) => text.is_empty(),
Self::Parts(parts) => parts.iter().all(ChatContentPart::is_empty_text),
}
}
/// Return whether this content contains any multimodal parts.
pub fn has_multimodal(&self) -> bool {
match self {
Self::Text(_) => false,
Self::Parts(parts) => parts.iter().any(ChatContentPart::is_multimodal),
}
}
}
impl From<String> for ChatContent {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<&str> for ChatContent {
fn from(value: &str) -> Self {
Self::Text(value.to_string())
}
}
impl From<Vec<ChatContentPart>> for ChatContent {
fn from(value: Vec<ChatContentPart>) -> Self {
Self::Parts(value)
}
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//! Engine-independent data types shared by chat renderers and output parsers.
//!
//! This crate defines chat history, rendering options, tool descriptions, and
//! structured assistant payloads. Serving requests, streamed events, renderer
//! implementations, parser state, and engine metadata live in their owning
//! crates.
mod assistant;
mod content;
mod message;
mod options;
#[cfg(test)]
mod tests;
mod tool;
pub use assistant::{
AssistantBlockKind, AssistantContentBlock, AssistantMessage, AssistantMessageExt,
AssistantToolCall,
};
pub use content::{ChatContent, ChatContentPart, ImageDetail};
pub use message::{ChatMessage, ChatRole};
pub use options::{ChatOptions, ChatToolChoice, GenerationPromptMode, ReasoningEffort};
pub use tool::Tool;
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use serde::{Deserialize, Serialize};
use crate::{AssistantContentBlock, AssistantMessage, AssistantMessageExt as _, ChatContent, Tool};
/// Role label for one chat message.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatRole {
/// System instructions.
System,
/// Developer instructions.
Developer,
/// User input.
User,
/// Assistant history.
Assistant,
/// Result of an assistant tool call.
ToolResponse,
}
impl ChatRole {
/// Return the role string exposed to chat templates.
pub fn as_str(&self) -> &'static str {
match self {
Self::System => "system",
Self::Developer => "developer",
Self::User => "user",
Self::Assistant => "assistant",
Self::ToolResponse => "tool_response",
}
}
}
/// One chat message.
///
/// Original Python API reference:
/// <https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/entrypoints/chat_utils.py#L309-L333>
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ChatMessage {
/// System message.
System {
/// Message content.
content: ChatContent,
},
/// Developer message with optional message-local tools.
Developer {
/// Message content.
content: ChatContent,
/// Tools introduced by this developer message.
tools: Option<Vec<Tool>>,
},
/// User message.
User {
/// Message content.
content: ChatContent,
},
/// Assistant history assembled from structured blocks.
Assistant {
/// Structured assistant content.
content: Vec<AssistantContentBlock>,
},
/// Tool response associated with one prior assistant tool call.
ToolResponse {
/// Tool response content.
content: ChatContent,
/// Identifier of the assistant tool call being answered.
tool_call_id: String,
},
}
impl ChatMessage {
/// Construct one chat message with plain string content.
///
/// # Panics
///
/// Panics for [`ChatRole::ToolResponse`], which requires a tool-call ID.
/// Use [`Self::tool_response`] for tool responses.
pub fn text(role: ChatRole, text: impl Into<String>) -> Self {
let content: String = text.into();
match role {
ChatRole::System => Self::system(content),
ChatRole::Developer => Self::developer(content, None),
ChatRole::User => Self::user(content),
ChatRole::Assistant => Self::assistant_text(content),
ChatRole::ToolResponse => {
panic!(
"tool response messages require a tool_call_id; \
use ChatMessage::tool_response() instead"
)
}
}
}
/// Construct one system message.
pub fn system(content: impl Into<ChatContent>) -> Self {
Self::System {
content: content.into(),
}
}
/// Construct one developer message.
pub fn developer(content: impl Into<ChatContent>, tools: Option<Vec<Tool>>) -> Self {
Self::Developer {
content: content.into(),
tools,
}
}
/// Construct one user message.
pub fn user(content: impl Into<ChatContent>) -> Self {
Self::User {
content: content.into(),
}
}
/// Construct one assistant message with plain string content.
pub fn assistant_text(text: impl Into<String>) -> Self {
Self::Assistant {
content: vec![AssistantContentBlock::Text { text: text.into() }],
}
}
/// Construct one assistant message with structured content blocks.
pub fn assistant_blocks(content: Vec<AssistantContentBlock>) -> Self {
Self::Assistant { content }
}
/// Construct one tool-response message.
pub fn tool_response(content: impl Into<ChatContent>, tool_call_id: impl Into<String>) -> Self {
Self::ToolResponse {
content: content.into(),
tool_call_id: tool_call_id.into(),
}
}
/// Return the role of this message.
pub fn role(&self) -> ChatRole {
match self {
Self::System { .. } => ChatRole::System,
Self::Developer { .. } => ChatRole::Developer,
Self::User { .. } => ChatRole::User,
Self::Assistant { .. } => ChatRole::Assistant,
Self::ToolResponse { .. } => ChatRole::ToolResponse,
}
}
/// Concatenate the visible text carried by this message.
///
/// Returns the static content-part type when a non-assistant message
/// contains multimodal content.
pub fn text_content(&self) -> Result<String, &'static str> {
match self {
Self::System { content }
| Self::Developer { content, .. }
| Self::User { content }
| Self::ToolResponse { content, .. } => content.try_flatten_to_text(),
Self::Assistant { content } => Ok(content.text()),
}
}
/// Concatenate assistant reasoning text when present.
pub fn reasoning_content(&self) -> Option<String> {
match self {
Self::Assistant { content } => content.reasoning(),
Self::System { .. }
| Self::Developer { .. }
| Self::User { .. }
| Self::ToolResponse { .. } => None,
}
}
/// Return whether this message contains multimodal content.
pub fn has_multimodal(&self) -> bool {
match self {
Self::System { content }
| Self::Developer { content, .. }
| Self::User { content }
| Self::ToolResponse { content, .. } => content.has_multimodal(),
Self::Assistant { .. } => false,
}
}
}
impl From<AssistantMessage> for ChatMessage {
fn from(value: AssistantMessage) -> Self {
Self::Assistant {
content: value.content,
}
}
}
+134
View File
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Controls how prompt rendering should end after the existing chat history.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GenerationPromptMode {
/// Append a generation prompt for a new assistant turn.
///
/// Equivalent to `add_generation_prompt = true` and
/// `continue_final_message = false`.
#[default]
StartNewAssistant,
/// Leave the final assistant message open so generation continues it.
///
/// Equivalent to `add_generation_prompt = false` and
/// `continue_final_message = true`.
ContinueFinalAssistant,
/// Render the existing chat history without adding any trailing generation
/// prompt.
///
/// Equivalent to `add_generation_prompt = false` and
/// `continue_final_message = false`.
NoGenerationPrompt,
}
/// Effort level for reasoning models.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReasoningEffort {
/// Disable reasoning.
None,
/// Use the smallest available reasoning effort.
Minimal,
/// Use low reasoning effort.
Low,
/// Use medium reasoning effort.
Medium,
/// Use high reasoning effort.
High,
/// Use extra-high reasoning effort.
XHigh,
/// Use the largest available reasoning effort.
Max,
}
impl ReasoningEffort {
/// Return the lowercase value exposed to chat templates.
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Max => "max",
}
}
}
/// Chat-template-related request options.
///
/// These are the chat controls that currently affect prompt rendering.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChatOptions {
/// Controls whether rendering starts a new assistant turn, continues the
/// final assistant message, or emits no trailing generation prompt.
pub generation_prompt_mode: GenerationPromptMode,
/// Per-request Jinja chat template override.
///
/// The renderer uses this template in place of the model's default chat
/// template when it is present.
pub chat_template: Option<String>,
/// Effort level exposed to chat templates for reasoning models.
pub reasoning_effort: Option<ReasoningEffort>,
/// Additional keyword arguments exposed to the chat template.
pub template_kwargs: HashMap<String, Value>,
}
impl Default for ChatOptions {
fn default() -> Self {
Self {
generation_prompt_mode: GenerationPromptMode::StartNewAssistant,
chat_template: None,
reasoning_effort: None,
template_kwargs: HashMap::new(),
}
}
}
impl ChatOptions {
/// Return whether rendering adds a prompt for a new assistant turn.
pub fn add_generation_prompt(&self) -> bool {
matches!(
self.generation_prompt_mode,
GenerationPromptMode::StartNewAssistant
)
}
/// Return whether rendering continues the final assistant message.
pub fn continue_final_message(&self) -> bool {
matches!(
self.generation_prompt_mode,
GenerationPromptMode::ContinueFinalAssistant
)
}
}
/// Tool-choice semantics supported by the shared chat types.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatToolChoice {
/// Disable tool calling.
#[default]
None,
/// Let the model choose whether to call a tool.
Auto,
/// Require the model to call a tool.
Required,
/// Require one named function.
Function {
/// Required function name.
name: String,
},
}
+117
View File
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use serde_json::{json, to_value};
use crate::{AssistantContentBlock, ChatContent, ChatContentPart, ChatMessage, ChatRole, Tool};
#[test]
fn chat_content_deserializes_from_raw_string() {
let content: ChatContent = serde_json::from_value(json!("hello")).unwrap();
assert_eq!(content, ChatContent::Text("hello".to_string()));
}
#[test]
fn chat_content_video_url_part_round_trips_through_serde() {
let content = ChatContent::Parts(vec![ChatContentPart::VideoUrl {
video_url: "https://example.com/demo.mp4".to_string(),
uuid: Some("video-1".to_string()),
}]);
let value = to_value(&content).unwrap();
assert_eq!(
value,
json!([{
"type": "video_url",
"video_url": "https://example.com/demo.mp4",
"uuid": "video-1",
}])
);
let decoded: ChatContent = serde_json::from_value(value).unwrap();
assert_eq!(decoded, content);
}
#[test]
fn chat_content_deserializes_from_openai_text_blocks() {
let content: ChatContent =
serde_json::from_value(json!([{ "type": "text", "text": "hello" }])).unwrap();
assert_eq!(
content,
ChatContent::Parts(vec![ChatContentPart::text("hello")])
);
}
#[test]
fn chat_content_from_string_like_values_builds_text() {
assert_eq!(
ChatContent::from("hello"),
ChatContent::Text("hello".to_string())
);
assert_eq!(
ChatContent::from("hello".to_string()),
ChatContent::Text("hello".to_string())
);
}
#[test]
fn chat_content_try_flattens_text_parts_without_separators() {
let content = ChatContent::Parts(vec![
ChatContentPart::text("hello"),
ChatContentPart::text(" world"),
]);
assert_eq!(content.try_flatten_to_text().unwrap(), "hello world");
}
#[test]
fn multimodal_content_parts_return_static_type_names() {
let parts = [
(ChatContentPart::image_url("image"), "image_url"),
(ChatContentPart::video_url("video"), "video_url"),
(ChatContentPart::input_audio("audio", None), "input_audio"),
(ChatContentPart::audio_url("audio"), "audio_url"),
];
for (part, expected) in parts {
assert_eq!(part.as_text(), Err(expected));
assert_eq!(
ChatContent::Parts(vec![part]).try_flatten_to_text(),
Err(expected)
);
}
}
#[test]
fn assistant_message_collects_visible_and_reasoning_text() {
let message = ChatMessage::assistant_blocks(vec![
AssistantContentBlock::Reasoning {
text: "inner".to_string(),
},
AssistantContentBlock::Text {
text: "outer".to_string(),
},
]);
assert_eq!(message.role(), ChatRole::Assistant);
assert_eq!(message.text_content().unwrap(), "outer");
assert_eq!(message.reasoning_content().as_deref(), Some("inner"));
}
#[test]
fn developer_message_round_trips_through_serde() {
let message = ChatMessage::developer(
"hello",
Some(vec![Tool {
name: "get_weather".to_string(),
description: Some("Get weather".to_string()),
parameters: json!({
"type": "object",
"properties": {"city": {"type": "string"}},
}),
strict: Some(true),
}]),
);
let value = to_value(&message).unwrap();
let decoded: ChatMessage = serde_json::from_value(value).unwrap();
assert_eq!(decoded, message);
}
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// One function-style tool made available to the model.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tool {
/// Function name exposed to the model.
pub name: String,
/// Optional human-readable function description.
pub description: Option<String>,
/// JSON Schema describing the function parameters.
pub parameters: Value,
/// Optional strict-schema enforcement request.
pub strict: Option<bool>,
}
+1 -1
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
anyhow.workspace = true
asynk-strim-attr.workspace = true
easy-ext.workspace = true
futures.workspace = true
half.workspace = true
indexmap.workspace = true
@@ -30,6 +29,7 @@ tokio.workspace = true
tracing.workspace = true
trait-set.workspace = true
uuid.workspace = true
vllm-chat-types.workspace = true
vllm-engine-core-client.workspace = true
vllm-llm.workspace = true
vllm-parser.workspace = true
+29 -146
View File
@@ -1,155 +1,17 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use std::ops::Deref;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vllm_llm::TokenUsage;
use vllm_text::{DecodedLogprobs, DecodedPromptLogprobs};
use crate::FinishReason;
/// One finalized assistant tool call.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssistantToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
/// Semantic kind of one assistant output block.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssistantBlockKind {
/// Visible final-answer text.
Text,
/// Extracted reasoning content.
Reasoning,
/// One finalized tool call.
ToolCall,
}
/// One structured assistant output block.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssistantContentBlock {
/// Visible final-answer text.
Text { text: String },
/// Extracted reasoning content.
Reasoning { text: String },
/// One finalized tool call.
ToolCall(AssistantToolCall),
}
impl AssistantContentBlock {
/// Return the semantic kind of this block.
pub fn kind(&self) -> AssistantBlockKind {
match self {
Self::Text { .. } => AssistantBlockKind::Text,
Self::Reasoning { .. } => AssistantBlockKind::Reasoning,
Self::ToolCall(..) => AssistantBlockKind::ToolCall,
}
}
/// Return this block as one finalized tool call, if applicable.
pub fn as_tool_call(&self) -> Option<&AssistantToolCall> {
match self {
Self::ToolCall(call) => Some(call),
_ => None,
}
}
/// Return a copy of this block with leading and trailing whitespace trimmed from all text
/// fields and tool call arguments, or `None` if the resulting text would be empty.
pub fn trim(mut self) -> Option<Self> {
match &mut self {
Self::Text { text } | Self::Reasoning { text } => {
let trimmed_text = text.trim();
if trimmed_text.is_empty() {
return None;
} else {
*text = trimmed_text.to_string();
}
}
Self::ToolCall(call) => {
call.arguments = call.arguments.trim().to_string();
}
}
Some(self)
}
}
#[easy_ext::ext(AssistantMessageExt)]
impl [AssistantContentBlock] {
/// Concatenate all visible final-answer text blocks.
pub fn text(&self) -> String {
self.iter()
.filter_map(|block| match block {
AssistantContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect()
}
/// Concatenate all extracted reasoning blocks, if any.
pub fn reasoning(&self) -> Option<String> {
Some(
self.iter()
.filter_map(|block| match block {
AssistantContentBlock::Reasoning { text } => Some(text.as_str()),
_ => None,
})
.collect(),
)
.filter(|s: &String| !s.is_empty())
}
/// Return whether this assistant message contains any non-empty reasoning
/// text blocks.
pub fn has_reasoning(&self) -> bool {
self.iter().any(|block| match block {
AssistantContentBlock::Reasoning { text } => !text.is_empty(),
_ => false,
})
}
/// Return finalized assistant tool calls in encounter order.
pub fn tool_calls(&self) -> impl Iterator<Item = &AssistantToolCall> {
self.iter().filter_map(AssistantContentBlock::as_tool_call)
}
/// Return whether this assistant message contains any tool-call blocks.
pub fn has_tool_calls(&self) -> bool {
self.iter().any(|block| matches!(block, AssistantContentBlock::ToolCall(_)))
}
}
/// Final structured assistant message assembled from the event stream.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssistantMessage {
pub content: Vec<AssistantContentBlock>,
}
impl Deref for AssistantMessage {
type Target = [AssistantContentBlock];
fn deref(&self) -> &Self::Target {
&self.content
}
}
impl AssistantMessage {
/// Push one new block to the end of the message content.
pub(crate) fn push_block(&mut self, block: AssistantContentBlock) {
self.content.push(block);
}
/// Return a copy of this message with leading and trailing whitespace trimmed from all text
/// fields and tool call arguments, and with any blocks that are empty after trimming removed.
pub fn trim(mut self) -> Self {
self.content = self.content.into_iter().filter_map(|block| block.trim()).collect();
self
}
}
pub use vllm_chat_types::{
AssistantBlockKind, AssistantContentBlock, AssistantMessage, AssistantMessageExt,
AssistantToolCall,
};
/// Streamed chat event emitted by [`crate::ChatEventStream`].
#[derive(Debug, Clone, PartialEq)]
@@ -164,44 +26,65 @@ pub enum ChatEvent {
},
/// A new assistant output block has started.
BlockStart {
/// Stable block index within the assistant message.
index: usize,
/// Semantic kind of the opened block.
kind: AssistantBlockKind,
},
/// A newly observed delta for one open assistant output block.
BlockDelta {
/// Stable block index within the assistant message.
index: usize,
/// Semantic kind of the open block.
kind: AssistantBlockKind,
/// Newly emitted text.
delta: String,
},
/// Per-decoded-update sample metadata: logprobs and/or output token IDs.
/// Per-decoded-update sample metadata.
LogprobsDelta {
/// Decoded output logprobs, when requested.
logprobs: Option<DecodedLogprobs>,
/// Output token IDs emitted by this update.
token_ids: Vec<u32>,
},
/// One assistant output block has ended.
BlockEnd {
/// Stable block index within the assistant message.
index: usize,
/// Finalized block.
block: AssistantContentBlock,
},
/// One tool call has started.
ToolCallStart {
/// Stable tool-call index within the assistant message.
index: usize,
/// Stable tool-call identifier.
id: String,
/// Function name selected by the assistant.
name: String,
},
/// One incremental tool-call arguments delta for the currently open tool
/// call.
ToolCallArgumentsDelta { index: usize, delta: String },
/// One incremental tool-call arguments delta.
ToolCallArgumentsDelta {
/// Stable tool-call index within the assistant message.
index: usize,
/// Newly emitted arguments text.
delta: String,
},
/// One tool call has ended.
ToolCallEnd {
/// Stable tool-call index within the assistant message.
index: usize,
/// Finalized tool call.
call: AssistantToolCall,
},
/// Terminal event carrying the final assembled assistant message and finish
/// metadata.
Done {
/// Final structured assistant message.
message: AssistantMessage,
/// Final token usage.
usage: TokenUsage,
/// Reason generation stopped.
finish_reason: FinishReason,
/// Connector-specific KV transfer parameters for disaggregated serving.
kv_transfer_params: Option<serde_json::Value>,
+4 -4
View File
@@ -37,7 +37,7 @@ pub use renderer::{
};
pub use request::{
ChatContent, ChatContentPart, ChatMessage, ChatOptions, ChatRequest, ChatRole, ChatTool,
ChatToolChoice, GenerationPromptMode, ReasoningEffort, SamplingParams,
ChatToolChoice, GenerationPromptMode, ImageDetail, ReasoningEffort, SamplingParams,
};
pub use stream::{ChatEventStream, ChatEventStreamTrait, CollectedAssistantMessage};
pub use vllm_llm::FinishReason;
@@ -277,9 +277,9 @@ impl ChatLlm {
/// Render through the chat template and tokenize, without submitting to the engine.
///
/// Same render → [`multimodal::finalize_rendered_prompt`] → encode pipeline as
/// [`Self::chat`], but stops after token IDs so `/tokenize` counts match what
/// generation would see. Used by `POST /tokenize` (chat form).
/// Uses the same render, multimodal finalization, and encoding pipeline as
/// [`Self::chat`], but stops after token IDs so `/tokenize` counts match
/// what generation would see. Used by `POST /tokenize` (chat form).
pub async fn tokenize_chat(&self, request: ChatRequest) -> Result<Vec<u32>> {
request.validate()?;
+11 -2
View File
@@ -33,7 +33,7 @@ use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
use crate::error::{Error, Result, bail_multimodal, multimodal};
use crate::renderer::RenderedPrompt;
use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest};
use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest, ImageDetail};
mod audio;
mod expand;
@@ -529,7 +529,7 @@ fn extract_media_parts(request: &ChatRequest) -> Result<Vec<MediaContentPart>> {
uuid,
} => all_parts.push(MediaContentPart::ImageUrl {
url: image_url.clone(),
detail: *detail,
detail: detail.map(to_multimodal_image_detail),
uuid: uuid.clone(),
}),
ChatContentPart::VideoUrl { video_url, uuid } => {
@@ -556,6 +556,15 @@ fn extract_media_parts(request: &ChatRequest) -> Result<Vec<MediaContentPart>> {
Ok(all_parts)
}
/// Convert the protocol-level image detail into the multimodal processor type.
fn to_multimodal_image_detail(detail: ImageDetail) -> llm_multimodal::ImageDetail {
match detail {
ImageDetail::Auto => llm_multimodal::ImageDetail::Auto,
ImageDetail::Low => llm_multimodal::ImageDetail::Low,
ImageDetail::High => llm_multimodal::ImageDetail::High,
}
}
/// Wrap OpenAI base64 audio in a data URL consumed by `MediaConnector`.
fn input_audio_data_url(data: &str, format: Option<&str>) -> Result<String> {
let mime_type = match format {
+2 -2
View File
@@ -178,8 +178,8 @@ impl ChatOutputProcessor for DefaultChatOutputProcessor {
/// events through two sequential stages once text decoding has
/// already happened:
///
/// 1. [`unified_event_stream`] — reasoning and tool-call parsing
/// 2. [`structured_chat_event_stream`] — final block assembly
/// 1. `unified_event_stream` — reasoning and tool-call parsing
/// 2. `structured_chat_event_stream` — final block assembly
fn process(self: Box<Self>, decoded: DynDecodedTextEventStream) -> Result<DynChatEventStream> {
let parsed = unified_event_stream(decoded, self.parser);
let structured = structured_chat_event_stream(parsed, self.parallel_tool_calls);
@@ -517,7 +517,7 @@ fn write_chat_content(out: &mut String, content: &ChatContent) -> Result<()> {
ChatContent::Text(text) => out.push_str(text),
ChatContent::Parts(parts) => {
for part in parts {
out.push_str(part.as_text()?);
out.push_str(part.as_text().map_err(Error::UnsupportedMultimodalContent)?);
}
}
}
@@ -519,7 +519,7 @@ fn write_chat_content(out: &mut String, content: &ChatContent) -> Result<()> {
ChatContent::Text(text) => out.push_str(text),
ChatContent::Parts(parts) => {
for part in parts {
out.push_str(part.as_text()?);
out.push_str(part.as_text().map_err(Error::UnsupportedMultimodalContent)?);
}
}
}
+1 -1
View File
@@ -427,7 +427,7 @@ fn auto_drop_analysis_messages(messages: Vec<Message>) -> Vec<Message> {
/// Flatten vLLM text content and reject unsupported multimodal parts.
fn flatten_text(content: &ChatContent) -> Result<String> {
content.try_flatten_to_text()
content.try_flatten_to_text().map_err(Error::UnsupportedMultimodalContent)
}
/// Convert vLLM function tool definitions to Harmony tool descriptions.
+1 -1
View File
@@ -277,7 +277,7 @@ impl InklingChatRenderer {
tool_call_id: &str,
tool_call_id_to_name: &HashMap<String, String>,
) -> Result<()> {
let text = content.try_flatten_to_text()?;
let text = content.try_flatten_to_text().map_err(Error::UnsupportedMultimodalContent)?;
let tool_name = tool_call_id_to_name.get(tool_call_id).map(String::as_str).unwrap_or("");
self.write_text_block(out, self.special.message_tool, Some(tool_name), &text)
}
+6 -546
View File
@@ -1,449 +1,17 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use std::collections::HashMap;
use llm_multimodal::ImageDetail;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use vllm_chat_types::{
ChatContent, ChatContentPart, ChatMessage, ChatOptions, ChatRole, ChatToolChoice,
GenerationPromptMode, ImageDetail, ReasoningEffort, Tool as ChatTool,
};
use vllm_engine_core_client::protocol::lora::LoraRequest;
pub use vllm_parser::tool::Tool as ChatTool;
pub use vllm_text::SamplingParams;
use vllm_text::TextDecodeOptions;
use crate::AssistantMessageExt;
use crate::error::{Error, Result};
use crate::event::{AssistantContentBlock, AssistantMessage};
/// Role label for one text-only chat message.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatRole {
System,
Developer,
User,
Assistant,
ToolResponse,
}
/// One text-only chat content part in OpenAI-style block format.
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ChatContentPart {
/// One plain-text content block.
Text { text: String },
/// One image URL/data URL content block.
ImageUrl {
image_url: String,
detail: Option<ImageDetail>,
uuid: Option<String>,
},
/// One video URL/data URL content block.
VideoUrl {
video_url: String,
uuid: Option<String>,
},
/// One `input_audio` content block carrying base64-encoded audio bytes.
InputAudio {
data: String,
format: Option<String>,
uuid: Option<String>,
},
/// One audio URL/data URL content block.
AudioUrl {
audio_url: String,
uuid: Option<String>,
},
// ImageData...
// VideoData...
// ImageEmbeds...
}
impl ChatContentPart {
/// Construct one text content part with plain string content.
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
/// Construct one image URL content part with the given URL string.
pub fn image_url(image_url: impl Into<String>) -> Self {
Self::ImageUrl {
image_url: image_url.into(),
detail: None,
uuid: None,
}
}
/// Construct one video URL content part with the given URL string.
pub fn video_url(video_url: impl Into<String>) -> Self {
Self::VideoUrl {
video_url: video_url.into(),
uuid: None,
}
}
/// Construct one base64-encoded input-audio content part.
pub fn input_audio(data: impl Into<String>, format: Option<String>) -> Self {
Self::InputAudio {
data: data.into(),
format,
uuid: None,
}
}
/// Construct one audio URL content part with the given URL string.
pub fn audio_url(audio_url: impl Into<String>) -> Self {
Self::AudioUrl {
audio_url: audio_url.into(),
uuid: None,
}
}
/// Return the text content of this part when it's a text block, or an
/// "unsupported multimodal content" error otherwise.
pub(crate) fn as_text(&self) -> Result<&str> {
match self {
Self::Text { text } => Ok(text),
Self::ImageUrl { .. } => Err(Error::UnsupportedMultimodalContent("image_url")),
Self::VideoUrl { .. } => Err(Error::UnsupportedMultimodalContent("video_url")),
Self::InputAudio { .. } => Err(Error::UnsupportedMultimodalContent("input_audio")),
Self::AudioUrl { .. } => Err(Error::UnsupportedMultimodalContent("audio_url")),
}
}
/// Return whether this part is a text block with empty content.
pub(crate) fn is_empty_text(&self) -> bool {
matches!(self, Self::Text { text } if text.is_empty())
}
/// Return whether this part contains any multimodal content.
pub(crate) fn is_multimodal(&self) -> bool {
match self {
Self::Text { .. } => false,
Self::ImageUrl { .. }
| Self::VideoUrl { .. }
| Self::InputAudio { .. }
| Self::AudioUrl { .. } => true,
}
}
}
/// Text-only chat content.
///
/// This supports either a simple string or an OpenAI-style list of text blocks.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ChatContent {
/// Simple text content.
Text(String),
/// OpenAI-style blocks.
Parts(Vec<ChatContentPart>),
}
impl ChatContent {
/// Flatten the text content into one plain string without adding
/// separators.
// TODO: this method will be truly fallible once we add non-text content parts.
pub fn try_flatten_to_text(&self) -> Result<String> {
Ok(match self {
Self::Text(text) => text.clone(),
Self::Parts(parts) => {
parts.iter().map(ChatContentPart::as_text).collect::<Result<Vec<_>>>()?.concat()
}
})
}
/// Return whether there's no text content or only empty text blocks.
pub fn is_empty(&self) -> bool {
match self {
Self::Text(text) => text.is_empty(),
Self::Parts(parts) => parts.iter().all(ChatContentPart::is_empty_text),
}
}
/// Return whether this content contains any multimodal parts.
pub fn has_multimodal(&self) -> bool {
match self {
Self::Text(_) => false,
Self::Parts(parts) => parts.iter().any(ChatContentPart::is_multimodal),
}
}
}
impl From<String> for ChatContent {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<&str> for ChatContent {
fn from(value: &str) -> Self {
Self::Text(value.to_string())
}
}
impl From<Vec<ChatContentPart>> for ChatContent {
fn from(value: Vec<ChatContentPart>) -> Self {
Self::Parts(value)
}
}
/// One chat message.
///
/// Original Python API reference:
/// <https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/entrypoints/chat_utils.py#L309-L333>
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ChatMessage {
/// System message content.
System { content: ChatContent },
/// Developer message content plus optional message-local tools.
Developer {
content: ChatContent,
tools: Option<Vec<ChatTool>>,
},
/// User message content.
User { content: ChatContent },
/// Assistant history content assembled from structured assistant blocks.
Assistant { content: Vec<AssistantContentBlock> },
/// Tool response content associated with one prior assistant tool call.
ToolResponse {
content: ChatContent,
tool_call_id: String,
},
}
impl ChatMessage {
/// Construct one chat message with plain string content.
pub fn text(role: ChatRole, text: impl Into<String>) -> Self {
let content: String = text.into();
match role {
ChatRole::System => Self::system(content),
ChatRole::Developer => Self::developer(content, None),
ChatRole::User => Self::user(content),
ChatRole::Assistant => Self::assistant_text(content),
ChatRole::ToolResponse => {
panic!(
"tool response messages require a tool_call_id; \
use ChatMessage::tool_response() instead"
)
}
}
}
/// Construct one chat message with system role.
pub fn system(content: impl Into<ChatContent>) -> Self {
Self::System {
content: content.into(),
}
}
/// Construct one chat message with developer role.
pub fn developer(content: impl Into<ChatContent>, tools: Option<Vec<ChatTool>>) -> Self {
Self::Developer {
content: content.into(),
tools,
}
}
/// Construct one chat message with user role.
pub fn user(content: impl Into<ChatContent>) -> Self {
Self::User {
content: content.into(),
}
}
/// Construct one chat message with assistant role and plain string content.
pub fn assistant_text(text: impl Into<String>) -> Self {
Self::Assistant {
content: vec![AssistantContentBlock::Text { text: text.into() }],
}
}
/// Construct one chat message with assistant role and structured content
/// blocks.
pub fn assistant_blocks(content: Vec<AssistantContentBlock>) -> Self {
Self::Assistant { content }
}
/// Construct one tool-role message.
pub fn tool_response(content: impl Into<ChatContent>, tool_call_id: impl Into<String>) -> Self {
Self::ToolResponse {
content: content.into(),
tool_call_id: tool_call_id.into(),
}
}
/// Return the chat role of this message.
pub fn role(&self) -> ChatRole {
match self {
Self::System { .. } => ChatRole::System,
Self::Developer { .. } => ChatRole::Developer,
Self::User { .. } => ChatRole::User,
Self::Assistant { .. } => ChatRole::Assistant,
Self::ToolResponse { .. } => ChatRole::ToolResponse,
}
}
/// Concatenate the visible text carried by this message.
pub fn text_content(&self) -> Result<String> {
match self {
Self::System { content }
| Self::Developer { content, .. }
| Self::User { content }
| Self::ToolResponse { content, .. } => content.try_flatten_to_text(),
Self::Assistant { content } => Ok(content.text()),
}
}
/// Concatenate assistant reasoning text when present.
pub fn reasoning_content(&self) -> Option<String> {
match self {
Self::Assistant { content } => content.reasoning(),
Self::System { .. }
| Self::Developer { .. }
| Self::User { .. }
| Self::ToolResponse { .. } => None,
}
}
/// Return whether this message contains any multimodal content.
pub fn has_multimodal(&self) -> bool {
match self {
Self::System { content }
| Self::Developer { content, .. }
| Self::User { content }
| Self::ToolResponse { content, .. } => content.has_multimodal(),
Self::Assistant { .. } => false,
}
}
}
impl From<AssistantMessage> for ChatMessage {
fn from(value: AssistantMessage) -> Self {
Self::Assistant {
content: value.content,
}
}
}
/// Controls how prompt rendering should end after the existing chat history.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GenerationPromptMode {
/// Append a generation prompt for a new assistant turn.
///
/// Equivalent to `add_generation_prompt = true` and `continue_final_message
/// = false`.
#[default]
StartNewAssistant,
/// Leave the final assistant message open so generation continues it.
///
/// Equivalent to `add_generation_prompt = false` and
/// `continue_final_message = true`.
ContinueFinalAssistant,
/// Render the existing chat history without adding any trailing generation
/// prompt.
///
/// Equivalent to `add_generation_prompt = false` and
/// `continue_final_message = false`.
NoGenerationPrompt,
}
/// Effort level for reasoning models.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
XHigh,
Max,
}
impl ReasoningEffort {
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Max => "max",
}
}
}
/// Chat-template-related request options.
///
/// These are the small subset of chat controls that currently affect prompt
/// rendering in `vllm-chat`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChatOptions {
/// Controls whether rendering starts a new assistant turn, continues the
/// final assistant message, or emits no trailing generation prompt at
/// all.
pub generation_prompt_mode: GenerationPromptMode,
/// Per-request Jinja chat template override. When set, this template is
/// used instead of the model's default chat template.
pub chat_template: Option<String>,
/// Effort level exposed to chat templates for reasoning models.
pub reasoning_effort: Option<ReasoningEffort>,
/// Additional keyword arguments exposed to the chat template.
pub template_kwargs: HashMap<String, Value>,
}
impl Default for ChatOptions {
fn default() -> Self {
Self {
generation_prompt_mode: GenerationPromptMode::StartNewAssistant,
chat_template: None,
reasoning_effort: None,
template_kwargs: HashMap::new(),
}
}
}
impl ChatOptions {
/// Whether to add a generation prompt for a new assistant turn after the
/// existing chat history.
pub fn add_generation_prompt(&self) -> bool {
matches!(
self.generation_prompt_mode,
GenerationPromptMode::StartNewAssistant
)
}
/// Whether to leave the final assistant message open so generation
/// continues it.
pub fn continue_final_message(&self) -> bool {
matches!(
self.generation_prompt_mode,
GenerationPromptMode::ContinueFinalAssistant
)
}
}
/// Tool-choice semantics supported by `vllm-chat`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatToolChoice {
#[default]
None,
Auto,
Required,
Function {
name: String,
},
}
/// One chat request ready to be rendered into a prompt and lowered into a
/// generate request.
@@ -578,120 +146,12 @@ impl ChatRequest {
}
}
impl ChatRole {
/// Return the chat-template role string used by the current text-only chat
/// backend.
pub fn as_str(&self) -> &'static str {
match self {
Self::System => "system",
Self::Developer => "developer",
Self::User => "user",
Self::Assistant => "assistant",
Self::ToolResponse => "tool_response",
}
}
}
#[cfg(test)]
mod tests {
use serde_json::{json, to_value};
use serde_json::json;
use super::{ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatRole, ChatTool};
use super::ChatRequest;
use crate::Error;
use crate::event::AssistantContentBlock;
#[test]
fn chat_content_deserializes_from_raw_string() {
let content: ChatContent = serde_json::from_value(json!("hello")).unwrap();
assert_eq!(content, ChatContent::Text("hello".to_string()));
}
#[test]
fn chat_content_video_url_part_round_trips_through_serde() {
let content = ChatContent::Parts(vec![ChatContentPart::VideoUrl {
video_url: "https://example.com/demo.mp4".to_string(),
uuid: Some("video-1".to_string()),
}]);
let value = to_value(&content).unwrap();
assert_eq!(
value,
json!([{
"type": "video_url",
"video_url": "https://example.com/demo.mp4",
"uuid": "video-1",
}])
);
let decoded: ChatContent = serde_json::from_value(value).unwrap();
assert_eq!(decoded, content);
}
#[test]
fn chat_content_deserializes_from_openai_text_blocks() {
let content: ChatContent =
serde_json::from_value(json!([{ "type": "text", "text": "hello" }])).unwrap();
assert_eq!(
content,
ChatContent::Parts(vec![ChatContentPart::text("hello")])
);
}
#[test]
fn chat_content_from_string_like_values_builds_text() {
assert_eq!(
ChatContent::from("hello"),
ChatContent::Text("hello".to_string())
);
assert_eq!(
ChatContent::from("hello".to_string()),
ChatContent::Text("hello".to_string())
);
}
#[test]
fn chat_content_try_flattens_text_parts_without_separators() {
let content = ChatContent::Parts(vec![
ChatContentPart::text("hello"),
ChatContentPart::text(" world"),
]);
assert_eq!(content.try_flatten_to_text().unwrap(), "hello world");
}
#[test]
fn assistant_message_collects_visible_and_reasoning_text() {
let message = ChatMessage::assistant_blocks(vec![
AssistantContentBlock::Reasoning {
text: "inner".to_string(),
},
AssistantContentBlock::Text {
text: "outer".to_string(),
},
]);
assert_eq!(message.role(), ChatRole::Assistant);
assert_eq!(message.text_content().unwrap(), "outer");
assert_eq!(message.reasoning_content().as_deref(), Some("inner"));
}
#[test]
fn developer_message_round_trips_through_serde() {
let message = ChatMessage::developer(
"hello",
Some(vec![ChatTool {
name: "get_weather".to_string(),
description: Some("Get weather".to_string()),
parameters: json!({
"type": "object",
"properties": {"city": {"type": "string"}},
}),
strict: Some(true),
}]),
);
let value = to_value(&message).unwrap();
let decoded: ChatMessage = serde_json::from_value(value).unwrap();
assert_eq!(decoded, message);
}
#[test]
fn enable_thinking_is_none_when_no_kwargs_are_present() {
+2 -2
View File
@@ -11,7 +11,7 @@ use tokio::time::timeout;
use vllm_chat::{
AssistantBlockKind, AssistantContentBlock, AssistantMessageExt as _, ChatBackend, ChatEvent,
ChatLlm, ChatMessage, ChatRenderer, ChatRequest, ChatRole, ChatTextBackend, ChatTool,
ChatToolChoice, DefaultChatOutputProcessor, DynChatOutputProcessor, DynChatRenderer,
ChatToolChoice, DefaultChatOutputProcessor, DynChatOutputProcessor, DynChatRenderer, Error,
FinishReason, GenerationPromptMode, NewChatOutputProcessorOptions, ParserSelection,
RenderedPrompt, SamplingParams,
};
@@ -257,7 +257,7 @@ impl ChatRenderer for FakeChatBackend {
for message in &request.messages {
prompt.push_str(message.role().as_str());
prompt.push_str(": ");
prompt.push_str(&message.text_content()?);
prompt.push_str(&message.text_content().map_err(Error::UnsupportedMultimodalContent)?);
prompt.push('\n');
}
if request.chat_options.add_generation_prompt() {
+1 -1
View File
@@ -9,10 +9,10 @@ test-util = []
[dependencies]
easy-ext.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
thiserror-ext.workspace = true
vllm-chat-types.workspace = true
vllm-tokenizer.workspace = true
winnow.workspace = true
xgrammar-structural-tag.workspace = true
+1 -11
View File
@@ -34,21 +34,11 @@ pub use minimax_m2::MinimaxM2ToolParser;
pub use minimax_m3::MinimaxM3ToolParser;
pub use qwen_coder::Qwen3CoderToolParser;
pub use seed_oss::SeedOssToolParser;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use vllm_chat_types::Tool;
pub use xgrammar_structural_tag::builders::StructuralTagBuilder;
use crate::utils;
/// One function-style tool made available to the model.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tool {
pub name: String,
pub description: Option<String>,
pub parameters: Value,
pub strict: Option<bool>,
}
/// One tool-call update emitted while parsing assistant text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCallDelta {
@@ -19,7 +19,7 @@ use futures::StreamExt as _;
use serial_test::serial;
use vllm_chat::{
ChatBackend, ChatLlm, ChatRenderer, ChatRequest, ChatTextBackend, DefaultChatOutputProcessor,
DynChatOutputProcessor, DynChatRenderer, NewChatOutputProcessorOptions, RenderedPrompt,
DynChatOutputProcessor, DynChatRenderer, Error, NewChatOutputProcessorOptions, RenderedPrompt,
};
use vllm_engine_core_client::protocol::output::{
EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, RequestBatchOutputs,
@@ -188,7 +188,7 @@ impl ChatRenderer for FakeChatBackend {
for message in &request.messages {
prompt.push_str(message.role().as_str());
prompt.push_str(": ");
prompt.push_str(&message.text_content()?);
prompt.push_str(&message.text_content().map_err(Error::UnsupportedMultimodalContent)?);
prompt.push('\n');
}
if request.chat_options.add_generation_prompt() {
@@ -399,11 +399,10 @@ mod tests {
use axum::http::HeaderMap;
use expect_test::expect;
use llm_multimodal::ImageDetail;
use serde_json::json;
use vllm_chat::{
AssistantContentBlock, AssistantToolCall, ChatContentPart, ChatMessage as VllmChatMessage,
ChatTool as VllmChatTool, ChatToolChoice, GenerationPromptMode,
ChatTool as VllmChatTool, ChatToolChoice, GenerationPromptMode, ImageDetail,
SamplingParams as VllmSamplingParams,
};
use vllm_text::output::TextDecodeOptions;
@@ -4,9 +4,9 @@
use std::collections::HashMap;
use std::slice;
use llm_multimodal::ImageDetail;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vllm_chat::ImageDetail;
use vllm_llm::TokenUsage;
// ============================================================================
+4 -2
View File
@@ -23,7 +23,7 @@ use serial_test::serial;
use tower::{Service as _, ServiceExt as _};
use vllm_chat::{
ChatBackend, ChatContent, ChatContentPart, ChatLlm, ChatMessage, ChatRenderer, ChatRequest,
ChatTextBackend, DefaultChatOutputProcessor, DynChatOutputProcessor, DynChatRenderer,
ChatTextBackend, DefaultChatOutputProcessor, DynChatOutputProcessor, DynChatRenderer, Error,
NewChatOutputProcessorOptions,
};
use vllm_engine_core_client::mock_engine::default_ready_response;
@@ -539,7 +539,9 @@ fn render_fake_message_content(
| ChatMessage::Developer { content, .. }
| ChatMessage::User { content }
| ChatMessage::ToolResponse { content, .. } => render_fake_content(content, placeholder),
ChatMessage::Assistant { .. } => message.text_content(),
ChatMessage::Assistant { .. } => {
message.text_content().map_err(Error::UnsupportedMultimodalContent)
}
}
}