Compare commits

...
Author SHA1 Message Date
Bugen Zhao b6db7065e6 minor refactor
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-07-24 09:43:05 +00:00
Bugen Zhao a2bedb84b6 do not take &&, better error categorization
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-07-24 09:26:14 +00:00
Bugen Zhao 21d8aa7ecc simplify
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-07-24 09:22:01 +00:00
Bugen Zhao 4288f3d3a5 extract chat-renderer crate
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
2026-07-24 09:10:46 +00:00
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
125 changed files with 1916 additions and 1269 deletions
+40 -20
View File
@@ -4252,18 +4252,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "subenum"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3d08fe7078c57309d5c3d938e50eba95ba1d33b9c3a101a8465fc6861a5416"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -5516,15 +5504,11 @@ dependencies = [
"asynk-strim-attr",
"bytes",
"clap",
"easy-ext",
"expect-test",
"futures",
"half",
"indexmap 2.13.0",
"itertools 0.14.0",
"llm-multimodal",
"minijinja",
"minijinja-contrib",
"ndarray 0.17.2",
"oss-harmony",
"paste",
@@ -5535,17 +5519,16 @@ dependencies = [
"serde_json",
"serde_with",
"serial_test",
"strum",
"subenum",
"tempfile",
"thiserror 2.0.18",
"thiserror-ext",
"time",
"tokio",
"tracing",
"tracing-subscriber",
"trait-set",
"uuid",
"vllm-chat-renderer",
"vllm-chat-types",
"vllm-engine-core-client",
"vllm-llm",
"vllm-parser",
@@ -5555,6 +5538,42 @@ dependencies = [
"zeromq",
]
[[package]]
name = "vllm-chat-renderer"
version = "0.1.0"
dependencies = [
"anyhow",
"enum-as-inner",
"expect-test",
"indexmap 2.13.0",
"itertools 0.14.0",
"minijinja",
"minijinja-contrib",
"oss-harmony",
"serde",
"serde-json-fmt",
"serde_json",
"serde_with",
"strum",
"tempfile",
"thiserror 2.0.18",
"thiserror-ext",
"time",
"tracing",
"vllm-chat-types",
"vllm-tokenizer",
]
[[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 +5714,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",
@@ -5806,6 +5825,7 @@ dependencies = [
"rustc-hash 1.1.0",
"serde",
"serde_json",
"serde_with",
"tekken-rs",
"tempfile",
"thiserror 2.0.18",
+4
View File
@@ -2,6 +2,8 @@
members = [
"src/bench",
"src/chat",
"src/chat-renderer",
"src/chat-types",
"src/cmd",
"src/engine-core-client",
"src/llm",
@@ -135,6 +137,8 @@ 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-renderer = { path = "src/chat-renderer" }
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" }
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "vllm-chat-renderer"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
enum-as-inner.workspace = true
indexmap.workspace = true
itertools.workspace = true
minijinja.workspace = true
minijinja-contrib.workspace = true
openai-harmony.workspace = true
serde.workspace = true
serde-json-fmt.workspace = true
serde_json.workspace = true
serde_with.workspace = true
strum.workspace = true
thiserror.workspace = true
thiserror-ext.workspace = true
time.workspace = true
tracing.workspace = true
vllm-chat-types.workspace = true
vllm-tokenizer.workspace = true
[dev-dependencies]
expect-test.workspace = true
tempfile.workspace = true
[lints]
workspace = true
@@ -11,8 +11,10 @@ use serde_json::Value;
use serde_json_fmt::JsonFormat;
use crate::error::{Error, Result};
use crate::request::{ChatContent, ChatMessage, ChatRequest, ChatRole, ChatTool};
use crate::{AssistantContentBlock, AssistantMessageExt, AssistantToolCall};
use crate::{
AssistantContentBlock, AssistantMessageExt, AssistantToolCall, ChatContent, ChatMessage,
ChatRole, RenderRequest, Tool,
};
const BOS_TOKEN: &str = "<begin▁of▁sentence>";
const EOS_TOKEN: &str = "<end▁of▁sentence>";
@@ -39,7 +41,7 @@ struct RenderedToolSchema<'a> {
}
/// Render one chat request into the final prompt string.
pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
pub(super) fn render_request(request: &RenderRequest<'_>) -> Result<String> {
let thinking_mode = match request.enable_thinking()?.unwrap_or(false) {
true => ThinkingMode::Thinking,
false => ThinkingMode::Chat,
@@ -49,20 +51,19 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
Some(ChatRole::User | ChatRole::Developer)
);
let render_offset = isize::from(request.tool_parsing_enabled());
let last_user_render_index =
find_last_user_render_index(request.messages.as_slice(), render_offset);
let last_user_actual_index = find_last_user_actual_index(request.messages.as_slice());
let last_user_render_index = find_last_user_render_index(request.messages, render_offset);
let last_user_actual_index = find_last_user_actual_index(request.messages);
let continue_final_message = request.chat_options.continue_final_message();
let mut prompt = String::from(BOS_TOKEN);
if request.tool_parsing_enabled() {
render_system_message(&mut prompt, None, &request.tools)?;
render_system_message(&mut prompt, None, request.tools)?;
}
for (message_index, message) in request.messages.iter().enumerate() {
render_message(
&mut prompt,
request.messages.as_slice(),
request.messages,
message_index,
message,
render_offset,
@@ -168,7 +169,7 @@ fn find_last_user_actual_index(messages: &[ChatMessage]) -> usize {
fn render_system_message(
out: &mut String,
content: Option<&ChatContent>,
tools: &[ChatTool],
tools: &[Tool],
) -> Result<()> {
if let Some(content) = content {
write_chat_content(out, content)?;
@@ -185,7 +186,7 @@ fn render_system_message(
fn render_developer_message(
out: &mut String,
content: &ChatContent,
tools: &[ChatTool],
tools: &[Tool],
opens_thinking: bool,
) -> Result<()> {
if content.is_empty() {
@@ -452,7 +453,7 @@ fn encode_arguments_to_dsml(out: &mut String, tool_call: &AssistantToolCall) ->
}
/// Render the full tool preamble shown to the model.
fn render_tools(out: &mut String, tools: &[ChatTool]) -> Result<()> {
fn render_tools(out: &mut String, tools: &[Tool]) -> Result<()> {
out.push_str(
r#"## Tools
@@ -500,7 +501,7 @@ Here are the functions available in JSONSchema format:
/// Serialize one typed tool schema into the JSON shape embedded inside
/// `<functions>`.
fn render_tool_schema(out: &mut String, tool: &ChatTool) -> Result<()> {
fn render_tool_schema(out: &mut String, tool: &Tool) -> Result<()> {
out.push_str(&json_dumps(&RenderedToolSchema {
name: &tool.name,
description: tool.description.as_deref(),
@@ -517,7 +518,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)?);
}
}
}
@@ -3,11 +3,10 @@
mod encoding;
use vllm_text::Prompt;
use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
use super::{
ChatRenderer, RenderRequest, RenderedPrompt, RenderedPromptContent, request_template_kwargs,
};
use crate::Result;
use crate::request::ChatRequest;
/// Dedicated DeepSeek V3.2 renderer.
#[derive(Debug, Clone, Copy, Default)]
@@ -21,12 +20,12 @@ impl DeepSeekV32ChatRenderer {
}
impl ChatRenderer for DeepSeekV32ChatRenderer {
fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
fn render(&self, request: RenderRequest<'_>) -> Result<RenderedPrompt> {
request.validate()?;
Ok(RenderedPrompt {
prompt: Prompt::Text(encoding::render_request(request)?),
effective_template_kwargs: request_template_kwargs(request),
content: RenderedPromptContent::Text(encoding::render_request(&request)?),
effective_template_kwargs: request_template_kwargs(&request),
})
}
}
@@ -9,36 +9,35 @@ use thiserror_ext::AsReport;
use super::DeepSeekV32ChatRenderer;
use crate::error::Error;
use crate::event::{AssistantContentBlock, AssistantToolCall};
use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
use crate::request::{
ChatContentPart, ChatMessage, ChatRequest, ChatTool, ChatToolChoice, GenerationPromptMode,
use crate::test_utils::{FixtureRequestOptions, fixture_chat_request};
use crate::{AssistantContentBlock, AssistantToolCall};
use crate::{
ChatContentPart, ChatMessage, ChatToolChoice, GenerationPromptMode, TestRenderRequest, Tool,
};
use crate::{ChatRenderer, ChatRole};
fn render_request(request: &ChatRequest) -> String {
fn render_request(request: &TestRenderRequest) -> String {
DeepSeekV32ChatRenderer::new()
.render(request)
.render(request.as_request())
.unwrap()
.prompt
.content
.into_text()
.expect("deepseek renderer should return text prompt")
}
fn render_result(request: &ChatRequest) -> Result<String, Error> {
DeepSeekV32ChatRenderer::new().render(request).map(|rendered| {
fn render_result(request: &TestRenderRequest) -> Result<String, Error> {
DeepSeekV32ChatRenderer::new().render(request.as_request()).map(|rendered| {
rendered
.prompt
.content
.into_text()
.expect("deepseek renderer should return text prompt")
})
}
fn thinking_request(messages: Vec<ChatMessage>) -> ChatRequest {
let mut request = ChatRequest {
request_id: "deepseek-v32-small-test".to_string(),
fn thinking_request(messages: Vec<ChatMessage>) -> TestRenderRequest {
let mut request = TestRenderRequest {
messages,
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
if matches!(
request.messages.last().map(ChatMessage::role),
@@ -53,7 +52,7 @@ fn thinking_request(messages: Vec<ChatMessage>) -> ChatRequest {
request
}
fn fixture_request(input_name: &str) -> ChatRequest {
fn fixture_request(input_name: &str) -> TestRenderRequest {
fixture_chat_request(&fixture_path(input_name), deepseek_fixture_options())
}
@@ -66,7 +65,7 @@ fn deepseek_fixture_options() -> FixtureRequestOptions {
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src/renderer/deepseek_v32")
.join("src/deepseek_v32")
.join("fixtures")
.join(name)
}
@@ -103,13 +102,12 @@ fn renders_official_search_fixture_with_date() {
#[test]
fn request_level_tools_are_lowered_as_synthetic_leading_system_message() {
let mut request = ChatRequest {
request_id: "deepseek-v32-tools".to_string(),
let mut request = TestRenderRequest {
messages: vec![
ChatMessage::system("System prompt."),
ChatMessage::text(ChatRole::User, "Hello"),
],
tools: vec![ChatTool {
tools: vec![Tool {
name: "lookup".to_string(),
description: Some("Look things up".to_string()),
parameters: json!({
@@ -124,7 +122,7 @@ fn request_level_tools_are_lowered_as_synthetic_leading_system_message() {
strict: None,
}],
tool_choice: ChatToolChoice::Auto,
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
request
.chat_options
@@ -272,12 +270,12 @@ fn assistant_after_last_user_requires_reasoning_or_tool_calls() {
#[test]
fn continue_final_assistant_omits_final_eos() {
let mut request = ChatRequest {
let mut request = TestRenderRequest {
messages: vec![
ChatMessage::user("write"),
ChatMessage::assistant_text("partial answer"),
],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
@@ -289,14 +287,14 @@ fn continue_final_assistant_omits_final_eos() {
#[test]
fn render_rejects_multimodal_input() {
let request = ChatRequest {
let request = TestRenderRequest {
messages: vec![ChatMessage::user(vec![ChatContentPart::image_url(
"data:image/png;base64,test",
)])],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
let error = DeepSeekV32ChatRenderer::new().render(&request).unwrap_err();
let error = DeepSeekV32ChatRenderer::new().render(request.as_request()).unwrap_err();
assert!(matches!(
error,
@@ -14,8 +14,10 @@ use serde_json::Value;
use serde_json_fmt::JsonFormat;
use crate::error::{Error, Result};
use crate::request::{ChatContent, ChatMessage, ChatRequest, ChatTool, ReasoningEffort};
use crate::{AssistantContentBlock, AssistantMessageExt, AssistantToolCall};
use crate::{
AssistantContentBlock, AssistantMessageExt, AssistantToolCall, ChatContent, ChatMessage,
ReasoningEffort, RenderRequest, Tool,
};
const BOS_TOKEN: &str = "<begin▁of▁sentence>";
const EOS_TOKEN: &str = "<end▁of▁sentence>";
@@ -46,14 +48,14 @@ struct RenderedToolSchema<'a> {
}
/// Render one chat request into the final prompt string.
pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
pub(super) fn render_request(request: &RenderRequest<'_>) -> Result<String> {
let (thinking_mode, max_reasoning_effort) = resolve_thinking_options(request)?;
let request_tools = request_tools(request);
let synthetic_tool_system = needs_synthetic_tool_system(request, request_tools);
let drop_thinking = request.parse_template_bool("drop_thinking")?.unwrap_or(true)
&& !rendered_tools_present(request, request_tools);
let last_user_render_index =
find_last_user_render_index(request.messages.as_slice(), synthetic_tool_system);
find_last_user_render_index(request.messages, synthetic_tool_system);
let mut out = String::from(BOS_TOKEN);
if thinking_mode == ThinkingMode::Thinking && max_reasoning_effort {
out.push_str(REASONING_EFFORT_MAX);
@@ -68,7 +70,7 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
}
for (message_index, message) in request.messages.iter().enumerate() {
if is_following_tool_response(request.messages.as_slice(), message_index) {
if is_following_tool_response(request.messages, message_index) {
continue;
}
@@ -101,12 +103,12 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
render_assistant_message(&mut out, emit_thinking_block, append_eos, content)?;
}
ChatMessage::ToolResponse { .. } => {
render_tool_response_block(&mut out, request.messages.as_slice(), message_index)?;
render_tool_response_block(&mut out, request.messages, message_index)?;
}
}
if is_user_like_entry(message)
&& next_rendered_entry_is_assistant_or_end(request.messages.as_slice(), message_index)
&& next_rendered_entry_is_assistant_or_end(request.messages, message_index)
{
write_assistant_transition(
&mut out,
@@ -124,7 +126,7 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
/// wrapper, the Rust renderer only consumes the typed top-level
/// `reasoning_effort`; the generic template-kwargs map is left for HF
/// templates.
fn resolve_thinking_options(request: &ChatRequest) -> Result<(ThinkingMode, bool)> {
fn resolve_thinking_options(request: &RenderRequest<'_>) -> Result<(ThinkingMode, bool)> {
let mut thinking_mode = match request.enable_thinking()?.unwrap_or(false) {
true => ThinkingMode::Thinking,
false => ThinkingMode::Chat,
@@ -141,16 +143,16 @@ fn resolve_thinking_options(request: &ChatRequest) -> Result<(ThinkingMode, bool
}
/// Return request-level tools only when native tool parsing is enabled.
fn request_tools(request: &ChatRequest) -> &[ChatTool] {
fn request_tools<'a>(request: &RenderRequest<'a>) -> &'a [Tool] {
if request.tool_parsing_enabled() {
request.tools.as_slice()
request.tools
} else {
&[]
}
}
/// Return whether request tools need a synthetic leading system entry.
fn needs_synthetic_tool_system(request: &ChatRequest, request_tools: &[ChatTool]) -> bool {
fn needs_synthetic_tool_system(request: &RenderRequest<'_>, request_tools: &[Tool]) -> bool {
!request_tools.is_empty()
&& !request
.messages
@@ -159,7 +161,7 @@ fn needs_synthetic_tool_system(request: &ChatRequest, request_tools: &[ChatTool]
}
/// Return whether any rendered message carries tool schemas.
fn rendered_tools_present(request: &ChatRequest, request_tools: &[ChatTool]) -> bool {
fn rendered_tools_present(request: &RenderRequest<'_>, request_tools: &[Tool]) -> bool {
!request_tools.is_empty()
|| request.messages.iter().any(|message| {
matches!(
@@ -228,7 +230,7 @@ fn next_rendered_entry_is_assistant_or_end(messages: &[ChatMessage], message_ind
}
/// Render the tool preamble shown to the model, V4 flavor.
fn render_tools(out: &mut String, tools: &[ChatTool]) -> Result<()> {
fn render_tools(out: &mut String, tools: &[Tool]) -> Result<()> {
out.push_str(
r#"## Tools
@@ -269,7 +271,7 @@ Otherwise, output directly after </think> with tool calls or final response.
}
/// Serialize one typed tool schema into the JSON shape embedded in the prompt.
fn render_tool_schema(out: &mut String, tool: &ChatTool) -> Result<()> {
fn render_tool_schema(out: &mut String, tool: &Tool) -> Result<()> {
out.push_str(&json_dumps(&RenderedToolSchema {
name: &tool.name,
description: tool.description.as_deref(),
@@ -283,7 +285,7 @@ fn render_tool_schema(out: &mut String, tool: &ChatTool) -> Result<()> {
fn render_system_message(
out: &mut String,
content: Option<&ChatContent>,
tools: &[ChatTool],
tools: &[Tool],
) -> Result<()> {
if let Some(content) = content {
write_chat_content(out, content)?;
@@ -296,11 +298,7 @@ fn render_system_message(
}
/// Developer messages are rendered as user-like turns with optional tools.
fn render_developer_message(
out: &mut String,
content: &ChatContent,
tools: &[ChatTool],
) -> Result<()> {
fn render_developer_message(out: &mut String, content: &ChatContent, tools: &[Tool]) -> Result<()> {
if content.is_empty() {
return Err(Error::ChatTemplate(
"invalid DeepSeek V4 developer message: empty content".to_string(),
@@ -519,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)?);
}
}
}
@@ -3,29 +3,29 @@
mod encoding;
use vllm_text::Prompt;
use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
use super::{
ChatRenderer, RenderRequest, RenderedPrompt, RenderedPromptContent, request_template_kwargs,
};
use crate::Result;
use crate::request::ChatRequest;
/// Dedicated DeepSeek V4 renderer.
#[derive(Debug, Clone, Copy, Default)]
pub struct DeepSeekV4ChatRenderer;
impl DeepSeekV4ChatRenderer {
/// Create the dedicated DeepSeek V4 renderer.
pub fn new() -> Self {
Self
}
}
impl ChatRenderer for DeepSeekV4ChatRenderer {
fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
fn render(&self, request: RenderRequest<'_>) -> Result<RenderedPrompt> {
request.validate()?;
Ok(RenderedPrompt {
prompt: Prompt::Text(encoding::render_request(request)?),
effective_template_kwargs: request_template_kwargs(request),
content: RenderedPromptContent::Text(encoding::render_request(&request)?),
effective_template_kwargs: request_template_kwargs(&request),
})
}
}
@@ -8,20 +8,20 @@ use serde_json::Value;
use super::DeepSeekV4ChatRenderer;
use crate::ChatRenderer;
use crate::event::{AssistantContentBlock, AssistantToolCall};
use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
use crate::request::{ChatMessage, ChatRequest, GenerationPromptMode, ReasoningEffort};
use crate::test_utils::{FixtureRequestOptions, fixture_chat_request};
use crate::{AssistantContentBlock, AssistantToolCall};
use crate::{ChatMessage, GenerationPromptMode, ReasoningEffort, TestRenderRequest};
fn render_request(request: &ChatRequest) -> String {
fn render_request(request: &TestRenderRequest) -> String {
DeepSeekV4ChatRenderer::new()
.render(request)
.render(request.as_request())
.unwrap()
.prompt
.content
.into_text()
.expect("deepseek v4 renderer should return text prompt")
}
fn fixture_request(input_name: &str) -> ChatRequest {
fn fixture_request(input_name: &str) -> TestRenderRequest {
fixture_chat_request(&fixture_path(input_name), deepseek_fixture_options())
}
@@ -34,7 +34,7 @@ fn deepseek_fixture_options() -> FixtureRequestOptions {
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src/renderer/deepseek_v4")
.join("src/deepseek_v4")
.join("fixtures")
.join(name)
}
@@ -63,9 +63,9 @@ fn renders_v4_fixture_2_multi_turn_drop_thinking() {
#[test]
fn reasoning_effort_max_adds_prefix_when_thinking_is_enabled() {
let mut request = ChatRequest {
let mut request = TestRenderRequest {
messages: vec![ChatMessage::user("solve it")],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
request
.chat_options
@@ -86,9 +86,9 @@ fn reasoning_effort_max_adds_prefix_when_thinking_is_enabled() {
#[test]
fn reasoning_effort_none_disables_thinking() {
let mut request = ChatRequest {
let mut request = TestRenderRequest {
messages: vec![ChatMessage::user("answer directly")],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
request
.chat_options
@@ -104,9 +104,9 @@ fn reasoning_effort_none_disables_thinking() {
#[test]
fn reasoning_effort_template_kwarg_is_ignored() {
let mut request = ChatRequest {
let mut request = TestRenderRequest {
messages: vec![ChatMessage::user("solve it")],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
request
.chat_options
@@ -124,7 +124,7 @@ fn reasoning_effort_template_kwarg_is_ignored() {
#[test]
fn tool_results_are_sorted_by_previous_assistant_tool_call_order() {
let request = ChatRequest {
let request = TestRenderRequest {
messages: vec![
ChatMessage::assistant_blocks(vec![
AssistantContentBlock::ToolCall(AssistantToolCall {
@@ -141,7 +141,7 @@ fn tool_results_are_sorted_by_previous_assistant_tool_call_order() {
ChatMessage::tool_response("first result", "first"),
ChatMessage::tool_response("second result", "second"),
],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
let rendered = render_request(&request);
@@ -164,7 +164,7 @@ fn tool_results_are_sorted_by_previous_assistant_tool_call_order() {
#[test]
fn drop_thinking_false_keeps_prior_assistant_reasoning() {
let mut request = ChatRequest {
let mut request = TestRenderRequest {
messages: vec![
ChatMessage::assistant_blocks(vec![
AssistantContentBlock::Reasoning {
@@ -176,7 +176,7 @@ fn drop_thinking_false_keeps_prior_assistant_reasoning() {
]),
ChatMessage::user("next"),
],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
request
.chat_options
@@ -197,16 +197,16 @@ fn drop_thinking_false_keeps_prior_assistant_reasoning() {
#[test]
fn continue_final_assistant_omits_final_eos() {
let request = ChatRequest {
let request = TestRenderRequest {
messages: vec![
ChatMessage::user("write"),
ChatMessage::assistant_text("partial answer"),
],
chat_options: crate::request::ChatOptions {
chat_options: crate::ChatOptions {
generation_prompt_mode: GenerationPromptMode::ContinueFinalAssistant,
..Default::default()
},
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
let rendered = render_request(&request);
+54
View File
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use thiserror::Error;
type BoxedError = Box<dyn std::error::Error + Send + Sync>;
/// Error returned while constructing or applying a chat renderer.
#[derive(Debug, Error)]
pub enum Error {
/// Rendering requires at least one chat message.
#[error("chat request must contain at least one message")]
EmptyMessages,
/// Continuation mode requires the final message to be an assistant turn.
#[error("cannot continue the final message when the last message is not from the assistant")]
ContinueFinalAssistantWithoutFinalAssistant,
/// The selected renderer requires a chat template and none was configured.
#[error("chat template is required but none was configured")]
MissingChatTemplate,
/// A chat template could not be compiled or applied.
#[error("chat template error: {0}")]
ChatTemplate(String),
/// The selected renderer cannot represent the given multimodal part.
#[error("unsupported multimodal content: {0}")]
UnsupportedMultimodalContent(&'static str),
/// The process-wide GPT-OSS Harmony encoding could not be initialized.
#[error("failed to initialize the Harmony encoding")]
HarmonyEncoding {
/// Underlying Harmony initialization failure.
#[source]
error: BoxedError,
},
/// Tokenizer construction or encoding failed.
#[error(transparent)]
Tokenizer(#[from] vllm_tokenizer::TokenizerError),
}
impl Error {
/// Whether this error should be reported as invalid request input when
/// raised while rendering.
pub fn is_request_validation_error(&self) -> bool {
matches!(
self,
Self::EmptyMessages
| Self::ContinueFinalAssistantWithoutFinalAssistant
| Self::MissingChatTemplate
| Self::ChatTemplate(_)
| Self::UnsupportedMultimodalContent(_)
)
}
}
/// Result returned by chat renderer operations.
pub type Result<T> = std::result::Result<T, Error>;
@@ -12,13 +12,13 @@ use thiserror_ext::AsReport as _;
use crate::error::{Error, Result};
/// Lazily load the shared GPT-OSS Harmony encoding once per process.
pub(crate) fn harmony_encoding() -> Result<&'static HarmonyEncoding> {
pub fn harmony_encoding() -> Result<&'static HarmonyEncoding> {
static ENCODING: LazyLock<anyhow::Result<HarmonyEncoding>> = LazyLock::new(|| {
load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss)
.context("failed to load harmony encoding for gpt-oss")
});
ENCODING.as_ref().map_err(|error| Error::HarmonyOutputParsing {
ENCODING.as_ref().map_err(|error| Error::HarmonyEncoding {
error: error.to_report_string().into(),
})
}
@@ -3,7 +3,7 @@
//! Native Harmony chat renderer for `gpt_oss`.
pub(crate) mod encoding;
mod encoding;
use openai_harmony::HarmonyEncoding;
use openai_harmony::chat::{
@@ -12,14 +12,16 @@ use openai_harmony::chat::{
};
use thiserror_ext::AsReport as _;
use time::macros::format_description;
use vllm_text::Prompt;
use self::encoding::harmony_encoding;
use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
pub use self::encoding::harmony_encoding;
use super::{
ChatRenderer, RenderRequest, RenderedPrompt, RenderedPromptContent, request_template_kwargs,
};
use crate::error::{Error, Result};
use crate::event::AssistantContentBlock;
use crate::request::{ChatContent, ChatMessage, ChatRequest, ChatTool, GenerationPromptMode};
use crate::{AssistantMessageExt as _, ReasoningEffort};
use crate::{
AssistantContentBlock, AssistantMessageExt as _, ChatContent, ChatMessage,
GenerationPromptMode, ReasoningEffort, Tool,
};
const SYSTEM_START_DATE_ENV: &str = "VLLM_SYSTEM_START_DATE";
const HARMONY_SYSTEM_INSTRUCTIONS_ENV: &str = "VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS";
@@ -73,7 +75,7 @@ impl HarmonyChatRenderer {
///
/// Harmony owns both prompt formatting and tokenization, so the Rust
/// frontend bypasses the generic HF tokenizer path for GPT-OSS input.
fn render_token_ids(&self, request: &ChatRequest) -> Result<Vec<u32>> {
fn render_token_ids(&self, request: &RenderRequest<'_>) -> Result<Vec<u32>> {
if request.has_multimodal() {
return Err(Error::UnsupportedMultimodalContent("image_url"));
}
@@ -111,12 +113,12 @@ impl HarmonyChatRenderer {
}
impl ChatRenderer for HarmonyChatRenderer {
/// Render a chat request as [`Prompt::TokenIds`] with template kwargs echoed
/// for downstream accounting/debugging.
fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
/// Render a chat request as token IDs with template kwargs echoed for
/// downstream accounting/debugging.
fn render(&self, request: RenderRequest<'_>) -> Result<RenderedPrompt> {
Ok(RenderedPrompt {
prompt: Prompt::TokenIds(self.render_token_ids(request)?),
effective_template_kwargs: request_template_kwargs(request),
content: RenderedPromptContent::TokenIds(self.render_token_ids(&request)?),
effective_template_kwargs: request_template_kwargs(&request),
})
}
}
@@ -126,10 +128,10 @@ impl ChatRenderer for HarmonyChatRenderer {
/// This adds the Harmony system/developer preamble, peels at most one leading
/// system/developer instruction message, and then lowers the remaining chat
/// history message-by-message.
fn to_harmony_messages(request: &ChatRequest, options: &Options) -> Result<Vec<Message>> {
fn to_harmony_messages(request: &RenderRequest<'_>, options: &Options) -> Result<Vec<Message>> {
let (instructions, leading_developer_tools, remaining_messages) =
peel_leading_instructions(&request.messages)?;
let tool_call_names = tool_call_names(&request.messages);
peel_leading_instructions(request.messages)?;
let tool_call_names = tool_call_names(request.messages);
let mut messages =
build_harmony_preamble(request, instructions, leading_developer_tools, options)?;
@@ -147,7 +149,7 @@ fn to_harmony_messages(request: &ChatRequest, options: &Options) -> Result<Vec<M
#[allow(clippy::type_complexity)]
fn peel_leading_instructions(
messages: &[ChatMessage],
) -> Result<(Option<String>, Option<&[ChatTool]>, &[ChatMessage])> {
) -> Result<(Option<String>, Option<&[Tool]>, &[ChatMessage])> {
let Some(first) = messages.first() else {
return Ok((None, None, messages));
};
@@ -172,9 +174,9 @@ fn peel_leading_instructions(
/// a developer message depending on `use_system_instructions`; request-level and
/// leading developer tools are attached to the developer message.
fn build_harmony_preamble(
request: &ChatRequest,
request: &RenderRequest<'_>,
instructions: Option<String>,
leading_developer_tools: Option<&[ChatTool]>,
leading_developer_tools: Option<&[Tool]>,
options: &Options,
) -> Result<Vec<Message>> {
let mut messages = vec![Message::from_role_and_content(
@@ -211,12 +213,12 @@ fn build_harmony_preamble(
/// Collect request-level and leading developer function tools for the preamble.
fn preamble_tool_descriptions(
request: &ChatRequest,
leading_developer_tools: Option<&[ChatTool]>,
request: &RenderRequest<'_>,
leading_developer_tools: Option<&[Tool]>,
) -> Vec<ToolDescription> {
let mut tools = Vec::new();
if request.tool_parsing_enabled() {
tools.extend(to_tool_descriptions(&request.tools));
tools.extend(to_tool_descriptions(request.tools));
}
if let Some(leading_developer_tools) = leading_developer_tools {
tools.extend(to_tool_descriptions(leading_developer_tools));
@@ -311,7 +313,7 @@ fn to_harmony_message(
fn system_or_developer_message(
role: &str,
instructions: String,
tools: Option<&[ChatTool]>,
tools: Option<&[Tool]>,
options: &Options,
) -> Result<Message> {
if role == "system" && options.use_system_instructions {
@@ -325,7 +327,7 @@ fn system_or_developer_message(
}
/// Build a Harmony developer message with optional instructions and function tools.
fn developer_message(instructions: Option<String>, tools: Option<&[ChatTool]>) -> Message {
fn developer_message(instructions: Option<String>, tools: Option<&[Tool]>) -> Message {
let mut content = DeveloperContent::new();
if let Some(instructions) = instructions.filter(|text| !text.is_empty()) {
content = content.with_instructions(instructions);
@@ -427,11 +429,11 @@ 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.
fn to_tool_descriptions(tools: &[ChatTool]) -> Vec<ToolDescription> {
fn to_tool_descriptions(tools: &[Tool]) -> Vec<ToolDescription> {
tools
.iter()
.map(|tool| {
@@ -10,15 +10,15 @@ use super::HarmonyChatRenderer;
use super::encoding::harmony_encoding;
use crate::ChatRenderer;
use crate::error::Error;
use crate::event::{AssistantContentBlock, AssistantToolCall};
use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
use crate::request::{
ChatContentPart, ChatMessage, ChatRequest, GenerationPromptMode, ReasoningEffort,
use crate::test_utils::{FixtureRequestOptions, fixture_chat_request};
use crate::{AssistantContentBlock, AssistantToolCall};
use crate::{
ChatContentPart, ChatMessage, GenerationPromptMode, ReasoningEffort, TestRenderRequest,
};
const PINNED_DATE: &str = "2025-06-28";
fn fixture_request(input_name: &str) -> ChatRequest {
fn fixture_request(input_name: &str) -> TestRenderRequest {
fixture_chat_request(
&fixture_path(input_name),
FixtureRequestOptions {
@@ -30,7 +30,7 @@ fn fixture_request(input_name: &str) -> ChatRequest {
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src/renderer/harmony")
.join("src/harmony")
.join("fixtures")
.join(name)
}
@@ -39,24 +39,24 @@ fn test_renderer(use_system_instructions: bool) -> HarmonyChatRenderer {
HarmonyChatRenderer::with_options(PINNED_DATE, use_system_instructions).unwrap()
}
fn render_token_ids(request: &ChatRequest) -> Vec<u32> {
fn render_token_ids(request: &TestRenderRequest) -> Vec<u32> {
render_token_ids_with(&test_renderer(false), request)
}
fn render_token_ids_with(renderer: &HarmonyChatRenderer, request: &ChatRequest) -> Vec<u32> {
fn render_token_ids_with(renderer: &HarmonyChatRenderer, request: &TestRenderRequest) -> Vec<u32> {
renderer
.render(request)
.render(request.as_request())
.unwrap()
.prompt
.content
.into_token_ids()
.expect("Harmony renderer returns token IDs")
}
fn render_prompt_text(request: &ChatRequest) -> String {
fn render_prompt_text(request: &TestRenderRequest) -> String {
render_prompt_text_with(&test_renderer(false), request)
}
fn render_prompt_text_with(renderer: &HarmonyChatRenderer, request: &ChatRequest) -> String {
fn render_prompt_text_with(renderer: &HarmonyChatRenderer, request: &TestRenderRequest) -> String {
let token_ids = render_token_ids_with(renderer, request);
harmony_encoding().unwrap().tokenizer().decode_utf8(&token_ids).unwrap()
}
@@ -137,10 +137,10 @@ fn drops_stale_analysis_fixture() {
#[test]
fn rejects_invalid_reasoning_effort() {
let mut request = ChatRequest::for_test();
let mut request = TestRenderRequest::for_test();
request.chat_options.reasoning_effort = Some(ReasoningEffort::None);
let error = test_renderer(false).render(&request).unwrap_err();
let error = test_renderer(false).render(request.as_request()).unwrap_err();
expect![[r#"chat template error: reasoning_effort="none" is not supported by Harmony. Supported values are: low, medium, high."#]]
.assert_eq(&error.to_report_string());
@@ -148,7 +148,7 @@ fn rejects_invalid_reasoning_effort() {
#[test]
fn rejects_unknown_tool_response_id() {
let request = ChatRequest {
let request = TestRenderRequest {
messages: vec![
ChatMessage::assistant_blocks(vec![AssistantContentBlock::ToolCall(
AssistantToolCall {
@@ -159,10 +159,10 @@ fn rejects_unknown_tool_response_id() {
)]),
ChatMessage::tool_response("{}", "call-unknown"),
],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
let error = test_renderer(false).render(&request).unwrap_err();
let error = test_renderer(false).render(request.as_request()).unwrap_err();
expect![
"chat template error: invalid Harmony tool message: unknown tool_call_id `call-unknown`"
@@ -172,14 +172,14 @@ fn rejects_unknown_tool_response_id() {
#[test]
fn rejects_multimodal_input() {
let request = ChatRequest {
let request = TestRenderRequest {
messages: vec![ChatMessage::user(vec![ChatContentPart::image_url(
"data:image/png;base64,test",
)])],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
let error = test_renderer(false).render(&request).unwrap_err();
let error = test_renderer(false).render(request.as_request()).unwrap_err();
assert!(matches!(
error,
@@ -189,16 +189,16 @@ fn rejects_multimodal_input() {
#[test]
fn rejects_continue_final_assistant() {
let mut request = ChatRequest {
let mut request = TestRenderRequest {
messages: vec![
ChatMessage::user("write"),
ChatMessage::assistant_text("partial"),
],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
let error = test_renderer(false).render(&request).unwrap_err();
let error = test_renderer(false).render(request.as_request()).unwrap_err();
expect!["chat template error: Harmony renderer does not support continue_final_message"]
.assert_eq(&error.to_report_string());
@@ -33,8 +33,11 @@ pub enum ChatTemplateContentFormatOption {
}
impl ChatTemplateContentFormatOption {
/// CLI/config literal for automatic content-format detection.
pub const AUTO_LITERAL: &str = "auto";
/// CLI/config literal for OpenAI-compatible structured content.
pub const OPENAI_LITERAL: &str = "openai";
/// CLI/config literal for flattened string content.
pub const STRING_LITERAL: &str = "string";
}
@@ -1,27 +1,31 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//! Hugging Face Jinja chat-template renderer.
use std::collections::HashMap;
use std::path::Path;
use serde::Serialize;
use serde_json::Value as JsonValue;
use thiserror_ext::AsReport as _;
use tracing::{info, trace, warn};
use vllm_text::Prompt;
use vllm_text::backend::hf::{
HfSpecialTokens, HfTokenizerConfig, ResolvedModelFiles, load_tokenizer_config,
};
use vllm_tokenizer::{HfSpecialTokens, HfTokenizerConfig, load_tokenizer_config};
use self::format::{
ChatTemplateContentFormat, ChatTemplateContentFormatOption as ContentFormatOption,
};
use self::template::{CompiledChatTemplate, TemplateContext};
use self::template::{
CompiledChatTemplate, TemplateContext, load_chat_template, resolve_chat_template,
};
use self::value::{TemplateValue, to_template_value};
use super::{ChatRenderer, RenderedPrompt, effective_template_kwargs};
use super::{
ChatRenderer, RenderRequest, RenderedPrompt, RenderedPromptContent, effective_template_kwargs,
};
use crate::error::Result;
use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest};
use crate::{
AssistantContentBlock, AssistantMessageExt, ChatTool, Error, LoadModelBackendsOptions,
AssistantContentBlock, AssistantMessageExt, ChatContent, ChatContentPart, ChatMessage, Error,
Tool,
};
mod error;
@@ -30,8 +34,6 @@ mod template;
mod tojson;
mod value;
pub use template::{load_chat_template, resolve_chat_template};
pub use self::format::ChatTemplateContentFormatOption;
/// Template-visible placeholder tokens per supported modality.
@@ -40,11 +42,36 @@ pub use self::format::ChatTemplateContentFormatOption;
/// content parts of that modality are rejected during rendering.
#[derive(Debug, Clone, Default)]
pub struct MultimodalRenderInfo {
/// Template-visible image placeholder token.
pub image_token: Option<String>,
/// Template-visible video placeholder token.
pub video_token: Option<String>,
/// Template-visible audio placeholder token.
pub audio_token: Option<String>,
}
/// Resolved local files consumed while constructing an HF renderer.
#[derive(Debug, Clone, Copy, Default)]
pub struct HfRendererFiles<'a> {
/// Resolved `tokenizer_config.json`, when available.
pub tokenizer_config: Option<&'a Path>,
/// Resolved dedicated chat-template file, when available.
pub chat_template: Option<&'a Path>,
}
/// Model-level options used to construct an HF renderer.
#[derive(Debug, Clone, Default)]
pub struct HfRendererConfig {
/// Optional inline or file-backed chat-template override.
pub chat_template: Option<String>,
/// Default template kwargs merged before request-level kwargs.
pub default_template_kwargs: HashMap<String, JsonValue>,
/// How `message.content` should be represented in the template.
pub content_format: ContentFormatOption,
/// Template-visible multimodal placeholder tokens.
pub multimodal: Option<MultimodalRenderInfo>,
}
/// Hugging Face chat-template renderer backed by the local Jinja chat-template
/// state.
pub struct HfChatRenderer {
@@ -76,27 +103,25 @@ impl HfChatRenderer {
})
}
/// Attach named special tokens exposed to chat templates.
pub fn with_special_tokens(mut self, special_tokens: Option<HfSpecialTokens>) -> Self {
self.special_tokens = special_tokens;
self
}
/// Attach template-visible multimodal placeholder tokens.
pub fn with_multimodal(mut self, multimodal: Option<MultimodalRenderInfo>) -> Self {
self.multimodal = multimodal;
self
}
/// Create a renderer from the given model files and loading options.
pub fn load(
files: &ResolvedModelFiles,
options: LoadModelBackendsOptions,
multimodal: Option<MultimodalRenderInfo>,
) -> Result<Self> {
/// Create a renderer from resolved local model files and renderer options.
pub fn load(files: HfRendererFiles<'_>, options: HfRendererConfig) -> Result<Self> {
let HfTokenizerConfig {
special_tokens,
chat_template,
..
} = load_tokenizer_config(files.tokenizer_config_path.as_deref())?;
} = load_tokenizer_config(files.tokenizer_config)?;
let mut template = chat_template;
let special_tokens = (!special_tokens.is_empty()).then_some(special_tokens);
@@ -106,7 +131,7 @@ impl HfChatRenderer {
.map_err(|error| Error::ChatTemplate(error.to_report_string()))?,
);
info!("using configured chat template override");
} else if let Some(chat_template_path) = files.chat_template_path.as_deref() {
} else if let Some(chat_template_path) = files.chat_template {
// If independent chat template file(s) exist and contain non-empty content,
// they take priority over template entries in the tokenizer config
let file_template = load_chat_template(chat_template_path)
@@ -128,11 +153,11 @@ impl HfChatRenderer {
Ok(Self::new(
template,
options.default_chat_template_kwargs,
options.chat_template_content_format,
options.default_template_kwargs,
options.content_format,
)?
.with_special_tokens(special_tokens)
.with_multimodal(multimodal))
.with_multimodal(options.multimodal))
}
/// Apply the chat template to one chat request, rendering the prompt string
@@ -141,7 +166,7 @@ impl HfChatRenderer {
/// If the request carries a per-request `chat_template` override, a
/// temporary template is compiled from that string and used instead of
/// the model's default.
fn apply_chat_template(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
fn apply_chat_template(&self, request: &RenderRequest<'_>) -> Result<RenderedPrompt> {
let override_template = request
.chat_options
.chat_template
@@ -162,10 +187,10 @@ impl HfChatRenderer {
fn apply_chat_template_inner(
&self,
effective_template: &CompiledChatTemplate,
request: &ChatRequest,
request: &RenderRequest<'_>,
) -> Result<RenderedPrompt> {
let mut messages = to_template_messages(
&request.messages,
request.messages,
effective_template.content_format(),
self.multimodal.as_ref(),
)?;
@@ -181,7 +206,7 @@ impl HfChatRenderer {
None
};
let tools = request.tool_parsing_enabled().then(|| to_template_tools(&request.tools));
let tools = request.tool_parsing_enabled().then(|| to_template_tools(request.tools));
trace!(
message_count = messages.len(),
content_format = ?effective_template.content_format(),
@@ -198,7 +223,7 @@ impl HfChatRenderer {
add_generation_prompt: request.chat_options.add_generation_prompt(),
continue_final_message: request.chat_options.continue_final_message(),
tools: tools.as_deref(),
documents: request.documents.as_deref(),
documents: request.documents,
template_kwargs: Some(&effective_template_kwargs),
special_tokens: self.special_tokens.as_ref(),
})
@@ -217,15 +242,15 @@ impl HfChatRenderer {
);
Ok(RenderedPrompt {
prompt: Prompt::Text(prompt),
content: RenderedPromptContent::Text(prompt),
effective_template_kwargs,
})
}
}
impl ChatRenderer for HfChatRenderer {
fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
self.apply_chat_template(request)
fn render(&self, request: RenderRequest<'_>) -> Result<RenderedPrompt> {
self.apply_chat_template(&request)
}
}
@@ -556,7 +581,7 @@ fn truncate_prompt_at_continue_final_message_tag(
Ok(rendered)
}
fn to_template_tools(tools: &[ChatTool]) -> Vec<TemplateTool> {
fn to_template_tools(tools: &[Tool]) -> Vec<TemplateTool> {
tools
.iter()
.map(|tool| TemplateTool {
@@ -577,42 +602,39 @@ mod tests {
use expect_test::expect;
use serde_json::Value;
use vllm_text::Prompt;
use vllm_text::backend::hf::{HfSpecialTokens, NamedSpecialToken};
use vllm_tokenizer::{HfSpecialTokens, NamedSpecialToken};
use super::{ChatTemplateContentFormatOption, HfChatRenderer, MultimodalRenderInfo};
use crate::request::{
ChatContentPart, ChatMessage, ChatRequest, ChatRole, ChatTool, ChatToolChoice,
GenerationPromptMode, ReasoningEffort,
use crate::{
AssistantContentBlock, ChatContentPart, ChatMessage, ChatRenderer, ChatRole,
ChatToolChoice, Error, GenerationPromptMode, ReasoningEffort, RenderedPromptContent,
Result, TestRenderRequest, Tool,
};
use crate::{AssistantContentBlock, ChatRenderer, Error, Result};
const QWEN3_0_6B_TEMPLATE: &str = include_str!("../../../tests/templates/qwen3.jinja");
const QWEN3_5_0_8B_TEMPLATE: &str = include_str!("../../../tests/templates/qwen35.jinja");
const QWEN3_0_6B_TEMPLATE: &str = include_str!("../../../chat/tests/templates/qwen3.jinja");
const QWEN3_5_0_8B_TEMPLATE: &str = include_str!("../../../chat/tests/templates/qwen35.jinja");
fn sample_request(messages: Vec<ChatMessage>) -> ChatRequest {
ChatRequest {
fn sample_request(messages: Vec<ChatMessage>) -> TestRenderRequest {
TestRenderRequest {
messages,
request_id: "render-test".to_string(),
..ChatRequest::for_test()
..TestRenderRequest::for_test()
}
}
fn render(template: Option<&str>, request: &ChatRequest) -> Result<String> {
HfChatRenderer::new(
fn render(template: Option<&str>, request: &TestRenderRequest) -> Result<String> {
let rendered = HfChatRenderer::new(
template.map(str::to_owned),
HashMap::new(),
ChatTemplateContentFormatOption::Auto,
)?
.render(request)?
.prompt
.into_text()
.map_err(|_| unreachable!("HF renderer should return text prompt"))
.render(request.as_request())?
.content;
Ok(rendered.into_text().expect("HF renderer should return text prompt"))
}
fn render_mm(
template: &str,
request: &ChatRequest,
request: &TestRenderRequest,
content_format: ChatTemplateContentFormatOption,
) -> Result<crate::RenderedPrompt> {
HfChatRenderer::new(Some(template.to_string()), HashMap::new(), content_format)?
@@ -621,10 +643,10 @@ mod tests {
video_token: Some("<video>".to_string()),
audio_token: Some("<audio>".to_string()),
}))
.render(request)
.render(request.as_request())
}
fn image_request() -> ChatRequest {
fn image_request() -> TestRenderRequest {
sample_request(vec![ChatMessage::user(vec![
ChatContentPart::text("a"),
ChatContentPart::image_url("data:image/png;base64,test"),
@@ -632,7 +654,7 @@ mod tests {
])])
}
fn video_request() -> ChatRequest {
fn video_request() -> TestRenderRequest {
sample_request(vec![ChatMessage::user(vec![
ChatContentPart::text("a"),
ChatContentPart::video_url("https://example.com/demo.mp4"),
@@ -640,7 +662,7 @@ mod tests {
])])
}
fn audio_request() -> ChatRequest {
fn audio_request() -> TestRenderRequest {
sample_request(vec![ChatMessage::user(vec![
ChatContentPart::text("a"),
ChatContentPart::input_audio("dGVzdA==", Some("wav".to_string())),
@@ -658,7 +680,10 @@ mod tests {
)
.unwrap();
assert_eq!(rendered.prompt, Prompt::Text("a<image>b".to_string()));
assert_eq!(
rendered.content,
RenderedPromptContent::Text("a<image>b".to_string())
);
}
#[test]
@@ -670,7 +695,10 @@ mod tests {
)
.unwrap();
assert_eq!(rendered.prompt, Prompt::Text("a<video>b".to_string()));
assert_eq!(
rendered.content,
RenderedPromptContent::Text("a<video>b".to_string())
);
}
#[test]
@@ -683,8 +711,8 @@ mod tests {
.unwrap();
assert_eq!(
rendered.prompt,
Prompt::Text("a<audio><audio>b".to_string())
rendered.content,
RenderedPromptContent::Text("a<audio><audio>b".to_string())
);
}
@@ -697,7 +725,10 @@ mod tests {
)
.unwrap();
assert_eq!(rendered.prompt, Prompt::Text("a<|image_pad|>b".to_string()));
assert_eq!(
rendered.content,
RenderedPromptContent::Text("a<|image_pad|>b".to_string())
);
}
#[test]
@@ -709,7 +740,10 @@ mod tests {
)
.unwrap();
assert_eq!(rendered.prompt, Prompt::Text("a<|video_pad|>b".to_string()));
assert_eq!(
rendered.content,
RenderedPromptContent::Text("a<|video_pad|>b".to_string())
);
}
#[test]
@@ -722,8 +756,8 @@ mod tests {
.unwrap();
assert_eq!(
rendered.prompt,
Prompt::Text("a<|audio_pad|><|audio_pad|>b".to_string())
rendered.content,
RenderedPromptContent::Text("a<|audio_pad|><|audio_pad|>b".to_string())
);
}
@@ -740,7 +774,7 @@ mod tests {
video_token: None,
audio_token: None,
}))
.render(&video_request())
.render(video_request().as_request())
.unwrap_err();
assert!(matches!(
@@ -842,12 +876,12 @@ mod tests {
ChatTemplateContentFormatOption::OpenAi,
)
.unwrap()
.prompt;
.content;
// Anything rendered after the continued text (here the image
// placeholder and the end marker) is truncated away, matching
// transformers.
assert_eq!(rendered, Prompt::Text("Sure,".to_string()));
assert_eq!(rendered, RenderedPromptContent::Text("Sure,".to_string()));
}
#[test]
@@ -904,7 +938,7 @@ mod tests {
fn chat_template_exposes_developer_tools() {
let request = sample_request(vec![ChatMessage::developer(
"policy",
Some(vec![ChatTool {
Some(vec![Tool {
name: "get_weather".to_string(),
description: Some("Get weather".to_string()),
parameters: serde_json::json!({
@@ -1005,10 +1039,13 @@ mod tests {
)
.unwrap()
.with_special_tokens(Some(special_tokens))
.apply_chat_template(&request)
.apply_chat_template(&request.as_request())
.unwrap();
assert_eq!(rendered.prompt, Prompt::Text("<bos>|true".to_string()));
assert_eq!(
rendered.content,
RenderedPromptContent::Text("<bos>|true".to_string())
);
}
#[test]
@@ -1046,11 +1083,14 @@ mod tests {
ChatTemplateContentFormatOption::String,
)
.unwrap()
.render(&request)
.render(request.as_request())
.unwrap()
.prompt;
.content;
assert_eq!(rendered, Prompt::Text("hello world".to_string()));
assert_eq!(
rendered,
RenderedPromptContent::Text("hello world".to_string())
);
}
#[test]
@@ -1066,11 +1106,14 @@ mod tests {
ChatTemplateContentFormatOption::OpenAi,
)
.unwrap()
.render(&request)
.render(request.as_request())
.unwrap()
.prompt;
.content;
assert_eq!(rendered, Prompt::Text("hello world".to_string()));
assert_eq!(
rendered,
RenderedPromptContent::Text("hello world".to_string())
);
}
#[test]
@@ -1091,9 +1134,9 @@ mod tests {
)
.unwrap();
let rendered = renderer.render(&request).unwrap().prompt;
let rendered = renderer.render(request.as_request()).unwrap().content;
assert_eq!(rendered, Prompt::Text("true|x".to_string()));
assert_eq!(rendered, RenderedPromptContent::Text("true|x".to_string()));
}
#[test]
@@ -1115,9 +1158,12 @@ mod tests {
)
.unwrap();
let rendered = renderer.render(&request).unwrap();
let rendered = renderer.render(request.as_request()).unwrap();
assert_eq!(rendered.prompt, Prompt::Text("max".to_string()));
assert_eq!(
rendered.content,
RenderedPromptContent::Text("max".to_string())
);
assert_eq!(
rendered.effective_template_kwargs.get("reasoning_effort"),
Some(&Value::String("max".to_string()))
@@ -1144,9 +1190,12 @@ mod tests {
)
.unwrap();
let rendered = renderer.render(&request).unwrap();
let rendered = renderer.render(request.as_request()).unwrap();
assert_eq!(rendered.prompt, Prompt::Text("none|true".to_string()));
assert_eq!(
rendered.content,
RenderedPromptContent::Text("none|true".to_string())
);
assert_eq!(
rendered.effective_template_kwargs.get("reasoning_effort"),
Some(&Value::String("none".to_string()))
@@ -1222,7 +1271,7 @@ mod tests {
#[test]
fn chat_template_exposes_tools_to_templates_when_auto_enabled() {
let mut request = sample_request(vec![ChatMessage::text(ChatRole::User, "hello")]);
request.tools = vec![ChatTool {
request.tools = vec![Tool {
name: "get_weather".to_string(),
description: Some("Get weather".to_string()),
parameters: serde_json::json!({
@@ -1326,7 +1375,7 @@ mod tests {
"<|im_start|>user\na<|vision_start|><|image_pad|><|vision_end|>b<|im_end|>\n",
)
"#]]
.assert_debug_eq(&rendered.prompt);
.assert_debug_eq(&rendered.content);
}
#[test]
@@ -5,7 +5,7 @@
//!
//! This module is inlined from SMG's tokenizer crate with local adaptations:
//! - thinking-related detection/state is removed
//! - special tokens are wired to `vllm_text::backends::hf::HfSpecialTokens`
//! - special tokens are wired to `vllm_tokenizer::HfSpecialTokens`
use std::collections::HashMap;
use std::fs;
@@ -14,14 +14,14 @@ use std::path::Path;
use minijinja::Environment;
use serde::{Deserialize, Serialize};
use serde_json::{self};
use vllm_text::backend::hf::HfSpecialTokens;
use vllm_tokenizer::HfSpecialTokens;
use super::error::TemplateError;
use super::format::{
ChatTemplateContentFormat, ChatTemplateContentFormatOption, detect_chat_template_content_format,
};
use super::tojson::hf_tojson_filter;
use crate::renderer::hf::{TemplateMessage, TemplateTool};
use crate::hf::{TemplateMessage, TemplateTool};
type Result<T> = std::result::Result<T, TemplateError>;
@@ -55,7 +55,7 @@ pub(super) struct TemplateContext<'a> {
}
/// Load chat template from a file (`.jinja` or `.json` containing Jinja).
pub fn load_chat_template(template_path: &Path) -> Result<Option<String>> {
pub(crate) fn load_chat_template(template_path: &Path) -> Result<Option<String>> {
let content = fs::read_to_string(template_path).map_err(TemplateError::ReadTemplateFile)?;
if template_path.extension().is_some_and(|ext| ext == "json") {
@@ -82,7 +82,7 @@ pub fn load_chat_template(template_path: &Path) -> Result<Option<String>> {
}
/// Resolve a configured chat template value into a template string.
pub fn resolve_chat_template(chat_template: &str) -> Result<String> {
pub(crate) fn resolve_chat_template(chat_template: &str) -> Result<String> {
let path = Path::new(chat_template);
if path.exists() {
return load_chat_template(path).map(|template| template.unwrap_or_default());
@@ -136,7 +136,7 @@ mod tests {
use std::fs;
use tempfile::TempDir;
use vllm_text::backend::hf::{HfSpecialTokens, NamedSpecialToken};
use vllm_tokenizer::{HfSpecialTokens, NamedSpecialToken};
use super::*;
@@ -5,13 +5,15 @@ use std::collections::HashMap;
use serde_json::{Map, Value, json};
use thiserror_ext::AsReport as _;
use vllm_text::Prompt;
use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
use vllm_tokenizer::{DynTokenizer, Tokenizer};
use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
use super::{
ChatRenderer, RenderRequest, RenderedPrompt, RenderedPromptContent, request_template_kwargs,
};
use crate::error::{Error, Result};
use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool};
use crate::{AssistantContentBlock, AssistantToolCall};
use crate::{
AssistantContentBlock, AssistantToolCall, ChatContent, ChatContentPart, ChatMessage, Tool,
};
const MESSAGE_USER: &str = "<|message_user|>";
const MESSAGE_MODEL: &str = "<|message_model|>";
@@ -170,7 +172,7 @@ impl InklingChatRenderer {
)
}
fn write_tool_declarations(&self, out: &mut Vec<u32>, tools: &[&ChatTool]) -> Result<()> {
fn write_tool_declarations(&self, out: &mut Vec<u32>, tools: &[&Tool]) -> Result<()> {
if tools.is_empty() {
return Ok(());
}
@@ -277,14 +279,14 @@ 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)
}
}
impl ChatRenderer for InklingChatRenderer {
fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
fn render(&self, request: RenderRequest<'_>) -> Result<RenderedPrompt> {
request.validate()?;
if request.chat_options.continue_final_message() {
return Err(Error::ChatTemplate(
@@ -294,13 +296,13 @@ impl ChatRenderer for InklingChatRenderer {
let mut out = Vec::new();
let mut tool_call_id_to_name = HashMap::new();
let effective_template_kwargs = request_template_kwargs(request);
let tools = rendered_tools(request);
let effective_template_kwargs = request_template_kwargs(&request);
let tools = rendered_tools(&request);
self.write_tool_declarations(&mut out, &tools)?;
let mut reasoning_effort =
resolve_reasoning_effort(effective_template_kwargs.get("reasoning_effort"));
for message in &request.messages {
for message in request.messages {
if !matches!(
message,
ChatMessage::System { .. } | ChatMessage::Developer { .. }
@@ -345,7 +347,7 @@ impl ChatRenderer for InklingChatRenderer {
}
Ok(RenderedPrompt {
prompt: Prompt::TokenIds(out),
content: RenderedPromptContent::TokenIds(out),
effective_template_kwargs,
})
}
@@ -378,14 +380,14 @@ fn resolve_special_token(tokenizer: &dyn Tokenizer, token: &str) -> Result<u32>
})
}
fn rendered_tools(request: &ChatRequest) -> Vec<&ChatTool> {
fn rendered_tools<'a>(request: &RenderRequest<'a>) -> Vec<&'a Tool> {
if !request.tool_parsing_enabled() {
return Vec::new();
}
let mut tools = Vec::with_capacity(request.tools.len());
tools.extend(request.tools.iter());
for message in &request.messages {
for message in request.messages {
if let ChatMessage::Developer {
tools: Some(local_tools),
..
@@ -7,27 +7,23 @@ use std::sync::Arc;
use expect_test::{ExpectFile, expect_file};
use serde_json::json;
use thiserror_ext::AsReport;
use vllm_text::tokenizer::Tokenizer;
use vllm_tokenizer::Tokenizer;
use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
use crate::test_utils::{FixtureRequestOptions, fixture_chat_request};
use super::{
AUDIO_END, CONTENT_AUDIO_INPUT, CONTENT_IMAGE, CONTENT_INVOKE_TOOL_JSON, CONTENT_TEXT,
CONTENT_THINKING, CONTENT_XML, END_MESSAGE, InklingChatRenderer, MESSAGE_MODEL, MESSAGE_SYSTEM,
MESSAGE_TOOL, MESSAGE_USER,
};
use crate::event::{AssistantContentBlock, AssistantToolCall};
use crate::request::{ChatMessage, ChatRequest, GenerationPromptMode, ReasoningEffort};
use crate::{AssistantContentBlock, AssistantToolCall};
use crate::{ChatMessage, GenerationPromptMode, ReasoningEffort, TestRenderRequest};
use crate::{ChatRenderer, Error};
struct FixtureTokenizer;
impl Tokenizer for FixtureTokenizer {
fn encode(
&self,
text: &str,
_add_special_tokens: bool,
) -> vllm_text::tokenizer::Result<Vec<u32>> {
fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result<Vec<u32>> {
Ok(text.bytes().map(u32::from).collect())
}
@@ -35,7 +31,7 @@ impl Tokenizer for FixtureTokenizer {
&self,
token_ids: &[u32],
_skip_special_tokens: bool,
) -> vllm_text::tokenizer::Result<String> {
) -> vllm_tokenizer::Result<String> {
Ok(token_ids
.iter()
.map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
@@ -86,16 +82,16 @@ fn renderer() -> InklingChatRenderer {
InklingChatRenderer::new(Arc::new(FixtureTokenizer)).unwrap()
}
fn render_token_ids(request: &ChatRequest) -> Vec<u32> {
fn render_token_ids(request: &TestRenderRequest) -> Vec<u32> {
renderer()
.render(request)
.render(request.as_request())
.unwrap()
.prompt
.content
.into_token_ids()
.expect("Inkling renderer returns token IDs")
}
fn fixture_request(name: &str) -> ChatRequest {
fn fixture_request(name: &str) -> TestRenderRequest {
fixture_chat_request(&fixture_path(name), inkling_fixture_options())
}
@@ -108,7 +104,7 @@ fn inkling_fixture_options() -> FixtureRequestOptions {
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src/renderer/inkling")
.join("src/inkling")
.join("fixtures")
.join(name)
}
@@ -229,14 +225,14 @@ fn renders_named_reasoning_effort_after_tool_declarations() {
#[test]
fn emits_one_reasoning_effort_for_multi_turn_conversation() {
let request = ChatRequest {
let request = TestRenderRequest {
messages: vec![
ChatMessage::system("rules"),
ChatMessage::user("user1"),
ChatMessage::assistant_text("assistant1"),
ChatMessage::user("user2"),
],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
let rendered = render_symbolic_tokens(&render_token_ids(&request));
@@ -253,7 +249,7 @@ fn emits_one_reasoning_effort_for_multi_turn_conversation() {
#[test]
fn canonicalizes_numeric_zero_reasoning_effort() {
for value in [0.0, -0.0] {
let mut request = ChatRequest::for_test();
let mut request = TestRenderRequest::for_test();
request
.chat_options
.template_kwargs
@@ -270,7 +266,7 @@ fn canonicalizes_numeric_zero_reasoning_effort() {
#[test]
fn defaults_reasoning_effort_to_high() {
let request = ChatRequest::for_test();
let request = TestRenderRequest::for_test();
assert!(
render_symbolic_tokens(&render_token_ids(&request)).starts_with(
@@ -282,7 +278,7 @@ fn defaults_reasoning_effort_to_high() {
#[test]
fn renders_numeric_reasoning_effort_template_kwarg() {
let mut request = ChatRequest::for_test();
let mut request = TestRenderRequest::for_test();
request
.chat_options
.template_kwargs
@@ -299,7 +295,7 @@ fn renders_numeric_reasoning_effort_template_kwarg() {
#[test]
fn ignores_unsupported_reasoning_effort_values() {
for value in [json!(true), json!("invalid"), json!(null)] {
let mut request = ChatRequest::for_test();
let mut request = TestRenderRequest::for_test();
request
.chat_options
.template_kwargs
@@ -315,38 +311,38 @@ fn ignores_unsupported_reasoning_effort_values() {
#[test]
fn rejects_out_of_range_reasoning_effort() {
for value in [0.990_000_1, 1.0, 1.5, -0.1] {
let mut request = ChatRequest::for_test();
let mut request = TestRenderRequest::for_test();
request
.chat_options
.template_kwargs
.insert("reasoning_effort".to_string(), json!(value));
let error = renderer().render(&request).unwrap_err();
let error = renderer().render(request.as_request()).unwrap_err();
assert!(error.as_report().to_string().contains("must be in [0.0, 0.99]"));
}
}
#[test]
fn rejects_continue_final_message() {
let request = ChatRequest {
let request = TestRenderRequest {
messages: vec![ChatMessage::assistant_text("partial")],
chat_options: crate::ChatOptions {
generation_prompt_mode: GenerationPromptMode::ContinueFinalAssistant,
..Default::default()
},
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
let error = renderer().render(&request).unwrap_err();
let error = renderer().render(request.as_request()).unwrap_err();
assert!(matches!(error, Error::ChatTemplate(_)));
assert!(error.as_report().to_string().contains("continue_final_message"));
}
#[test]
fn renders_developer_messages_as_system() {
let request = ChatRequest {
let request = TestRenderRequest {
messages: vec![ChatMessage::developer("rules", None)],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
assert_eq!(
@@ -359,7 +355,7 @@ fn renders_developer_messages_as_system() {
#[test]
fn rejects_non_object_tool_call_arguments() {
let request = ChatRequest {
let request = TestRenderRequest {
messages: vec![ChatMessage::assistant_blocks(vec![
AssistantContentBlock::ToolCall(AssistantToolCall {
id: "call_1".to_string(),
@@ -367,9 +363,9 @@ fn rejects_non_object_tool_call_arguments() {
arguments: "[]".to_string(),
}),
])],
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
let error = renderer().render(&request).unwrap_err();
let error = renderer().render(request.as_request()).unwrap_err();
assert!(error.as_report().to_string().contains("JSON object"));
}
@@ -1,45 +1,70 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//! Engine-independent chat prompt renderers.
//!
//! The crate accepts borrowed chat-domain inputs and produces text or token-ID
//! prompt artifacts. Serving requests, sampling policy, multimodal
//! preprocessing, parser state, and engine transport remain in consumer
//! crates.
use std::collections::HashMap;
use std::sync::Arc;
use enum_as_inner::EnumAsInner;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vllm_text::Prompt;
use crate::error::Result;
use crate::request::{ChatRequest, ReasoningEffort};
pub mod deepseek_v32;
pub mod deepseek_v4;
mod deepseek_v32;
mod deepseek_v4;
mod error;
pub mod harmony;
pub mod hf;
mod inkling;
mod request;
mod selection;
#[cfg(test)]
mod test_utils;
#[cfg(test)]
pub(crate) use test_utils::TestRenderRequest;
pub use deepseek_v4::DeepSeekV4ChatRenderer;
pub use deepseek_v32::DeepSeekV32ChatRenderer;
pub use error::{Error, Result};
pub use harmony::HarmonyChatRenderer;
pub use inkling::InklingChatRenderer;
pub use request::RenderRequest;
pub use selection::RendererSelection;
pub use vllm_chat_types::{
AssistantBlockKind, AssistantContentBlock, AssistantMessage, AssistantMessageExt,
AssistantToolCall, ChatContent, ChatContentPart, ChatMessage, ChatOptions, ChatRole,
ChatToolChoice, GenerationPromptMode, ImageDetail, ReasoningEffort, Tool,
};
/// Rendered chat prompt submitted to the text backend.
#[derive(Debug, Clone, PartialEq)]
/// Engine-independent prompt content produced by a chat renderer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, EnumAsInner)]
#[serde(untagged)]
pub enum RenderedPromptContent {
/// Untokenized prompt text.
Text(String),
/// Prompt token IDs produced by a format-specific renderer.
TokenIds(Vec<u32>),
}
/// Rendered chat prompt plus effective template metadata.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RenderedPrompt {
/// The rendered prompt, either as text or already tokenized.
pub prompt: Prompt,
/// Engine-independent prompt content.
pub content: RenderedPromptContent,
/// Effective chat-template kwargs visible to the renderer after applying
/// server defaults, request overrides, and typed reasoning controls.
pub effective_template_kwargs: HashMap<String, Value>,
}
/// Minimal chat-prompt renderer used by `vllm-chat`.
/// Synchronous chat-prompt renderer.
pub trait ChatRenderer: Send + Sync {
/// Render one chat request into the text prompt submitted to the text
/// backend.
fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt>;
/// Render one borrowed chat request into text or token IDs.
fn render(&self, request: RenderRequest<'_>) -> Result<RenderedPrompt>;
}
/// Shared trait-object form of [`ChatRenderer`].
@@ -49,7 +74,7 @@ pub type DynChatRenderer = Arc<dyn ChatRenderer>;
/// using the provided defaults as the base.
pub(crate) fn effective_template_kwargs(
default_template_kwargs: &HashMap<String, Value>,
request: &ChatRequest,
request: &RenderRequest<'_>,
) -> HashMap<String, Value> {
let mut kwargs = default_template_kwargs.clone();
kwargs.extend(request.chat_options.template_kwargs.clone());
@@ -71,6 +96,6 @@ pub(crate) fn effective_template_kwargs(
}
/// Extract the effective chat-template kwargs visible to the renderer from the request.
pub(crate) fn request_template_kwargs(request: &ChatRequest) -> HashMap<String, Value> {
pub(crate) fn request_template_kwargs(request: &RenderRequest<'_>) -> HashMap<String, Value> {
effective_template_kwargs(&HashMap::new(), request)
}
+172
View File
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use serde_json::Value;
use vllm_chat_types::{
ChatMessage, ChatOptions, ChatRole, ChatToolChoice, GenerationPromptMode, Tool,
};
use crate::{Error, Result};
/// Borrowed input consumed by one chat renderer.
///
/// This view contains only chat-domain values that affect prompt construction.
/// Serving, sampling, decoding, scheduling, and engine metadata stay with the
/// caller.
#[derive(Debug, Clone, Copy)]
pub struct RenderRequest<'a> {
/// Ordered chat history to render.
pub messages: &'a [ChatMessage],
/// Chat-template and generation-prompt controls.
pub chat_options: &'a ChatOptions,
/// Request-level tools available to the model.
pub tools: &'a [Tool],
/// Tool-choice behavior used to decide whether tools are exposed.
pub tool_choice: &'a ChatToolChoice,
/// Optional retrieval documents exposed to HF chat templates.
pub documents: Option<&'a [Value]>,
}
impl RenderRequest<'_> {
/// Validate renderer-owned request invariants.
pub fn validate(&self) -> Result<()> {
if self.messages.is_empty() {
return Err(Error::EmptyMessages);
}
match (
self.chat_options.generation_prompt_mode,
self.messages.last().map(ChatMessage::role),
) {
(GenerationPromptMode::ContinueFinalAssistant, Some(ChatRole::Assistant)) => {}
(GenerationPromptMode::ContinueFinalAssistant, _) => {
return Err(Error::ContinueFinalAssistantWithoutFinalAssistant);
}
(GenerationPromptMode::NoGenerationPrompt, _)
| (GenerationPromptMode::StartNewAssistant, _) => {}
}
Ok(())
}
/// Return whether any message contains multimodal content.
pub fn has_multimodal(&self) -> bool {
self.messages.iter().any(ChatMessage::has_multimodal)
}
/// Return whether request-level tools should be exposed to the renderer.
pub fn tool_parsing_enabled(&self) -> bool {
!matches!(self.tool_choice, ChatToolChoice::None) && !self.tools.is_empty()
}
/// Return the request-level thinking toggle when explicitly requested.
///
/// The `thinking` and `enable_thinking` kwargs must be booleans when
/// present and must carry the same value when both are set.
pub fn enable_thinking(&self) -> Result<Option<bool>> {
let thinking = self.parse_template_bool("thinking")?;
let enable_thinking = self.parse_template_bool("enable_thinking")?;
match (thinking, enable_thinking) {
(None, None) => Ok(None),
(Some(thinking), Some(enable_thinking)) if thinking != enable_thinking => {
Err(Error::ChatTemplate(
"template kwargs `thinking` and `enable_thinking` must match when both are set"
.to_string(),
))
}
(Some(thinking), _) => Ok(Some(thinking)),
(None, Some(enable_thinking)) => Ok(Some(enable_thinking)),
}
}
/// Parse one optional boolean chat-template kwarg.
pub fn parse_template_bool(&self, key: &str) -> Result<Option<bool>> {
match self.chat_options.template_kwargs.get(key) {
None => Ok(None),
Some(Value::Bool(value)) => Ok(Some(*value)),
Some(other) => Err(Error::ChatTemplate(format!(
"template kwarg `{key}` must be a boolean, got {other}"
))),
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::RenderRequest;
use crate::{ChatMessage, ChatToolChoice, Error, GenerationPromptMode, TestRenderRequest};
#[test]
fn rejects_empty_message_history() {
let mut request = TestRenderRequest::for_test();
request.messages.clear();
assert!(matches!(
request.as_request().validate(),
Err(Error::EmptyMessages)
));
}
#[test]
fn continuation_requires_a_final_assistant_message() {
let mut request = TestRenderRequest::for_test();
request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
assert!(matches!(
request.as_request().validate(),
Err(Error::ContinueFinalAssistantWithoutFinalAssistant)
));
request.messages.push(ChatMessage::assistant_text("partial"));
request.as_request().validate().unwrap();
}
#[test]
fn thinking_aliases_must_match() {
let mut request = TestRenderRequest::for_test();
request
.chat_options
.template_kwargs
.insert("thinking".to_string(), json!(false));
request
.chat_options
.template_kwargs
.insert("enable_thinking".to_string(), json!(true));
assert!(matches!(
request.as_request().enable_thinking(),
Err(Error::ChatTemplate(message))
if message.contains("`thinking` and `enable_thinking` must match")
));
}
#[test]
fn tool_exposure_requires_tools_and_an_enabled_choice() {
let mut request = TestRenderRequest::for_test();
assert!(!request.as_request().tool_parsing_enabled());
request.tools.push(crate::Tool {
name: "lookup".to_string(),
description: None,
parameters: json!({"type": "object"}),
strict: None,
});
assert!(!request.as_request().tool_parsing_enabled());
request.tool_choice = ChatToolChoice::Auto;
assert!(request.as_request().tool_parsing_enabled());
}
#[test]
fn borrowed_request_carries_optional_documents() {
let mut request = TestRenderRequest::for_test();
request.documents = Some(vec![json!({"title": "doc"})]);
let borrowed: RenderRequest<'_> = request.as_request();
assert_eq!(
borrowed.documents.unwrap(),
request.documents.as_deref().unwrap()
);
}
}
@@ -29,13 +29,21 @@ pub enum RendererSelection {
}
impl RendererSelection {
/// CLI/config literal for automatic renderer selection.
pub const AUTO_LITERAL: &str = "auto";
/// CLI/config literal and model type for DeepSeek V3.2.
pub const DEEPSEEK_V32_LITERAL: &str = "deepseek_v32";
/// CLI/config literal and model type for DeepSeek V4.
pub const DEEPSEEK_V4_LITERAL: &str = "deepseek_v4";
/// Hugging Face model type routed to the Harmony renderer.
pub const GPT_OSS_MODEL_TYPE: &str = "gpt_oss";
/// CLI/config literal for the Harmony renderer.
pub const HARMONY_LITERAL: &str = "harmony";
/// CLI/config literal for the Hugging Face renderer.
pub const HF_LITERAL: &str = "hf";
/// CLI/config literal for the Inkling renderer.
pub const INKLING_LITERAL: &str = "inkling";
/// Hugging Face model type routed to the Inkling renderer.
pub const INKLING_MODEL_TYPE: &str = "inkling_mm_model";
/// Resolve the renderer selection using the given model type string, if
@@ -7,13 +7,45 @@ use std::path::Path;
use serde::Deserialize;
use serde_json::Value;
use crate::event::{AssistantContentBlock, AssistantToolCall};
use crate::request::{
ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ChatToolChoice,
GenerationPromptMode, ReasoningEffort,
use crate::{
AssistantContentBlock, AssistantToolCall, ChatContent, ChatContentPart, ChatMessage,
ChatOptions, ChatRole, ChatToolChoice, GenerationPromptMode, ReasoningEffort, RenderRequest,
Tool,
};
/// Options for constructing a [`ChatRequest`] from a fixture file.
/// Owned test input used to construct borrowed [`RenderRequest`] values.
#[derive(Debug, Clone)]
pub(crate) struct TestRenderRequest {
pub messages: Vec<ChatMessage>,
pub chat_options: ChatOptions,
pub tools: Vec<Tool>,
pub tool_choice: ChatToolChoice,
pub documents: Option<Vec<Value>>,
}
impl TestRenderRequest {
pub(crate) fn for_test() -> Self {
Self {
messages: vec![ChatMessage::text(ChatRole::User, "test")],
chat_options: ChatOptions::default(),
tools: Vec::new(),
tool_choice: ChatToolChoice::None,
documents: None,
}
}
pub(crate) fn as_request(&self) -> RenderRequest<'_> {
RenderRequest {
messages: &self.messages,
chat_options: &self.chat_options,
tools: &self.tools,
tool_choice: &self.tool_choice,
documents: self.documents.as_deref(),
}
}
}
/// Options for constructing a [`TestRenderRequest`] from a fixture file.
#[derive(Debug, Clone, Copy)]
pub(crate) struct FixtureRequestOptions {
/// Whether to set the template kwarg `[enable_]thinking=true`.
@@ -23,9 +55,11 @@ pub(crate) struct FixtureRequestOptions {
pub no_generation_prompt_when_last_assistant: bool,
}
/// Read a fixture file from the given path and convert it into a [`ChatRequest`]
/// using the provided options.
pub(crate) fn fixture_chat_request(path: &Path, options: FixtureRequestOptions) -> ChatRequest {
/// Read a fixture file and convert it into an owned renderer test request.
pub(crate) fn fixture_chat_request(
path: &Path,
options: FixtureRequestOptions,
) -> TestRenderRequest {
let fixture = fs::read_to_string(path).unwrap();
let fixture: FixtureFile = serde_json::from_str(&fixture).unwrap();
fixture.into_request().into_chat_request(options)
@@ -135,9 +169,8 @@ struct FixtureToolCallFunction {
}
impl FixtureRequest {
fn into_chat_request(self, options: FixtureRequestOptions) -> ChatRequest {
let mut request = ChatRequest {
request_id: "renderer-fixture".to_string(),
fn into_chat_request(self, options: FixtureRequestOptions) -> TestRenderRequest {
let mut request = TestRenderRequest {
messages: self
.messages
.into_iter()
@@ -150,7 +183,7 @@ impl FixtureRequest {
} else {
ChatToolChoice::Auto
},
..ChatRequest::for_test()
..TestRenderRequest::for_test()
};
if options.no_generation_prompt_when_last_assistant
@@ -243,10 +276,10 @@ fn to_chat_content(content: FixtureContent) -> ChatContent {
}
}
fn to_chat_tools(tools: &[FixtureTool]) -> Vec<ChatTool> {
fn to_chat_tools(tools: &[FixtureTool]) -> Vec<Tool> {
tools
.iter()
.map(|tool| ChatTool {
.map(|tool| Tool {
name: tool.function.name.clone(),
description: tool.function.description.clone(),
parameters: tool.function.parameters.clone(),
@@ -0,0 +1,6 @@
# vLLM Example Templates
These fixtures are copied from `vllm/examples/`.
They are used by `src/chat-renderer/src/hf/format.rs` tests to keep
chat-template content-format detection aligned with Python vLLM behavior.
+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>,
}

Some files were not shown because too many files have changed in this diff Show More