forked from Karylab-cklius/vllm
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6db7065e6 | ||
|
|
a2bedb84b6 | ||
|
|
21d8aa7ecc | ||
|
|
4288f3d3a5 | ||
|
|
97cbc212b4 |
Generated
+40
-20
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
+14
-13
@@ -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)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
+23
-25
@@ -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,
|
||||
+19
-21
@@ -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)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
+21
-21
@@ -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);
|
||||
@@ -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>;
|
||||
+2
-2
@@ -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(),
|
||||
})
|
||||
}
|
||||
+28
-26
@@ -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| {
|
||||
+23
-23
@@ -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]
|
||||
+6
-6
@@ -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::*;
|
||||
|
||||
+16
-14
@@ -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),
|
||||
..
|
||||
+27
-31
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
+47
-14
@@ -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.
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "vllm-chat-types"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
easy-ext.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_with.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,155 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
use std::ops::Deref;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One finalized assistant tool call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssistantToolCall {
|
||||
/// Stable tool-call identifier.
|
||||
pub id: String,
|
||||
/// Function name selected by the assistant.
|
||||
pub name: String,
|
||||
/// Serialized function arguments.
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// Semantic kind of one assistant output block.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AssistantBlockKind {
|
||||
/// Visible final-answer text.
|
||||
Text,
|
||||
/// Extracted reasoning content.
|
||||
Reasoning,
|
||||
/// One finalized tool call.
|
||||
ToolCall,
|
||||
}
|
||||
|
||||
/// One structured assistant output block.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AssistantContentBlock {
|
||||
/// Visible final-answer text.
|
||||
Text {
|
||||
/// Visible text.
|
||||
text: String,
|
||||
},
|
||||
/// Extracted reasoning content.
|
||||
Reasoning {
|
||||
/// Reasoning text.
|
||||
text: String,
|
||||
},
|
||||
/// One finalized tool call.
|
||||
ToolCall(AssistantToolCall),
|
||||
}
|
||||
|
||||
impl AssistantContentBlock {
|
||||
/// Return the semantic kind of this block.
|
||||
pub fn kind(&self) -> AssistantBlockKind {
|
||||
match self {
|
||||
Self::Text { .. } => AssistantBlockKind::Text,
|
||||
Self::Reasoning { .. } => AssistantBlockKind::Reasoning,
|
||||
Self::ToolCall(..) => AssistantBlockKind::ToolCall,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return this block as one finalized tool call when applicable.
|
||||
pub fn as_tool_call(&self) -> Option<&AssistantToolCall> {
|
||||
match self {
|
||||
Self::ToolCall(call) => Some(call),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim whitespace from text and tool arguments.
|
||||
///
|
||||
/// Returns `None` when trimming makes a text or reasoning block empty.
|
||||
pub fn trim(mut self) -> Option<Self> {
|
||||
match &mut self {
|
||||
Self::Text { text } | Self::Reasoning { text } => {
|
||||
let trimmed_text = text.trim();
|
||||
if trimmed_text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
*text = trimmed_text.to_string();
|
||||
}
|
||||
Self::ToolCall(call) => {
|
||||
call.arguments = call.arguments.trim().to_string();
|
||||
}
|
||||
}
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[easy_ext::ext(AssistantMessageExt)]
|
||||
impl [AssistantContentBlock] {
|
||||
/// Concatenate all visible final-answer text blocks.
|
||||
pub fn text(&self) -> String {
|
||||
self.iter()
|
||||
.filter_map(|block| match block {
|
||||
AssistantContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Concatenate all extracted reasoning blocks.
|
||||
pub fn reasoning(&self) -> Option<String> {
|
||||
Some(
|
||||
self.iter()
|
||||
.filter_map(|block| match block {
|
||||
AssistantContentBlock::Reasoning { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.filter(|text: &String| !text.is_empty())
|
||||
}
|
||||
|
||||
/// Return whether this assistant message contains reasoning text.
|
||||
pub fn has_reasoning(&self) -> bool {
|
||||
self.iter().any(|block| match block {
|
||||
AssistantContentBlock::Reasoning { text } => !text.is_empty(),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Iterate over finalized assistant tool calls in encounter order.
|
||||
pub fn tool_calls(&self) -> impl Iterator<Item = &AssistantToolCall> {
|
||||
self.iter().filter_map(AssistantContentBlock::as_tool_call)
|
||||
}
|
||||
|
||||
/// Return whether this assistant message contains any tool-call blocks.
|
||||
pub fn has_tool_calls(&self) -> bool {
|
||||
self.iter().any(|block| matches!(block, AssistantContentBlock::ToolCall(_)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Final structured assistant message assembled from parsed output.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssistantMessage {
|
||||
/// Assistant content blocks in emission order.
|
||||
pub content: Vec<AssistantContentBlock>,
|
||||
}
|
||||
|
||||
impl Deref for AssistantMessage {
|
||||
type Target = [AssistantContentBlock];
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.content
|
||||
}
|
||||
}
|
||||
|
||||
impl AssistantMessage {
|
||||
/// Push one new block to the end of the message content.
|
||||
pub fn push_block(&mut self, block: AssistantContentBlock) {
|
||||
self.content.push(block);
|
||||
}
|
||||
|
||||
/// Trim all blocks and remove text blocks that become empty.
|
||||
pub fn trim(mut self) -> Self {
|
||||
self.content = self.content.into_iter().filter_map(AssistantContentBlock::trim).collect();
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Detail level requested for an OpenAI-style image input.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ImageDetail {
|
||||
/// Let the model-specific multimodal processor select the detail level.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Request low-detail image processing.
|
||||
Low,
|
||||
/// Request high-detail image processing.
|
||||
High,
|
||||
}
|
||||
|
||||
/// One chat content part in OpenAI-style block format.
|
||||
#[serde_with::skip_serializing_none]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ChatContentPart {
|
||||
/// One plain-text content block.
|
||||
Text {
|
||||
/// Plain-text content.
|
||||
text: String,
|
||||
},
|
||||
/// One image URL or data URL content block.
|
||||
ImageUrl {
|
||||
/// Image URL or data URL.
|
||||
image_url: String,
|
||||
/// Requested image detail level.
|
||||
detail: Option<ImageDetail>,
|
||||
/// Optional caller-provided media identifier.
|
||||
uuid: Option<String>,
|
||||
},
|
||||
/// One video URL or data URL content block.
|
||||
VideoUrl {
|
||||
/// Video URL or data URL.
|
||||
video_url: String,
|
||||
/// Optional caller-provided media identifier.
|
||||
uuid: Option<String>,
|
||||
},
|
||||
/// One `input_audio` content block carrying base64-encoded audio bytes.
|
||||
InputAudio {
|
||||
/// Base64-encoded audio bytes.
|
||||
data: String,
|
||||
/// Optional audio format such as `wav` or `mp3`.
|
||||
format: Option<String>,
|
||||
/// Optional caller-provided media identifier.
|
||||
uuid: Option<String>,
|
||||
},
|
||||
/// One audio URL or data URL content block.
|
||||
AudioUrl {
|
||||
/// Audio URL or data URL.
|
||||
audio_url: String,
|
||||
/// Optional caller-provided media identifier.
|
||||
uuid: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ChatContentPart {
|
||||
/// Construct one text content part with plain string content.
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self::Text { text: text.into() }
|
||||
}
|
||||
|
||||
/// Construct one image URL content part with the given URL string.
|
||||
pub fn image_url(image_url: impl Into<String>) -> Self {
|
||||
Self::ImageUrl {
|
||||
image_url: image_url.into(),
|
||||
detail: None,
|
||||
uuid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct one video URL content part with the given URL string.
|
||||
pub fn video_url(video_url: impl Into<String>) -> Self {
|
||||
Self::VideoUrl {
|
||||
video_url: video_url.into(),
|
||||
uuid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct one base64-encoded input-audio content part.
|
||||
pub fn input_audio(data: impl Into<String>, format: Option<String>) -> Self {
|
||||
Self::InputAudio {
|
||||
data: data.into(),
|
||||
format,
|
||||
uuid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct one audio URL content part with the given URL string.
|
||||
pub fn audio_url(audio_url: impl Into<String>) -> Self {
|
||||
Self::AudioUrl {
|
||||
audio_url: audio_url.into(),
|
||||
uuid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the text content of this part.
|
||||
///
|
||||
/// Returns the static content-part type for multimodal content.
|
||||
pub fn as_text(&self) -> Result<&str, &'static str> {
|
||||
match self {
|
||||
Self::Text { text } => Ok(text),
|
||||
Self::ImageUrl { .. } => Err("image_url"),
|
||||
Self::VideoUrl { .. } => Err("video_url"),
|
||||
Self::InputAudio { .. } => Err("input_audio"),
|
||||
Self::AudioUrl { .. } => Err("audio_url"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether this part is a text block with empty content.
|
||||
fn is_empty_text(&self) -> bool {
|
||||
matches!(self, Self::Text { text } if text.is_empty())
|
||||
}
|
||||
|
||||
/// Return whether this part contains any multimodal content.
|
||||
fn is_multimodal(&self) -> bool {
|
||||
match self {
|
||||
Self::Text { .. } => false,
|
||||
Self::ImageUrl { .. }
|
||||
| Self::VideoUrl { .. }
|
||||
| Self::InputAudio { .. }
|
||||
| Self::AudioUrl { .. } => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat content represented as a string or OpenAI-style content parts.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ChatContent {
|
||||
/// Simple text content.
|
||||
Text(String),
|
||||
/// OpenAI-style content parts.
|
||||
Parts(Vec<ChatContentPart>),
|
||||
}
|
||||
|
||||
impl ChatContent {
|
||||
/// Flatten text parts into one string without adding separators.
|
||||
///
|
||||
/// Returns the static content-part type when the content is multimodal.
|
||||
pub fn try_flatten_to_text(&self) -> Result<String, &'static str> {
|
||||
Ok(match self {
|
||||
Self::Text(text) => text.clone(),
|
||||
Self::Parts(parts) => parts
|
||||
.iter()
|
||||
.map(ChatContentPart::as_text)
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.concat(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return whether the content has no text or only empty text blocks.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
Self::Text(text) => text.is_empty(),
|
||||
Self::Parts(parts) => parts.iter().all(ChatContentPart::is_empty_text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether this content contains any multimodal parts.
|
||||
pub fn has_multimodal(&self) -> bool {
|
||||
match self {
|
||||
Self::Text(_) => false,
|
||||
Self::Parts(parts) => parts.iter().any(ChatContentPart::is_multimodal),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ChatContent {
|
||||
fn from(value: String) -> Self {
|
||||
Self::Text(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ChatContent {
|
||||
fn from(value: &str) -> Self {
|
||||
Self::Text(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<ChatContentPart>> for ChatContent {
|
||||
fn from(value: Vec<ChatContentPart>) -> Self {
|
||||
Self::Parts(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
//! Engine-independent data types shared by chat renderers and output parsers.
|
||||
//!
|
||||
//! This crate defines chat history, rendering options, tool descriptions, and
|
||||
//! structured assistant payloads. Serving requests, streamed events, renderer
|
||||
//! implementations, parser state, and engine metadata live in their owning
|
||||
//! crates.
|
||||
|
||||
mod assistant;
|
||||
mod content;
|
||||
mod message;
|
||||
mod options;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tool;
|
||||
|
||||
pub use assistant::{
|
||||
AssistantBlockKind, AssistantContentBlock, AssistantMessage, AssistantMessageExt,
|
||||
AssistantToolCall,
|
||||
};
|
||||
pub use content::{ChatContent, ChatContentPart, ImageDetail};
|
||||
pub use message::{ChatMessage, ChatRole};
|
||||
pub use options::{ChatOptions, ChatToolChoice, GenerationPromptMode, ReasoningEffort};
|
||||
pub use tool::Tool;
|
||||
@@ -0,0 +1,195 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AssistantContentBlock, AssistantMessage, AssistantMessageExt as _, ChatContent, Tool};
|
||||
|
||||
/// Role label for one chat message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChatRole {
|
||||
/// System instructions.
|
||||
System,
|
||||
/// Developer instructions.
|
||||
Developer,
|
||||
/// User input.
|
||||
User,
|
||||
/// Assistant history.
|
||||
Assistant,
|
||||
/// Result of an assistant tool call.
|
||||
ToolResponse,
|
||||
}
|
||||
|
||||
impl ChatRole {
|
||||
/// Return the role string exposed to chat templates.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "system",
|
||||
Self::Developer => "developer",
|
||||
Self::User => "user",
|
||||
Self::Assistant => "assistant",
|
||||
Self::ToolResponse => "tool_response",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One chat message.
|
||||
///
|
||||
/// Original Python API reference:
|
||||
/// <https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/entrypoints/chat_utils.py#L309-L333>
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "role", rename_all = "snake_case")]
|
||||
pub enum ChatMessage {
|
||||
/// System message.
|
||||
System {
|
||||
/// Message content.
|
||||
content: ChatContent,
|
||||
},
|
||||
/// Developer message with optional message-local tools.
|
||||
Developer {
|
||||
/// Message content.
|
||||
content: ChatContent,
|
||||
/// Tools introduced by this developer message.
|
||||
tools: Option<Vec<Tool>>,
|
||||
},
|
||||
/// User message.
|
||||
User {
|
||||
/// Message content.
|
||||
content: ChatContent,
|
||||
},
|
||||
/// Assistant history assembled from structured blocks.
|
||||
Assistant {
|
||||
/// Structured assistant content.
|
||||
content: Vec<AssistantContentBlock>,
|
||||
},
|
||||
/// Tool response associated with one prior assistant tool call.
|
||||
ToolResponse {
|
||||
/// Tool response content.
|
||||
content: ChatContent,
|
||||
/// Identifier of the assistant tool call being answered.
|
||||
tool_call_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// Construct one chat message with plain string content.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics for [`ChatRole::ToolResponse`], which requires a tool-call ID.
|
||||
/// Use [`Self::tool_response`] for tool responses.
|
||||
pub fn text(role: ChatRole, text: impl Into<String>) -> Self {
|
||||
let content: String = text.into();
|
||||
|
||||
match role {
|
||||
ChatRole::System => Self::system(content),
|
||||
ChatRole::Developer => Self::developer(content, None),
|
||||
ChatRole::User => Self::user(content),
|
||||
ChatRole::Assistant => Self::assistant_text(content),
|
||||
ChatRole::ToolResponse => {
|
||||
panic!(
|
||||
"tool response messages require a tool_call_id; \
|
||||
use ChatMessage::tool_response() instead"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct one system message.
|
||||
pub fn system(content: impl Into<ChatContent>) -> Self {
|
||||
Self::System {
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct one developer message.
|
||||
pub fn developer(content: impl Into<ChatContent>, tools: Option<Vec<Tool>>) -> Self {
|
||||
Self::Developer {
|
||||
content: content.into(),
|
||||
tools,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct one user message.
|
||||
pub fn user(content: impl Into<ChatContent>) -> Self {
|
||||
Self::User {
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct one assistant message with plain string content.
|
||||
pub fn assistant_text(text: impl Into<String>) -> Self {
|
||||
Self::Assistant {
|
||||
content: vec![AssistantContentBlock::Text { text: text.into() }],
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct one assistant message with structured content blocks.
|
||||
pub fn assistant_blocks(content: Vec<AssistantContentBlock>) -> Self {
|
||||
Self::Assistant { content }
|
||||
}
|
||||
|
||||
/// Construct one tool-response message.
|
||||
pub fn tool_response(content: impl Into<ChatContent>, tool_call_id: impl Into<String>) -> Self {
|
||||
Self::ToolResponse {
|
||||
content: content.into(),
|
||||
tool_call_id: tool_call_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the role of this message.
|
||||
pub fn role(&self) -> ChatRole {
|
||||
match self {
|
||||
Self::System { .. } => ChatRole::System,
|
||||
Self::Developer { .. } => ChatRole::Developer,
|
||||
Self::User { .. } => ChatRole::User,
|
||||
Self::Assistant { .. } => ChatRole::Assistant,
|
||||
Self::ToolResponse { .. } => ChatRole::ToolResponse,
|
||||
}
|
||||
}
|
||||
|
||||
/// Concatenate the visible text carried by this message.
|
||||
///
|
||||
/// Returns the static content-part type when a non-assistant message
|
||||
/// contains multimodal content.
|
||||
pub fn text_content(&self) -> Result<String, &'static str> {
|
||||
match self {
|
||||
Self::System { content }
|
||||
| Self::Developer { content, .. }
|
||||
| Self::User { content }
|
||||
| Self::ToolResponse { content, .. } => content.try_flatten_to_text(),
|
||||
Self::Assistant { content } => Ok(content.text()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Concatenate assistant reasoning text when present.
|
||||
pub fn reasoning_content(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Assistant { content } => content.reasoning(),
|
||||
Self::System { .. }
|
||||
| Self::Developer { .. }
|
||||
| Self::User { .. }
|
||||
| Self::ToolResponse { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether this message contains multimodal content.
|
||||
pub fn has_multimodal(&self) -> bool {
|
||||
match self {
|
||||
Self::System { content }
|
||||
| Self::Developer { content, .. }
|
||||
| Self::User { content }
|
||||
| Self::ToolResponse { content, .. } => content.has_multimodal(),
|
||||
Self::Assistant { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AssistantMessage> for ChatMessage {
|
||||
fn from(value: AssistantMessage) -> Self {
|
||||
Self::Assistant {
|
||||
content: value.content,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Controls how prompt rendering should end after the existing chat history.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GenerationPromptMode {
|
||||
/// Append a generation prompt for a new assistant turn.
|
||||
///
|
||||
/// Equivalent to `add_generation_prompt = true` and
|
||||
/// `continue_final_message = false`.
|
||||
#[default]
|
||||
StartNewAssistant,
|
||||
/// Leave the final assistant message open so generation continues it.
|
||||
///
|
||||
/// Equivalent to `add_generation_prompt = false` and
|
||||
/// `continue_final_message = true`.
|
||||
ContinueFinalAssistant,
|
||||
/// Render the existing chat history without adding any trailing generation
|
||||
/// prompt.
|
||||
///
|
||||
/// Equivalent to `add_generation_prompt = false` and
|
||||
/// `continue_final_message = false`.
|
||||
NoGenerationPrompt,
|
||||
}
|
||||
|
||||
/// Effort level for reasoning models.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ReasoningEffort {
|
||||
/// Disable reasoning.
|
||||
None,
|
||||
/// Use the smallest available reasoning effort.
|
||||
Minimal,
|
||||
/// Use low reasoning effort.
|
||||
Low,
|
||||
/// Use medium reasoning effort.
|
||||
Medium,
|
||||
/// Use high reasoning effort.
|
||||
High,
|
||||
/// Use extra-high reasoning effort.
|
||||
XHigh,
|
||||
/// Use the largest available reasoning effort.
|
||||
Max,
|
||||
}
|
||||
|
||||
impl ReasoningEffort {
|
||||
/// Return the lowercase value exposed to chat templates.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Minimal => "minimal",
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::Max => "max",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat-template-related request options.
|
||||
///
|
||||
/// These are the chat controls that currently affect prompt rendering.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChatOptions {
|
||||
/// Controls whether rendering starts a new assistant turn, continues the
|
||||
/// final assistant message, or emits no trailing generation prompt.
|
||||
pub generation_prompt_mode: GenerationPromptMode,
|
||||
|
||||
/// Per-request Jinja chat template override.
|
||||
///
|
||||
/// The renderer uses this template in place of the model's default chat
|
||||
/// template when it is present.
|
||||
pub chat_template: Option<String>,
|
||||
|
||||
/// Effort level exposed to chat templates for reasoning models.
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
|
||||
/// Additional keyword arguments exposed to the chat template.
|
||||
pub template_kwargs: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl Default for ChatOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
generation_prompt_mode: GenerationPromptMode::StartNewAssistant,
|
||||
chat_template: None,
|
||||
reasoning_effort: None,
|
||||
template_kwargs: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatOptions {
|
||||
/// Return whether rendering adds a prompt for a new assistant turn.
|
||||
pub fn add_generation_prompt(&self) -> bool {
|
||||
matches!(
|
||||
self.generation_prompt_mode,
|
||||
GenerationPromptMode::StartNewAssistant
|
||||
)
|
||||
}
|
||||
|
||||
/// Return whether rendering continues the final assistant message.
|
||||
pub fn continue_final_message(&self) -> bool {
|
||||
matches!(
|
||||
self.generation_prompt_mode,
|
||||
GenerationPromptMode::ContinueFinalAssistant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool-choice semantics supported by the shared chat types.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChatToolChoice {
|
||||
/// Disable tool calling.
|
||||
#[default]
|
||||
None,
|
||||
/// Let the model choose whether to call a tool.
|
||||
Auto,
|
||||
/// Require the model to call a tool.
|
||||
Required,
|
||||
/// Require one named function.
|
||||
Function {
|
||||
/// Required function name.
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
use serde_json::{json, to_value};
|
||||
|
||||
use crate::{AssistantContentBlock, ChatContent, ChatContentPart, ChatMessage, ChatRole, Tool};
|
||||
|
||||
#[test]
|
||||
fn chat_content_deserializes_from_raw_string() {
|
||||
let content: ChatContent = serde_json::from_value(json!("hello")).unwrap();
|
||||
assert_eq!(content, ChatContent::Text("hello".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_content_video_url_part_round_trips_through_serde() {
|
||||
let content = ChatContent::Parts(vec![ChatContentPart::VideoUrl {
|
||||
video_url: "https://example.com/demo.mp4".to_string(),
|
||||
uuid: Some("video-1".to_string()),
|
||||
}]);
|
||||
|
||||
let value = to_value(&content).unwrap();
|
||||
assert_eq!(
|
||||
value,
|
||||
json!([{
|
||||
"type": "video_url",
|
||||
"video_url": "https://example.com/demo.mp4",
|
||||
"uuid": "video-1",
|
||||
}])
|
||||
);
|
||||
let decoded: ChatContent = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(decoded, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_content_deserializes_from_openai_text_blocks() {
|
||||
let content: ChatContent =
|
||||
serde_json::from_value(json!([{ "type": "text", "text": "hello" }])).unwrap();
|
||||
assert_eq!(
|
||||
content,
|
||||
ChatContent::Parts(vec![ChatContentPart::text("hello")])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_content_from_string_like_values_builds_text() {
|
||||
assert_eq!(
|
||||
ChatContent::from("hello"),
|
||||
ChatContent::Text("hello".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ChatContent::from("hello".to_string()),
|
||||
ChatContent::Text("hello".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_content_try_flattens_text_parts_without_separators() {
|
||||
let content = ChatContent::Parts(vec![
|
||||
ChatContentPart::text("hello"),
|
||||
ChatContentPart::text(" world"),
|
||||
]);
|
||||
assert_eq!(content.try_flatten_to_text().unwrap(), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multimodal_content_parts_return_static_type_names() {
|
||||
let parts = [
|
||||
(ChatContentPart::image_url("image"), "image_url"),
|
||||
(ChatContentPart::video_url("video"), "video_url"),
|
||||
(ChatContentPart::input_audio("audio", None), "input_audio"),
|
||||
(ChatContentPart::audio_url("audio"), "audio_url"),
|
||||
];
|
||||
|
||||
for (part, expected) in parts {
|
||||
assert_eq!(part.as_text(), Err(expected));
|
||||
assert_eq!(
|
||||
ChatContent::Parts(vec![part]).try_flatten_to_text(),
|
||||
Err(expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_message_collects_visible_and_reasoning_text() {
|
||||
let message = ChatMessage::assistant_blocks(vec![
|
||||
AssistantContentBlock::Reasoning {
|
||||
text: "inner".to_string(),
|
||||
},
|
||||
AssistantContentBlock::Text {
|
||||
text: "outer".to_string(),
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(message.role(), ChatRole::Assistant);
|
||||
assert_eq!(message.text_content().unwrap(), "outer");
|
||||
assert_eq!(message.reasoning_content().as_deref(), Some("inner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn developer_message_round_trips_through_serde() {
|
||||
let message = ChatMessage::developer(
|
||||
"hello",
|
||||
Some(vec![Tool {
|
||||
name: "get_weather".to_string(),
|
||||
description: Some("Get weather".to_string()),
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
}),
|
||||
strict: Some(true),
|
||||
}]),
|
||||
);
|
||||
|
||||
let value = to_value(&message).unwrap();
|
||||
let decoded: ChatMessage = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(decoded, message);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// One function-style tool made available to the model.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Tool {
|
||||
/// Function name exposed to the model.
|
||||
pub name: String,
|
||||
/// Optional human-readable function description.
|
||||
pub description: Option<String>,
|
||||
/// JSON Schema describing the function parameters.
|
||||
pub parameters: Value,
|
||||
/// Optional strict-schema enforcement request.
|
||||
pub strict: Option<bool>,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user