forked from Karylab-cklius/vllm
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97cbc212b4 |
Generated
+12
-2
@@ -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,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" }
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user