[Rust] Use Dynamo native renderers when chat templates are missing (#38939)
This commit is contained in:
@@ -69,6 +69,7 @@ def _build_server_args(scheduler: Scheduler) -> ServerArgs:
|
|||||||
context_len=mc.context_len,
|
context_len=mc.context_len,
|
||||||
vocab_size=mc.vocab_size,
|
vocab_size=mc.vocab_size,
|
||||||
is_multimodal=mc.is_multimodal,
|
is_multimodal=mc.is_multimodal,
|
||||||
|
model_type=getattr(mc.hf_config, "model_type", None),
|
||||||
# Resolved default sampling params (generation_config.json when
|
# Resolved default sampling params (generation_config.json when
|
||||||
# `--sampling-defaults model`, {} otherwise). The rust server
|
# `--sampling-defaults model`, {} otherwise). The rust server
|
||||||
# consumes these for omitted temperature/top_p in chat
|
# consumes these for omitted temperature/top_p in chat
|
||||||
|
|||||||
Generated
+7
-6
@@ -1005,9 +1005,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dynamo-protocols"
|
name = "dynamo-protocols"
|
||||||
version = "5.3.1"
|
version = "5.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97fd951c32c033f4f220b6087db91d7f7d475f1bf1f886c8e60798ac1cf4cc9a"
|
checksum = "b1cdacfc1398779cf173d5ff54438d9f14682f76875b6879853a84cc7d646228"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-openai",
|
"async-openai",
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
@@ -1022,9 +1022,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dynamo-renderer"
|
name = "dynamo-renderer"
|
||||||
version = "5.0.1"
|
version = "5.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfe04753d666e3462e3eed4abcf2a4d05997cf2dea45858733f2bdea5b61f733"
|
checksum = "3ae2eaa139651c535aeaad8856c5546709608931ccd4d24d3a529f6c5233c4b6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1040,9 +1040,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dynamo-tokenizers"
|
name = "dynamo-tokenizers"
|
||||||
version = "1.8.0"
|
version = "1.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "821c767e896f1f225b6411a5ce41dba7f114c2c97db862a13fd962195665075e"
|
checksum = "ed232a9d254149a4e2e14f0a7d77f59bd64161964c0ba55267beea3955650faa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -3715,6 +3715,7 @@ dependencies = [
|
|||||||
"hf-hub",
|
"hf-hub",
|
||||||
"itertools",
|
"itertools",
|
||||||
"libc",
|
"libc",
|
||||||
|
"minijinja",
|
||||||
"numpy",
|
"numpy",
|
||||||
"pyo3",
|
"pyo3",
|
||||||
"regex-syntax",
|
"regex-syntax",
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ dynamo-tokenizers = "1.7.0"
|
|||||||
# small service helpers.
|
# small service helpers.
|
||||||
dynamo-parsers = "7.0.1"
|
dynamo-parsers = "7.0.1"
|
||||||
dynamo-protocols = "5.1.0"
|
dynamo-protocols = "5.1.0"
|
||||||
dynamo-renderer = "5.0.0"
|
dynamo-renderer = "5.1.2"
|
||||||
|
minijinja = "2.24"
|
||||||
flume = "0.12.0"
|
flume = "0.12.0"
|
||||||
hf-hub = { version = "0.4", default-features = false }
|
hf-hub = { version = "0.4", default-features = false }
|
||||||
itertools = "0.14"
|
itertools = "0.14"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ mod template_legacy;
|
|||||||
mod template_loader;
|
mod template_loader;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
pub(super) use template::ChatFormatter;
|
pub(super) use template::{ChatFormatter, ChatTemplateKwargs};
|
||||||
|
|
||||||
use super::app::AppState;
|
use super::app::AppState;
|
||||||
use super::frame::OutputAccumulator;
|
use super::frame::OutputAccumulator;
|
||||||
@@ -62,6 +62,7 @@ pub(super) fn load_chat_support(server_args: &ServerArgs) -> Option<ChatFormatte
|
|||||||
match template::load_chat_formatter(
|
match template::load_chat_formatter(
|
||||||
config_file.as_deref(),
|
config_file.as_deref(),
|
||||||
(!server_args.model_path.is_empty()).then_some(server_args.model_path.as_str()),
|
(!server_args.model_path.is_empty()).then_some(server_args.model_path.as_str()),
|
||||||
|
server_args.model_config.model_type.as_deref(),
|
||||||
server_args.chat_template.as_deref(),
|
server_args.chat_template.as_deref(),
|
||||||
) {
|
) {
|
||||||
Ok(formatter) => {
|
Ok(formatter) => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use dynamo_protocols::types::{
|
|||||||
TopLogprobs,
|
TopLogprobs,
|
||||||
};
|
};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
use serde::Deserialize;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use super::super::guard::AbortGuard;
|
use super::super::guard::AbortGuard;
|
||||||
@@ -34,8 +35,8 @@ use super::tools::{
|
|||||||
parse_chat_tool_calls,
|
parse_chat_tool_calls,
|
||||||
};
|
};
|
||||||
use super::{
|
use super::{
|
||||||
AppState, ChatFormatter, collect_output, contains_media, error_payload, indexed_decode_stream,
|
AppState, ChatFormatter, ChatTemplateKwargs, collect_output, contains_media, error_payload,
|
||||||
openai_error, submit_generation, unix_seconds_u32,
|
indexed_decode_stream, openai_error, submit_generation, unix_seconds_u32,
|
||||||
};
|
};
|
||||||
use crate::message::config::{DefaultSamplingParams, ServerArgs};
|
use crate::message::config::{DefaultSamplingParams, ServerArgs};
|
||||||
use crate::message::ids::Rid;
|
use crate::message::ids::Rid;
|
||||||
@@ -48,11 +49,21 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
|
|||||||
Router::new().route("/v1/chat/completions", post(chat_completions))
|
Router::new().route("/v1/chat/completions", post(chat_completions))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChatRequest {
|
||||||
|
#[serde(flatten)]
|
||||||
|
request: CreateChatCompletionRequest,
|
||||||
|
chat_template_kwargs: Option<ChatTemplateKwargs>,
|
||||||
|
}
|
||||||
|
|
||||||
async fn chat_completions(
|
async fn chat_completions(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
body: Result<Json<CreateChatCompletionRequest>, JsonRejection>,
|
body: Result<Json<ChatRequest>, JsonRejection>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let request = match body {
|
let ChatRequest {
|
||||||
|
request,
|
||||||
|
chat_template_kwargs,
|
||||||
|
} = match body {
|
||||||
Ok(Json(request)) => request,
|
Ok(Json(request)) => request,
|
||||||
Err(rejection) => {
|
Err(rejection) => {
|
||||||
return openai_error(StatusCode::BAD_REQUEST, rejection.body_text(), false);
|
return openai_error(StatusCode::BAD_REQUEST, rejection.body_text(), false);
|
||||||
@@ -141,10 +152,11 @@ async fn chat_completions(
|
|||||||
});
|
});
|
||||||
let tools_slice = tools.as_deref().unwrap_or_default();
|
let tools_slice = tools.as_deref().unwrap_or_default();
|
||||||
|
|
||||||
let (request, prompt) = match prepare_chat_request(&state, request).await {
|
let (request, prompt) =
|
||||||
Ok(prepared) => prepared,
|
match prepare_chat_request(&state, request, chat_template_kwargs.as_ref()).await {
|
||||||
Err(response) => return response,
|
Ok(prepared) => prepared,
|
||||||
};
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
|
||||||
let sampling = match chat_sampling(
|
let sampling = match chat_sampling(
|
||||||
&request,
|
&request,
|
||||||
@@ -178,6 +190,11 @@ async fn chat_completions(
|
|||||||
let mut guard = AbortGuard::new_empty(state.senders.clone());
|
let mut guard = AbortGuard::new_empty(state.senders.clone());
|
||||||
let mut submitted = Vec::with_capacity(n);
|
let mut submitted = Vec::with_capacity(n);
|
||||||
|
|
||||||
|
// V4 prefills <think>, so the generated stream has no opening marker.
|
||||||
|
let starts_in_reasoning = matches!(
|
||||||
|
reasoning_parser.as_deref(),
|
||||||
|
Some("deepseek-v4" | "deepseek_v4" | "deepseekv4")
|
||||||
|
) && prompt.ends_with("<think>");
|
||||||
let mut prompt = Some(prompt);
|
let mut prompt = Some(prompt);
|
||||||
for index in 0..n {
|
for index in 0..n {
|
||||||
let rid = Rid::from_client(&format!("{response_id}-{index}"));
|
let rid = Rid::from_client(&format!("{response_id}-{index}"));
|
||||||
@@ -221,6 +238,7 @@ async fn chat_completions(
|
|||||||
include_usage,
|
include_usage,
|
||||||
parser,
|
parser,
|
||||||
reasoning_parser,
|
reasoning_parser,
|
||||||
|
starts_in_reasoning,
|
||||||
tools,
|
tools,
|
||||||
stream_tool_choice,
|
stream_tool_choice,
|
||||||
uses_tool_call_structural_tag,
|
uses_tool_call_structural_tag,
|
||||||
@@ -254,6 +272,7 @@ async fn chat_completions(
|
|||||||
pub(super) async fn prepare_chat_request(
|
pub(super) async fn prepare_chat_request(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
mut request: CreateChatCompletionRequest,
|
mut request: CreateChatCompletionRequest,
|
||||||
|
kwargs: Option<&ChatTemplateKwargs>,
|
||||||
) -> Result<(CreateChatCompletionRequest, String), Response> {
|
) -> Result<(CreateChatCompletionRequest, String), Response> {
|
||||||
let Some(formatter) = state.chat_formatter.clone() else {
|
let Some(formatter) = state.chat_formatter.clone() else {
|
||||||
return Err(openai_error(
|
return Err(openai_error(
|
||||||
@@ -267,7 +286,7 @@ pub(super) async fn prepare_chat_request(
|
|||||||
// token-id stop cannot be merged into the string list (Python has no such
|
// token-id stop cannot be merged into the string list (Python has no such
|
||||||
// field), so it is kept alone.
|
// field), so it is kept alone.
|
||||||
merge_template_stops(&mut request, &formatter);
|
merge_template_stops(&mut request, &formatter);
|
||||||
let prompt = formatter.render(&request).map_err(|error| {
|
let prompt = formatter.render(&request, kwargs).map_err(|error| {
|
||||||
openai_error(
|
openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
format!("chat template render failed: {error}"),
|
format!("chat template render failed: {error}"),
|
||||||
@@ -516,6 +535,7 @@ pub(super) fn chat_event_stream(
|
|||||||
include_usage: bool,
|
include_usage: bool,
|
||||||
parser: Option<String>,
|
parser: Option<String>,
|
||||||
reasoning_parser: Option<String>,
|
reasoning_parser: Option<String>,
|
||||||
|
starts_in_reasoning: bool,
|
||||||
tools: Option<Vec<ToolDefinition>>,
|
tools: Option<Vec<ToolDefinition>>,
|
||||||
tool_choice: Option<ChatCompletionToolChoiceOption>,
|
tool_choice: Option<ChatCompletionToolChoiceOption>,
|
||||||
uses_tool_call_structural_tag: bool,
|
uses_tool_call_structural_tag: bool,
|
||||||
@@ -534,7 +554,7 @@ pub(super) fn chat_event_stream(
|
|||||||
let mut reasoning_splitters: Vec<ReasoningStreamSplitter> =
|
let mut reasoning_splitters: Vec<ReasoningStreamSplitter> =
|
||||||
if reasoning_parser.is_some() {
|
if reasoning_parser.is_some() {
|
||||||
(0..count)
|
(0..count)
|
||||||
.map(|_| ReasoningStreamSplitter::new(reasoning_parser.as_deref()))
|
.map(|_| ReasoningStreamSplitter::new(reasoning_parser.as_deref(), starts_in_reasoning))
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
vec![]
|
||||||
@@ -1120,6 +1140,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
Some("deepseek-r1".into()),
|
Some("deepseek-r1".into()),
|
||||||
|
false,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
@@ -1166,6 +1187,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
false,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -71,13 +71,15 @@ pub(super) fn split_reasoning_unary(
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(super) struct ReasoningStreamSplitter {
|
pub(super) struct ReasoningStreamSplitter {
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
starts_in_reasoning: bool,
|
||||||
parser: Option<ReasoningParserWrapper>,
|
parser: Option<ReasoningParserWrapper>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReasoningStreamSplitter {
|
impl ReasoningStreamSplitter {
|
||||||
pub(super) fn new(name: Option<&str>) -> Self {
|
pub(super) fn new(name: Option<&str>, starts_in_reasoning: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.map(str::to_owned),
|
name: name.map(str::to_owned),
|
||||||
|
starts_in_reasoning,
|
||||||
parser: None,
|
parser: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,9 +89,13 @@ impl ReasoningStreamSplitter {
|
|||||||
let Some(name) = self.name.as_deref() else {
|
let Some(name) = self.name.as_deref() else {
|
||||||
return (String::new(), text.to_owned());
|
return (String::new(), text.to_owned());
|
||||||
};
|
};
|
||||||
let parser = self
|
let parser = self.parser.get_or_insert_with(|| {
|
||||||
.parser
|
let mut parser = build_reasoning_parser(name);
|
||||||
.get_or_insert_with(|| build_reasoning_parser(name));
|
if self.starts_in_reasoning {
|
||||||
|
parser.set_in_reasoning(true);
|
||||||
|
}
|
||||||
|
parser
|
||||||
|
});
|
||||||
let token_ids = token_ids
|
let token_ids = token_ids
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|&id| u32::try_from(id).ok())
|
.filter_map(|&id| u32::try_from(id).ok())
|
||||||
@@ -171,13 +177,23 @@ mod tests {
|
|||||||
assert_eq!(normal, "<think>kept as text</think>");
|
assert_eq!(normal, "<think>kept as text</think>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_streaming_separates_prefilled_reasoning() {
|
||||||
|
let mut splitter = ReasoningStreamSplitter::new(Some("deepseek-v4"), true);
|
||||||
|
assert_eq!(splitter.split("reason", &[]), ("reason".into(), "".into()));
|
||||||
|
assert_eq!(
|
||||||
|
splitter.split("</think>answer", &[]),
|
||||||
|
("".into(), "answer".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// REASONING_P1: MiniMax M3's implicit-tool-start recovery buffers the
|
/// REASONING_P1: MiniMax M3's implicit-tool-start recovery buffers the
|
||||||
/// answer text until a boundary establishes the mode; with no opener the
|
/// answer text until a boundary establishes the mode; with no opener the
|
||||||
/// whole buffer is released as normal text only at `finish`. The chat
|
/// whole buffer is released as normal text only at `finish`. The chat
|
||||||
/// terminal flush must emit the normal half of the tail.
|
/// terminal flush must emit the normal half of the tail.
|
||||||
#[test]
|
#[test]
|
||||||
fn streaming_tail_releases_normal_text_only_at_finish() {
|
fn streaming_tail_releases_normal_text_only_at_finish() {
|
||||||
let mut splitter = ReasoningStreamSplitter::new(Some("minimax_m3"));
|
let mut splitter = ReasoningStreamSplitter::new(Some("minimax_m3"), false);
|
||||||
let (reasoning, normal) = splitter.split("The answer is", &[]);
|
let (reasoning, normal) = splitter.split("The answer is", &[]);
|
||||||
assert_eq!(reasoning, "");
|
assert_eq!(reasoning, "");
|
||||||
assert_eq!(normal, "", "M3 holds the ambiguous prefix until a boundary");
|
assert_eq!(normal, "", "M3 holds the ambiguous prefix until a boundary");
|
||||||
@@ -191,7 +207,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn streaming_tail_releases_reasoning_after_marker_boundary() {
|
fn streaming_tail_releases_reasoning_after_marker_boundary() {
|
||||||
let mut splitter = ReasoningStreamSplitter::new(Some("minimax_m3"));
|
let mut splitter = ReasoningStreamSplitter::new(Some("minimax_m3"), false);
|
||||||
let (reasoning, normal) = splitter.split("<mm:think>think", &[]);
|
let (reasoning, normal) = splitter.split("<mm:think>think", &[]);
|
||||||
assert_eq!(reasoning, "think");
|
assert_eq!(reasoning, "think");
|
||||||
assert_eq!(normal, "");
|
assert_eq!(normal, "");
|
||||||
@@ -205,7 +221,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finish_without_a_parser_is_empty() {
|
fn finish_without_a_parser_is_empty() {
|
||||||
let mut splitter = ReasoningStreamSplitter::new(None);
|
let mut splitter = ReasoningStreamSplitter::new(None, false);
|
||||||
let (reasoning, normal) = splitter.split("plain", &[]);
|
let (reasoning, normal) = splitter.split("plain", &[]);
|
||||||
assert_eq!(reasoning, "");
|
assert_eq!(reasoning, "");
|
||||||
assert_eq!(normal, "plain");
|
assert_eq!(normal, "plain");
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
//! `Conversation.get_prompt()` so there is exactly one implementation of the
|
//! `Conversation.get_prompt()` so there is exactly one implementation of the
|
||||||
//! per-style formatting logic (no Jinja translation to drift).
|
//! per-style formatting logic (no Jinja translation to drift).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use dynamo_protocols::types::CreateChatCompletionRequest;
|
use dynamo_protocols::types::{ChatCompletionRequestMessage, CreateChatCompletionRequest};
|
||||||
use dynamo_renderer::PromptFormatter;
|
use dynamo_renderer::{OAIChatLikeRequest, PromptFormatter, TextInput};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::message::types::OneOrMany;
|
use crate::message::types::OneOrMany;
|
||||||
@@ -23,8 +24,12 @@ pub(super) use super::template_legacy::LegacySpec;
|
|||||||
use super::template_loader::infer_legacy_template_from_model_path;
|
use super::template_loader::infer_legacy_template_from_model_path;
|
||||||
pub(super) use super::template_loader::load_chat_formatter;
|
pub(super) use super::template_loader::load_chat_formatter;
|
||||||
|
|
||||||
/// A chat prompt formatter: either the model's HuggingFace Jinja template or a
|
/// Extra variables for the chat template (`chat_template_kwargs`).
|
||||||
/// legacy SGLang conversation template.
|
pub type ChatTemplateKwargs = HashMap<String, serde_json::Value>;
|
||||||
|
|
||||||
|
/// A chat prompt formatter: either the model's HuggingFace Jinja template (or
|
||||||
|
/// Dynamo's built-in encoder for models that ship none) or a legacy SGLang
|
||||||
|
/// conversation template.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum ChatFormatter {
|
pub enum ChatFormatter {
|
||||||
HuggingFace(PromptFormatter),
|
HuggingFace(PromptFormatter),
|
||||||
@@ -36,16 +41,14 @@ impl ChatFormatter {
|
|||||||
pub(super) fn render(
|
pub(super) fn render(
|
||||||
&self,
|
&self,
|
||||||
request: &CreateChatCompletionRequest,
|
request: &CreateChatCompletionRequest,
|
||||||
|
kwargs: Option<&ChatTemplateKwargs>,
|
||||||
) -> Result<String, TemplateError> {
|
) -> Result<String, TemplateError> {
|
||||||
match self {
|
match self {
|
||||||
ChatFormatter::HuggingFace(formatter) => {
|
ChatFormatter::HuggingFace(PromptFormatter::OAI(formatter)) => formatter
|
||||||
let PromptFormatter::OAI(formatter) = formatter;
|
.render(&TemplateRequest { request, kwargs })
|
||||||
formatter
|
.map_err(|error| TemplateError::Renderer {
|
||||||
.render(request)
|
message: error.to_string(),
|
||||||
.map_err(|error| TemplateError::Renderer {
|
}),
|
||||||
message: error.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ChatFormatter::Legacy(formatter) => formatter.render(request),
|
ChatFormatter::Legacy(formatter) => formatter.render(request),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,49 @@ impl ChatFormatter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The wire request plus its `chat_template_kwargs`, which the protocol type
|
||||||
|
/// does not carry.
|
||||||
|
struct TemplateRequest<'a> {
|
||||||
|
request: &'a CreateChatCompletionRequest,
|
||||||
|
kwargs: Option<&'a ChatTemplateKwargs>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAIChatLikeRequest for TemplateRequest<'_> {
|
||||||
|
fn model(&self) -> String {
|
||||||
|
self.request.model()
|
||||||
|
}
|
||||||
|
fn messages(&self) -> minijinja::Value {
|
||||||
|
self.request.messages()
|
||||||
|
}
|
||||||
|
fn typed_messages(&self) -> Option<&[ChatCompletionRequestMessage]> {
|
||||||
|
self.request.typed_messages()
|
||||||
|
}
|
||||||
|
fn tools(&self) -> Option<minijinja::Value> {
|
||||||
|
self.request.tools()
|
||||||
|
}
|
||||||
|
fn tool_choice(&self) -> Option<minijinja::Value> {
|
||||||
|
self.request.tool_choice()
|
||||||
|
}
|
||||||
|
fn response_format(&self) -> Option<minijinja::Value> {
|
||||||
|
self.request.response_format()
|
||||||
|
}
|
||||||
|
fn reasoning_effort(&self) -> Option<minijinja::Value> {
|
||||||
|
self.request.reasoning_effort()
|
||||||
|
}
|
||||||
|
fn should_add_generation_prompt(&self) -> bool {
|
||||||
|
self.request.should_add_generation_prompt()
|
||||||
|
}
|
||||||
|
fn chat_template_args(&self) -> Option<&ChatTemplateKwargs> {
|
||||||
|
self.kwargs
|
||||||
|
}
|
||||||
|
fn extract_text(&self) -> Option<TextInput> {
|
||||||
|
self.request.extract_text()
|
||||||
|
}
|
||||||
|
fn mm_processor_kwargs(&self) -> Option<&serde_json::Value> {
|
||||||
|
self.request.mm_processor_kwargs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub(super) enum TemplateError {
|
pub(super) enum TemplateError {
|
||||||
#[error("failed to read {kind} `{path}`: {source}")]
|
#[error("failed to read {kind} `{path}`: {source}")]
|
||||||
@@ -134,6 +180,8 @@ pub(super) enum TemplateError {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use dynamo_protocols::types::{
|
use dynamo_protocols::types::{
|
||||||
ChatCompletionRequestMessage, ChatCompletionRequestMessageContentPartText,
|
ChatCompletionRequestMessage, ChatCompletionRequestMessageContentPartText,
|
||||||
ChatCompletionRequestSystemMessage, ChatCompletionRequestSystemMessageContent,
|
ChatCompletionRequestSystemMessage, ChatCompletionRequestSystemMessageContent,
|
||||||
@@ -191,7 +239,7 @@ mod tests {
|
|||||||
let formatter = ChatFormatter::Legacy(Box::new(LegacyFormatter {
|
let formatter = ChatFormatter::Legacy(Box::new(LegacyFormatter {
|
||||||
spec: builtin_template("chatml").unwrap(),
|
spec: builtin_template("chatml").unwrap(),
|
||||||
}));
|
}));
|
||||||
let rendered = formatter.render(&request()).unwrap();
|
let rendered = formatter.render(&request(), None).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rendered,
|
rendered,
|
||||||
"<|im_start|>system\nBe concise.<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n"
|
"<|im_start|>system\nBe concise.<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n"
|
||||||
@@ -420,6 +468,7 @@ mod tests {
|
|||||||
let formatter = load_chat_formatter(
|
let formatter = load_chat_formatter(
|
||||||
Some(base.to_str().unwrap()),
|
Some(base.to_str().unwrap()),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some(base.to_str().unwrap()),
|
Some(base.to_str().unwrap()),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -456,10 +505,11 @@ mod tests {
|
|||||||
let formatter = load_chat_formatter(
|
let formatter = load_chat_formatter(
|
||||||
Some(base.to_str().unwrap()),
|
Some(base.to_str().unwrap()),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some(legacy.to_str().unwrap()),
|
Some(legacy.to_str().unwrap()),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let rendered = formatter.render(&request()).unwrap();
|
let rendered = formatter.render(&request(), None).unwrap();
|
||||||
assert_eq!(rendered, "System\nBe concise.\nUSER: Hello\nASSISTANT:");
|
assert_eq!(rendered, "System\nBe concise.\nUSER: Hello\nASSISTANT:");
|
||||||
|
|
||||||
let _ = std::fs::remove_file(base);
|
let _ = std::fs::remove_file(base);
|
||||||
@@ -557,7 +607,7 @@ mod tests {
|
|||||||
/// A built-in `--chat-template` name resolves without any tokenizer config.
|
/// A built-in `--chat-template` name resolves without any tokenizer config.
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_argument_works_without_tokenizer_config() {
|
fn builtin_argument_works_without_tokenizer_config() {
|
||||||
let formatter = load_chat_formatter(None, None, Some("chatml")).unwrap();
|
let formatter = load_chat_formatter(None, None, None, Some("chatml")).unwrap();
|
||||||
let ChatFormatter::Legacy(formatter) = &formatter else {
|
let ChatFormatter::Legacy(formatter) = &formatter else {
|
||||||
panic!("expected a legacy formatter");
|
panic!("expected a legacy formatter");
|
||||||
};
|
};
|
||||||
@@ -585,6 +635,7 @@ mod tests {
|
|||||||
Some(base.to_str().unwrap()),
|
Some(base.to_str().unwrap()),
|
||||||
Some("models/vicuna-7b-v1.5"),
|
Some("models/vicuna-7b-v1.5"),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let ChatFormatter::Legacy(formatter) = &formatter else {
|
let ChatFormatter::Legacy(formatter) = &formatter else {
|
||||||
@@ -592,7 +643,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(formatter.spec.name, "vicuna_v1.1");
|
assert_eq!(formatter.spec.name, "vicuna_v1.1");
|
||||||
// No config at all + path matcher.
|
// No config at all + path matcher.
|
||||||
let formatter = load_chat_formatter(None, Some("deepseek-vl2-7b"), None).unwrap();
|
let formatter = load_chat_formatter(None, Some("deepseek-vl2-7b"), None, None).unwrap();
|
||||||
let ChatFormatter::Legacy(formatter) = &formatter else {
|
let ChatFormatter::Legacy(formatter) = &formatter else {
|
||||||
panic!("expected a legacy formatter");
|
panic!("expected a legacy formatter");
|
||||||
};
|
};
|
||||||
@@ -609,7 +660,8 @@ mod tests {
|
|||||||
r#"{"model_type":"phi4mm","architectures":["Phi4MMForCausalLM"]}"#,
|
r#"{"model_type":"phi4mm","architectures":["Phi4MMForCausalLM"]}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let formatter = load_chat_formatter(None, Some(model_dir.to_str().unwrap()), None).unwrap();
|
let formatter =
|
||||||
|
load_chat_formatter(None, Some(model_dir.to_str().unwrap()), None, None).unwrap();
|
||||||
let ChatFormatter::Legacy(formatter) = &formatter else {
|
let ChatFormatter::Legacy(formatter) = &formatter else {
|
||||||
panic!("expected a legacy formatter");
|
panic!("expected a legacy formatter");
|
||||||
};
|
};
|
||||||
@@ -653,7 +705,66 @@ mod tests {
|
|||||||
fn minicpm_4_6_skips_legacy_inference() {
|
fn minicpm_4_6_skips_legacy_inference() {
|
||||||
assert!(infer_legacy_template_from_model_path("minicpm-v-4.6").is_none());
|
assert!(infer_legacy_template_from_model_path("minicpm-v-4.6").is_none());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
load_chat_formatter(None, Some("minicpm-v-4.6"), None),
|
load_chat_formatter(None, Some("minicpm-v-4.6"), None, None),
|
||||||
|
Err(TemplateError::MissingConfig)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_config(contents: &str) -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("sglang-template-{}", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let config = dir.join("tokenizer_config.json");
|
||||||
|
std::fs::write(&config, contents).unwrap();
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_template_kwargs_reach_the_template() {
|
||||||
|
let config = temp_config(r#"{"chat_template": "thinking={{ thinking }}"}"#);
|
||||||
|
let formatter =
|
||||||
|
load_chat_formatter(Some(config.to_str().unwrap()), None, None, None).unwrap();
|
||||||
|
let kwargs = HashMap::from([("thinking".into(), serde_json::json!(true))]);
|
||||||
|
assert_eq!(formatter.render(&request(), None).unwrap(), "thinking=");
|
||||||
|
assert_eq!(
|
||||||
|
formatter.render(&request(), Some(&kwargs)).unwrap(),
|
||||||
|
"thinking=True"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_template_falls_back_to_native_formatter() {
|
||||||
|
let config = temp_config("{}");
|
||||||
|
let config = config.to_str().unwrap();
|
||||||
|
let load = |config, model_type, arg| {
|
||||||
|
load_chat_formatter(config, Some("/models/x"), model_type, arg)
|
||||||
|
};
|
||||||
|
|
||||||
|
let formatter = load(Some(config), Some("deepseek_v4"), None).unwrap();
|
||||||
|
assert!(matches!(formatter, ChatFormatter::HuggingFace(_)));
|
||||||
|
let kwargs = HashMap::from([("thinking".into(), serde_json::json!(false))]);
|
||||||
|
assert_eq!(
|
||||||
|
formatter.render(&request(), Some(&kwargs)).unwrap(),
|
||||||
|
"<|begin▁of▁sentence|>Be concise.<|User|>Hello<|Assistant|></think>"
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
load(None, Some("deepseek_v4"), None),
|
||||||
|
Ok(ChatFormatter::HuggingFace(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
// A template, `--chat-template`, or an unknown architecture wins.
|
||||||
|
let templated = temp_config(r#"{"chat_template": "Hi"}"#);
|
||||||
|
let formatter = load(Some(templated.to_str().unwrap()), Some("deepseek_v4"), None).unwrap();
|
||||||
|
assert_eq!(formatter.render(&request(), None).unwrap(), "Hi");
|
||||||
|
assert!(matches!(
|
||||||
|
load(Some(config), Some("deepseek_v4"), Some("chatml")),
|
||||||
|
Ok(ChatFormatter::Legacy(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
load(Some(config), Some("llama"), None),
|
||||||
|
Err(TemplateError::Missing)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
load(None, None, None),
|
||||||
Err(TemplateError::MissingConfig)
|
Err(TemplateError::MissingConfig)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use dynamo_renderer::{ChatTemplate, ContextMixins, PromptContextMixin, PromptFormatter};
|
use dynamo_renderer::{
|
||||||
|
ChatTemplate, ContextMixins, PromptContextMixin, PromptFormatter, native_formatter_for,
|
||||||
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::message::types::OneOrMany;
|
use crate::message::types::OneOrMany;
|
||||||
@@ -44,6 +46,7 @@ const SUPPORTED_STYLES: &[&str] = &[
|
|||||||
pub(super) fn load_chat_formatter(
|
pub(super) fn load_chat_formatter(
|
||||||
config_file: Option<&str>,
|
config_file: Option<&str>,
|
||||||
model_path: Option<&str>,
|
model_path: Option<&str>,
|
||||||
|
model_type: Option<&str>,
|
||||||
chat_template_arg: Option<&str>,
|
chat_template_arg: Option<&str>,
|
||||||
) -> Result<ChatFormatter, TemplateError> {
|
) -> Result<ChatFormatter, TemplateError> {
|
||||||
// Python resolves registry names before looking at the filesystem — and
|
// Python resolves registry names before looking at the filesystem — and
|
||||||
@@ -66,10 +69,21 @@ pub(super) fn load_chat_formatter(
|
|||||||
return Ok(ChatFormatter::Legacy(Box::new(LegacyFormatter { spec })));
|
return Ok(ChatFormatter::Legacy(Box::new(LegacyFormatter { spec })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Models that ship no template (Python `resolve_chat_encoding_spec`) use
|
||||||
|
// Dynamo's built-in encoder for their architecture.
|
||||||
|
let native_formatter = || match chat_template_arg {
|
||||||
|
None => native_formatter_for(
|
||||||
|
&model_type.map(str::to_lowercase),
|
||||||
|
&model_path.unwrap_or_default().to_lowercase(),
|
||||||
|
)
|
||||||
|
.map(ChatFormatter::HuggingFace),
|
||||||
|
Some(_) => None,
|
||||||
|
};
|
||||||
|
|
||||||
// Every remaining source builds the HF renderer around the tokenizer
|
// Every remaining source builds the HF renderer around the tokenizer
|
||||||
// config (the template itself, or the argument injected into it).
|
// config (the template itself, or the argument injected into it).
|
||||||
let Some(config_file) = config_file else {
|
let Some(config_file) = config_file else {
|
||||||
return Err(TemplateError::MissingConfig);
|
return native_formatter().ok_or(TemplateError::MissingConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
let config_path = Path::new(config_file);
|
let config_path = Path::new(config_file);
|
||||||
@@ -77,7 +91,10 @@ pub(super) fn load_chat_formatter(
|
|||||||
let mut config = parse_json(&config_text, config_path, "tokenizer config")?;
|
let mut config = parse_json(&config_text, config_path, "tokenizer config")?;
|
||||||
|
|
||||||
let Some(argument) = chat_template_arg else {
|
let Some(argument) = chat_template_arg else {
|
||||||
return formatter_from_config(&config);
|
return match formatter_from_config(&config) {
|
||||||
|
Err(TemplateError::Missing) => native_formatter().ok_or(TemplateError::Missing),
|
||||||
|
result => result,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
let path = Path::new(argument);
|
let path = Path::new(argument);
|
||||||
|
|||||||
@@ -329,6 +329,8 @@ pub enum DisaggregationMode {
|
|||||||
#[pyo3::pyclass(frozen, from_py_object, module = "sglang.srt.rust_extensions._server")]
|
#[pyo3::pyclass(frozen, from_py_object, module = "sglang.srt.rust_extensions._server")]
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ModelConfig {
|
pub struct ModelConfig {
|
||||||
|
/// Authoritative HF model type, used to select a native chat formatter.
|
||||||
|
pub model_type: Option<String>,
|
||||||
/// Resolved context length (`max_model_len` in `/v1/models`); the ceiling
|
/// Resolved context length (`max_model_len` in `/v1/models`); the ceiling
|
||||||
/// for input + `max_new_tokens`.
|
/// for input + `max_new_tokens`.
|
||||||
pub context_len: u64,
|
pub context_len: u64,
|
||||||
@@ -351,18 +353,20 @@ pub struct ModelConfig {
|
|||||||
#[pyo3::pymethods]
|
#[pyo3::pymethods]
|
||||||
impl ModelConfig {
|
impl ModelConfig {
|
||||||
#[new]
|
#[new]
|
||||||
#[pyo3(signature = (*, context_len, vocab_size, is_multimodal, default_sampling_params))]
|
#[pyo3(signature = (*, context_len, vocab_size, is_multimodal, default_sampling_params, model_type))]
|
||||||
fn py_new(
|
fn py_new(
|
||||||
context_len: u64,
|
context_len: u64,
|
||||||
vocab_size: u64,
|
vocab_size: u64,
|
||||||
is_multimodal: bool,
|
is_multimodal: bool,
|
||||||
default_sampling_params: DefaultSamplingParams,
|
default_sampling_params: DefaultSamplingParams,
|
||||||
|
model_type: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
context_len,
|
context_len,
|
||||||
vocab_size,
|
vocab_size,
|
||||||
is_multimodal,
|
is_multimodal,
|
||||||
default_sampling_params,
|
default_sampling_params,
|
||||||
|
model_type,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,6 +376,7 @@ impl Default for ModelConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
context_len: 2048,
|
context_len: 2048,
|
||||||
|
model_type: None,
|
||||||
vocab_size: 1000,
|
vocab_size: 1000,
|
||||||
is_multimodal: false,
|
is_multimodal: false,
|
||||||
default_sampling_params: DefaultSamplingParams::default(),
|
default_sampling_params: DefaultSamplingParams::default(),
|
||||||
|
|||||||
Reference in New Issue
Block a user